diff --git a/.github/workflows/bit.ci.Bswup.yml b/.github/workflows/bit.ci.Bswup.yml index 5192c57506..2e74e2aad2 100644 --- a/.github/workflows/bit.ci.Bswup.yml +++ b/.github/workflows/bit.ci.Bswup.yml @@ -53,3 +53,11 @@ jobs: - name: dotnet build run: dotnet build src/Bswup/Bit.Bswup.slnx + + # Runs against Bit.Bswup/wwwroot/*.js, which the build above produces, so this also covers + # the build step itself (e.g. the minifier re-introducing downleveled syntax). + - name: JS tests (service worker, page script, progress UI) + run: | + cd src/Bswup/Tests/bit-bswup-js + npm ci || npm install + npm test diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor index 4ef62a7a95..bb30021363 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor @@ -3,7 +3,7 @@ Home -

222

+

111

Hello, world!

diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js index 234b154b45..713c3d4128 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js @@ -2,13 +2,15 @@ self.assetsExclude = [/\.scp\.css$/, /weather\.json$/]; self.caseInsensitiveUrl = true; -self.precachedAssetsInclude = [/favicon\.ico$/, /icon-512\.png$/, /bit-bw-64\.png$/]; self.externalAssets = [ { "url": "not-found/script.file.js" } ]; +// 'lax' is already the default; it is spelled out here because the demo intentionally +// references a non-existent asset to exercise the progress / error reporting UI, and under +// 'strict' that would abort the install. See README.md > errorTolerance. self.errorTolerance = 'lax'; self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js index 98df52a75d..659e94b115 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js @@ -2,7 +2,6 @@ self.assetsExclude = [/\.scp\.css$/, /weather\.json$/]; self.caseInsensitiveUrl = true; -self.precachedAssetsInclude = [/favicon\.ico$/, /icon-512\.png$/, /bit-bw-64\.png$/]; //self.externalAssets = [ // { diff --git a/src/Bswup/Bit.Bswup.slnx b/src/Bswup/Bit.Bswup.slnx index 2b41b652c7..6bf34c2593 100644 --- a/src/Bswup/Bit.Bswup.slnx +++ b/src/Bswup/Bit.Bswup.slnx @@ -7,12 +7,12 @@ - - + + - - + + diff --git a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj index 0335bfd2f5..9969d5d918 100644 --- a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj +++ b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj @@ -5,10 +5,15 @@ net10.0;net9.0;net8.0 true - - BeforeBuildTasks; - $(ResolveStaticWebAssetsInputsDependsOn) - + + es2019 @@ -21,41 +26,126 @@ + - - + + - - - + + - - - - + + + + + + + + + + + + + + - + + + + + + + - + + + - - - + + + + + + + + + + + + + + + + + + - - - - - - - - \ No newline at end of file + diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index 3d6344e5a2..38441d9f76 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -10,24 +10,60 @@ [Parameter] public string? Handler { get; set; } } -
- @if (ChildContent is not null) +@* Configuration is published as data-* attributes and read by bit-bswup.progress.js when it + loads (it self-initializes from these attributes). This deliberately avoids emitting an + inline + + + + + + + + diff --git a/src/Bswup/FullDemo/Server/Components/_Imports.razor b/src/Bswup/FullDemo/Server/Components/_Imports.razor new file mode 100644 index 0000000000..23aacbed1f --- /dev/null +++ b/src/Bswup/FullDemo/Server/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Bit.Bswup +@using Bit.Bswup.FullDemo.Server +@using Bit.Bswup.FullDemo.Server.Components +@using Bit.Bswup.FullDemo.Client +@using Bit.Bswup.FullDemo.Client.Shared diff --git a/src/Bswup/FullDemo/Server/Pages/_Host.cshtml b/src/Bswup/FullDemo/Server/Pages/_Host.cshtml deleted file mode 100644 index 9f671f70ec..0000000000 --- a/src/Bswup/FullDemo/Server/Pages/_Host.cshtml +++ /dev/null @@ -1,14 +0,0 @@ -@page "/" -@namespace Bit.Bswup.Demo.Web.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers - -@using RenderMode = Microsoft.AspNetCore.Mvc.Rendering.RenderMode - -@{ - Layout = "_Layout"; - - var noPrerender = Request.Query["no-prerender"].Count > 0; - var renderMode = noPrerender ? RenderMode.WebAssembly : RenderMode.WebAssemblyPrerendered; -} - - \ No newline at end of file diff --git a/src/Bswup/FullDemo/Server/Pages/_Layout.cshtml b/src/Bswup/FullDemo/Server/Pages/_Layout.cshtml deleted file mode 100644 index 34dacd5b93..0000000000 --- a/src/Bswup/FullDemo/Server/Pages/_Layout.cshtml +++ /dev/null @@ -1,44 +0,0 @@ -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@namespace Bit.Bswup.Demo.Web.Pages - -@using Bit.Bswup.Demo.Web -@using Microsoft.AspNetCore.Http -@using Microsoft.AspNetCore.Components.Web -@using RenderMode = Microsoft.AspNetCore.Mvc.Rendering.RenderMode - - - - - - - bit Bswup Full Demo - - - - - - -
- @RenderBody() -
- - - - - - - - @* - *@ - - \ No newline at end of file diff --git a/src/Bswup/FullDemo/Server/Program.cs b/src/Bswup/FullDemo/Server/Program.cs index 49c224c338..37bf9cc289 100644 --- a/src/Bswup/FullDemo/Server/Program.cs +++ b/src/Bswup/FullDemo/Server/Program.cs @@ -1,20 +1,50 @@ -var builder = WebApplication.CreateBuilder(args); +using System.IO.Compression; +using Bit.Bswup.FullDemo.Server.Components; +using Microsoft.AspNetCore.ResponseCompression; -#if DEBUG -if (OperatingSystem.IsWindows()) +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddControllers(); +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddResponseCompression(opts => { - builder.WebHost.UseUrls("https://localhost:5021", "http://localhost:5020", "https://*:5021", "http://*:5020"); + opts.EnableForHttps = true; + opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]); + opts.Providers.Add(); + opts.Providers.Add(); +}) + .Configure(opt => opt.Level = CompressionLevel.Fastest) + .Configure(opt => opt.Level = CompressionLevel.Fastest); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); } else { - builder.WebHost.UseUrls("https://localhost:5021", "http://localhost:5020"); + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + app.UseResponseCompression(); } -#endif -Bit.Bswup.Demo.Server.Startup.Services.Add(builder.Services); +app.UseHttpsRedirection(); -var app = builder.Build(); +app.MapStaticAssets(); +app.UseAntiforgery(); + +app.MapControllers(); -Bit.Bswup.Demo.Server.Startup.Middlewares.Use(app, builder.Environment); +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(Bit.Bswup.FullDemo.Client._Imports).Assembly); app.Run(); diff --git a/src/Bswup/FullDemo/Server/Properties/launchSettings.json b/src/Bswup/FullDemo/Server/Properties/launchSettings.json index c60dbf41d8..a257a7aa19 100644 --- a/src/Bswup/FullDemo/Server/Properties/launchSettings.json +++ b/src/Bswup/FullDemo/Server/Properties/launchSettings.json @@ -1,7 +1,7 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "Bit.Bswup.Demo.Server": { + "Bit.Bswup.FullDemo.Server": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, diff --git a/src/Bswup/FullDemo/Server/Startup/Middlewares.cs b/src/Bswup/FullDemo/Server/Startup/Middlewares.cs deleted file mode 100644 index 2cadf34289..0000000000 --- a/src/Bswup/FullDemo/Server/Startup/Middlewares.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.Net.Http.Headers; - -namespace Bit.Bswup.Demo.Server.Startup; - -public static class Middlewares -{ - public static void Use(IApplicationBuilder app, IHostEnvironment env) - { - //app.Use(async (context, next) => - //{ - // await Task.Delay(new Random().Next(500, 800)); - // await next.Invoke(context); - //}); - - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - app.UseWebAssemblyDebugging(); - } - - app.UseBlazorFrameworkFiles(); - - if (env.IsDevelopment() is false) - { - app.UseResponseCompression(); - } - app.UseStaticFiles(new StaticFileOptions - { - OnPrepareResponse = ctx => - { - ctx.Context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromDays(7), - Public = true - }; - } - }); - - app.UseRouting(); - app.UseCors(options => options.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); - - app.UseEndpoints(endpoints => - { - endpoints.MapDefaultControllerRoute(); - - endpoints.MapFallbackToPage("/_Host"); - }); - } -} diff --git a/src/Bswup/FullDemo/Server/Startup/Services.cs b/src/Bswup/FullDemo/Server/Startup/Services.cs deleted file mode 100644 index 7137f85770..0000000000 --- a/src/Bswup/FullDemo/Server/Startup/Services.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.IO.Compression; -using Microsoft.AspNetCore.ResponseCompression; - -namespace Bit.Bswup.Demo.Server.Startup; - -public static class Services -{ - public static void Add(IServiceCollection services) - { - services.AddRazorPages(); - - services.AddCors(); - - services.AddControllers(); - - services.AddHttpContextAccessor(); - - services.AddResponseCompression(opts => - { - opts.EnableForHttps = true; - opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] { "application/octet-stream" }).ToArray(); - opts.Providers.Add(); - opts.Providers.Add(); - }) - .Configure(opt => opt.Level = CompressionLevel.Fastest) - .Configure(opt => opt.Level = CompressionLevel.Fastest); - } -} diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Bit.Bswup.NewDemo.Client.csproj b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Bit.Bswup.NewDemo.Client.csproj similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Bit.Bswup.NewDemo.Client.csproj rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Bit.Bswup.NewDemo.Client.csproj diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor.css b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor.css similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor.css rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor.css diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Pages/Counter.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Counter.razor similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Pages/Counter.razor rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Counter.razor diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Pages/Home.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Home.razor similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Pages/Home.razor rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Home.razor diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Program.cs b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Program.cs similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Program.cs rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Program.cs diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Routes.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Routes.razor similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/Routes.razor rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Routes.razor diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/_Imports.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/_Imports.razor similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/_Imports.razor rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/_Imports.razor diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/app.css b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/app.css similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/app.css rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/app.css diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/bit-bw-64.png b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/bit-bw-64.png similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/bit-bw-64.png rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/bit-bw-64.png diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/favicon.ico b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/favicon.ico similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/favicon.ico rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/favicon.ico diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/icon-512.png b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/icon-512.png similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/icon-512.png rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/icon-512.png diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/manifest.json b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/manifest.json similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/manifest.json rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/manifest.json diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js similarity index 82% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js index 476267b680..0431b8cdc5 100644 --- a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js @@ -31,6 +31,12 @@ self.externalAssets = [ }, { "url": "Bit.Bswup.NewDemo.Client.bundle.scp.css" + }, + { + // Server-generated Blazor Web boot module with a fingerprint that changes each publish, + // so it can't be listed as an exact asset. This RegExp lets Bswup cache it lazily so the + // app still boots offline. + "url": /\/_framework\/resource-collection\..+\.js$/ } ]; diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js similarity index 79% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js index 7468555824..560207459d 100644 --- a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js @@ -26,6 +26,12 @@ self.externalAssets = [ }, { "url": "Bit.Bswup.NewDemo.Client.bundle.scp.css" + }, + { + // Server-generated Blazor Web boot module with a fingerprint that changes each publish, + // so it can't be listed as an exact asset. This RegExp lets Bswup cache it lazily so the + // app still boots offline. + "url": /\/_framework\/resource-collection\..+\.js$/ } ]; diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.csproj b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.csproj similarity index 100% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.csproj rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.csproj diff --git a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo/Components/App.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor similarity index 98% rename from src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo/Components/App.razor rename to src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor index 3110bb5054..a7a2710331 100644 --- a/src/Bswup/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo/Components/App.razor +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor @@ -21,7 +21,6 @@ -

111

+ handler="bitBswupHandler" + updateInterval="3600" + updateOnVisibility="true"> ``` -- `scope`: The scope of the service-worker ([read more](https://developer.chrome.com/docs/workbox/service-worker-lifecycle/#scope)). -- `log`: The log level of the Bswup logger. available options are: `info`, `verbose`, `debug`, and `error`. (not implemented yet) +- `scope`: The scope of the service-worker ([read more](https://developer.chrome.com/docs/workbox/service-worker-lifecycle/#scope)). Defaults to `/`. A service-worker can only control URLs beneath its own folder unless the server sends a `Service-Worker-Allowed` header, so if your app is mounted on a sub-path (e.g. `https://host/myapp/`) set this to that sub-path. If the browser refuses the configured scope, Bswup automatically retries with the default scope - the folder containing the service-worker script - so the app keeps working with offline support rather than losing the service-worker entirely; the fallback is reported as a warning in the console. +- `log`: The log level of the Bswup logger. Available options are: `none`, `error`, `warn`, `info`, `verbose`, and `debug`. Each level includes everything above it (e.g. `info` also shows `warn` and `error`). Defaults to `warn`. Use `none` to silence all output. - `sw`: The file path of the service-worker file. - `handler`: The name of the handler function for the service-worker events. +- `blazorScript`: The path of the Blazor entry-point script (the one you added `autostart="false"` to in step 3). When omitted, Bswup auto-detects both the Blazor Web App script (`_framework/blazor.web.js`) and the standalone Blazor WebAssembly script (`_framework/blazor.webassembly.js`), so you only need to set this if your script lives at a non-default path. +- `updateInterval`: Number of seconds between automatic update checks. By default the browser only re-checks the service worker on navigation and roughly every 24 hours, so a long-lived SPA tab can run a stale version for a long time. Set this to a positive number (e.g. `3600` for hourly) to have Bswup call `reg.update()` on a timer. Checks are skipped while the tab is in the background (the browser throttles those timers anyway) and resume when it becomes visible again. Omit or set to `0` to disable (the default). +- `updateOnVisibility`: When set to `true`, Bswup checks for an update every time the tab returns to the foreground (the `visibilitychange` event). This is a lightweight way to catch updates right when a user comes back to a tab they left open. Disabled by default. > You can remove any of these attributes, and use the default values mentioned above. @@ -73,8 +78,13 @@ function bitBswupHandler(type, data) { return console.log('downloading assets started:', data?.version); case BswupMessage.downloadProgress: + // data.percent is 0-100, data.index is the 1-based count of assets handled so far, + // and data.asset describes the asset that just finished: `url` (the path as declared + // in service-worker-assets.js / externalAssets), `reqUrl` (the absolute URL it was + // fetched from) and `hash` when the asset has one. + const percent = Math.round(data.percent); progressBar.style.width = `${percent}%`; - return console.log('asset downloaded:', data); + return console.log('asset downloaded:', data.asset.url, data); case BswupMessage.downloadFinished: if (data.firstInstall) { @@ -92,10 +102,44 @@ function bitBswupHandler(type, data) { reloadButton.style.display = 'block'; reloadButton.onclick = data.reload; return console.log('new update ready.'); + + case BswupMessage.updateNotFound: + return console.log('checked for an update, already on the latest version.'); + + case BswupMessage.error: + // Structured install failure. data.reason is one of 'manifest' | 'integrity' | + // 'fetch' | 'cache' | 'request' | 'install-incomplete' | 'install-aborted'; + // data.message is human readable, and data.url / data.hash point at the offending + // asset when known. + // + // data.fatal says whether the install actually stopped. Under the default 'lax' + // tolerance a failed asset is reported with `fatal: false` - the install still + // succeeds and that asset is fetched from the network on first use - so treat it + // as a warning, not a dead app. Only `fatal: true` (an invalid manifest, or an + // abort under errorTolerance 'strict') means no new version was installed; in that + // case Bswup starts the app without a service worker so it still boots. + if (data.fatal === false) { + console.warn('Bswup asset skipped:', data.reason, data.message, data); + return; + } + console.error('Bswup install error:', data.reason, data.message, + ...(data.url ? [`url: ${data.url}`] : []), + ...(data.hash ? [`hash: ${data.hash}`] : []), + data); + return; } } ``` +> **Multi-tab updates:** Service workers are single-instance per origin, so accepting an +> update in one tab activates the new version for every open tab. When that happens, Bswup +> has the new worker claim all clients and each *other* tab reloads itself automatically +> (via the `controllerchange` event) onto the new version. This keeps every tab consistent +> and avoids the classic failure where an old tab keeps running old app code while its +> asset requests are served from the new version's cache (mismatched boot config / DLL +> hashes). The first install is exempt: claiming a client for the first time starts Blazor +> and does not trigger a reload. + 6. Configure additional settings in the service-worker file like the following code: ```js @@ -115,6 +159,7 @@ self.externalAssets = [ ]; self.assetsUrl = '/service-worker-assets.js'; self.noPrerenderQuery = 'no-prerender=true'; +self.cacheVersion = '2026.05.31-abc1234'; self.caseInsensitiveUrl = true; self.ignoreDefaultInclude = true; @@ -133,15 +178,47 @@ The most important line here is the last line which is the only mandatory config self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); ``` +> **Security note - the service worker is part of your trusted base.** Unlike the assets in +> `service-worker-assets.js` (which Bswup verifies with Subresource Integrity), the +> service-worker script itself cannot be integrity-pinned: browsers do not support an +> `integrity` option on `navigator.serviceWorker.register()`, and `importScripts()` has no +> SRI mechanism either. This is not Bswup-specific - Workbox and every other SW library share +> the limitation - but it matters because a service worker can intercept every request, so a +> tampered `service-worker.js` or `bit-bswup.sw.js` is effectively persistent, fully-privileged +> XSS. Treat the origin/CDN that serves these two files as part of your trusted computing base: +> serve them over HTTPS from an origin you control, and apply a strict Content-Security-Policy. +> +> To keep clients from getting stuck on a stale worker, Bswup registers with +> `updateViaCache: 'none'`, which tells the browser to bypass the HTTP cache for the +> service-worker script **and** the scripts it pulls in via `importScripts()` during update +> checks (the browser default, `'imports'`, would still serve imported scripts from the HTTP +> cache). That covers the whole `service-worker.js` -> `bit-bswup.sw.js` -> `service-worker-assets.js` +> import chain. As defense-in-depth - and because `updateViaCache` support is uneven (older +> Safari/iOS in particular) and intermediary proxies are not bound by it - also configure your +> server to send `Cache-Control: no-cache` (or `no-store`) for `service-worker.js` and +> `_content/Bit.Bswup/bit-bswup.sw.js` so every fetch revalidates against the origin. + The other settings are: +> **How the URL-matching lists are matched.** `assetsInclude`, `assetsExclude`, `prohibitedUrls`, +> `serverHandledUrls` and `serverRenderedUrls` all accept the same two kinds of entry: +> - a **`RegExp`** (e.g. `/\/admin\//`) is used as the pattern it is - this is what the +> examples below use, and what you want for anything non-trivial; +> - a **string** (e.g. `'/admin/'`) is matched **literally** as a substring of the URL. It is +> regex-escaped, so `'v1.0'` matches only `v1.0` and never `v1X0`. +> +> Prefer a `RegExp` whenever you need anchoring, alternation or wildcards - a literal string +> cannot express "ends with" or "starts with". Note that a string is a *substring* match, so +> `'app.css'` matches `/css/app.css` anywhere in the URL; anchor with a `RegExp` such as +> `/\/app\.css$/` if that is too broad. + - `assetsInclude`: The list of file names from the assets list to **include** when the Bswup tries to store them in the cache storage (regex supported). - `assetsExclude`: The list of file names from the assets list to **exclude** when the Bswup tries to store them in the cache storage (regex supported). - `externalAssets`: The list of external assets to cache that are not included in the auto-generated assets file. For example, if you're not using `index.html` (like `_host.cshtml`), then you should add `{ "url": "/" }`. - `defaultUrl`: The default page URL. Use `/` when using `_Host.cshtml`. - `assetsUrl`: The file path of the service-worker assets file generated at compile time (the default file name is `service-worker-assets.js`). -- `prohibitedUrls`: The list of file names that should not be accessed (regex supported). -- `caseInsensitiveUrl`: Enables the case insensitivity in the URL checking of the cache process. +- `prohibitedUrls`: The list of file names that should not be accessed (regex supported). Matching requests are answered by the service-worker with `403 Forbidden` and a short `text/plain` body, for every HTTP method. **Changed in 10.5.0:** previous versions answered `405 Method Not Allowed`; if your code detects a blocked URL by checking the status, look for `403`. +- `caseInsensitiveUrl`: Enables case-insensitive URL checking. This applies both to the asset cache matching and to every URL-matching regex list (`prohibitedUrls`, `serverHandledUrls`, `serverRenderedUrls`, `assetsInclude`, `assetsExclude`): when enabled, those patterns are compiled with the `i` flag so e.g. `prohibitedUrls: [/\/admin\//]` also blocks `/ADMIN/`. Patterns that already specify the `i` flag are left unchanged. - `serverHandledUrls`: The list of URLs that do not enter the service-worker offline process and will be handled only by server (regex supported). such as `/api`, `/swagger`, ... - `serverRenderedUrls`: The list of URLs that should be rendered by the server and not client while navigating (regex supported). such as `/about.html`, `/privacy`, ... - `noPrerenderQuery`: The query string attached to the default document request to disable the prerendering from the server so an unwanted prerendered result not be cached. @@ -156,14 +233,62 @@ The other settings are: #### Keep in mind that caching service-worker related files will corrupt the update cycle of the service-worker. Only the browser should handle these files. - `isPassive`: Enables the Bswup's passive mode. In this mode, the assets won't be cached in advance but rather upon initial request. - `enableIntegrityCheck`: Enables the default integrity check available in browsers by setting the `integrity` attribute of the request object created in the service-worker to fetch the assets. -- `errorTolerance`: Determines how the Bswup should handle the errors while downloading assets. Possible values are: `strict`, `lax`, `config`. +- `errorTolerance`: Controls how the service worker reacts to asset download / cache failures during install. Possible values: + - `lax` (default): best-effort install. The install always succeeds; missing assets are filled in lazily on the first fetch (in both passive and non-passive modes). Failed assets are reported through the `error` message with `fatal: false` and are counted toward the progress so the bar can still reach 100%. This is the default because it tolerates optional `externalAssets` that may legitimately 404, and because a failed install on a *first* visit would otherwise leave the app with no service-worker to complete the startup handshake. + - `strict`: mirrors the standard Microsoft template / Workbox behavior. If any required asset fails to fetch or store during install, the install promise rejects, the partially populated cache is discarded, and the previous service-worker (if any) keeps serving the app. Failed assets are reported via the `error` message and are *not* counted toward the progress percentage, so 100% means every asset succeeded; the abort itself is reported once more with `reason: 'install-aborted'` and `fatal: true`. Choose this when a partial cache is unacceptable and you would rather stay on the previous version. On a first install (where there is no previous version to fall back to) Bswup starts the app without a service worker so it still boots from the network, and the install is retried on the next load. - `enableDiagnostics`: Enables diagnostics by pushing service-worker logs to the browser console. - `enableFetchDiagnostics`: Enables fetch event diagnostics by pushing service-worker fetch event logs to the browser console. -- `disableHashlessAssetsUpdate`: Disables the update of the hash-less assets. By default, the Bswup tries to automatically update all of the hash-less assets (e.g. the external assets) every time an update found for the app. +- `disableHashlessAssetsUpdate`: Disables the update of hash-less assets. By default, Bswup automatically updates all hash-less assets (e.g. the external assets) every time an update is found for the app. - `forcePrerender`: Forces the prerendering of the default document for every navigation request to ensure that the server always has the latest version of the app. This is useful when you have a server-rendered app and you want to make sure that the client always has the latest version of the app. - `enableCacheControl`: Enables the cache-control mechanism by providing cache busting setting and header to each request (`cache:no-store` settings and `cache-control:no-cache` header). +- `cacheVersion`: Overrides the value used to name the cache storage bucket (`bit-bswup - `). By default this tracks Blazor's `assetsManifest.version` (a hash over the published assets), which means the cache is rotated automatically whenever any asset hash changes - and *only* then. Set `cacheVersion` to take manual control: pin it to a stable string so noisy dev rebuilds that perturb asset hashes don't needlessly evict the whole cache (runtime `.dll`/`.wasm` included), or bump it to force a refresh when a meaningful change lives outside Blazor's asset manifest. Only the cache bucket name (`CACHE_NAME`) is affected. Per-asset cache busting (`?v=`) is set in `createNewAssetRequest()` from each asset's `asset.hash` (falling back to `assetsManifest.version`), and Subresource Integrity uses `asset.hash` when integrity checking is enabled. When unset (or not a non-empty string) it falls back to the manifest version. Tip: feed it a build-stamped value (commit SHA, build timestamp, or your app's informational version) so it bumps automatically per publish. - `mode`: Determines the mode of the Bswup. Possible values are: - `NoPrerender`: Disables the prerendering of the default document for every navigation request. - `InitialPrerender`: Enables the prerendering of the default document only for the initial navigation request. - `AlwaysPrerender`: Enables the prerendering of the default document for every navigation request. - - `FullOffline`: Enables the full offline mode where all assets are cached and served from the cache from first time the app is loaded. \ No newline at end of file + - `FullOffline`: Enables the full offline mode where all assets are cached and served from the cache from the first time the app is loaded. + +## JavaScript API + +Bswup exposes a small global `BitBswup` object on the page so you can drive the update lifecycle from your own code (a "check for updates" button, a custom poller, a "reset app" action, etc.): + +- `BitBswup.checkForUpdate()`: Asks the browser to re-fetch the service-worker script and check for a new version. If a new version is found, the normal update flow runs (`updateFound` -> `stateChanged` -> `updateReady`/`downloadFinished`). If the app is already on the latest version, Bswup raises the `updateNotFound` event so you can stop a spinner or show an "up to date" message. If the check itself fails for a transient reason (offline, server hiccup, a throttled background tab), Bswup raises the non-blocking `updateCheckFailed` event instead of the install-path `error` event, so the default progress handler does **not** hide the app or show the install-failed UI; the payload still carries `reason`/`message` so you can surface it yourself. This is the registration-aware version that powers the built-in polling; it is safe to call as often as you like. +- `BitBswup.skipWaiting()`: If an update has finished downloading and is waiting, this activates it immediately (equivalent to calling the `reload` callback you receive in `updateReady`/`downloadFinished`). Returns `true` when there was a waiting worker to activate, otherwise `false`. +- `BitBswup.forceRefresh(cacheFilter?)`: Clears caches, unregisters the service worker controlling the current page, and reloads. Use this as a last-resort "reset" when a client gets into a bad state. It only removes this app's own registration (the one whose scope controls the current page, via `navigator.serviceWorker.getRegistration()`), not every same-origin service worker - so other apps or sub-apps mounted under different scopes on the same origin are left untouched. By default it clears only the caches Bswup and Blazor own (names starting with `bit-bswup` or `blazor-resources`), leaving app-owned CacheStorage buckets - Workbox add-ons, offline app-data, cached API responses - intact, since those can hold data with no other copy. To change what gets cleared, pass an optional `cacheFilter`: a string (prefix match against the cache name, e.g. `'bit-bswup'`), a `RegExp` (tested against the cache name), or a predicate function `(key) => boolean` that returns `true` for caches to delete. Pass `() => true` to wipe **every** cache on the origin: + +```js +BitBswup.forceRefresh(); // Bswup + Blazor caches (default) +BitBswup.forceRefresh(() => true); // every cache on the origin +BitBswup.forceRefresh('bit-bswup'); // only Bswup's own caches +BitBswup.forceRefresh(/^(bit-bswup|my-app-data)/) // a specific set +``` + +### Polling for updates + +By default a service worker is only re-checked by the browser on navigation and roughly every 24 hours, so a tab that stays open for a long time can keep running an old version. There are two ways to check more often: + +1. Set `updateInterval` (and/or `updateOnVisibility`) on the script tag for built-in polling (see the options above). This is the simplest approach and requires no extra code. +2. Call `BitBswup.checkForUpdate()` yourself, for example from a timer or after a user action. + +```js +// check every hour from your own code (equivalent to updateInterval="3600") +setInterval(() => BitBswup.checkForUpdate(), 60 * 60 * 1000); + +// or check whenever the user clicks a button, and react to the result +document.getElementById('check-updates').onclick = () => BitBswup.checkForUpdate(); +``` + +Either way, the result surfaces through your `bitBswupHandler`: a found update flows through `updateFound`/`updateReady`, "nothing new" flows through `updateNotFound`, and a transient check failure flows through `updateCheckFailed` (handle it the same way as the other events, e.g. stop your spinner and optionally show a "couldn't check right now" hint - the app keeps running on the current version): + +```js +window.bitBswupHandler = (message, data) => { + switch (message) { + case 'UPDATE_NOT_FOUND': /* already up to date - stop the spinner */ break; + case 'UPDATE_CHECK_FAILED': /* transient failure - keep running, optionally notify */ break; + // updateFound / stateChanged / updateReady / downloadFinished drive the update UI + } +}; +``` + +> Built-in polling skips checks while the tab is in the background (the browser throttles +> those timers anyway) and catches up automatically when the tab becomes visible again. diff --git a/src/Bswup/Tests/bit-bswup-js/.gitignore b/src/Bswup/Tests/bit-bswup-js/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/src/Bswup/Tests/bit-bswup-js/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/src/Bswup/Tests/bit-bswup-js/harness.js b/src/Bswup/Tests/bit-bswup-js/harness.js new file mode 100644 index 0000000000..3e99cf967d --- /dev/null +++ b/src/Bswup/Tests/bit-bswup-js/harness.js @@ -0,0 +1,342 @@ +// Test harness for the Bswup browser bundles. +// +// Bmotion's JS tests can `import` the functions they exercise because bit-bmotion.js is an ES +// module. Bswup's bundles cannot be imported: bit-bswup.js and bit-bswup.progress.js are classic +// scripts that wire up listeners the moment they are parsed, and bit-bswup.sw.js is a service +// worker that reads globals (`self.assetsManifest`, `caches`, `fetch`) at module-evaluation +// time. So instead of importing, we build a fake browser/worker global object, run the real +// shipped bundle inside a `vm` context, and drive it through the same entry points a browser +// would - the captured `install`/`fetch`/`message` listeners, or `window.BitBswup.*`. +// +// Two consequences worth knowing before adding tests: +// 1. Tests run against wwwroot/*.js, i.e. the built output, not the TypeScript. Run a +// `dotnet build` of Bit.Bswup first (CI does). This is deliberate: it is the artifact that +// ships in the NuGet package, so it also catches build-step regressions - for example the +// minifier re-introducing syntax that tsc had downleveled. +// 2. `instanceof` is realm-scoped, and the worker branches on exactly that - `p instanceof +// RegExp` in prepareRegExpArray, `value instanceof Array` in prepareRegExpArray / +// prepareExternalAssetsArray. A RegExp or Array built out here fails those checks inside +// the sandbox, and the failure is SILENT: a host-realm array is treated as a single +// unrecognized entry and dropped, so the config just appears to do nothing. Always build +// config values with the context's own `regex()` / `array()` helpers, or assign them via +// `configure()`, which marshals arrays for you. (`Array.isArray`, which the worker uses for +// the manifest, is realm-safe - only `instanceof` is affected.) + +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const WWWROOT = path.resolve(HERE, '../../Bit.Bswup/wwwroot'); + +export const ORIGIN = 'https://app.example.com'; + +export function readBundle(name) { + const file = path.join(WWWROOT, name); + if (!fs.existsSync(file)) { + throw new Error( + `Missing ${file}.\nBuild the package first: dotnet build ../../Bit.Bswup/Bit.Bswup.csproj` + ); + } + return fs.readFileSync(file, 'utf8'); +} + +// --------------------------------------------------------------------------- +// Minimal Request/Response/CacheStorage +// --------------------------------------------------------------------------- + +class FakeRequest { + constructor(url, init = {}) { + this.url = new URL(String(url), ORIGIN + '/').toString(); + this.method = init.method || 'GET'; + this.mode = init.mode || 'cors'; + this.integrity = init.integrity; + this.cache = init.cache; + this.headers = init.headers; + } +} + +class FakeResponse { + constructor(body, init = {}) { + this.body = body; + this.status = init.status === undefined ? 200 : init.status; + this.statusText = init.statusText || ''; + this.headers = init.headers || {}; + this.ok = this.status >= 200 && this.status < 300; + } + clone() { + const copy = new FakeResponse(this.body, { status: this.status, statusText: this.statusText, headers: this.headers }); + copy.cloned = true; + return copy; + } +} + +class FakeCache { + constructor() { this.entries = new Map(); } + async match(url) { return this.entries.get(String(url && url.url ? url.url : url)); } + async put(url, response) { this.entries.set(String(url && url.url ? url.url : url), response); } + async delete(url) { return this.entries.delete(String(url && url.url ? url.url : url)); } + // The Cache API returns Request objects from keys(); the worker only reads `.url`. + async keys() { return [...this.entries.keys()].map(url => ({ url })); } +} + +class FakeCacheStorage { + constructor() { this.buckets = new Map(); } + async open(name) { + if (!this.buckets.has(name)) this.buckets.set(name, new FakeCache()); + return this.buckets.get(name); + } + async keys() { return [...this.buckets.keys()]; } + async delete(name) { return this.buckets.delete(name); } + // test helper + snapshot() { + const out = {}; + for (const [name, cache] of this.buckets) out[name] = [...cache.entries.keys()].sort(); + return out; + } +} + +// --------------------------------------------------------------------------- +// Service worker context +// --------------------------------------------------------------------------- + +/** + * Builds a service-worker-like global, but does NOT run the bundle yet - configuration + * (`self.assetsManifest`, `self.mode`, ...) must be assigned first, because bit-bswup.sw.js + * reads it during module evaluation. Call `load()` when the config is in place. + */ +export function createServiceWorkerContext({ fetchHandler } = {}) { + const clients = []; + const posted = []; // everything the worker broadcast to clients + const fetchLog = []; + const handlers = {}; + const caches = new FakeCacheStorage(); + + const addClient = (id = `client-${clients.length + 1}`) => { + const client = { id, type: 'window', url: ORIGIN + '/', postMessage: m => posted.push(m) }; + clients.push(client); + return client; + }; + + const sandbox = { + console: { log() { }, info() { }, warn() { }, error() { }, group() { }, groupEnd() { } }, + setTimeout: (fn, ms) => setTimeout(fn, Math.min(ms || 0, 5)), // keep retry backoff fast + clearTimeout, + URL, URLSearchParams, + Request: FakeRequest, + Response: FakeResponse, + caches, + fetch: async (req) => { + const url = typeof req === 'string' ? req : req.url; + fetchLog.push(url); + if (fetchHandler) return fetchHandler(url, req); + return new FakeResponse('ok', { status: 200 }); + }, + }; + sandbox.self = sandbox; // a worker's global IS `self` + sandbox.globalThis = sandbox; + + sandbox.location = { origin: ORIGIN, href: ORIGIN + '/' }; + sandbox.importScripts = () => { }; // tests assign self.assetsManifest directly + sandbox.addEventListener = (type, fn) => { handlers[type] = fn; }; + sandbox.skipWaiting = async () => { }; + sandbox.clients = { + claim: async () => { }, + matchAll: async () => clients.slice(), + }; + + vm.createContext(sandbox); + + const api = { + self: sandbox, + handlers, + caches, + clients, + posted, + fetchLog, + addClient, + /** Build a RegExp with the sandbox's own intrinsic so `instanceof RegExp` holds inside. */ + regex: (source, flags = '') => + vm.runInContext(`new RegExp(${JSON.stringify(source)}, ${JSON.stringify(flags)})`, sandbox), + /** Build an Array with the sandbox's own intrinsic so `instanceof Array` holds inside. */ + array(items = []) { + const arr = vm.runInContext('[]', sandbox); + for (const item of items) arr.push(item); + return arr; + }, + /** Assign worker config onto `self`, marshalling arrays into the sandbox realm. */ + configure(config) { + for (const [key, value] of Object.entries(config)) { + sandbox[key] = Array.isArray(value) ? api.array(value) : value; + } + return api; + }, + /** + * Let background work finish. Under the default 'lax' tolerance handleInstall does NOT + * await createAssetsCache - install completes immediately and the cache fills in the + * background - so progress/error messages land after the install promise resolves. + * Also drains the (clamped) retry backoff timers. + */ + async settle() { + for (let i = 0; i < 8; i++) await new Promise(r => setTimeout(r, 1)); + }, + /** Run the real bundle. Everything above must already be configured. */ + load() { + vm.runInContext(readBundle('bit-bswup.sw.js'), sandbox); + return api; + }, + /** Read a top-level function declared by the bundle (they become globals in a script). */ + fn(name) { + const f = sandbox[name]; + if (typeof f !== 'function') throw new Error(`bit-bswup.sw.js does not expose ${name}()`); + return f; + }, + /** Decoded status messages the worker broadcast (plain string commands are kept as-is). */ + messages() { + return posted.map(m => { try { return JSON.parse(m); } catch { return m; } }); + }, + messagesOfType(type) { + return api.messages().filter(m => m && m.type === type); + }, + /** Drive a lifecycle event and await whatever it passed to waitUntil/respondWith. */ + async fire(type, event = {}) { + const handler = handlers[type]; + if (!handler) throw new Error(`no '${type}' listener registered`); + let awaited; + const e = { ...event, waitUntil: p => { awaited = p; }, respondWith: p => { awaited = p; } }; + handler(e); + return awaited; + }, + }; + return api; +} + +// --------------------------------------------------------------------------- +// Page context (bit-bswup.js and/or bit-bswup.progress.js) +// --------------------------------------------------------------------------- + +function createElement(tag) { + const el = { + tagName: tag, + _attrs: {}, + children: [], + textContent: '', + style: { + _props: {}, + setProperty(k, v) { this._props[k] = v; }, + getPropertyValue(k) { return this._props[k]; }, + }, + getAttribute(n) { return n in this._attrs ? this._attrs[n] : null; }, + setAttribute(n, v) { this._attrs[n] = String(v); }, + append(...nodes) { this.children.push(...nodes); }, + prepend(...nodes) { this.children.unshift(...nodes); }, + }; + return el; +} + +/** + * Builds a browser-like global for the page bundles. `elements` maps element id -> attributes, + * so a test can stand up just the parts of the splash markup it cares about. + */ +export function createPageContext({ elements = {}, appContainer = null, readyState = 'complete' } = {}) { + const byId = {}; + for (const [id, attrs] of Object.entries(elements)) { + byId[id] = createElement('div'); + for (const [k, v] of Object.entries(attrs || {})) byId[id].setAttribute(k, v); + } + + const listeners = {}; + const observers = []; + const scripts = []; + const reloads = { count: 0 }; + const registrations = []; + + const sandbox = { + console: { log() { }, info() { }, warn() { }, error() { }, debug() { } }, + setTimeout: (fn, ms) => setTimeout(fn, Math.min(ms || 0, 5)), + clearTimeout, setInterval: () => 0, clearInterval() { }, + URL, URLSearchParams, + MutationObserver: class { + constructor(cb) { this.cb = cb; this.observing = false; observers.push(this); } + observe() { this.observing = true; } + disconnect() { this.observing = false; } + }, + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + + sandbox.location = { origin: ORIGIN, href: ORIGIN + '/', reload: () => { reloads.count++; } }; + sandbox.document = { + readyState, + currentScript: null, + scripts, + documentElement: createElement('html'), + visibilityState: 'visible', + getElementById: id => byId[id] || null, + querySelector: sel => (appContainer && sel === appContainer.selector ? appContainer.el : null), + createElement, + addEventListener: (t, fn) => { (listeners[t] ||= []).push(fn); }, + }; + sandbox.addEventListener = (t, fn) => { (listeners[t] ||= []).push(fn); }; + sandbox.caches = new FakeCacheStorage(); + + vm.createContext(sandbox); + + const api = { + window: sandbox, + elements: byId, + listeners, + observers, + reloads, + registrations, + caches: sandbox.caches, + regex: (source, flags = '') => + vm.runInContext(`new RegExp(${JSON.stringify(source)}, ${JSON.stringify(flags)})`, sandbox), + /** Add an element after load, as an interactive Blazor render would. */ + addElement(id, attrs = {}) { + byId[id] = createElement('div'); + for (const [k, v] of Object.entries(attrs)) byId[id].setAttribute(k, v); + observers.filter(o => o.observing).forEach(o => o.cb()); + return byId[id]; + }, + /** Declare the