From 9d765ec8747d179b2fc4559f6e0ad0484ad3b1e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:25:46 +0000 Subject: [PATCH 1/8] feat(mobile): an app-shell service worker strategy with network-only API and push handlers getServiceWorkerSource gains strategy 'app-shell' (build-versioned cache, network-only API, network-first HTML entry, cache-first hashed assets) and notifications (push and notificationclick, in-app routes only). The default 'offline-first' output is byte-identical. The Console's vite config emits the worker at the build root, named after the build. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt --- ui/apps/console/vite.config.ts | 68 ++++ ui/e2e/console-service-worker.spec.ts | 153 ++++++++ ui/packages/mobile/README.md | 32 ++ .../src/__tests__/serviceWorkerSource.test.ts | 367 ++++++++++++++++++ ui/packages/mobile/src/index.ts | 4 +- ui/packages/mobile/src/serviceWorker.ts | 15 + ui/packages/mobile/src/serviceWorkerSource.ts | 299 +++++++++++++- 7 files changed, 918 insertions(+), 20 deletions(-) create mode 100644 ui/e2e/console-service-worker.spec.ts create mode 100644 ui/packages/mobile/src/__tests__/serviceWorkerSource.test.ts diff --git a/ui/apps/console/vite.config.ts b/ui/apps/console/vite.config.ts index b4e573aa46e..760050c271b 100644 --- a/ui/apps/console/vite.config.ts +++ b/ui/apps/console/vite.config.ts @@ -9,6 +9,7 @@ import path from 'path'; import fs from 'fs'; import zlib from 'node:zlib'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; // Relative specifiers carry their real file extension, and `__dirname` is // spelled `import.meta.dirname` throughout this file, so the config stays // loadable by Vite's `configLoader: 'native'` — which imports this file with @@ -27,6 +28,7 @@ import { } from '../../scripts/vite-objectstack-workspace.ts'; import { viteDeclaredLazyViews } from '../../scripts/vite-declared-lazy-views.ts'; import { viteTypesZodLazy } from '../../scripts/vite-types-zod-lazy.ts'; +import { getServiceWorkerSource } from '../../packages/mobile/src/serviceWorkerSource.ts'; import { compression } from 'vite-plugin-compression2'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -744,6 +746,70 @@ const LAZY_ONLY_INITIAL_ICONS: ReadonlySet = new Set([ 'webhook', ]); +/** + * Emits the Console's Service Worker, `service-worker.js`, at the root of the + * build beside `index.html` (objectstack#29155). Served from the base path, it + * is scoped to the Console (`/_console/`), never the site root. + * + * It is `@object-ui/mobile`'s `'app-shell'` worker: API requests network-only, + * the HTML entry network-first, hashed assets cache-first, push and + * notification-click handlers. Its cache is named after this build, so the + * next deploy's worker deletes this one's cache when it activates. + * + * Precache: the entry chunk, its static-import closure and their CSS (Vite's + * emitted entry assets). Lazy chunks are cached on fetch, and the page posts + * the files it loaded before the worker controlled it. + * + * Build identity: the console build name (`VITE_APP_VERSION` or the git sha) + * plus a digest of every emitted file name and the HTML entry. Hashed names + * change with their bytes, so any changed chunk gives a new worker. + */ +function emitServiceWorker(buildName: string): Plugin { + return { + name: 'emit-console-service-worker', + enforce: 'post', + generateBundle(_options, bundle) { + const fileNames = Object.keys(bundle).sort(); + const digest = createHash('sha256'); + for (const fileName of fileNames) { + digest.update(fileName).update('\0'); + const output = bundle[fileName]; + if (output.type === 'asset' && fileName.endsWith('.html')) digest.update(output.source); + } + const build = `${buildName || 'local'}-${digest.digest('hex').slice(0, 12)}`; + + const chunks = new Map(); + for (const output of Object.values(bundle)) { + if (output.type === 'chunk') chunks.set(output.fileName, output); + } + const precache = new Set(); + const queue = [...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName); + if (queue.length === 0) this.error('[emit-console-service-worker] the bundle has no entry chunk to precache.'); + while (queue.length > 0) { + const chunk = chunks.get(queue.pop() as string); + // `imports` also lists unresolved bare specifiers; only real chunks count. + if (!chunk || precache.has(chunk.fileName)) continue; + precache.add(chunk.fileName); + for (const css of chunk.viteMetadata?.importedCss ?? []) precache.add(css); + queue.push(...chunk.imports); + } + + this.emitFile({ + type: 'asset', + fileName: 'service-worker.js', + source: getServiceWorkerSource({ + strategy: 'app-shell', + build, + cacheName: 'objectui-console', + precache: [...precache].sort().map((fileName) => `./${fileName}`), + apiPrefix: '/api/', + notifications: true, + }), + }); + }, + }; +} + // https://vitejs.dev/config/ export default defineConfig({ base: basePath, @@ -810,6 +876,8 @@ export default defineConfig({ // imports) must be copied into assets/ or every map page 404s // (objectui#3297). Asserts on drift; never silently skips. viteMaplibreWorker(), + // The Console's Service Worker (objectstack#29155), named after this build. + emitServiceWorker(consoleBuild), // Dev-only plugin: serve runtime assets (product logo/favicon) from the // host project's runtime/assets directory so branding URLs configured // via OS_LOGO_URL / OS_FAVICON_URL resolve without a fragile symlink. diff --git a/ui/e2e/console-service-worker.spec.ts b/ui/e2e/console-service-worker.spec.ts new file mode 100644 index 00000000000..22e9c5de2bf --- /dev/null +++ b/ui/e2e/console-service-worker.spec.ts @@ -0,0 +1,153 @@ +import { test, expect, type Page } from '@playwright/test'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { CONSOLE_BASE, waitForReactMount } from './helpers'; + +/** + * objectstack#29155 — the Console's Service Worker, on the production build. + * + * 1. After one load, an offline reload shows the Console shell (the document + * comes from the worker), and data calls fail visibly as offline: the + * sign-in screen's "Cannot connect to server" panel, and a direct API + * request rejects instead of answering from a cache. + * 2. A new build is picked up within one navigation: a server that switches + * to a "next" build (new document, new worker build id) serves that + * document on the next navigation, and the new worker deletes the old + * build's cache when it activates. + * + * Chromium only: `context.setOffline` and Cache Storage are measured there, + * and the CI lane runs `--project=chromium`. + */ + +const CACHE_PREFIX = 'objectui-console::'; + +/** Waits until the worker controls the page and holds every console file the page loaded. */ +async function waitForShellCached(page: Page) { + await page.waitForFunction(() => navigator.serviceWorker?.controller != null, undefined, { timeout: 30_000 }); + await page.waitForFunction( + async ({ prefix, base }) => { + const loaded = performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .filter((url) => new URL(url).pathname.startsWith(`${base}/assets/`)); + const name = (await caches.keys()).find((key) => key.startsWith(prefix)); + if (!name || loaded.length === 0) return false; + const cache = await caches.open(name); + for (const url of loaded) if (!(await cache.match(url))) return false; + return true; + }, + { prefix: CACHE_PREFIX, base: CONSOLE_BASE }, + { timeout: 30_000, polling: 250 }, + ); +} + +test.describe('Console service worker', () => { + test.skip(({ browserName }) => browserName !== 'chromium', 'measured on chromium, the CI E2E project'); + + test('after one load, an offline reload shows the shell and data calls fail as offline', async ({ + page, + context, + }) => { + await page.goto(`${CONSOLE_BASE}/`); + await waitForReactMount(page); + await waitForShellCached(page); + + await context.setOffline(true); + const document = await page.reload(); + expect(document?.fromServiceWorker(), 'the offline document is the worker-cached shell').toBe(true); + await waitForReactMount(page); + + // The shell renders; its data read fails and says so. + await expect(page.getByTestId('auth-server-unreachable')).toBeVisible({ timeout: 30_000 }); + + // The API is network-only: offline, a data call rejects instead of + // answering from the worker's cache. + const apiAnswer = await page.evaluate(() => + fetch('/api/v1/auth/get-session').then( + (res) => `answered ${res.status}`, + (error: Error) => error.name, + ), + ); + expect(apiAnswer).toBe('TypeError'); + const cachedApi = await page.evaluate(async (prefix) => { + const hits: string[] = []; + for (const key of await caches.keys()) { + if (!key.startsWith(prefix)) continue; + for (const req of await (await caches.open(key)).keys()) { + if (new URL(req.url).pathname.startsWith('/api/')) hits.push(req.url); + } + } + return hits; + }, CACHE_PREFIX); + expect(cachedApi, 'the worker cached an API response').toEqual([]); + + await context.setOffline(false); + }); + + test('a new build is picked up within one navigation, and its worker drops the old cache', async ({ page }) => { + const dist = path.resolve(import.meta.dirname, '../apps/console/dist'); + const index = fs.readFileSync(path.join(dist, 'index.html'), 'utf8'); + const worker = fs.readFileSync(path.join(dist, 'service-worker.js'), 'utf8'); + const build = /const BUILD = "([^"]+)";/.exec(worker)?.[1]; + expect(build, 'the emitted worker names its build').toBeTruthy(); + + // A server for the same dist that can switch to a "next" build: a new + // document and a worker with a new build id, the two things a deploy + // changes for the browser. + let next = false; + const server = http.createServer((req, res) => { + const pathname = new URL(req.url ?? '/', 'http://localhost').pathname; + if (!pathname.startsWith(`${CONSOLE_BASE}/`)) { + res.writeHead(404).end(); + return; + } + const rel = pathname.slice(CONSOLE_BASE.length + 1); + if (rel === 'service-worker.js') { + const body = next ? worker.replace(`const BUILD = "${build}";`, `const BUILD = "${build}-next";`) : worker; + res.writeHead(200, { 'content-type': 'text/javascript', 'cache-control': 'no-cache' }).end(body); + return; + } + const file = path.join(dist, rel); + if (rel && file.startsWith(dist) && fs.existsSync(file) && fs.statSync(file).isFile()) { + const type = file.endsWith('.js') ? 'text/javascript' : file.endsWith('.css') ? 'text/css' : 'application/octet-stream'; + res.writeHead(200, { 'content-type': type }).end(fs.readFileSync(file)); + return; + } + if (rel.startsWith('assets/')) { + res.writeHead(404).end(); + return; + } + const html = next ? index.replace('', '') : index; + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + try { + await page.goto(`${origin}${CONSOLE_BASE}/`); + await waitForReactMount(page); + await waitForShellCached(page); + expect(await page.evaluate(() => caches.keys())).toContain(`${CACHE_PREFIX}${CONSOLE_BASE}/::${build}`); + + // Deploy. + next = true; + await page.goto(`${origin}${CONSOLE_BASE}/login`); + await expect(page.locator('meta[name="e2e-build"]')).toHaveAttribute('content', 'next'); + await waitForReactMount(page); + + // The new worker installs, takes over and deletes the old build's cache. + await page.waitForFunction( + async ({ prefix, oldBuild }) => { + const keys = (await caches.keys()).filter((key) => key.startsWith(prefix)); + return keys.length === 1 && keys[0].endsWith(`::${oldBuild}-next`); + }, + { prefix: CACHE_PREFIX, oldBuild: build }, + { timeout: 30_000, polling: 250 }, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/ui/packages/mobile/README.md b/ui/packages/mobile/README.md index ed5575ba41d..78dadbec076 100644 --- a/ui/packages/mobile/README.md +++ b/ui/packages/mobile/README.md @@ -275,6 +275,38 @@ export const manifest = generatePWAManifest({ void registerServiceWorker({ url: '/service-worker.js' }); ``` +`getServiceWorkerSource(options)` writes the worker's source for a build step to +emit. It has two strategies: + +- `'offline-first'` (the default): a cache-first shell, network-first API reads with + a cached fallback, and the `REPLAY_QUEUE` broadcast on Background Sync. Version it + by `cacheName`. +- `'app-shell'`: a build-versioned shell that never holds data. Serve the worker from + the app's base directory so its scope is the app (`/_console/`, not `/`), and pass + the build's identity as `build` (required). Requests under `apiPrefix` and every + request outside the scope are network-only (no cache read, no cache write, no + stored cookie). Navigations are network-first with the precached HTML entry as the + offline fallback, so the next navigation after a deploy runs the new build. Hashed + files under `assets/` are cache-first. Activation deletes the scope's caches of + every other build. After registering, `cacheLoadedResources(registration.active)` + caches the files the page loaded before the worker controlled it. + +`notifications: true` adds `push` and `notificationclick` handlers. A push payload is +JSON `{ title, body?, route?, tag? }`; a click focuses or opens an app window at +`route`, an in-app path relative to the scope. A route that is not an in-app path, or +that resolves outside the scope, opens nothing. The worker does not subscribe to push. + +```ts +import { getServiceWorkerSource } from '@object-ui/mobile'; + +const source = getServiceWorkerSource({ + strategy: 'app-shell', + build: process.env.BUILD_ID, + precache: ['./assets/index-AbC12345.js', './assets/index-XyZ98765.css'], + notifications: true, +}); +``` + ## Links - 📦 [npm package](https://www.npmjs.com/package/@object-ui/mobile) diff --git a/ui/packages/mobile/src/__tests__/serviceWorkerSource.test.ts b/ui/packages/mobile/src/__tests__/serviceWorkerSource.test.ts new file mode 100644 index 00000000000..f6b1d7a9ada --- /dev/null +++ b/ui/packages/mobile/src/__tests__/serviceWorkerSource.test.ts @@ -0,0 +1,367 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#29155 — the generated `'app-shell'` worker, run against a fake + * worker global: cache versioning by build, the API-bypass rule, the HTML + * entry's offline fallback, and the `push` / `notificationclick` handlers. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { getServiceWorkerSource, SERVICE_WORKER_CACHE_URLS_MESSAGE } from '../serviceWorkerSource'; + +const ORIGIN = 'https://app.test'; +const SCOPE = `${ORIGIN}/_console/`; + +type Listener = (event: any) => void; +type NetworkAnswer = (url: string) => Response | Promise; + +interface FakeRequest { + url: string; + method: string; + mode: string; +} + +function keyOf(input: string | { url: string }): string { + return typeof input === 'string' ? new URL(input).href : input.url; +} + +function basic(body: string, init: ResponseInit = {}): Response { + const res = new Response(body, { status: 200, ...init }); + Object.defineProperty(res, 'type', { value: 'basic' }); + return res; +} + +/** A fake worker global running the generated source. */ +function bootWorker(source: string, options: { network?: NetworkAnswer; caches?: Record> } = {}) { + const listeners = new Map(); + const store = new Map>(); + for (const [name, entries] of Object.entries(options.caches ?? {})) { + store.set(name, new Map(Object.entries(entries).map(([url, body]) => [url, basic(body)]))); + } + + let online = true; + const network = options.network ?? ((url: string) => basic(`network:${url}`)); + const requested: string[] = []; + const fetchImpl = vi.fn(async (input: string | { url: string }) => { + const url = keyOf(input); + requested.push(url); + if (!online) throw new TypeError('Failed to fetch'); + return network(url); + }); + + const cacheFor = (name: string) => { + if (!store.has(name)) store.set(name, new Map()); + const entries = store.get(name)!; + return { + match: async (req: string | { url: string }) => entries.get(keyOf(req))?.clone(), + put: async (req: string | { url: string }, res: Response) => { + entries.set(keyOf(req), res.clone()); + }, + addAll: async (reqs: Array) => { + for (const req of reqs) { + const res = await fetchImpl(req); + if (!res.ok) throw new TypeError(`addAll: ${keyOf(req)} answered ${res.status}`); + entries.set(keyOf(req), res); + } + }, + }; + }; + const caches = { + open: async (name: string) => cacheFor(name), + keys: async () => [...store.keys()], + delete: async (name: string) => store.delete(name), + match: async (req: string | { url: string }, opts: { cacheName?: string } = {}) => { + const names = opts.cacheName ? [opts.cacheName] : [...store.keys()]; + for (const name of names) { + const hit = store.get(name)?.get(keyOf(req)); + if (hit) return hit.clone(); + } + return undefined; + }, + }; + + const windows: any[] = []; + const self = { + location: new URL(`${SCOPE}service-worker.js`), + registration: { scope: SCOPE, showNotification: vi.fn(async () => {}) }, + skipWaiting: vi.fn(async () => {}), + clients: { + claim: vi.fn(async () => {}), + matchAll: vi.fn(async () => windows), + openWindow: vi.fn(async () => null), + }, + addEventListener: (type: string, fn: Listener) => { + listeners.set(type, [...(listeners.get(type) ?? []), fn]); + }, + }; + + // eslint-disable-next-line no-new-func + new Function('self', 'caches', 'fetch', source)(self, caches, fetchImpl); + + async function dispatch(type: string, extra: Record = {}) { + const pending: Promise[] = []; + let response: Promise | undefined; + const event = { + ...extra, + waitUntil: (p: Promise) => pending.push(p), + respondWith: (p: Promise) => { + response = p; + }, + }; + for (const fn of listeners.get(type) ?? []) fn(event); + const answer = response ? await response.catch((error: unknown) => error) : undefined; + await Promise.all(pending); + // A waitUntil registered while the answer resolved. + await Promise.all(pending); + return { answered: response !== undefined, answer, event }; + } + + return { + self, + store, + windows, + fetchImpl, + requested, + setOnline: (value: boolean) => { + online = value; + }, + install: () => dispatch('install'), + activate: () => dispatch('activate'), + fetch: (url: string, init: Partial = {}) => + dispatch('fetch', { request: { url: new URL(url, SCOPE).href, method: 'GET', mode: 'cors', ...init } }), + message: (data: unknown) => dispatch('message', { data }), + push: (data: unknown) => + dispatch('push', { + data: + data === undefined + ? null + : { json: () => (typeof data === 'string' ? JSON.parse(data) : data), text: () => String(data) }, + }), + click: (route: unknown) => { + const notification = { data: { route }, close: vi.fn() }; + return dispatch('notificationclick', { notification }).then((r) => ({ ...r, notification })); + }, + }; +} + +function appShell(build: string, extra: Record = {}) { + return getServiceWorkerSource({ + strategy: 'app-shell', + build, + cacheName: 'objectui-console', + precache: ['./assets/index-AbC12345.js', './assets/index-XyZ98765.css'], + notifications: true, + ...extra, + }); +} + +const cacheOf = (build: string) => `objectui-console::/_console/::${build}`; + +describe('getServiceWorkerSource — options contract', () => { + it("keeps the default worker as it was: API network-first with a cached fallback, no push handlers", () => { + const source = getServiceWorkerSource(); + expect(source).toContain('const CACHE = "objectui-shell-v1";'); + expect(source).toContain('// Network-first for API'); + expect(source).not.toContain("addEventListener('push'"); + expect(source).not.toContain("addEventListener('notificationclick'"); + }); + + it("refuses 'app-shell' without a build, a build under 'offline-first', and an unknown strategy", () => { + expect(() => getServiceWorkerSource({ strategy: 'app-shell' })).toThrow(/needs `build`/); + expect(() => getServiceWorkerSource({ strategy: 'app-shell', build: ' ' })).toThrow(/needs `build`/); + expect(() => getServiceWorkerSource({ build: 'b1' })).toThrow(/read only by the 'app-shell' strategy/); + expect(() => getServiceWorkerSource({ strategy: 'other' as never, build: 'b1' })).toThrow(/unknown strategy/); + }); + + it('emits source that parses', () => { + expect(() => new Function(appShell('b1'))).not.toThrow(); + expect(() => new Function(getServiceWorkerSource({ notifications: true }))).not.toThrow(); + }); +}); + +describe("'app-shell' worker — cache versioning by build", () => { + it('precaches the HTML entry and the entry assets into a cache named after the scope and the build', async () => { + const sw = bootWorker(appShell('b1')); + await sw.install(); + expect([...sw.store.keys()]).toEqual([cacheOf('b1')]); + expect([...sw.store.get(cacheOf('b1'))!.keys()].sort()).toEqual([ + SCOPE, + `${SCOPE}assets/index-AbC12345.js`, + `${SCOPE}assets/index-XyZ98765.css`, + ]); + expect(sw.self.skipWaiting).toHaveBeenCalledTimes(1); + }); + + it("activation deletes every older cache of this app and nothing else, then claims the app's windows", async () => { + const sw = bootWorker(appShell('b2'), { + caches: { + [cacheOf('b1')]: { [`${SCOPE}assets/old-Aa111111.js`]: 'old' }, + [cacheOf('b0')]: { [SCOPE]: 'older' }, + 'objectui-console::/other-app/::b1': { [`${ORIGIN}/other-app/`]: 'other app' }, + 'someone-elses-cache': { [`${ORIGIN}/x`]: 'x' }, + }, + }); + await sw.install(); + await sw.activate(); + expect([...sw.store.keys()].sort()).toEqual( + [cacheOf('b2'), 'objectui-console::/other-app/::b1', 'someone-elses-cache'].sort(), + ); + expect(sw.self.clients.claim).toHaveBeenCalledTimes(1); + }); +}); + +describe("'app-shell' worker — the API bypass rule", () => { + it('never answers, reads or writes an API request, even with that URL in its cache', async () => { + const api = `${ORIGIN}/api/v1/data/account`; + const sw = bootWorker(appShell('b1'), { caches: { [cacheOf('b1')]: { [api]: 'stale private data' } } }); + for (const mode of ['cors', 'navigate', 'same-origin']) { + const { answered } = await sw.fetch(api, { mode }); + expect(answered, `mode ${mode}`).toBe(false); + } + // A cross-origin API server is bypassed by both rules. + expect((await sw.fetch('https://api.example.com/api/v1/auth/session')).answered).toBe(false); + expect([...sw.store.get(cacheOf('b1'))!.keys()]).toEqual([api]); + expect(sw.fetchImpl).not.toHaveBeenCalled(); + }); + + it('leaves non-GET requests, other origins and paths outside its scope to the network', async () => { + const sw = bootWorker(appShell('b1')); + expect((await sw.fetch(`${SCOPE}assets/a-Bb222222.js`, { method: 'POST' })).answered).toBe(false); + expect((await sw.fetch('https://tiles.example.com/1/2/3.png')).answered).toBe(false); + expect((await sw.fetch(`${ORIGIN}/forms/abc`, { mode: 'navigate' })).answered).toBe(false); + expect(sw.fetchImpl).not.toHaveBeenCalled(); + expect(sw.store.size).toBe(0); + }); + + it('honours a custom API prefix', async () => { + const sw = bootWorker(appShell('b1', { apiPrefix: '/_console/rpc/' })); + expect((await sw.fetch(`${SCOPE}rpc/call`)).answered).toBe(false); + }); +}); + +describe("'app-shell' worker — HTML entry and assets", () => { + it('serves navigations from the network while online, and the cached entry offline', async () => { + let html = 'build b1'; + const sw = bootWorker(appShell('b1'), { + network: (url) => basic(url === SCOPE || url.startsWith(`${SCOPE}apps/`) ? html : `asset ${url}`), + }); + await sw.install(); + + // A deploy changes the document: the next navigation runs it, no stale shell. + html = 'build b2'; + const online = await sw.fetch(`${SCOPE}apps/crm`, { mode: 'navigate' }); + expect(await (online.answer as Response).text()).toBe('build b2'); + + sw.setOnline(false); + const offline = await sw.fetch(`${SCOPE}apps/crm/account`, { mode: 'navigate' }); + expect(await (offline.answer as Response).text()).toBe('build b1'); + }); + + it('a navigation offline with no cached entry fails as a network error', async () => { + const sw = bootWorker(appShell('b1')); + sw.setOnline(false); + const { answered, answer } = await sw.fetch(SCOPE, { mode: 'navigate' }); + expect(answered).toBe(true); + expect(answer).toBeInstanceOf(TypeError); + }); + + it('answers hashed assets cache-first and caches them on fetch', async () => { + const sw = bootWorker(appShell('b1')); + const chunk = `${SCOPE}assets/route-login-Cc333333.js`; + const first = await sw.fetch(chunk); + expect(await (first.answer as Response).text()).toBe(`network:${chunk}`); + sw.setOnline(false); + const second = await sw.fetch(chunk); + expect(await (second.answer as Response).text()).toBe(`network:${chunk}`); + expect(sw.requested.filter((u) => u === chunk)).toHaveLength(1); + }); + + it('answers other files in scope network-first, with the cached copy offline', async () => { + let logo = 'logo v1'; + const sw = bootWorker(appShell('b1'), { network: () => basic(logo) }); + await sw.fetch(`${SCOPE}favicon.svg`); + logo = 'logo v2'; + expect(await ((await sw.fetch(`${SCOPE}favicon.svg`)).answer as Response).text()).toBe('logo v2'); + sw.setOnline(false); + expect(await ((await sw.fetch(`${SCOPE}favicon.svg`)).answer as Response).text()).toBe('logo v2'); + }); + + it('caches only the in-scope, non-API files a page posts', async () => { + const sw = bootWorker(appShell('b1')); + await sw.message({ + type: SERVICE_WORKER_CACHE_URLS_MESSAGE, + urls: [ + `${SCOPE}assets/lazy-Dd444444.js`, + `${ORIGIN}/api/v1/auth/get-session`, + 'https://cdn.example.com/font.woff2', + `${ORIGIN}/elsewhere.js`, + 42, + ], + }); + expect([...sw.store.get(cacheOf('b1'))!.keys()]).toEqual([`${SCOPE}assets/lazy-Dd444444.js`]); + }); +}); + +describe('notification handlers', () => { + it('shows a push payload with its route, and tolerates a missing or non-JSON payload', async () => { + const sw = bootWorker(appShell('b1')); + await sw.push({ title: 'Approval needed', body: 'Deal #42', route: '/apps/crm/deal/42', tag: 'deal-42' }); + expect(sw.self.registration.showNotification).toHaveBeenLastCalledWith('Approval needed', { + body: 'Deal #42', + tag: 'deal-42', + data: { route: '/apps/crm/deal/42' }, + }); + await sw.push(undefined); + expect(sw.self.registration.showNotification).toHaveBeenLastCalledWith('', { data: { route: null } }); + }); + + it('a click focuses an open app window at the route', async () => { + const sw = bootWorker(appShell('b1')); + const focused = { focus: vi.fn(async () => {}) }; + const win = { url: `${SCOPE}apps/crm`, navigate: vi.fn(async () => focused), focus: vi.fn() }; + sw.windows.push({ url: `${ORIGIN}/other/`, navigate: vi.fn(), focus: vi.fn() }, win); + const { notification } = await sw.click('/apps/crm/deal/42?tab=history'); + expect(notification.close).toHaveBeenCalled(); + expect(win.navigate).toHaveBeenCalledWith(`${SCOPE}apps/crm/deal/42?tab=history`); + expect(focused.focus).toHaveBeenCalled(); + expect(sw.self.clients.openWindow).not.toHaveBeenCalled(); + }); + + it('a click opens a window at the route when none is open, or when the open one is not controlled', async () => { + const sw = bootWorker(appShell('b1')); + await sw.click('/apps/crm/deal/42'); + expect(sw.self.clients.openWindow).toHaveBeenLastCalledWith(`${SCOPE}apps/crm/deal/42`); + + sw.windows.push({ url: SCOPE, navigate: vi.fn(async () => Promise.reject(new TypeError('not controlled'))) }); + await sw.click('/'); + expect(sw.self.clients.openWindow).toHaveBeenLastCalledWith(SCOPE); + + await sw.click(null); + expect(sw.self.clients.openWindow).toHaveBeenLastCalledWith(SCOPE); + }); + + it.each([ + ['climbs out of the scope', '/../api/v1/data/user'], + ['climbs out encoded', '/%2e%2e/%2e%2e/admin'], + ['is protocol-relative', '//evil.example.com/phish'], + ['is an absolute URL', 'https://evil.example.com/'], + ['has a backslash', '/\\evil.example.com'], + ['has a control character', '/apps\n/crm'], + ['is relative', 'apps/crm'], + ['is not a string', 42], + ])('refuses a route that %s: no window opens or moves', async (_label, route) => { + const sw = bootWorker(appShell('b1')); + const win = { url: SCOPE, navigate: vi.fn(async () => null), focus: vi.fn() }; + sw.windows.push(win); + const { notification } = await sw.click(route); + expect(notification.close).toHaveBeenCalled(); + expect(win.navigate).not.toHaveBeenCalled(); + expect(sw.self.clients.openWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/mobile/src/index.ts b/ui/packages/mobile/src/index.ts index 49ce561aa99..2c763db4b80 100644 --- a/ui/packages/mobile/src/index.ts +++ b/ui/packages/mobile/src/index.ts @@ -35,7 +35,7 @@ export { usePullToRefresh, type PullToRefreshOptions } from './usePullToRefresh. export { MobileProvider, type MobileProviderProps } from './MobileProvider.js'; export { ResponsiveContainer, type ResponsiveContainerProps } from './ResponsiveContainer.js'; export { generatePWAManifest } from './pwa.js'; -export { registerServiceWorker, type ServiceWorkerConfig } from './serviceWorker.js'; +export { registerServiceWorker, cacheLoadedResources, type ServiceWorkerConfig } from './serviceWorker.js'; export { createOfflineQueue, IndexedDbOfflineQueue, @@ -52,7 +52,9 @@ export { } from './createOfflineDataSource.js'; export { getServiceWorkerSource, + SERVICE_WORKER_CACHE_URLS_MESSAGE, type ServiceWorkerSourceOptions, + type ServiceWorkerStrategy, } from './serviceWorkerSource.js'; export { BREAKPOINTS } from './breakpoints.js'; diff --git a/ui/packages/mobile/src/serviceWorker.ts b/ui/packages/mobile/src/serviceWorker.ts index 704ebfa0644..08579f8e277 100644 --- a/ui/packages/mobile/src/serviceWorker.ts +++ b/ui/packages/mobile/src/serviceWorker.ts @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +import { SERVICE_WORKER_CACHE_URLS_MESSAGE } from './serviceWorkerSource.js'; + export interface ServiceWorkerConfig { /** URL of the service worker script */ url?: string; @@ -61,3 +63,16 @@ export async function registerServiceWorker( return null; } } + +/** + * Asks an `'app-shell'` worker (`getServiceWorkerSource({ strategy: 'app-shell' })`) + * to cache the files this page loaded before the worker controlled it, so the + * first visit's lazy chunks are there for an offline start. The worker keeps + * only same-origin files in its scope and never one under its API prefix. + * No-op without a worker or the Performance API. + */ +export function cacheLoadedResources(worker: ServiceWorker | null | undefined): void { + if (!worker || typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') return; + const urls = performance.getEntriesByType('resource').map((entry) => entry.name); + worker.postMessage({ type: SERVICE_WORKER_CACHE_URLS_MESSAGE, urls }); +} diff --git a/ui/packages/mobile/src/serviceWorkerSource.ts b/ui/packages/mobile/src/serviceWorkerSource.ts index 18a3cbe5320..3240af749ad 100644 --- a/ui/packages/mobile/src/serviceWorkerSource.ts +++ b/ui/packages/mobile/src/serviceWorkerSource.ts @@ -3,45 +3,140 @@ * Copyright (c) 2024-present ObjectStack Inc. * * Generates a Service Worker source string with offline support for - * ObjectUI apps. The output is intended to be written to a file at the - * web root (e.g. `/service-worker.js`) by the application's build - * pipeline; it is *not* shipped at runtime by the mobile package itself. + * ObjectUI apps. The output is intended to be written to a file by the + * application's build pipeline; it is *not* shipped at runtime by the + * mobile package itself. * - * The generated SW does three things: + * Two strategies: + * + * `'offline-first'` (the default, unchanged) does three things: * 1. Caches the application shell on install (`cache-first` strategy). * 2. Falls back to network-first for API calls, so reads are fresh * when online and degrade to cached responses when offline. * 3. Listens for `sync` events with tag `objectui-offline-queue` and * posts a `REPLAY_QUEUE` message to all clients so the running * pages can flush their queues from the foreground. + * + * `'app-shell'` is a build-versioned shell that never holds data + * (objectstack#29155). The worker must be served from the app's base + * directory, so its registration scope is the app itself: + * 1. Its cache is named after the scope and the build (`options.build`). + * Activation deletes every other cache of the same scope, so the previous + * build's chunks go when the new build takes over. + * 2. Requests under `apiPrefix`, and every request outside the scope, are + * network-only: the worker never answers them, never reads the cache for + * them and never writes them to it, so no data and no cookie is stored. + * 3. Navigations are network-first. Offline, they get the cached HTML entry + * (the scope root, precached on install), so a cold start shows the shell. + * 4. Hashed assets under `assets/` are cache-first; every other file + * in scope is network-first with a cached fallback. Both cache on fetch. + * 5. A page can post `{ type: 'objectui:cache-urls', urls }` to cache the + * in-scope files it loaded before the worker controlled it. + * 6. It installs with `skipWaiting()` and claims its clients on activation. + * With the entry network-first, the next navigation after a deploy runs + * the new build, and no shell can pin a user to an old one. + * + * `notifications: true` adds `push` and `notificationclick` handlers (either + * strategy). A push payload is JSON `{ title, body?, route?, tag? }`; `route` + * is an in-app path relative to the scope, the field the mobile shell reads. + * A click focuses (or opens) an app window at that route. A route that is not + * an in-app path, or that resolves outside the scope (`/../api/...`), is + * refused: the click only closes the notification. */ +export type ServiceWorkerStrategy = 'offline-first' | 'app-shell'; + export interface ServiceWorkerSourceOptions { - /** Cache name; bump to invalidate on deploy. */ + /** + * Cache name; bump to invalidate on deploy. Under `'app-shell'` it is the + * prefix of the cache name, which adds the scope and the build to it. + */ cacheName?: string; - /** Application shell URLs to pre-cache on install. */ + /** + * Application shell URLs to pre-cache on install. Under `'app-shell'` they + * resolve against the worker's own URL, and the scope root (`./`) is always + * added. + */ precache?: string[]; - /** URL prefix that identifies API requests (network-first). */ + /** URL path prefix that identifies API requests. */ apiPrefix?: string; /** Background-sync tag name. */ syncTag?: string; + /** Caching strategy; see the module notes. Default `'offline-first'`. */ + strategy?: ServiceWorkerStrategy; + /** + * The build's identity, which names the `'app-shell'` cache. Required by + * `'app-shell'` and refused by `'offline-first'`, which does not read it. + */ + build?: string; + /** Add the `push` and `notificationclick` handlers. Default `false`. */ + notifications?: boolean; } +/** The message a page posts to an `'app-shell'` worker to cache files it already loaded. */ +export const SERVICE_WORKER_CACHE_URLS_MESSAGE = 'objectui:cache-urls'; + /** * Build a Service Worker JavaScript source string. Customize the cache * version, app shell, and sync tag via `options`. */ export function getServiceWorkerSource(options: ServiceWorkerSourceOptions = {}): string { - const cacheName = options.cacheName ?? 'objectui-shell-v1'; - const precache = options.precache ?? ['/', '/index.html']; + const strategy = options.strategy ?? 'offline-first'; + if (strategy !== 'offline-first' && strategy !== 'app-shell') { + throw new Error( + `getServiceWorkerSource: unknown strategy ${JSON.stringify(strategy)}; use 'offline-first' or 'app-shell'.`, + ); + } + const build = options.build?.trim(); + if (strategy === 'app-shell' && !build) { + throw new Error( + "getServiceWorkerSource: the 'app-shell' strategy needs `build` (the build's identity), which names its cache.", + ); + } + if (strategy === 'offline-first' && options.build !== undefined) { + throw new Error( + "getServiceWorkerSource: `build` is read only by the 'app-shell' strategy; the 'offline-first' worker is versioned by `cacheName`.", + ); + } + const apiPrefix = options.apiPrefix ?? '/api/'; const syncTag = options.syncTag ?? 'objectui-offline-queue'; + const body = + strategy === 'app-shell' + ? appShellSource({ + cachePrefix: options.cacheName ?? 'objectui-app-shell', + build: build as string, + precache: options.precache ?? [], + apiPrefix, + syncTag, + }) + : offlineFirstSource({ + cacheName: options.cacheName ?? 'objectui-shell-v1', + precache: options.precache ?? ['/', '/index.html'], + apiPrefix, + syncTag, + }); return `// Auto-generated by @object-ui/mobile getServiceWorkerSource() -const CACHE = ${JSON.stringify(cacheName)}; -const PRECACHE = ${JSON.stringify(precache)}; -const API_PREFIX = ${JSON.stringify(apiPrefix)}; -const SYNC_TAG = ${JSON.stringify(syncTag)}; +${body} + +self.addEventListener('sync', (event) => { + if (event.tag !== SYNC_TAG) return; + event.waitUntil((async () => { + const all = await self.clients.matchAll({ includeUncontrolled: true }); + for (const client of all) { + client.postMessage({ type: 'REPLAY_QUEUE', tag: SYNC_TAG }); + } + })()); +}); +${options.notifications ? NOTIFICATION_HANDLERS : ''}`; +} + +function offlineFirstSource(o: { cacheName: string; precache: string[]; apiPrefix: string; syncTag: string }): string { + return `const CACHE = ${JSON.stringify(o.cacheName)}; +const PRECACHE = ${JSON.stringify(o.precache)}; +const API_PREFIX = ${JSON.stringify(o.apiPrefix)}; +const SYNC_TAG = ${JSON.stringify(o.syncTag)}; self.addEventListener('install', (event) => { event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE))); @@ -82,16 +177,182 @@ self.addEventListener('fetch', (event) => { return res; })), ); +});`; +} + +function appShellSource(o: { + cachePrefix: string; + build: string; + precache: string[]; + apiPrefix: string; + syncTag: string; +}): string { + return `const BUILD = ${JSON.stringify(o.build)}; +const API_PREFIX = ${JSON.stringify(o.apiPrefix)}; +const SYNC_TAG = ${JSON.stringify(o.syncTag)}; +const SCOPE = new URL(self.registration.scope); +// One cache per build of THIS app: the scope keeps two apps of one origin apart. +const CACHE_PREFIX = ${JSON.stringify(o.cachePrefix)} + '::' + SCOPE.pathname + '::'; +const CACHE = CACHE_PREFIX + BUILD; +// The HTML entry: the scope root, which serves the app's index document. +const ENTRY = SCOPE.href; +const ASSETS = new URL('assets/', SCOPE).pathname; +const PRECACHE = Array.from(new Set([ENTRY].concat( + ${JSON.stringify(o.precache)}.map((u) => new URL(u, self.location.href).href), +))); +const CACHE_URLS_MESSAGE = ${JSON.stringify(SERVICE_WORKER_CACHE_URLS_MESSAGE)}; + +// Network-only: under the API prefix, or outside this app. +function bypasses(url) { + return url.pathname.startsWith(API_PREFIX) + || url.origin !== SCOPE.origin + || !url.pathname.startsWith(SCOPE.pathname); +} + +function storable(res) { + return !!res && res.ok && res.type === 'basic' && !res.redirected; +} + +self.addEventListener('install', (event) => { + event.waitUntil((async () => { + const cache = await caches.open(CACHE); + await cache.addAll(PRECACHE.map((u) => new Request(u, { cache: 'no-cache' }))); + await self.skipWaiting(); + })()); }); -self.addEventListener('sync', (event) => { - if (event.tag !== SYNC_TAG) return; +self.addEventListener('activate', (event) => { event.waitUntil((async () => { - const all = await self.clients.matchAll({ includeUncontrolled: true }); - for (const client of all) { - client.postMessage({ type: 'REPLAY_QUEUE', tag: SYNC_TAG }); + const keys = await caches.keys(); + await Promise.all( + keys.filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE).map((k) => caches.delete(k)), + ); + await self.clients.claim(); + })()); +}); + +// Navigations: network-first; offline, the precached HTML entry. +async function navigation(event) { + try { + return await fetch(event.request); + } catch (error) { + const cached = await caches.match(ENTRY, { cacheName: CACHE }); + if (cached) return cached; + throw error; + } +} + +// Hashed assets: cache-first, cached on fetch. +async function cacheFirst(event) { + const cache = await caches.open(CACHE); + const cached = await cache.match(event.request); + if (cached) return cached; + const res = await fetch(event.request); + if (storable(res)) event.waitUntil(cache.put(event.request, res.clone())); + return res; +} + +// Every other file in scope: network-first, cached on fetch, cached copy offline. +async function networkFirst(event) { + const cache = await caches.open(CACHE); + try { + const res = await fetch(event.request); + if (storable(res)) event.waitUntil(cache.put(event.request, res.clone())); + return res; + } catch (error) { + const cached = await cache.match(event.request); + if (cached) return cached; + throw error; + } +} + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (bypasses(url)) return; + if (req.mode === 'navigate') event.respondWith(navigation(event)); + else if (url.pathname.startsWith(ASSETS)) event.respondWith(cacheFirst(event)); + else event.respondWith(networkFirst(event)); +}); + +self.addEventListener('message', (event) => { + const data = event.data; + if (!data || data.type !== CACHE_URLS_MESSAGE || !Array.isArray(data.urls)) return; + event.waitUntil((async () => { + const cache = await caches.open(CACHE); + for (const raw of data.urls) { + if (typeof raw !== 'string') continue; + let url; + try { url = new URL(raw, SCOPE); } catch { continue; } + url.hash = ''; + if (bypasses(url) || url.href === ENTRY) continue; + if (await cache.match(url.href)) continue; + try { + const res = await fetch(url.href); + if (storable(res)) await cache.put(url.href, res); + } catch { + // Offline or gone: the next controlled load caches it on fetch. + } + } + })()); +});`; +} + +// Shared by both strategies. `isInAppRoute` follows `@object-ui/mobile`'s +// page-side check of the same name; the scope test after it is what refuses +// a route that climbs out (`/../api/x`) once the URL parser normalizes it. +const NOTIFICATION_HANDLERS = ` +const NOTIFICATION_SCOPE = new URL(self.registration.scope); + +function isInAppRoute(route) { + if (typeof route !== 'string' || !route.startsWith('/') || route.startsWith('//')) return false; + for (let i = 0; i < route.length; i += 1) { + const c = route.charCodeAt(i); + if (c < 0x20 || c === 0x7f || c === 0x5c) return false; + } + return true; +} + +// The window URL for an in-app route, or null when the route is refused. +function routeTarget(route) { + if (!isInAppRoute(route)) return null; + let target; + try { target = new URL('.' + route, NOTIFICATION_SCOPE); } catch { return null; } + if (target.origin !== NOTIFICATION_SCOPE.origin) return null; + if (!target.pathname.startsWith(NOTIFICATION_SCOPE.pathname)) return null; + return target.href; +} + +self.addEventListener('push', (event) => { + let payload = {}; + if (event.data) { + try { payload = event.data.json() || {}; } catch { payload = { body: event.data.text() }; } + } + const title = typeof payload.title === 'string' ? payload.title : ''; + const options = { data: { route: typeof payload.route === 'string' ? payload.route : null } }; + if (typeof payload.body === 'string') options.body = payload.body; + if (typeof payload.tag === 'string' && payload.tag) options.tag = payload.tag; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const route = event.notification.data && event.notification.data.route; + const target = route == null ? NOTIFICATION_SCOPE.href : routeTarget(route); + if (!target) return; + event.waitUntil((async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const open = windows.find((w) => w.url.startsWith(NOTIFICATION_SCOPE.href)); + if (open) { + // \`navigate()\` answers only for a window this worker controls. + const moved = await open.navigate(target).catch(() => null); + if (moved) { + await moved.focus(); + return; + } } + await self.clients.openWindow(target); })()); }); `; -} From 163cfd147fcdd8fca359bae583f52b7a6c09182a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:46:05 +0000 Subject: [PATCH 2/8] fix(cli): allow the Console's blob: manifest in the document policy, and serve the e2e like the CLI The Console links its branded web app manifest as a blob: URL, which default-src 'self' refused, so the served Console was not installable. The Playwright service-worker spec serves the build with the CLI's base tag, so routes stay inside the worker's scope. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt --- .changeset/29155-console-desktop-pwa.md | 18 ++ .../console.content-security-policy.test.ts | 3 + packages/cli/src/utils/console.ts | 9 +- ui/e2e/console-service-worker.spec.ts | 168 +++++++++++------- 4 files changed, 128 insertions(+), 70 deletions(-) create mode 100644 .changeset/29155-console-desktop-pwa.md diff --git a/.changeset/29155-console-desktop-pwa.md b/.changeset/29155-console-desktop-pwa.md new file mode 100644 index 00000000000..be5bebc9444 --- /dev/null +++ b/.changeset/29155-console-desktop-pwa.md @@ -0,0 +1,18 @@ +--- +'@object-ui/mobile': patch +'@object-ui/console': patch +'@objectstack/cli': patch +--- + +feat(console): the Console is an installable PWA on desktop, with an offline app shell and a Service Worker ready for Web Push + +Clause-②: yes (widening) +Metadata-protocol: none + +The Console already injected a web app manifest, but nothing registered a Service Worker, so the installed app showed a browser error page on a cold start without network, and Web Push had no worker to reach. Under the CLI's content-security-policy the branded manifest was refused too, so the served Console was not installable at all. + +- **`@object-ui/mobile` gains an `'app-shell'` worker strategy.** `getServiceWorkerSource({ strategy: 'app-shell', build })` writes a build-versioned shell worker: its cache is named after the scope and the build, and activation deletes the scope's caches of every other build. Requests under `apiPrefix` and every request outside the scope are network-only (the worker never answers them, never reads or writes the cache for them, so no data and no cookie is stored). Navigations are network-first with the precached HTML entry as the offline fallback; hashed `assets/` files are cache-first. `build` is required by `'app-shell'` and refused by `'offline-first'`. The default `'offline-first'` worker is byte-identical to before. +- **`notifications: true`** adds `push` and `notificationclick` handlers. A click focuses or opens an app window at the payload's `route`, an in-app path relative to the scope; a route that is not an in-app path, or that climbs out of the scope, opens nothing. No push subscription is made. +- **`cacheLoadedResources(worker)`** asks an `'app-shell'` worker to cache the files the page loaded before the worker controlled it, so the first visit's lazy chunks are there for an offline start. `SERVICE_WORKER_CACHE_URLS_MESSAGE` and `ServiceWorkerStrategy` are exported beside it. +- **The Console registers it.** The build emits `service-worker.js` at its root (precache: the entry chunk, its static imports and their CSS), and the Console registers it under its own mount (`/_console/`), never the site root, in production builds. The next navigation after a deploy runs the new build. Inside the ObjectStack mobile shell no worker is registered, and one registered earlier in that WebView is removed. The generated manifest's `start_url`, `scope` and icon are absolute URLs under the mount. +- **`@objectstack/cli`**: the Console document's policy adds `manifest-src 'self' blob:`, so the branded manifest loads and the Console is installable. diff --git a/packages/cli/src/utils/console.content-security-policy.test.ts b/packages/cli/src/utils/console.content-security-policy.test.ts index d2a5799be66..4ca4f9adb7c 100644 --- a/packages/cli/src/utils/console.content-security-policy.test.ts +++ b/packages/cli/src/utils/console.content-security-policy.test.ts @@ -157,6 +157,9 @@ describe('the console document is served with a nonce-based content-security-pol expect(policy.get('base-uri')).toEqual(["'self'"]); expect(policy.get('frame-ancestors')).toEqual(["'self'"]); expect(policy.get('connect-src')).toEqual(["'self'", 'https://demotiles.maplibre.org']); + // The Console links its branded manifest as a `blob:` URL; without this + // the browser refuses it and the Console is not installable (#29155). + expect(policy.get('manifest-src')).toEqual(["'self'", 'blob:']); }); it('stamps every