@ultimat3/cli 17.0.0 → 19.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +194 -17
- package/package.json +30 -30
- package/src/app-root.ts +22 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev.ts +28 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/compile-externals.ts +11 -4
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +20 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +46 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +37 -4
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +15 -0
- package/src/messages.ts +5 -1
- package/src/prerender.ts +41 -1
- package/src/pwa-artifacts.ts +230 -0
- package/src/serve.ts +24 -1
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +17 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// `sw.js` and its registration script, built from the route table and the island bundle — the one
|
|
2
|
+
// caller of `@ultimat3/pwa`'s `generateServiceWorker`, which had none outside its own package
|
|
3
|
+
// until #390. Beside `pwa-artifacts.ts` and not inside it: that file needs a root and a config
|
|
4
|
+
// file, this one needs a booted app and a finished build.
|
|
5
|
+
|
|
6
|
+
import type { PrecacheAsset, PrecacheManifest, PwaRoute } from '@ultimat3/pwa';
|
|
7
|
+
import { generateServiceWorker } from '@ultimat3/pwa';
|
|
8
|
+
import type { RouteDescriptor } from '@ultimat3/render';
|
|
9
|
+
import type { IslandBundle } from './island-bundle';
|
|
10
|
+
import { msg } from './messages';
|
|
11
|
+
import type { PwaArtifacts } from './pwa-artifacts';
|
|
12
|
+
|
|
13
|
+
/** Root scope, so `/sw.js` and nothing under a directory — `assertScope` refuses the rest. */
|
|
14
|
+
export const SERVICE_WORKER_PATH = '/sw.js';
|
|
15
|
+
export const SW_SCOPE = '/';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The registration is an EXTERNAL script, never inline, and that is a CSP fact rather than a
|
|
19
|
+
* preference: `startWeb` computes a `script-src` sha256 for each inline script it serves, so an
|
|
20
|
+
* unhashed one is blocked in the container while passing report-only under `x dev` — which is
|
|
21
|
+
* exactly how the hydration runtime shipped broken once already.
|
|
22
|
+
*/
|
|
23
|
+
export const SW_REGISTER_PATH = '/x-sw-register.js';
|
|
24
|
+
|
|
25
|
+
export interface ServiceWorkerArtifacts {
|
|
26
|
+
/** `sw.js`, deterministic for identical input. */
|
|
27
|
+
readonly source: string;
|
|
28
|
+
/** `x-sw-register.js`, the four lines that install it. */
|
|
29
|
+
readonly register: string;
|
|
30
|
+
/** The `<script src>` tag, appended to `PwaArtifacts.head` by every surface that serves it. */
|
|
31
|
+
readonly head: string;
|
|
32
|
+
readonly precache: PrecacheManifest;
|
|
33
|
+
/** Precache budget findings — reported by `x build`, so the ceiling is not a designed thing. */
|
|
34
|
+
readonly warnings: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ServiceWorkerInput {
|
|
38
|
+
/**
|
|
39
|
+
* What `loadPwaArtifacts` read out of `app.config.ts`. The whole object rather than three loose
|
|
40
|
+
* fields, because a second reader of that file is a second answer to what the app declared.
|
|
41
|
+
*/
|
|
42
|
+
readonly pwa: PwaArtifacts;
|
|
43
|
+
readonly buildId: string;
|
|
44
|
+
readonly routes: readonly RouteDescriptor[];
|
|
45
|
+
readonly islands: IslandBundle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The route table, as the service worker sees it. `api/` is dropped: an API response is a JSON
|
|
50
|
+
* document whose freshness is the app's business, and precaching one serves a stale answer to a
|
|
51
|
+
* client that had a network. Only the four fields `PwaRoute` reads cross — a descriptor carries
|
|
52
|
+
* budgets and policy flags that a browser has no use for.
|
|
53
|
+
*/
|
|
54
|
+
const pwaRoutes = (routes: readonly RouteDescriptor[]): readonly PwaRoute[] =>
|
|
55
|
+
// `flatMap` rather than `filter().map()`: the filter's predicate does not narrow `surface` for
|
|
56
|
+
// the map that follows it, and `PwaRoute` declares the two navigable surfaces only. A cast would
|
|
57
|
+
// hide the day a fifth surface arrives.
|
|
58
|
+
routes.flatMap((route): readonly PwaRoute[] => {
|
|
59
|
+
// `shared/` is dropped with `api/`, and for a stronger reason: it is not a URL at all — the
|
|
60
|
+
// surface exists so two routes can import one module, and a browser can never navigate to it.
|
|
61
|
+
if (route.surface !== 'site' && route.surface !== 'app') return [];
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
path: route.path,
|
|
65
|
+
surface: route.surface,
|
|
66
|
+
mode: route.mode,
|
|
67
|
+
offline: route.offline,
|
|
68
|
+
dynamic: route.dynamic,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every island chunk, precached. They are content-addressed and served `immutable`, so the
|
|
75
|
+
* revision IS the URL's hash and a byte-identical chunk across deploys is never re-downloaded.
|
|
76
|
+
*
|
|
77
|
+
* Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
|
|
78
|
+
* decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
|
|
79
|
+
*/
|
|
80
|
+
const islandAssets = (islands: IslandBundle): readonly PrecacheAsset[] =>
|
|
81
|
+
[...islands.chunks]
|
|
82
|
+
.map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
|
|
83
|
+
.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Four lines, and every one of them earns it. `load` because registration competes with the page's
|
|
87
|
+
* own first paint for the same network. `scope: '/'` stated rather than inferred, so a change to
|
|
88
|
+
* where the file is served is a build-time refusal (`assertScope`) instead of a worker that
|
|
89
|
+
* silently controls a subdirectory. The `catch` because a registration that throws in a browser
|
|
90
|
+
* with service workers disabled — an incognito profile, an enterprise policy — must not take an
|
|
91
|
+
* otherwise working page down with it.
|
|
92
|
+
*/
|
|
93
|
+
const registerSource = (): string =>
|
|
94
|
+
`if ('serviceWorker' in navigator) {
|
|
95
|
+
addEventListener('load', function () {
|
|
96
|
+
navigator.serviceWorker
|
|
97
|
+
.register(${JSON.stringify(SERVICE_WORKER_PATH)}, { scope: ${JSON.stringify(SW_SCOPE)} })
|
|
98
|
+
.catch(function (error) { console.warn('service worker registration failed', error); });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the worker, or answer `undefined` for an app that declared no PWA.
|
|
105
|
+
*
|
|
106
|
+
* A bad `sw.js` is sticky in a way a manifest is not: a manifest a browser dislikes is ignored, a
|
|
107
|
+
* worker that installs and caches wrong keeps serving wrong bytes until the user clears site data.
|
|
108
|
+
* That is why this landed only once a real browser could be driven —
|
|
109
|
+
* `packages/cli/e2e/service-worker.e2e.test.ts` installs the emitted worker in Chrome, takes the
|
|
110
|
+
* network away and asserts the fallback renders.
|
|
111
|
+
*
|
|
112
|
+
* `pwa.enabled` is the one switch, and it is already spent: `loadPwaArtifacts` answers `undefined`
|
|
113
|
+
* for an app that declared no PWA, so a caller only reaches this with an installable one.
|
|
114
|
+
* `defineConfig` refuses `enabled: true` without an absolute `offline.fallback`, and a
|
|
115
|
+
* hand-written config that lacks one reads as `null` here — so `requireOfflineFallback` inside
|
|
116
|
+
* `generateServiceWorker` can never be the thing that fails, and the app gets no worker rather
|
|
117
|
+
* than a worker caching a path nobody declared.
|
|
118
|
+
*/
|
|
119
|
+
export function serviceWorkerArtifacts(
|
|
120
|
+
input: ServiceWorkerInput,
|
|
121
|
+
): ServiceWorkerArtifacts | undefined {
|
|
122
|
+
const pwa = input.pwa;
|
|
123
|
+
if (pwa.offline.fallback === null) return undefined;
|
|
124
|
+
const output = generateServiceWorker(
|
|
125
|
+
pwaRoutes(input.routes),
|
|
126
|
+
{
|
|
127
|
+
scope: SW_SCOPE,
|
|
128
|
+
swPath: SERVICE_WORKER_PATH,
|
|
129
|
+
offline: {
|
|
130
|
+
fallback: pwa.offline.fallback,
|
|
131
|
+
...(pwa.offline.image === null ? {} : { image: pwa.offline.image }),
|
|
132
|
+
...(pwa.offline.font === null ? {} : { font: pwa.offline.font }),
|
|
133
|
+
neverCache: pwa.offline.neverCache,
|
|
134
|
+
},
|
|
135
|
+
capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
|
|
136
|
+
assets: islandAssets(input.islands),
|
|
137
|
+
},
|
|
138
|
+
input.buildId,
|
|
139
|
+
);
|
|
140
|
+
return {
|
|
141
|
+
source: output.source,
|
|
142
|
+
register: registerSource(),
|
|
143
|
+
head: `<script src="${SW_REGISTER_PATH}" defer></script>`,
|
|
144
|
+
precache: output.precache,
|
|
145
|
+
// `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
|
|
146
|
+
// verbatim — so it is read once, not twice. The push line is this module's own, and it is the
|
|
147
|
+
// one thing the generator cannot say: `generateServiceWorker` emits a push handler only when a
|
|
148
|
+
// VAPID key comes with the capability, and drops it in SILENCE otherwise. `pwa.push: true` in
|
|
149
|
+
// an `app.config.ts` therefore wires nothing and reports nothing, which is `jobs.driver`'s
|
|
150
|
+
// shape one package over.
|
|
151
|
+
warnings: [...output.warnings, ...pushWarning(pwa)],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `pwa.push: true` with nothing to sign a subscription with. There is no `pwa.vapid` config key
|
|
157
|
+
* yet, so today this fires for EVERY app that sets the flag — deliberately: a switch that silently
|
|
158
|
+
* does nothing is the defect this framework keeps re-shipping, and a warning naming the missing
|
|
159
|
+
* half is the smallest honest answer until the key exists.
|
|
160
|
+
*/
|
|
161
|
+
const pushWarning = (pwa: PwaArtifacts): readonly string[] =>
|
|
162
|
+
pwa.push ? [msg('cli.build.pushUnwired')] : [];
|
package/src/sw-routes.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The two GET routes that serve what `sw-artifacts.ts` built, and the cache policy each one needs.
|
|
2
|
+
// Split from the emitter because mounting is HTTP and generation is a build: `prerender.ts` writes
|
|
3
|
+
// both artifacts as files and mounts nothing, and it must not have to import a route table to do it.
|
|
4
|
+
|
|
5
|
+
import type { CacheHint, Route } from '@ultimat3/http';
|
|
6
|
+
import { applyCacheHeaders } from '@ultimat3/http';
|
|
7
|
+
import type { ServiceWorkerArtifacts } from './sw-artifacts';
|
|
8
|
+
import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, SW_SCOPE } from './sw-artifacts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `no-store`, and it is the one asset here that must be. A cached `sw.js` is a worker that cannot
|
|
12
|
+
* be replaced: the browser re-fetches it to decide whether an update exists, and an intermediary
|
|
13
|
+
* answering the old bytes pins every client to the deploy that shipped them. Browsers cap SW
|
|
14
|
+
* script caching at 24h on their own; this removes the question.
|
|
15
|
+
*/
|
|
16
|
+
const SW_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
|
|
17
|
+
|
|
18
|
+
/** Content-addressed in neither path, so the register script gets the favicon's hour. */
|
|
19
|
+
const REGISTER_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
|
|
20
|
+
|
|
21
|
+
/** `/sw.js` and `/x-sw-register.js`, mounted by `x dev` and by the container alike. */
|
|
22
|
+
export const serviceWorkerRoutes = (artifacts: ServiceWorkerArtifacts): readonly Route[] => [
|
|
23
|
+
{
|
|
24
|
+
method: 'GET',
|
|
25
|
+
path: SERVICE_WORKER_PATH,
|
|
26
|
+
meta: { name: 'pwa.serviceWorker', auth: 'public' },
|
|
27
|
+
handler: () =>
|
|
28
|
+
applyCacheHeaders(
|
|
29
|
+
new Response(artifacts.source, {
|
|
30
|
+
headers: {
|
|
31
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
32
|
+
// Without it the browser refuses to let a worker served from `/` control `/`, which
|
|
33
|
+
// is the failure `assertScope` cannot see: the scope a REGISTRATION asks for has to be
|
|
34
|
+
// allowed by the script's own response, not only by its path.
|
|
35
|
+
'service-worker-allowed': SW_SCOPE,
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
SW_CACHE,
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
method: 'GET',
|
|
43
|
+
path: SW_REGISTER_PATH,
|
|
44
|
+
meta: { name: 'pwa.serviceWorkerRegister', auth: 'public' },
|
|
45
|
+
handler: () =>
|
|
46
|
+
applyCacheHeaders(
|
|
47
|
+
new Response(artifacts.register, {
|
|
48
|
+
headers: { 'content-type': 'text/javascript; charset=utf-8' },
|
|
49
|
+
}),
|
|
50
|
+
REGISTER_CACHE,
|
|
51
|
+
),
|
|
52
|
+
},
|
|
53
|
+
];
|
package/src/templates/naming.ts
CHANGED
|
@@ -79,6 +79,17 @@ export const plural = (input: string): string => {
|
|
|
79
79
|
|
|
80
80
|
export const titleKey = (input: string): string => `app.${kebab(input)}.title`;
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A human title from a slug: `ledger-demo` -> `Ledger Demo`. Its one caller is the scaffolded
|
|
84
|
+
* `pwa.name`, which is what a browser shows a person in the install prompt — `app.name` is a slug
|
|
85
|
+
* by `NAME_RE` and `pascal` would offer to install `LedgerDemo`. Not a `NameSet` member: every
|
|
86
|
+
* other field there names a code identifier, and this one is prose.
|
|
87
|
+
*/
|
|
88
|
+
export const titleCase = (input: string): string =>
|
|
89
|
+
words(input)
|
|
90
|
+
.map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
|
|
91
|
+
.join(' ');
|
|
92
|
+
|
|
82
93
|
export interface NameSet {
|
|
83
94
|
readonly raw: string;
|
|
84
95
|
readonly kebab: string;
|
|
@@ -190,16 +190,63 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
|
|
|
190
190
|
});
|
|
191
191
|
`;
|
|
192
192
|
|
|
193
|
+
const offlineTest =
|
|
194
|
+
(): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
|
|
195
|
+
// JavaScript. Every one of those is a config field here, and every one of them rots the moment
|
|
196
|
+
// someone adds an import or a policy — at which point the page the service worker precaches is a
|
|
197
|
+
// page that cannot render when it is finally needed.
|
|
198
|
+
import { metaContextFor, routeDataFor } from '@ultimat3/render';
|
|
199
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
200
|
+
import { config } from './page';
|
|
201
|
+
|
|
202
|
+
const ctx = { params: {}, url: 'https://example.test/offline' };
|
|
203
|
+
|
|
204
|
+
unitTest('the offline fallback is static, precached, and ships no JavaScript', async () => {
|
|
205
|
+
expect(config.render).toBe('static');
|
|
206
|
+
// 'precache', or the document that answers a lost network is itself fetched over the network.
|
|
207
|
+
expect(config.offline).toBe('precache');
|
|
208
|
+
expect(config.hydrate).toBe('never');
|
|
209
|
+
expect(config.budget.js).toBe('0kb');
|
|
210
|
+
// A cached error page has nothing to index, and an indexed one outranks the page it stood in for
|
|
211
|
+
// on the day the crawler happened to be offline.
|
|
212
|
+
const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
|
|
213
|
+
expect(meta.robots?.index).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
`;
|
|
216
|
+
|
|
193
217
|
const offlineFallback = (
|
|
194
218
|
app: NameSet,
|
|
195
|
-
): string => `// The offline fallback
|
|
196
|
-
//
|
|
219
|
+
): string => `// The offline fallback, and it is a ROUTE — \`pwa.offline.fallback\` in app.config.ts names this
|
|
220
|
+
// path, the generated sw.js precaches it, and every app/ route with offline: 'runtime' falls back
|
|
221
|
+
// here. So a train tunnel shows the product's own shell instead of the browser's error page.
|
|
222
|
+
//
|
|
223
|
+
// site/ and render: 'static', deliberately: the document that answers a lost network has to render
|
|
224
|
+
// with no network, no session and no database, which is what site/ guarantees and app/ (ssr |
|
|
225
|
+
// stream) cannot. \`offline: 'precache'\` for the same reason one level down — a fallback fetched
|
|
226
|
+
// over the network when the network is gone is not a fallback.
|
|
197
227
|
|
|
198
228
|
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
199
|
-
|
|
200
|
-
import
|
|
229
|
+
${sortedImports([
|
|
230
|
+
`import { useT } from '@${app.kebab}/i18n';`,
|
|
231
|
+
`import { defineRoute } from '@ultimat3/render';`,
|
|
232
|
+
])}
|
|
233
|
+
import styles from './page.module.scss';
|
|
234
|
+
|
|
235
|
+
export const config = defineRoute({
|
|
236
|
+
render: 'static',
|
|
237
|
+
offline: 'precache',
|
|
238
|
+
hydrate: 'never',
|
|
239
|
+
budget: { js: '0kb' },
|
|
240
|
+
meta: ({ t }) => ({
|
|
241
|
+
title: t('app.offline.title'),
|
|
242
|
+
description: t('app.offline.description'),
|
|
243
|
+
// A cached error page has nothing to index, and an indexed one outranks the page it stood in
|
|
244
|
+
// for on the day the crawler happened to be offline.
|
|
245
|
+
robots: { index: false },
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
201
248
|
|
|
202
|
-
export function
|
|
249
|
+
export function OfflinePage() {
|
|
203
250
|
const t = useT();
|
|
204
251
|
|
|
205
252
|
return (
|
|
@@ -390,8 +437,12 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
|
|
|
390
437
|
// policy and `shared/roles.ts` declares the grants, and until this file existed nothing
|
|
391
438
|
// answered "who is this?" — so every one of those routes refused every request.
|
|
392
439
|
...authFiles(app),
|
|
393
|
-
|
|
394
|
-
|
|
440
|
+
// `site/offline/page.tsx`, not `app/offline.tsx`: the directory is the URL and `<name>.tsx` is
|
|
441
|
+
// not a route file, so the old path shipped a component nothing rendered and left `/offline` a
|
|
442
|
+
// URL the generated service worker could not fall back to.
|
|
443
|
+
{ path: 'apps/web/site/offline/page.tsx', contents: offlineFallback(app) },
|
|
444
|
+
{ path: 'apps/web/site/offline/page.module.scss', contents: offlineStyle() },
|
|
445
|
+
{ path: 'apps/web/site/offline/page.test.ts', contents: offlineTest() },
|
|
395
446
|
// The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
|
|
396
447
|
...apiFiles(example),
|
|
397
448
|
{ path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
|
|
@@ -8,6 +8,7 @@ import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
|
|
|
8
8
|
import { VERIFY_FLOOR_FILE } from '../verify-floor';
|
|
9
9
|
import type { VerifyStepName } from '../verify-step';
|
|
10
10
|
import type { GeneratedFile, NameSet } from './naming';
|
|
11
|
+
import { titleCase } from './naming';
|
|
11
12
|
import { dbPackageFiles } from './scaffold-db-package';
|
|
12
13
|
import { docsFiles } from './scaffold-docs';
|
|
13
14
|
import { domainPackageFiles } from './scaffold-domain-package';
|
|
@@ -180,7 +181,22 @@ export const config = defineConfig({
|
|
|
180
181
|
jobs: { queues: ['${app.kebab}-default'], concurrency: 4 },
|
|
181
182
|
// In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
|
|
182
183
|
realtime: { enabled: true, transport: 'memory' },
|
|
183
|
-
|
|
184
|
+
// \`name\` and \`colors\` are what an install prompt shows and what a browser paints the splash
|
|
185
|
+
// with before any stylesheet has loaded — the four values the framework cannot derive, so
|
|
186
|
+
// \`defineConfig\` refuses \`pwa.enabled: true\` without them. Raw hex is legal here and nowhere
|
|
187
|
+
// else in an app.
|
|
188
|
+
pwa: {
|
|
189
|
+
enabled: true,
|
|
190
|
+
// The document an offline navigation gets when the cache has no answer, and the path
|
|
191
|
+
// \`apps/web/site/offline/page.tsx\` serves. Required once \`enabled\` is true: an installable
|
|
192
|
+
// app that shows the browser's error page offline is the failure the block exists to prevent.
|
|
193
|
+
offline: { fallback: '/offline' },
|
|
194
|
+
name: '${titleCase(app.raw)}',
|
|
195
|
+
colors: {
|
|
196
|
+
light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
|
|
197
|
+
dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
184
200
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
185
201
|
});
|
|
186
202
|
`;
|