Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/29155-console-desktop-pwa.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,10 @@ jobs:
- 'ui/scripts/check-dist-completeness.mjs'
- 'ui/scripts/invoked-as.mjs'
- 'ui/scripts/vitest-invocation-guard.mjs'
# The Console's vite config emits its Service Worker from this
# generator's source (#29155), so it is a build and test input of
# every `@object-ui/*` package in turbo.json.
- 'ui/packages/mobile/src/serviceWorkerSource.ts'
# TEST INPUTS outside every package (#27961): each `$TURBO_ROOT$/…`
# input of turbo.json's generic `test` task. That task runs for
# every package Test Core tests, so a diff confined to one moves
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script> in the served document with the nonce the header names', async () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,8 +831,14 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
* stores; `readStorageUploadOrigin`), and the hosts an operator names in
* `OS_CONSOLE_CSP_CONNECT_SRC`.
* - `default-src 'self'` covers the rest the Console uses: the module workers
* maplibre starts from its own file, the PWA manifest, and the same-origin
* maplibre starts from its own file, its Service Worker, and the same-origin
* frames (the Studio's live canvas). The build ships no web fonts.
* - `manifest-src 'self' blob:`: the Console builds its web app manifest from
* the runtime branding and links it as a `blob:` URL, so the installed app
* carries the product's name and icon. Under `default-src 'self'` alone that
* manifest is refused and the Console is not installable (objectstack#29155).
* A `blob:` URL can only be minted by a script the page already runs, which
* `script-src` governs.
* - `object-src 'none'` and `base-uri 'self'`: no plugins, and only the
* server's own `<base href>`.
* - `frame-ancestors 'self'`, except on the public form page: the Console's
Expand Down Expand Up @@ -868,6 +874,7 @@ function consoleContentSecurityPolicy(input: {
['style-src', "'self'", "'unsafe-inline'"],
['img-src', "'self'", 'data:', 'blob:', 'https:'],
['connect-src', ...new Set(connectSrc)],
['manifest-src', "'self'", 'blob:'],
['object-src', "'none'"],
['base-uri', "'self'"],
];
Expand Down
9 changes: 6 additions & 3 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"$TURBO_ROOT$/ui/scripts/build-plugin-stylesheet.mjs",
"$TURBO_ROOT$/ui/scripts/check-dist-completeness.mjs",
"$TURBO_ROOT$/ui/scripts/invoked-as.mjs",
"$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs"
"$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs",
"$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"
]
},
"test": {
Expand All @@ -40,7 +41,8 @@
"$TURBO_ROOT$/ui/scripts/test-shard-*",
"$TURBO_ROOT$/ui/scripts/vite-*.ts",
"$TURBO_ROOT$/ui/apps/console/vitest.config.ts",
"$TURBO_ROOT$/ui/apps/console/vite.config.ts"
"$TURBO_ROOT$/ui/apps/console/vite.config.ts",
"$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"
]
},
"test:repo": {
Expand Down Expand Up @@ -69,7 +71,8 @@
"$TURBO_ROOT$/ui/packages/cli/src/utils/issue-path.ts",
"$TURBO_ROOT$/ui/packages/cli/src/utils/union-arm-diagnostics.ts",
"$TURBO_ROOT$/ui/scripts/check-doc-component-types.mjs",
"$TURBO_ROOT$/ui/scripts/markdown-fence-scan.mjs"
"$TURBO_ROOT$/ui/scripts/markdown-fence-scan.mjs",
"$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"
]
},
"lint": {
Expand Down
90 changes: 86 additions & 4 deletions ui/apps/console/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ import ReactDOM from 'react-dom/client';
import './index.css';
import { App } from './App';
import { I18nProvider, preloadBootstrapLocale } from '@object-ui/i18n';
import { MobileProvider, generatePWAManifest, getNativeHost } from '@object-ui/mobile';
import {
MobileProvider,
cacheLoadedResources,
generatePWAManifest,
getNativeHost,
registerServiceWorker,
} from '@object-ui/mobile';
import { registerPlaceholders } from '@object-ui/components';
import { initSentry, initRuntimeConfig, getProductName, getProductShortName, getFaviconUrl, getPwaDescription, getPwaThemeColor } from '@object-ui/app-shell';
import { loadLanguage } from './loadLanguage';
import { loadLocales } from './loadLocales';
import { seedTenantLanguage } from './languageSeed';
import { preflightAuth } from './lib/auth-preflight';
import { withConsoleBaseRootRelative } from './utils/consoleBase';

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

Expand Down Expand Up @@ -70,6 +77,16 @@ registerPlaceholders();
// Change the spelling here and that script has to change with it;
// `src/__tests__/runtimeConfigBootDedup.test.ts` fails when the two stop agreeing.
const SERVER_BASE = (import.meta.env.VITE_SERVER_URL || '').replace(/\/+$/, '');

/**
* The Console's mount as an absolute URL with its trailing slash
* (`https://host/_console/`): the Service Worker's scope and the installed
* app's `start_url` and `scope`. `/` resolves through the same
* `withConsoleBaseRootRelative` the auth pages use, so it agrees with where a
* full-page navigation lands under every mount.
*/
const CONSOLE_SCOPE = new URL(withConsoleBaseRootRelative('/'), window.location.origin).href;

// The third entry seeds the UI language from the server's resolved locale for
// the signed-in caller (objectui#4035). It joins this existing gate rather than adding one of its
// own, which is what keeps it off the critical path: it runs CONCURRENTLY with
Expand Down Expand Up @@ -149,10 +166,19 @@ Promise.all([
themeColor: getPwaThemeColor(),
backgroundColor: '#ffffff',
display: 'standalone',
startUrl: './',
scope: './',
// Absolute URLs, because a Blob URL is no base to resolve `./` against:
// the installed app opens at, and stays inside, the Console's mount.
startUrl: CONSOLE_SCOPE,
scope: CONSOLE_SCOPE,
orientation: 'any',
icons: [{ src: faviconUrl || './favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
icons: [
{
src: new URL(faviconUrl || './favicon.svg', document.baseURI).href,
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
},
],
});
const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' });
const manifestUrl = URL.createObjectURL(blob);
Expand All @@ -162,6 +188,10 @@ Promise.all([
document.head.appendChild(manifestLink);
}

// The Console's Service Worker (objectstack#29155): the offline app shell
// and the push handlers. Not awaited — it must never hold up first paint.
void syncServiceWorker();

// The two loaders wait for this page load's session answer. Signed in, they
// read `/i18n` with the session's credentials; signed out, they request
// nothing and the sign-in page renders from the built-in packs
Expand All @@ -176,3 +206,55 @@ Promise.all([
</React.StrictMode>
);
});

/**
* Registers the Console's Service Worker (`service-worker.js`, emitted at the
* build root by `vite.config.ts`) under the Console's own scope, never the site
* root. It is the `@object-ui/mobile` `'app-shell'` worker: API requests stay
* network-only, the HTML entry is network-first, so the next navigation after a
* deploy always runs the new build (the worker installs with `skipWaiting` and
* no reload prompt is needed).
*
* Inside the ObjectStack mobile shell (`getNativeHost()`), the shell owns
* offline: no worker is registered, and one this Console registered earlier in
* the same WebView is unregistered. A bridge that lands after this read leaves
* that page load's worker in place; the next load, which sees the bridge at
* boot, removes it.
*
* Production builds only: a dev server's modules are not hashed build output.
*/
async function syncServiceWorker(): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
try {
if (getNativeHost()) {
const registration = await navigator.serviceWorker.getRegistration(CONSOLE_SCOPE);
if (registration?.scope === CONSOLE_SCOPE) await registration.unregister();
return;
}
if (!import.meta.env.PROD) return;
const registration = await registerServiceWorker({
url: `${CONSOLE_SCOPE}service-worker.js`,
scope: CONSOLE_SCOPE,
});
if (!registration) return;
// Cache what this load fetched before the worker controlled it — the
// first visit's lazy chunks — so an offline start finds them. Read off this
// registration, not `serviceWorker.ready`, which waits on the page's URL.
cacheLoadedResources(await whenActive(registration));
} catch {
// A refused registration costs the offline shell, never the page.
}
}

/** The registration's worker once it is active (at once when it already is). */
function whenActive(registration: ServiceWorkerRegistration): Promise<ServiceWorker | null> {
if (registration.active) return Promise.resolve(registration.active);
const worker = registration.installing ?? registration.waiting;
if (!worker) return Promise.resolve(null);
return new Promise((resolve) => {
worker.addEventListener('statechange', () => {
if (worker.state === 'activated') resolve(worker);
else if (worker.state === 'redundant') resolve(registration.active);
});
});
}
3 changes: 2 additions & 1 deletion ui/apps/console/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"vite.config.ts",
"vitest.config.ts",
"../../vitest.config.mts",
"../../scripts/vite-*.ts"
"../../scripts/vite-*.ts",
"../../packages/mobile/src/serviceWorkerSource.ts"
]
}
68 changes: 68 additions & 0 deletions ui/apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

Expand Down Expand Up @@ -744,6 +746,70 @@ const LAZY_ONLY_INITIAL_ICONS: ReadonlySet<string> = 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<string, Rollup.OutputChunk>();
for (const output of Object.values(bundle)) {
if (output.type === 'chunk') chunks.set(output.fileName, output);
}
const precache = new Set<string>();
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,
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading