Skip to content

Commit b772078

Browse files
feat(console): installable desktop PWA with an offline app shell and a push-ready Service Worker (#29326)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbPHS2hBi91fybR5PLv1Jvm2)_ Fixes #29155 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:Console 生产构建现在在根目录产出 `service-worker.js`,并在 Console 自己的挂载路径(`/_console/`)下注册。它缓存应用外壳(HTML 入口 + 入口脚本),离线冷启动显示 Console 而不是浏览器错误页;`/api/` 请求一律直连网络,不读不写缓存。Worker 带 `push` / `notificationclick` 处理器(点击只打开 `/_console/` 内的路由),但不订阅推送。CLI 文档的 CSP 增加 `manifest-src 'self' blob:`,让带品牌的安装清单能加载,Console 因此可安装。另有三处构建配置:`tsconfig.node.json` 加一行 include、`turbo.json` 三个任务各加一个输入、**`.github/workflows/ci.yml` 的 `core:` 路径过滤加一行 `ui/packages/mobile/src/serviceWorkerSource.ts`**(只加过滤行,不改任何 job、step 或触发器)。 - **为什么改**:桌面用户在浏览器里用 Console(产品决定不做原生桌面端)。此前没有任何代码注册 Service Worker,装成应用后离线打开是错误页,也没有 worker 承接 Web Push;实测 CLI 的 CSP 还拒绝了 Console 注入的 blob 清单,线上 Console 根本不可安装。Console 的 `vite.config.ts` 从源码读取 mobile 包的 worker 生成器,所以 turbo 必须把这个文件算进 build/test/type-check 的缓存输入;`check:ci-filter-parity` 要求每个这样的 turbo 输入在 ci.yml 的 `core:` 过滤里有一行,否则只改这个文件的 PR 不会触发 Build Core / Test Core。改用包的子路径导出(不碰 ci.yml)已实测不可行:Test UI 和 Live E2E 两个 CI 作业不构建 mobile 的 dist,vitest 启动即失败。 - **风险与回滚**:新构建上线后的下一次导航即运行新构建(HTML 走网络优先,worker 用 `skipWaiting` 接管并删除旧构建缓存),旧外壳不会钉住用户。在原生壳(`getNativeHost()`)里不注册 worker,并注销之前注册过的。CSP 只多了 manifest 一条,blob URL 只能由已通过 `script-src` 的脚本创建。ci.yml 那一行只让更多 PR 触发核心作业,不会少跑。回滚即 revert 本 PR。 - **先看**:`ui/packages/mobile/src/serviceWorkerSource.ts` 的 `appShellSource()`(`bypasses()` 与 fetch 分支),然后 `ui/apps/console/src/main.tsx` 的 `syncServiceWorker()`,再看 `packages/cli/src/utils/console.ts` 的 `manifest-src` 一行和 ci.yml 的新增过滤行。 - **两分钟验证**:`pnpm --dir ui exec playwright test e2e/console-service-worker.spec.ts --project=chromium`(需先以 `VITE_BASE_PATH=/console/` 构建 Console),两条用例通过:离线刷新显示登录页的「无法连接服务器」面板,模拟部署后一次导航即换到新构建、旧缓存被删。 - **需要你做的**:本 PR 触及受管文件 `.github/workflows/ci.yml`,需要维护者批准并合并;席位不会自行翻 ready 或入队。 ## What changes | File | Change | |---|---| | `ui/packages/mobile/src/serviceWorkerSource.ts` | `getServiceWorkerSource` gains `strategy: 'offline-first' \| 'app-shell'`, `build` and `notifications`. The default `'offline-first'` output is byte-identical to `main` (checked for the default and a fully customised options object). `'app-shell'` needs `build`; `build` under `'offline-first'` is refused, since that worker would ignore it. | | `ui/packages/mobile/src/serviceWorker.ts` | `cacheLoadedResources(worker)`: posts the page's resource-timing URLs to an `'app-shell'` worker, which caches only same-origin, in-scope, non-API files. | | `ui/packages/mobile/src/index.ts` | Exports `cacheLoadedResources`, `SERVICE_WORKER_CACHE_URLS_MESSAGE`, `ServiceWorkerStrategy`. | | `ui/apps/console/vite.config.ts` | `emitServiceWorker()` emits `service-worker.js` at the build root with the build id and the precache list. | | `ui/apps/console/src/main.tsx` | `syncServiceWorker()` registers the worker under the Console's mount in production builds, or unregisters it in the native shell. The generated manifest's `start_url`, `scope` and icon are absolute URLs. | | `packages/cli/src/utils/console.ts` | The Console document's policy adds `manifest-src 'self' blob:`; the comment that said `default-src` covered the manifest is corrected. | | `packages/cli/src/utils/console.content-security-policy.test.ts` | Pins the directive. | | `ui/packages/mobile/src/__tests__/serviceWorkerSource.test.ts` | 25 cases running the generated worker against a fake worker global. | | `ui/e2e/console-service-worker.spec.ts` | Offline reload and update pickup on the production build. | | `ui/apps/console/tsconfig.node.json` | Lists `serviceWorkerSource.ts`, which `vite.config.ts` imports from source (TS6307 otherwise). | | `turbo.json` | Hashes `ui/packages/mobile/src/serviceWorkerSource.ts` in the `build`, `test` and `type-check` inputs, so a generator change never replays a stale cache. | | `.github/workflows/ci.yml` | One row in the `core:` paths filter for the same file, which `check:ci-filter-parity` requires for every turbo build and test input. No job, step or trigger changes. | | `ui/packages/mobile/README.md`, `.changeset/29155-console-desktop-pwa.md` | Docs and changeset. | **Surface amendments (seat rulings on the card).** The claimed surface was the mobile worker files, `main.tsx`, `vite.config.ts`, the manifest, a Playwright spec and a changeset. Four amendments widened it: the CLI policy and its pin (`packages/cli/src/utils/console.ts`, `console.content-security-policy.test.ts`, comment 6573999866), the `tsconfig.node.json` include (comment 6574807547), the three `turbo.json` input entries (comment 6575431847), and the one `ci.yml` filter row (comment 6575983994). Importing the generator through a dependency-free subpath export of `@object-ui/mobile` instead was measured and refused: Test UI and Live E2E UI build no `@object-ui` dist, and the root vitest config loads the Console's `vite.config.ts`, so every shard would fail at startup with "Failed to initialize projects … Cannot find module …/@object-ui/mobile/dist/serviceWorkerSource.js". `ui/apps/console/public/manifest.json` is unchanged. **The `'app-shell'` worker.** - **Scope.** Served from the Console's base directory, so its scope is `/_console/`, never `/`. Every request outside the scope, and every request under `/api/`, is network-only: the worker returns before `respondWith`, so it never answers, reads or writes the cache for it, and no cookie is stored. - **Cache versioning by build.** The cache is `objectui-console::SCOPE_PATH::BUILD`. Activation deletes every cache with the same `objectui-console::SCOPE_PATH::` prefix except its own, and nothing else on the origin. The build id is the console build name (`VITE_APP_VERSION` or the git sha) plus a 12-hex digest of every emitted file name and the HTML entry; hashed names change with their bytes, so any changed chunk gives a byte-different worker. - **Precache choice: Vite's emitted entry assets, plus cache-on-fetch.** The precache is the entry chunk, its static-import closure and their CSS (101 files on this build), plus the scope root (the HTML entry). Lazy chunks are cached on fetch, and after registration the page posts the files it loaded before the worker controlled it (`cacheLoadedResources`), so the first visit's route chunks are there for an offline start. - **Strategies.** Navigations: network-first, the precached entry offline. Hashed `assets/` files: cache-first. Other in-scope files (favicon, manifest): network-first with the cached copy offline. - **Update flow: `skipWaiting` + `clients.claim`, no reload prompt.** The HTML entry is network-first, so the next navigation after a deploy loads the new document and its new hashed chunks even before the new worker activates. The new worker installs (precaching the new build), activates at once and deletes the old build's cache. No shell can pin a user. - **Notifications.** `push` shows `{ title, body?, route?, tag? }` with `route` in the notification's data. `notificationclick` resolves `route` against the scope: it must pass the same in-app check as `isInAppRoute` (one leading `/`, no `//`, no backslash, no control character), and the URL-normalised result must still start with the scope path. A refused route only closes the notification. An open, controlled Console window is navigated and focused; otherwise a window is opened. ## Why Desktop users run the Console in a browser. Nothing registered a Service Worker, so an installed Console showed a browser error page when started offline, and Web Push had no worker to reach. The served Console was also not installable: measured before this change, Chromium refused the Console's branded `blob:` manifest under the CLI's `default-src 'self'` (see the security header note below). **Security-header widening (`manifest-src 'self' blob:`).** Measured with CDP `Page.getInstallabilityErrors` / `Page.getAppManifest` in full Chromium 1243 (new headless, persistent profile) on a default-base build served the way the CLI serves it (an injected base tag, `base href="/_console/"`, nonce-stamped scripts, the policy's exact directive list): | Policy | Console message | Installability errors | Manifest | |---|---|---|---| | `main` (no `manifest-src`) | `Loading a manifest from 'blob:…' violates … "default-src 'self'". Note that 'manifest-src' was not explicitly set` | `manifest-parsing-or-network-error`, `no-manifest` | none | | with `manifest-src 'self' blob:` | none | none | `name` ObjectOS, `start_url` and `scope` `http://127.0.0.1:PORT/_console/`, icon `…/_console/favicon.svg` | A `blob:` URL can only be minted by a script the page already runs, which `script-src` governs; no other directive changes. A screenshot of this check is on the card thread. ## Tests Measured on the head of this PR (`b5658de9`, `main` at `a19759ac` merged in): - `node scripts/pm/dispatch-gates.mjs --ran FILE`: **98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN, every exit code 0.** The 33 families the `ci.yml` row adds (workflow lints, filter parity, step-name checks) were run at `b0ef42a0`; `check:ci-filter-parity`, `check:type-check-debt`, `check-sdui-manifest` and `pnpm --dir ui exec vitest run scripts/` (169 files, 4903 tests) were re-run after the merge. - `@object-ui/mobile` vitest: 115 passed (25 new in `serviceWorkerSource.test.ts`). - `@objectstack/cli` content-security-policy suite: 50 passed. - `turbo run type-check` for `@object-ui/mobile` and `@object-ui/console` with their dependencies: 72 of 72 tasks green; Console `tsc` + `vite build` green. - Playwright, full chromium project on the production Console build (at `e4e73277`, before the config rows): 66 passed, 2 skipped (both skips pre-existing), including the two new cases in `console-service-worker.spec.ts`. **Ablations** (each restored byte-identical afterwards): | Removed | Result | |---|---| | the `apiPrefix` bypass in `bypasses()` | 2 tests red (including the root-mount case, where the scope bypass cannot cover `/api/`) | | the scope-prefix check on the click route | 2 tests red | | activation keeps only caches with this scope's prefix (made to delete every cache) | 1 test red | ## Acceptance notes - **Offline reload after one load shows the Console shell, and data calls fail visibly as offline:** `console-service-worker.spec.ts` case 1. The offline document is `fromServiceWorker()`, the sign-in screen's `auth-server-unreachable` panel renders, a direct `/api/v1/auth/get-session` fetch rejects with `TypeError`, and no `/api/` URL is in the worker's cache. - **A new build is picked up within one navigation:** case 2. A server switches to a "next" build (marked document, worker with a new build id); one navigation shows the marked document, and the caches end as exactly one, the new build's. - **Unit tests cover cache versioning, the API bypass, and the handlers, including an escaping route:** 25 cases, ablations in Tests above. - **Chrome installability check passes on the served Console:** the table above. - The e2e spec serves the build itself, the way the CLI does (with a base tag), not through `vite preview`: without a base tag, the router's basename is `/` and the app moves to `/login`, outside the worker's scope. - The push payload shape (`title`, `body`, `route`, `tag`) is provisional; the delivery contract is #29041's. No push subscription, device-registry call or push vendor is added here. - A `getNativeHost()` bridge that lands after boot leaves that page load's worker registered; the next load, which sees the bridge at boot, unregisters it. ## Verification - **Offline start and update pickup:** the two Playwright cases above, on a production build served with the CLI's base tag. - **Installability:** CDP before/after in the security-header table above; the report and a screenshot of the served Console are on the card thread. - **Default worker unchanged:** `getServiceWorkerSource()` with no options, and with a fully customised `'offline-first'` options object, emits the same bytes as `main`. - **Native shell:** `syncServiceWorker()` returns after unregistering when `getNativeHost()` is set; it never registers there. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI runs it) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b6709b commit b772078

14 files changed

Lines changed: 1089 additions & 29 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/console': patch
4+
'@objectstack/cli': patch
5+
---
6+
7+
feat(console): the Console is an installable PWA on desktop, with an offline app shell and a Service Worker ready for Web Push
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
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.
13+
14+
- **`@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.
15+
- **`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.
16+
- **`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.
17+
- **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.
18+
- **`@objectstack/cli`**: the Console document's policy adds `manifest-src 'self' blob:`, so the branded manifest loads and the Console is installable.

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,10 @@ jobs:
369369
- 'ui/scripts/check-dist-completeness.mjs'
370370
- 'ui/scripts/invoked-as.mjs'
371371
- 'ui/scripts/vitest-invocation-guard.mjs'
372+
# The Console's vite config emits its Service Worker from this
373+
# generator's source (#29155), so it is a build and test input of
374+
# every `@object-ui/*` package in turbo.json.
375+
- 'ui/packages/mobile/src/serviceWorkerSource.ts'
372376
# TEST INPUTS outside every package (#27961): each `$TURBO_ROOT$/…`
373377
# input of turbo.json's generic `test` task. That task runs for
374378
# every package Test Core tests, so a diff confined to one moves

‎packages/cli/src/utils/console.content-security-policy.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ describe('the console document is served with a nonce-based content-security-pol
157157
expect(policy.get('base-uri')).toEqual(["'self'"]);
158158
expect(policy.get('frame-ancestors')).toEqual(["'self'"]);
159159
expect(policy.get('connect-src')).toEqual(["'self'", 'https://demotiles.maplibre.org']);
160+
// The Console links its branded manifest as a `blob:` URL; without this
161+
// the browser refuses it and the Console is not installable (#29155).
162+
expect(policy.get('manifest-src')).toEqual(["'self'", 'blob:']);
160163
});
161164

162165
it('stamps every <script> in the served document with the nonce the header names', async () => {

‎packages/cli/src/utils/console.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,8 +831,14 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
831831
* stores; `readStorageUploadOrigin`), and the hosts an operator names in
832832
* `OS_CONSOLE_CSP_CONNECT_SRC`.
833833
* - `default-src 'self'` covers the rest the Console uses: the module workers
834-
* maplibre starts from its own file, the PWA manifest, and the same-origin
834+
* maplibre starts from its own file, its Service Worker, and the same-origin
835835
* frames (the Studio's live canvas). The build ships no web fonts.
836+
* - `manifest-src 'self' blob:`: the Console builds its web app manifest from
837+
* the runtime branding and links it as a `blob:` URL, so the installed app
838+
* carries the product's name and icon. Under `default-src 'self'` alone that
839+
* manifest is refused and the Console is not installable (objectstack#29155).
840+
* A `blob:` URL can only be minted by a script the page already runs, which
841+
* `script-src` governs.
836842
* - `object-src 'none'` and `base-uri 'self'`: no plugins, and only the
837843
* server's own `<base href>`.
838844
* - `frame-ancestors 'self'`, except on the public form page: the Console's
@@ -868,6 +874,7 @@ function consoleContentSecurityPolicy(input: {
868874
['style-src', "'self'", "'unsafe-inline'"],
869875
['img-src', "'self'", 'data:', 'blob:', 'https:'],
870876
['connect-src', ...new Set(connectSrc)],
877+
['manifest-src', "'self'", 'blob:'],
871878
['object-src', "'none'"],
872879
['base-uri', "'self'"],
873880
];

‎turbo.json‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"$TURBO_ROOT$/ui/scripts/build-plugin-stylesheet.mjs",
2222
"$TURBO_ROOT$/ui/scripts/check-dist-completeness.mjs",
2323
"$TURBO_ROOT$/ui/scripts/invoked-as.mjs",
24-
"$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs"
24+
"$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs",
25+
"$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"
2526
]
2627
},
2728
"test": {
@@ -40,7 +41,8 @@
4041
"$TURBO_ROOT$/ui/scripts/test-shard-*",
4142
"$TURBO_ROOT$/ui/scripts/vite-*.ts",
4243
"$TURBO_ROOT$/ui/apps/console/vitest.config.ts",
43-
"$TURBO_ROOT$/ui/apps/console/vite.config.ts"
44+
"$TURBO_ROOT$/ui/apps/console/vite.config.ts",
45+
"$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"
4446
]
4547
},
4648
"test:repo": {
@@ -69,7 +71,8 @@
6971
"$TURBO_ROOT$/ui/packages/cli/src/utils/issue-path.ts",
7072
"$TURBO_ROOT$/ui/packages/cli/src/utils/union-arm-diagnostics.ts",
7173
"$TURBO_ROOT$/ui/scripts/check-doc-component-types.mjs",
72-
"$TURBO_ROOT$/ui/scripts/markdown-fence-scan.mjs"
74+
"$TURBO_ROOT$/ui/scripts/markdown-fence-scan.mjs",
75+
"$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"
7376
]
7477
},
7578
"lint": {

‎ui/apps/console/src/main.tsx‎

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ import ReactDOM from 'react-dom/client';
99
import './index.css';
1010
import { App } from './App';
1111
import { I18nProvider, preloadBootstrapLocale } from '@object-ui/i18n';
12-
import { MobileProvider, generatePWAManifest, getNativeHost } from '@object-ui/mobile';
12+
import {
13+
MobileProvider,
14+
cacheLoadedResources,
15+
generatePWAManifest,
16+
getNativeHost,
17+
registerServiceWorker,
18+
} from '@object-ui/mobile';
1319
import { registerPlaceholders } from '@object-ui/components';
1420
import { initSentry, initRuntimeConfig, getProductName, getProductShortName, getFaviconUrl, getPwaDescription, getPwaThemeColor } from '@object-ui/app-shell';
1521
import { loadLanguage } from './loadLanguage';
1622
import { loadLocales } from './loadLocales';
1723
import { seedTenantLanguage } from './languageSeed';
1824
import { preflightAuth } from './lib/auth-preflight';
25+
import { withConsoleBaseRootRelative } from './utils/consoleBase';
1926

2027
const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`;
2128

@@ -70,6 +77,16 @@ registerPlaceholders();
7077
// Change the spelling here and that script has to change with it;
7178
// `src/__tests__/runtimeConfigBootDedup.test.ts` fails when the two stop agreeing.
7279
const SERVER_BASE = (import.meta.env.VITE_SERVER_URL || '').replace(/\/+$/, '');
80+
81+
/**
82+
* The Console's mount as an absolute URL with its trailing slash
83+
* (`https://host/_console/`): the Service Worker's scope and the installed
84+
* app's `start_url` and `scope`. `/` resolves through the same
85+
* `withConsoleBaseRootRelative` the auth pages use, so it agrees with where a
86+
* full-page navigation lands under every mount.
87+
*/
88+
const CONSOLE_SCOPE = new URL(withConsoleBaseRootRelative('/'), window.location.origin).href;
89+
7390
// The third entry seeds the UI language from the server's resolved locale for
7491
// the signed-in caller (objectui#4035). It joins this existing gate rather than adding one of its
7592
// own, which is what keeps it off the critical path: it runs CONCURRENTLY with
@@ -149,10 +166,19 @@ Promise.all([
149166
themeColor: getPwaThemeColor(),
150167
backgroundColor: '#ffffff',
151168
display: 'standalone',
152-
startUrl: './',
153-
scope: './',
169+
// Absolute URLs, because a Blob URL is no base to resolve `./` against:
170+
// the installed app opens at, and stays inside, the Console's mount.
171+
startUrl: CONSOLE_SCOPE,
172+
scope: CONSOLE_SCOPE,
154173
orientation: 'any',
155-
icons: [{ src: faviconUrl || './favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
174+
icons: [
175+
{
176+
src: new URL(faviconUrl || './favicon.svg', document.baseURI).href,
177+
sizes: 'any',
178+
type: 'image/svg+xml',
179+
purpose: 'any',
180+
},
181+
],
156182
});
157183
const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' });
158184
const manifestUrl = URL.createObjectURL(blob);
@@ -162,6 +188,10 @@ Promise.all([
162188
document.head.appendChild(manifestLink);
163189
}
164190

191+
// The Console's Service Worker (objectstack#29155): the offline app shell
192+
// and the push handlers. Not awaited — it must never hold up first paint.
193+
void syncServiceWorker();
194+
165195
// The two loaders wait for this page load's session answer. Signed in, they
166196
// read `/i18n` with the session's credentials; signed out, they request
167197
// nothing and the sign-in page renders from the built-in packs
@@ -176,3 +206,55 @@ Promise.all([
176206
</React.StrictMode>
177207
);
178208
});
209+
210+
/**
211+
* Registers the Console's Service Worker (`service-worker.js`, emitted at the
212+
* build root by `vite.config.ts`) under the Console's own scope, never the site
213+
* root. It is the `@object-ui/mobile` `'app-shell'` worker: API requests stay
214+
* network-only, the HTML entry is network-first, so the next navigation after a
215+
* deploy always runs the new build (the worker installs with `skipWaiting` and
216+
* no reload prompt is needed).
217+
*
218+
* Inside the ObjectStack mobile shell (`getNativeHost()`), the shell owns
219+
* offline: no worker is registered, and one this Console registered earlier in
220+
* the same WebView is unregistered. A bridge that lands after this read leaves
221+
* that page load's worker in place; the next load, which sees the bridge at
222+
* boot, removes it.
223+
*
224+
* Production builds only: a dev server's modules are not hashed build output.
225+
*/
226+
async function syncServiceWorker(): Promise<void> {
227+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
228+
try {
229+
if (getNativeHost()) {
230+
const registration = await navigator.serviceWorker.getRegistration(CONSOLE_SCOPE);
231+
if (registration?.scope === CONSOLE_SCOPE) await registration.unregister();
232+
return;
233+
}
234+
if (!import.meta.env.PROD) return;
235+
const registration = await registerServiceWorker({
236+
url: `${CONSOLE_SCOPE}service-worker.js`,
237+
scope: CONSOLE_SCOPE,
238+
});
239+
if (!registration) return;
240+
// Cache what this load fetched before the worker controlled it — the
241+
// first visit's lazy chunks — so an offline start finds them. Read off this
242+
// registration, not `serviceWorker.ready`, which waits on the page's URL.
243+
cacheLoadedResources(await whenActive(registration));
244+
} catch {
245+
// A refused registration costs the offline shell, never the page.
246+
}
247+
}
248+
249+
/** The registration's worker once it is active (at once when it already is). */
250+
function whenActive(registration: ServiceWorkerRegistration): Promise<ServiceWorker | null> {
251+
if (registration.active) return Promise.resolve(registration.active);
252+
const worker = registration.installing ?? registration.waiting;
253+
if (!worker) return Promise.resolve(null);
254+
return new Promise((resolve) => {
255+
worker.addEventListener('statechange', () => {
256+
if (worker.state === 'activated') resolve(worker);
257+
else if (worker.state === 'redundant') resolve(registration.active);
258+
});
259+
});
260+
}

‎ui/apps/console/tsconfig.node.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"vite.config.ts",
7171
"vitest.config.ts",
7272
"../../vitest.config.mts",
73-
"../../scripts/vite-*.ts"
73+
"../../scripts/vite-*.ts",
74+
"../../packages/mobile/src/serviceWorkerSource.ts"
7475
]
7576
}

‎ui/apps/console/vite.config.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import path from 'path';
99
import fs from 'fs';
1010
import zlib from 'node:zlib';
1111
import { execFileSync } from 'node:child_process';
12+
import { createHash } from 'node:crypto';
1213
// Relative specifiers carry their real file extension, and `__dirname` is
1314
// spelled `import.meta.dirname` throughout this file, so the config stays
1415
// loadable by Vite's `configLoader: 'native'` — which imports this file with
@@ -27,6 +28,7 @@ import {
2728
} from '../../scripts/vite-objectstack-workspace.ts';
2829
import { viteDeclaredLazyViews } from '../../scripts/vite-declared-lazy-views.ts';
2930
import { viteTypesZodLazy } from '../../scripts/vite-types-zod-lazy.ts';
31+
import { getServiceWorkerSource } from '../../packages/mobile/src/serviceWorkerSource.ts';
3032
import { compression } from 'vite-plugin-compression2';
3133
import { visualizer } from 'rollup-plugin-visualizer';
3234

@@ -744,6 +746,70 @@ const LAZY_ONLY_INITIAL_ICONS: ReadonlySet<string> = new Set([
744746
'webhook',
745747
]);
746748

749+
/**
750+
* Emits the Console's Service Worker, `service-worker.js`, at the root of the
751+
* build beside `index.html` (objectstack#29155). Served from the base path, it
752+
* is scoped to the Console (`/_console/`), never the site root.
753+
*
754+
* It is `@object-ui/mobile`'s `'app-shell'` worker: API requests network-only,
755+
* the HTML entry network-first, hashed assets cache-first, push and
756+
* notification-click handlers. Its cache is named after this build, so the
757+
* next deploy's worker deletes this one's cache when it activates.
758+
*
759+
* Precache: the entry chunk, its static-import closure and their CSS (Vite's
760+
* emitted entry assets). Lazy chunks are cached on fetch, and the page posts
761+
* the files it loaded before the worker controlled it.
762+
*
763+
* Build identity: the console build name (`VITE_APP_VERSION` or the git sha)
764+
* plus a digest of every emitted file name and the HTML entry. Hashed names
765+
* change with their bytes, so any changed chunk gives a new worker.
766+
*/
767+
function emitServiceWorker(buildName: string): Plugin {
768+
return {
769+
name: 'emit-console-service-worker',
770+
enforce: 'post',
771+
generateBundle(_options, bundle) {
772+
const fileNames = Object.keys(bundle).sort();
773+
const digest = createHash('sha256');
774+
for (const fileName of fileNames) {
775+
digest.update(fileName).update('\0');
776+
const output = bundle[fileName];
777+
if (output.type === 'asset' && fileName.endsWith('.html')) digest.update(output.source);
778+
}
779+
const build = `${buildName || 'local'}-${digest.digest('hex').slice(0, 12)}`;
780+
781+
const chunks = new Map<string, Rollup.OutputChunk>();
782+
for (const output of Object.values(bundle)) {
783+
if (output.type === 'chunk') chunks.set(output.fileName, output);
784+
}
785+
const precache = new Set<string>();
786+
const queue = [...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName);
787+
if (queue.length === 0) this.error('[emit-console-service-worker] the bundle has no entry chunk to precache.');
788+
while (queue.length > 0) {
789+
const chunk = chunks.get(queue.pop() as string);
790+
// `imports` also lists unresolved bare specifiers; only real chunks count.
791+
if (!chunk || precache.has(chunk.fileName)) continue;
792+
precache.add(chunk.fileName);
793+
for (const css of chunk.viteMetadata?.importedCss ?? []) precache.add(css);
794+
queue.push(...chunk.imports);
795+
}
796+
797+
this.emitFile({
798+
type: 'asset',
799+
fileName: 'service-worker.js',
800+
source: getServiceWorkerSource({
801+
strategy: 'app-shell',
802+
build,
803+
cacheName: 'objectui-console',
804+
precache: [...precache].sort().map((fileName) => `./${fileName}`),
805+
apiPrefix: '/api/',
806+
notifications: true,
807+
}),
808+
});
809+
},
810+
};
811+
}
812+
747813
// https://vitejs.dev/config/
748814
export default defineConfig({
749815
base: basePath,
@@ -810,6 +876,8 @@ export default defineConfig({
810876
// imports) must be copied into assets/ or every map page 404s
811877
// (objectui#3297). Asserts on drift; never silently skips.
812878
viteMaplibreWorker(),
879+
// The Console's Service Worker (objectstack#29155), named after this build.
880+
emitServiceWorker(consoleBuild),
813881
// Dev-only plugin: serve runtime assets (product logo/favicon) from the
814882
// host project's runtime/assets directory so branding URLs configured
815883
// via OS_LOGO_URL / OS_FAVICON_URL resolve without a fragile symlink.

0 commit comments

Comments
 (0)