@ultimat3/cli 22.3.1 → 22.3.2
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/package.json +28 -28
- package/src/revalidated-response.ts +38 -0
- package/src/runtime-assets.ts +10 -3
- package/src/sw-artifacts.ts +28 -8
- package/src/sw-routes.ts +8 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.3.
|
|
3
|
+
"version": "22.3.2",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.3.
|
|
42
|
-
"@ultimat3/admin": "22.3.
|
|
43
|
-
"@ultimat3/ai": "22.3.
|
|
44
|
-
"@ultimat3/auth": "22.3.
|
|
45
|
-
"@ultimat3/cache": "22.3.
|
|
46
|
-
"@ultimat3/core": "22.3.
|
|
47
|
-
"@ultimat3/db": "22.3.
|
|
48
|
-
"@ultimat3/entity": "22.3.
|
|
49
|
-
"@ultimat3/flags": "22.3.
|
|
50
|
-
"@ultimat3/http": "22.3.
|
|
51
|
-
"@ultimat3/i18n": "22.3.
|
|
52
|
-
"@ultimat3/jobs": "22.3.
|
|
53
|
-
"@ultimat3/mail": "22.3.
|
|
54
|
-
"@ultimat3/manifest": "22.3.
|
|
55
|
-
"@ultimat3/mcp": "22.3.
|
|
56
|
-
"@ultimat3/money": "22.3.
|
|
57
|
-
"@ultimat3/notify": "22.3.
|
|
58
|
-
"@ultimat3/policy": "22.3.
|
|
59
|
-
"@ultimat3/pwa": "22.3.
|
|
60
|
-
"@ultimat3/query": "22.3.
|
|
61
|
-
"@ultimat3/realtime": "22.3.
|
|
62
|
-
"@ultimat3/render": "22.3.
|
|
63
|
-
"@ultimat3/schema": "22.3.
|
|
64
|
-
"@ultimat3/seo": "22.3.
|
|
65
|
-
"@ultimat3/storage": "22.3.
|
|
66
|
-
"@ultimat3/testing": "22.3.
|
|
67
|
-
"@ultimat3/time": "22.3.
|
|
41
|
+
"@ultimat3/action": "22.3.2",
|
|
42
|
+
"@ultimat3/admin": "22.3.2",
|
|
43
|
+
"@ultimat3/ai": "22.3.2",
|
|
44
|
+
"@ultimat3/auth": "22.3.2",
|
|
45
|
+
"@ultimat3/cache": "22.3.2",
|
|
46
|
+
"@ultimat3/core": "22.3.2",
|
|
47
|
+
"@ultimat3/db": "22.3.2",
|
|
48
|
+
"@ultimat3/entity": "22.3.2",
|
|
49
|
+
"@ultimat3/flags": "22.3.2",
|
|
50
|
+
"@ultimat3/http": "22.3.2",
|
|
51
|
+
"@ultimat3/i18n": "22.3.2",
|
|
52
|
+
"@ultimat3/jobs": "22.3.2",
|
|
53
|
+
"@ultimat3/mail": "22.3.2",
|
|
54
|
+
"@ultimat3/manifest": "22.3.2",
|
|
55
|
+
"@ultimat3/mcp": "22.3.2",
|
|
56
|
+
"@ultimat3/money": "22.3.2",
|
|
57
|
+
"@ultimat3/notify": "22.3.2",
|
|
58
|
+
"@ultimat3/policy": "22.3.2",
|
|
59
|
+
"@ultimat3/pwa": "22.3.2",
|
|
60
|
+
"@ultimat3/query": "22.3.2",
|
|
61
|
+
"@ultimat3/realtime": "22.3.2",
|
|
62
|
+
"@ultimat3/render": "22.3.2",
|
|
63
|
+
"@ultimat3/schema": "22.3.2",
|
|
64
|
+
"@ultimat3/seo": "22.3.2",
|
|
65
|
+
"@ultimat3/storage": "22.3.2",
|
|
66
|
+
"@ultimat3/testing": "22.3.2",
|
|
67
|
+
"@ultimat3/time": "22.3.2",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The one answer for a public file whose URL carries NO content hash: cacheable, revalidated on
|
|
2
|
+
// every use, and a 304 when the bytes have not moved. `immutable` belongs to a URL that IS its
|
|
3
|
+
// content (islands, styles, `asset()` files) and to nothing else — a file served immutable under a
|
|
4
|
+
// name that outlives its bytes is a file no client refreshes for a year.
|
|
5
|
+
|
|
6
|
+
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { etagMatches } from './runtime-storage';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The document's own posture (`@ultimat3/render`'s `render-static.ts`), for the same reason: the
|
|
11
|
+
* URL says nothing about the bytes, so a cache may keep them and must ask before reusing them.
|
|
12
|
+
* Written as a header rather than a `CacheHint`, which has no `must-revalidate`.
|
|
13
|
+
*/
|
|
14
|
+
export const REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A strong validator from the bytes — xxHash32, the function `contentHash` applies to a document,
|
|
18
|
+
* taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
|
|
19
|
+
*/
|
|
20
|
+
export function revalidatedResponse(
|
|
21
|
+
request: UltimateRequest,
|
|
22
|
+
body: string | Uint8Array,
|
|
23
|
+
contentType: string,
|
|
24
|
+
): Response {
|
|
25
|
+
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
26
|
+
// `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
|
|
27
|
+
const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body);
|
|
28
|
+
const etag = `"${Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0')}"`;
|
|
29
|
+
const headers = {
|
|
30
|
+
'content-type': contentType,
|
|
31
|
+
'cache-control': REVALIDATE_CACHE_CONTROL,
|
|
32
|
+
etag,
|
|
33
|
+
};
|
|
34
|
+
if (etagMatches(request.header('if-none-match'), etag)) {
|
|
35
|
+
return new Response(null, { status: 304, headers });
|
|
36
|
+
}
|
|
37
|
+
return new Response(bytes, { headers });
|
|
38
|
+
}
|
package/src/runtime-assets.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { faviconRoute } from './favicon';
|
|
|
20
20
|
import { iconPlan, iconRenderer } from './icon-assets';
|
|
21
21
|
import type { PwaArtifacts } from './pwa-artifacts';
|
|
22
22
|
import { pwaManifestRoute } from './pwa-artifacts';
|
|
23
|
+
import { revalidatedResponse } from './revalidated-response';
|
|
23
24
|
import {
|
|
24
25
|
AUTHORIZED_OBJECT_CACHE,
|
|
25
26
|
assertReadableKey,
|
|
@@ -41,8 +42,10 @@ import { siteAssetTable } from './site-assets';
|
|
|
41
42
|
export const MEDIA_BASE_PATH = '/media';
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
|
-
* A
|
|
45
|
-
*
|
|
45
|
+
* A content-addressed variant answers forever with the same bytes, so the immutable hint is a fact
|
|
46
|
+
* about the key. It is NOT a fact about this route — see `mediaCache`. The generated ICONS are not
|
|
47
|
+
* content-addressed (`/icons/icon-192.png` names a size, not bytes), so they revalidate instead —
|
|
48
|
+
* see `revalidatedResponse`.
|
|
46
49
|
*/
|
|
47
50
|
const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
|
|
48
51
|
|
|
@@ -198,8 +201,12 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
|
198
201
|
method: 'GET',
|
|
199
202
|
path: entry.outputPath,
|
|
200
203
|
meta: { name: `assets.icon.${entry.spec.filename}`, auth: 'public', tags: ['assets'] },
|
|
204
|
+
// Revalidated, never `immutable` (22.3.2): the file name is the icon's SIZE, so a new
|
|
205
|
+
// `icon.png` shipped under the same URLs and every client that had seen the old one kept it for
|
|
206
|
+
// a year. The manifest and the apple-touch links name these paths, so a hashed URL would move
|
|
207
|
+
// every document and the manifest with it; a 304 is the cheaper fix.
|
|
201
208
|
handler: async (request: UltimateRequest): Promise<Response> =>
|
|
202
|
-
|
|
209
|
+
revalidatedResponse(request, await render(plan, request.pathname), 'image/png'),
|
|
203
210
|
}));
|
|
204
211
|
// The icons above are genuinely public — they are rendered from a file committed in the app, and
|
|
205
212
|
// an install prompt fetches them before anyone has signed in. `/media` is the opposite: it serves
|
package/src/sw-artifacts.ts
CHANGED
|
@@ -23,10 +23,13 @@ export const SW_SCOPE = '/';
|
|
|
23
23
|
*/
|
|
24
24
|
export const SW_REGISTER_PATH = '/x-sw-register.js';
|
|
25
25
|
|
|
26
|
+
/** How long a returning tab waits between two `sw.js` update checks — five minutes. */
|
|
27
|
+
export const SW_UPDATE_INTERVAL_MS = 300_000;
|
|
28
|
+
|
|
26
29
|
export interface ServiceWorkerArtifacts {
|
|
27
30
|
/** `sw.js`, deterministic for identical input. */
|
|
28
31
|
readonly source: string;
|
|
29
|
-
/** `x-sw-register.js`, the
|
|
32
|
+
/** `x-sw-register.js`, the lines that install it and look for its successor. */
|
|
30
33
|
readonly register: string;
|
|
31
34
|
/** The `<script src>` tag, appended to `PwaArtifacts.head` by every surface that serves it. */
|
|
32
35
|
readonly head: string;
|
|
@@ -133,25 +136,42 @@ const staticAssets = (
|
|
|
133
136
|
.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
134
137
|
|
|
135
138
|
/**
|
|
136
|
-
*
|
|
137
|
-
* own first paint for the same network. `scope: '/'` stated rather than inferred, so a
|
|
138
|
-
* where the file is served is a build-time refusal (`assertScope`) instead of a worker
|
|
139
|
-
* silently controls a subdirectory. The `catch` because a registration that throws in a
|
|
140
|
-
* with service workers disabled — an incognito profile, an enterprise policy — must not
|
|
141
|
-
* otherwise working page down with it.
|
|
139
|
+
* The registration, and every line of it earns its bytes. `load` because registration competes with
|
|
140
|
+
* the page's own first paint for the same network. `scope: '/'` stated rather than inferred, so a
|
|
141
|
+
* change to where the file is served is a build-time refusal (`assertScope`) instead of a worker
|
|
142
|
+
* that silently controls a subdirectory. The `catch` because a registration that throws in a
|
|
143
|
+
* browser with service workers disabled — an incognito profile, an enterprise policy — must not
|
|
144
|
+
* take an otherwise working page down with it.
|
|
145
|
+
*
|
|
146
|
+
* `registration.update()` when a hidden tab comes back, at most once per `SW_UPDATE_INTERVAL_MS`
|
|
147
|
+
* (22.3.2). A browser checks for a new `sw.js` on a NAVIGATION; a tab left open for days makes
|
|
148
|
+
* none, so the new worker was found only on the click that was already answered by the old one. The
|
|
149
|
+
* throttle keeps an alt-tabbing reader from re-downloading the worker on every focus, and the
|
|
150
|
+
* swallowed rejection is the offline case. It never posts `skip-waiting` and never reloads: the
|
|
151
|
+
* worker skips waiting itself (`@ultimat3/pwa`'s install block), and a reload under a reader who is
|
|
152
|
+
* typing is the one thing an update must not do — the next navigation gets the new HTML.
|
|
142
153
|
*
|
|
143
154
|
* EXPORTED because a static export has to put these bytes on disk BEFORE it renders, not after.
|
|
144
155
|
* The file is named by every document and weighed by `measureDocumentJs`, and it used to be
|
|
145
156
|
* written last, beside `sw.js` — so the measurement read a file that did not exist yet on a clean
|
|
146
157
|
* output directory, and the PREVIOUS build's copy on a reused one. `sw.js` still comes last, for
|
|
147
158
|
* the reason its own comment gives (its precache manifest is built from the rendered documents'
|
|
148
|
-
* content hashes); this half depends on nothing but
|
|
159
|
+
* content hashes); this half depends on nothing but constants, so it can and must come first.
|
|
149
160
|
*/
|
|
150
161
|
export const serviceWorkerRegistration = (): string =>
|
|
151
162
|
`if ('serviceWorker' in navigator) {
|
|
152
163
|
addEventListener('load', function () {
|
|
153
164
|
navigator.serviceWorker
|
|
154
165
|
.register(${JSON.stringify(SERVICE_WORKER_PATH)}, { scope: ${JSON.stringify(SW_SCOPE)} })
|
|
166
|
+
.then(function (registration) {
|
|
167
|
+
var checked = Date.now();
|
|
168
|
+
document.addEventListener('visibilitychange', function () {
|
|
169
|
+
if (document.visibilityState !== 'visible') return;
|
|
170
|
+
if (Date.now() - checked < ${SW_UPDATE_INTERVAL_MS}) return;
|
|
171
|
+
checked = Date.now();
|
|
172
|
+
registration.update().catch(function () {});
|
|
173
|
+
});
|
|
174
|
+
})
|
|
155
175
|
.catch(function (error) { console.warn('service worker registration failed', error); });
|
|
156
176
|
});
|
|
157
177
|
}
|
package/src/sw-routes.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Split from the emitter because mounting is HTTP and generation is a build: `prerender.ts` writes
|
|
3
3
|
// both artifacts as files and mounts nothing, and it must not have to import a route table to do it.
|
|
4
4
|
|
|
5
|
-
import type { CacheHint, Route } from '@ultimat3/http';
|
|
5
|
+
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
6
6
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
7
|
+
import { revalidatedResponse } from './revalidated-response';
|
|
7
8
|
import type { ServiceWorkerArtifacts } from './sw-artifacts';
|
|
8
9
|
import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, SW_SCOPE } from './sw-artifacts';
|
|
9
10
|
|
|
@@ -15,9 +16,6 @@ import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, SW_SCOPE } from './sw-artifacts'
|
|
|
15
16
|
*/
|
|
16
17
|
const SW_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
|
|
17
18
|
|
|
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
19
|
/** `/sw.js` and `/x-sw-register.js`, mounted by `x dev` and by the container alike. */
|
|
22
20
|
export const serviceWorkerRoutes = (artifacts: ServiceWorkerArtifacts): readonly Route[] => [
|
|
23
21
|
{
|
|
@@ -42,12 +40,11 @@ export const serviceWorkerRoutes = (artifacts: ServiceWorkerArtifacts): readonly
|
|
|
42
40
|
method: 'GET',
|
|
43
41
|
path: SW_REGISTER_PATH,
|
|
44
42
|
meta: { name: 'pwa.serviceWorkerRegister', auth: 'public' },
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
),
|
|
43
|
+
// Revalidated on every load, never an hour (22.3.2). The URL carries no content hash — it is
|
|
44
|
+
// named by every document, so hashing it would re-render them all for a four-line script — and
|
|
45
|
+
// at `max-age=3600` a changed register script reached a returning visitor up to an hour late.
|
|
46
|
+
// A strong ETag makes the check a 304. Still an EXTERNAL script: see `SW_REGISTER_PATH`.
|
|
47
|
+
handler: (request: UltimateRequest) =>
|
|
48
|
+
revalidatedResponse(request, artifacts.register, 'text/javascript; charset=utf-8'),
|
|
52
49
|
},
|
|
53
50
|
];
|