Skip to content

Commit 04b4602

Browse files
PWA template (#18878)
* Add service worker * Add manifest * Bring back BaselineTest.cs * Add baselines for blazorwasm templates * Add publishing test for PWA template * Baseline fixes * Fix baseline test logic to allow for multi-project outputs * Remove non-blazorwasm baselines, since this branch now only covers blazorwasm * Add test for PWA publish output * Beginning generation of assets manifest * Generate assets manifest including blazor outputs * Tweaks * Write assets manifest in JSON form * Publish service worker * Better API * More resilience * Better API again * Make ComputeBlazorAssetsManifestItems public as people will need to customize the list * Exclude service worker files from assets manifest * Use web standard format for hash * Update project template * In assets manifest, only include items being published * Renames * Compute default assets manifest version by combining hashes * Emit sw manifest in .js form * Update service worker in project * Actually isolate browser instances when requested during E2E tests * E2E test for published PWA operating offline * Fix SWAM path in template * Clarify targets
1 parent 52f4636 commit 04b4602

File tree

18 files changed

+580
-1233
lines changed

18 files changed

+580
-1233
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.IO;
5+
using System.Linq;
6+
using System.Runtime.Serialization.Json;
7+
using System.Text;
8+
using Microsoft.Build.Framework;
9+
using Microsoft.Build.Utilities;
10+
11+
namespace Microsoft.AspNetCore.Blazor.Build
12+
{
13+
public class GenerateServiceWorkerAssetsManifest : Task
14+
{
15+
[Required]
16+
public string Version { get; set; }
17+
18+
[Required]
19+
public ITaskItem[] AssetsWithHashes { get; set; }
20+
21+
[Required]
22+
public string OutputPath { get; set; }
23+
24+
public override bool Execute()
25+
{
26+
using var fileStream = File.Create(OutputPath);
27+
WriteFile(fileStream);
28+
return true;
29+
}
30+
31+
internal void WriteFile(Stream stream)
32+
{
33+
var data = new AssetsManifestFile
34+
{
35+
version = Version,
36+
assets = AssetsWithHashes.Select(item => new AssetsManifestFileEntry
37+
{
38+
url = item.GetMetadata("AssetUrl"),
39+
hash = $"sha256-{item.GetMetadata("FileHash")}",
40+
}).ToArray()
41+
};
42+
43+
using var streamWriter = new StreamWriter(stream, Encoding.UTF8, bufferSize: 50, leaveOpen: true);
44+
streamWriter.Write("self.assetsManifest = ");
45+
streamWriter.Flush();
46+
47+
using var jsonWriter = JsonReaderWriterFactory.CreateJsonWriter(stream, Encoding.UTF8, ownsStream: false, indent: true);
48+
new DataContractJsonSerializer(typeof(AssetsManifestFile)).WriteObject(jsonWriter, data);
49+
jsonWriter.Flush();
50+
51+
streamWriter.WriteLine(";");
52+
}
53+
54+
#pragma warning disable IDE1006 // Naming Styles
55+
public class AssetsManifestFile
56+
{
57+
/// <summary>
58+
/// Gets or sets a version string.
59+
/// </summary>
60+
public string version { get; set; }
61+
62+
/// <summary>
63+
/// Gets or sets the assets. Keys are URLs; values are base-64-formatted SHA256 content hashes.
64+
/// </summary>
65+
public AssetsManifestFileEntry[] assets { get; set; }
66+
}
67+
68+
public class AssetsManifestFileEntry
69+
{
70+
/// <summary>
71+
/// Gets or sets the asset URL. Normally this will be relative to the application's base href.
72+
/// </summary>
73+
public string url { get; set; }
74+
75+
/// <summary>
76+
/// Gets or sets the file content hash. This should be the base-64-formatted SHA256 value.
77+
/// </summary>
78+
public string hash { get; set; }
79+
}
80+
#pragma warning restore IDE1006 // Naming Styles
81+
}
82+
}

src/Components/Blazor/Build/src/targets/All.targets

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<Import Project="Blazor.MonoRuntime.targets" />
2222
<Import Project="Publish.targets" />
2323
<Import Project="StaticWebAssets.targets" />
24+
<Import Project="ServiceWorkerAssetsManifest.targets" />
2425

2526
<Target Name="GenerateBlazorMetadataFile"
2627
BeforeTargets="GetCopyToOutputDirectoryItems">

src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
<Target
2323
Name="_BlazorCopyFilesToOutputDirectory"
24-
DependsOnTargets="PrepareBlazorOutputs"
24+
DependsOnTargets="PrepareBlazorOutputs;$(_BlazorCopyFilesToOutputDirectoryDependsOn)"
2525
AfterTargets="CopyFilesToOutputDirectory"
2626
Condition="'$(OutputType.ToLowerInvariant())'=='exe'">
2727

@@ -133,7 +133,7 @@
133133
ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
134134
Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set.
135135
-->
136-
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" />
136+
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" Condition="'%(Extension)' == '.dll'" />
137137
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" />
138138

139139
<BlazorOutputWithTargetPath Include="@(_BlazorCopyLocalPaths)">
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<Project>
2+
3+
<PropertyGroup>
4+
<_BlazorCopyFilesToOutputDirectoryDependsOn>
5+
$(_BlazorCopyFilesToOutputDirectoryDependsOn);
6+
_ComputeServiceWorkerAssetsManifestInputs;
7+
_WriteServiceWorkerAssetsManifest;
8+
</_BlazorCopyFilesToOutputDirectoryDependsOn>
9+
</PropertyGroup>
10+
11+
<Target Name="_ComputeServiceWorkerAssetsManifestInputs"
12+
Condition="'$(ServiceWorkerAssetsManifest)' != ''"
13+
DependsOnTargets="PrepareBlazorOutputs">
14+
15+
<PropertyGroup>
16+
<_ServiceWorkerAssetsManifestIntermediateOutputPath>$(BlazorIntermediateOutputPath)serviceworkerassets.js</_ServiceWorkerAssetsManifestIntermediateOutputPath>
17+
</PropertyGroup>
18+
19+
<ItemGroup>
20+
<!-- Include _framework/* content -->
21+
<ServiceWorkerAssetsManifestItem
22+
Include="@(BlazorOutputWithTargetPath)"
23+
Condition="$([System.String]::Copy('%(BlazorOutputWithTargetPath.TargetOutputPath)').Replace('\','/').StartsWith('dist/'))">
24+
<AssetUrl>$([System.String]::Copy('%(BlazorOutputWithTargetPath.TargetOutputPath)').Replace('\','/').Substring(5))</AssetUrl>
25+
</ServiceWorkerAssetsManifestItem>
26+
27+
<!-- Include content from wwwroot -->
28+
<ServiceWorkerAssetsManifestItem
29+
Include="@(ContentWithTargetPath)"
30+
Condition="
31+
('%(ContentWithTargetPath.CopyToPublishDirectory)' == 'Always' OR '%(ContentWithTargetPath.CopyToPublishDirectory)' == 'PreserveNewest')
32+
AND $([System.String]::Copy('%(ContentWithTargetPath.TargetPath)').Replace('\','/').StartsWith('wwwroot/'))">
33+
<AssetUrl>$([System.String]::Copy('%(ContentWithTargetPath.TargetPath)').Replace('\','/').Substring(8))</AssetUrl>
34+
</ServiceWorkerAssetsManifestItem>
35+
36+
<!-- Include SWA from references -->
37+
<ServiceWorkerAssetsManifestItem
38+
Include="@(StaticWebAsset)"
39+
Condition="'%(StaticWebAsset.SourceType)' != ''">
40+
<AssetUrl>%(StaticWebAsset.BasePath)/%(StaticWebAsset.RelativePath)</AssetUrl>
41+
</ServiceWorkerAssetsManifestItem>
42+
</ItemGroup>
43+
44+
</Target>
45+
46+
<UsingTask TaskName="GenerateServiceWorkerAssetsManifest" AssemblyFile="$(BlazorTasksPath)" />
47+
48+
<Target Name="_WriteServiceWorkerAssetsManifest"
49+
Inputs="@(ServiceWorkerAssetsManifestItem)"
50+
Outputs="$(_ServiceWorkerAssetsManifestIntermediateOutputPath)"
51+
DependsOnTargets="_ComputeServiceWorkerAssetsManifestFileHashes; _ComputeDefaultServiceWorkerAssetsManifestVersion">
52+
53+
<GenerateServiceWorkerAssetsManifest
54+
Version="$(ServiceWorkerAssetsManifestVersion)"
55+
AssetsWithHashes="@(_ServiceWorkerAssetsManifestItemWithHash)"
56+
OutputPath="$(_ServiceWorkerAssetsManifestIntermediateOutputPath)" />
57+
58+
<ItemGroup>
59+
<BlazorOutputWithTargetPath
60+
Include="$(_ServiceWorkerAssetsManifestIntermediateOutputPath)"
61+
TargetOutputPath="$(BaseBlazorDistPath)$(ServiceWorkerAssetsManifest)" />
62+
63+
<FileWrites Include="$(_ServiceWorkerAssetsManifestIntermediateOutputPath)" />
64+
</ItemGroup>
65+
66+
</Target>
67+
68+
<Target Name="_ComputeServiceWorkerAssetsManifestFileHashes">
69+
<GetFileHash Files="@(ServiceWorkerAssetsManifestItem)" Algorithm="SHA256" HashEncoding="base64">
70+
<Output TaskParameter="Items" ItemName="_ServiceWorkerAssetsManifestItemWithHash" />
71+
</GetFileHash>
72+
</Target>
73+
74+
<!--
75+
If no ServiceWorkerAssetsManifestVersion was specified, we compute a default value by combining all the asset hashes.
76+
This is useful because then clients will only have to repopulate caches if the contents have changed.
77+
-->
78+
<Target Name="_ComputeDefaultServiceWorkerAssetsManifestVersion"
79+
Condition="'$(ServiceWorkerAssetsManifestVersion)' == ''">
80+
<PropertyGroup>
81+
<_CombinedHashIntermediatePath>$(BlazorIntermediateOutputPath)serviceworkerhashes.txt</_CombinedHashIntermediatePath>
82+
</PropertyGroup>
83+
84+
<WriteLinesToFile
85+
File="$(_CombinedHashIntermediatePath)"
86+
Lines="@(_ServiceWorkerAssetsManifestItemWithHash->'%(FileHash)')"
87+
Overwrite="true" />
88+
89+
<GetFileHash Files="$(_CombinedHashIntermediatePath)" Algorithm="SHA256" HashEncoding="base64">
90+
<Output TaskParameter="Items" ItemName="_ServiceWorkerAssetsManifestCombinedHash" />
91+
</GetFileHash>
92+
93+
<PropertyGroup>
94+
<ServiceWorkerAssetsManifestVersion>$([System.String]::Copy('%(_ServiceWorkerAssetsManifestCombinedHash.FileHash)').Substring(0, 8))</ServiceWorkerAssetsManifestVersion>
95+
</PropertyGroup>
96+
</Target>
97+
98+
</Project>

src/Components/startvs.cmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
@ECHO OFF
22

3-
%~dp0..\..\startvs.cmd %~dp0Components.sln
3+
%~dp0..\..\startvs.cmd %~dp0Blazor.sln

src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<PropertyGroup>
44
<TargetFramework>netstandard2.1</TargetFramework>
55
<RazorLangVersion>3.0</RazorLangVersion>
6+
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
67
</PropertyGroup>
78

89
<ItemGroup>
@@ -11,10 +12,22 @@
1112
<PackageReference Include="Microsoft.AspNetCore.Blazor.DevServer" Version="${MicrosoftAspNetCoreBlazorDevServerPackageVersion}" PrivateAssets="all" />
1213
<PackageReference Include="Microsoft.AspNetCore.Blazor.HttpClient" Version="${MicrosoftAspNetCoreBlazorHttpClientPackageVersion}" />
1314
</ItemGroup>
15+
1416
<!--#if Hosted -->
1517
<ItemGroup>
1618
<ProjectReference Include="..\Shared\BlazorWasm-CSharp.Shared.csproj" />
1719
</ItemGroup>
20+
1821
<!--#endif -->
22+
<!--#if PWA -->
23+
<ItemGroup>
24+
<!-- When publishing, swap service-worker.published.js in place of service-worker.js -->
25+
<Content Update="wwwroot\service-worker*.js" CopyToPublishDirectory="false" />
26+
<ContentWithTargetPath Include="wwwroot\service-worker.published.js">
27+
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
28+
<TargetPath>wwwroot\service-worker.js</TargetPath>
29+
</ContentWithTargetPath>
30+
</ItemGroup>
1931

32+
<!--#endif -->
2033
</Project>

src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/dotnetcli.host.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
"longName": "no-restore",
66
"shortName": ""
77
},
8-
"Hosted": {
9-
"longName": "hosted"
10-
},
8+
"Hosted": {
9+
"longName": "hosted"
10+
},
11+
"PWA": {
12+
"longName": "pwa"
13+
},
1114
"Framework": {
1215
"longName": "framework"
1316
}

src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@
7474
"exclude": [
7575
"*.sln"
7676
]
77+
},
78+
{
79+
"condition": "(!PWA)",
80+
"exclude": [
81+
"Client/wwwroot/service-worker*.js",
82+
"Client/wwwroot/manifest.json",
83+
"Client/wwwroot/icon-512.png"
84+
]
7785
}
7886
]
7987
}
@@ -147,6 +155,12 @@
147155
"fallbackVariableName": "HttpsPortGenerated"
148156
},
149157
"replaces": "44300"
158+
},
159+
"PWA": {
160+
"type": "parameter",
161+
"datatype": "bool",
162+
"defaultValue": "false",
163+
"description": "If specified, produces a Progressive Web Application (PWA) supporting installation and offline use."
150164
}
151165
},
152166
"tags": {

src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
"text": "ASP.NET Core _hosted"
2626
},
2727
"isVisible": "true"
28+
},
29+
{
30+
"id": "PWA",
31+
"name": {
32+
"text": "_Progressive Web Application"
33+
},
34+
"isVisible": "true"
2835
}
2936
]
3037
}

src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
<base href="/" />
99
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
1010
<link href="css/site.css" rel="stylesheet" />
11+
<!--#if PWA -->
12+
<link href="manifest.json" rel="manifest" />
13+
<!--#endif -->
1114
</head>
1215

1316
<body>
@@ -19,6 +22,9 @@
1922
<a class="dismiss">🗙</a>
2023
</div>
2124
<script src="_framework/blazor.webassembly.js"></script>
25+
<!--#if PWA -->
26+
<script>navigator.serviceWorker.register('service-worker.js');</script>
27+
<!--#endif -->
2228
</body>
2329

2430
</html>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "BlazorWasm-CSharp",
3+
"short_name": "BlazorWasm-CSharp",
4+
"start_url": "/",
5+
"display": "standalone",
6+
"background_color": "#ffffff",
7+
"theme_color": "#03173d",
8+
"icons": [
9+
{
10+
"src": "icon-512.png",
11+
"type": "image/png",
12+
"sizes": "512x512"
13+
}
14+
]
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// In development, always fetch from the network and do not enable offline support.
2+
// This is because caching would make development more difficult (changes would not
3+
// be reflected on the first load after each change).
4+
self.addEventListener('fetch', () => { });
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Caution! Be sure you understand the caveats before publishing an application with
2+
// offline support. See https://aka.ms/blazor-offline-considerations
3+
4+
self.importScripts('./service-worker-assets.js');
5+
self.addEventListener('install', event => event.waitUntil(onInstall(event)));
6+
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
7+
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
8+
9+
const cacheNamePrefix = 'offline-cache-';
10+
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
11+
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/ ];
12+
const offlineAssetsExclude = [ /^service-worker\.js$/ ];
13+
14+
async function onInstall(event) {
15+
console.info('Service worker: Install');
16+
17+
// Fetch and cache all matching items from the assets manifest
18+
const assetsRequests = self.assetsManifest.assets
19+
.filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
20+
.filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
21+
.map(asset => new Request(asset.url, { integrity: asset.hash }));
22+
await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
23+
}
24+
25+
async function onActivate(event) {
26+
console.info('Service worker: Activate');
27+
28+
// Delete unused caches
29+
const cacheKeys = await caches.keys();
30+
await Promise.all(cacheKeys
31+
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
32+
.map(key => caches.delete(key)));
33+
}
34+
35+
async function onFetch(event) {
36+
let cachedResponse = null;
37+
if (event.request.method === 'GET') {
38+
// For all navigation requests, try to serve index.html from cache
39+
// If you need some URLs to be server-rendered, edit the following check to exclude those URLs
40+
const shouldServeIndexHtml = event.request.mode === 'navigate';
41+
42+
const request = shouldServeIndexHtml ? 'index.html' : event.request;
43+
const cache = await caches.open(cacheName);
44+
cachedResponse = await cache.match(request);
45+
}
46+
47+
return cachedResponse || fetch(event.request);
48+
}

0 commit comments

Comments
 (0)