@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/role-sync.ts
CHANGED
|
@@ -2,25 +2,35 @@
|
|
|
2
2
|
// Split from `role-start.ts` because it is the one role with an authenticator, a presence registry
|
|
3
3
|
// and a listener of its own — and because that file is the boot's index, not its detail.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { ctxOf, logger, type RealtimeConfig, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
clientAddress,
|
|
8
|
+
configuredHttp,
|
|
9
|
+
defineHttpConfig,
|
|
10
|
+
mergeHttpConfig,
|
|
11
|
+
type WebSocketMount,
|
|
12
|
+
} from '@ultimat3/http';
|
|
7
13
|
import { listQueries } from '@ultimat3/query';
|
|
8
14
|
import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
|
|
9
15
|
import {
|
|
10
16
|
ChannelHub,
|
|
11
|
-
createSyncNode,
|
|
12
17
|
LiveQueryRegistry,
|
|
13
18
|
listenSyncNode,
|
|
14
19
|
liveQueryDefinition,
|
|
15
20
|
PresenceRegistry,
|
|
16
21
|
RingChangeBuffer,
|
|
17
22
|
SocketRegistry,
|
|
23
|
+
syncNode,
|
|
18
24
|
} from '@ultimat3/realtime/server';
|
|
19
25
|
import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
|
|
20
26
|
import { portFree } from './port-probe';
|
|
21
27
|
import type { StartRolesOptions } from './role-start';
|
|
28
|
+
import type { Env } from './runtime-bindings';
|
|
22
29
|
import { syncAuthenticator } from './sync-authenticator';
|
|
23
30
|
import { syncOriginsFrom } from './sync-url';
|
|
31
|
+
// A value edge back into `role-start.ts`, read only inside a call: the one parser of
|
|
32
|
+
// `TRUSTED_PROXY_HOPS`, never a second copy of it.
|
|
33
|
+
import { trustedHopsFromEnv } from './trusted-hops';
|
|
24
34
|
import { DEV_BINDING } from './web-binding';
|
|
25
35
|
|
|
26
36
|
/**
|
|
@@ -120,7 +130,8 @@ export interface PreparedSync {
|
|
|
120
130
|
readonly mount: WebSocketMount<SyncWs>;
|
|
121
131
|
/**
|
|
122
132
|
* Bind `PORT + 1`, and answer with the same object `startSync` always did. `appUrl` is the web
|
|
123
|
-
* role's own origin when one runs here
|
|
133
|
+
* role's own origin when one runs here: named in the boot line beside the node's, and — under
|
|
134
|
+
* `x dev` with an `APP_URL` declared — admitted as a page origin (`devOrigins`).
|
|
124
135
|
*/
|
|
125
136
|
listen(appUrl: string | null): Promise<RunningSync>;
|
|
126
137
|
/** Release the node when nothing ever bound it — a `web` role that threw after it was built. */
|
|
@@ -153,18 +164,26 @@ export interface RunningSync {
|
|
|
153
164
|
* close. And no default is defensible: one tenant is a single person and the next is five
|
|
154
165
|
* thousand seats, so any number here is either unreachable or an outage on a Monday morning. The
|
|
155
166
|
* per-socket 128 stands because a socket is one browser tab, which is a bound the framework can
|
|
156
|
-
* actually know
|
|
167
|
+
* actually know, and so does the per-ACTOR 1,000 (`realtime.maxSubscriptionsPerActor`): one
|
|
168
|
+
* principal's share is knowable where a tenant's is not. A deployment that wants the tenant cap
|
|
169
|
+
* passes both:
|
|
157
170
|
*
|
|
158
171
|
* new LiveQueryRegistry({ …, maxPerTenant: 5_000, tenantOf: (actor) => actor?.orgId ?? null })
|
|
159
172
|
*/
|
|
160
173
|
export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegistry {
|
|
174
|
+
// `?.` because a caller that builds only the registry (`role-sync-resume.test.ts`) hands no
|
|
175
|
+
// runtime; unset is the registry's own `DEFAULT_MAX_PER_ACTOR`, which is what core leaves it.
|
|
176
|
+
const maxPerActor = options.runtime?.realtime?.maxSubscriptionsPerActor;
|
|
161
177
|
const registry = new LiveQueryRegistry({
|
|
162
178
|
source: new RingChangeBuffer(),
|
|
179
|
+
// One actor's — or one anonymous address's — share of this node's live queries, from the app's
|
|
180
|
+
// `realtime.maxSubscriptionsPerActor`. Without it 79 sockets of one actor filled the node.
|
|
181
|
+
...(maxPerActor === undefined ? {} : { maxPerActor }),
|
|
163
182
|
// A withheld row is a metric, never a frame and never an error: telling a client "there is a
|
|
164
183
|
// row you may not see" is the leak the gate exists to prevent.
|
|
165
184
|
onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
|
|
166
185
|
});
|
|
167
|
-
const ctx =
|
|
186
|
+
const ctx = ctxOf({ role: 'sync', buildId: options.buildId });
|
|
168
187
|
// The position a snapshot claims: the newest change this node had received when the read began.
|
|
169
188
|
// The read then holds at least that change, so claiming it is true, and every later change is
|
|
170
189
|
// above it. Unwired, every snapshot claimed `''` and a read that landed after any fan-out was
|
|
@@ -176,6 +195,74 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
|
|
|
176
195
|
return registry;
|
|
177
196
|
}
|
|
178
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Who dialled the socket, resolved by `@ultimat3/http`'s `clientAddress()` — the rule the web role's
|
|
200
|
+
* `ctx.ip` follows, at the same `TRUSTED_PROXY_HOPS`. It keys an anonymous live reader's rate
|
|
201
|
+
* limit: read off the socket alone, a node behind an ingress put every anonymous viewer of the
|
|
202
|
+
* fleet in the ingress's one bucket, and one visitor looping subscribes locked out the rest.
|
|
203
|
+
*
|
|
204
|
+
* The config is built only to carry the hops — `rateLimit.scope` is declared because
|
|
205
|
+
* `defineHttpConfig` requires it, and this node runs no HTTP limiter that would read it.
|
|
206
|
+
*/
|
|
207
|
+
export function syncClientAddressOf(
|
|
208
|
+
env: Env,
|
|
209
|
+
): (request: Request, socketAddress: string | null) => string | null {
|
|
210
|
+
const hops = trustedHopsFromEnv(env);
|
|
211
|
+
const config = defineHttpConfig(
|
|
212
|
+
mergeHttpConfig(configuredHttp(), {
|
|
213
|
+
rateLimit: { scope: 'process' },
|
|
214
|
+
...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
return (request, socketAddress) =>
|
|
218
|
+
clientAddress({
|
|
219
|
+
headers: request.headers,
|
|
220
|
+
config,
|
|
221
|
+
socketAddress,
|
|
222
|
+
urlProtocol: new URL(request.url).protocol,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The node's half of the app's per-actor caps: `realtime.maxSocketsPerActor`, the sockets one
|
|
228
|
+
* principal may hold here. Absent, the node keeps its own default (16) — which is core's too.
|
|
229
|
+
*/
|
|
230
|
+
export function syncNodeCaps(realtime: RealtimeConfig | undefined): {
|
|
231
|
+
readonly maxSocketsPerActor?: number;
|
|
232
|
+
} {
|
|
233
|
+
const max = realtime?.maxSocketsPerActor;
|
|
234
|
+
return max === undefined ? {} : { maxSocketsPerActor: max };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const healthDetailPeersOf = (
|
|
238
|
+
peers: readonly string[] | undefined,
|
|
239
|
+
): { readonly healthDetailPeers?: readonly string[] } =>
|
|
240
|
+
peers === undefined ? {} : { healthDetailPeers: peers };
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What `x dev` adds to a DECLARED list: the origin its own web role is serving pages on, under
|
|
244
|
+
* each loopback spelling a browser may have been pointed at. A declaration is the whole list, and
|
|
245
|
+
* the scaffold's `.env` declares `APP_URL=http://localhost:3000` — so `x dev --port 4000`, or dev
|
|
246
|
+
* opened as `127.0.0.1`, was a page on an origin nothing named and every socket was refused.
|
|
247
|
+
*
|
|
248
|
+
* Nothing is added to an EMPTY list: an undeclared node admits the origin it was reached on, which
|
|
249
|
+
* already covers these and a forwarded host besides — and one entry would end that rule. Nothing
|
|
250
|
+
* is added outside dev either: in a container the declaration is the operator's, and a wrong one
|
|
251
|
+
* must refuse rather than be papered over by whatever address the process bound.
|
|
252
|
+
*/
|
|
253
|
+
function devOrigins(
|
|
254
|
+
options: StartRolesOptions,
|
|
255
|
+
declared: readonly string[],
|
|
256
|
+
appUrl: string | null,
|
|
257
|
+
): string[] {
|
|
258
|
+
if (!(options.http ?? DEV_BINDING).dev || appUrl === null || declared.length === 0) return [];
|
|
259
|
+
const own = new URL(appUrl);
|
|
260
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].map(
|
|
261
|
+
(host) => `${own.protocol}//${host}:${own.port}`,
|
|
262
|
+
);
|
|
263
|
+
return [own.origin, ...loopback].filter((origin) => !declared.includes(origin));
|
|
264
|
+
}
|
|
265
|
+
|
|
179
266
|
/**
|
|
180
267
|
* The node itself: its hub, its registry, its authenticator and its change subscription. Nothing
|
|
181
268
|
* bound — `listen()` and the `web` role's mount are the two doors, and this is what is behind both.
|
|
@@ -195,16 +282,27 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
195
282
|
// `exp`), or resolves identity from a header the adapter deliberately does not retain.
|
|
196
283
|
const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
|
|
197
284
|
const registry = registerLiveQueries(options);
|
|
198
|
-
const
|
|
285
|
+
const origins = syncOriginsFrom(options.env);
|
|
286
|
+
const node = syncNode({
|
|
199
287
|
hub,
|
|
200
288
|
registry,
|
|
201
289
|
transport: options.runtime.transport,
|
|
202
290
|
buildId: options.buildId,
|
|
203
291
|
sockets,
|
|
204
292
|
...(authenticate === undefined ? {} : { authenticate }),
|
|
205
|
-
//
|
|
206
|
-
// admitted
|
|
207
|
-
|
|
293
|
+
// `APP_URL`'s origin, and when one is declared it is the WHOLE list: the origin this node was
|
|
294
|
+
// reached on is no longer admitted beside it. Undeclared, that origin (and its https spelling
|
|
295
|
+
// when TLS ended at a proxy) is what is admitted; anything else is refused before
|
|
296
|
+
// `authenticate` (X_SOCKET_ORIGIN_REFUSED). Held by reference: `listen` adds `x dev`'s own.
|
|
297
|
+
allowedOrigins: origins,
|
|
298
|
+
// Dev may keep the reached-on origin beside a declaration and a container may not: `x dev`
|
|
299
|
+
// binds loopback, so a `Host` reaches it only through a forward its own user set up.
|
|
300
|
+
...((options.http ?? DEV_BINDING).dev ? { admitReachedOrigin: true } : {}),
|
|
301
|
+
// Who this node's OWN `/healthz` and `/readyz` tell the detail to: the app's one declaration,
|
|
302
|
+
// the same list the web role reads. Unset, the node keeps its default (the box itself).
|
|
303
|
+
...healthDetailPeersOf(configuredHttp()?.healthDetailPeers),
|
|
304
|
+
clientAddressOf: syncClientAddressOf(options.env),
|
|
305
|
+
...syncNodeCaps(options.runtime.realtime),
|
|
208
306
|
// Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
|
|
209
307
|
// list at all — the KV bucket the transport just created would hold nothing and every `sync`
|
|
210
308
|
// container would run a presence-less protocol. It reads and writes `transport.shared`, so it
|
|
@@ -223,7 +321,10 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
223
321
|
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
224
322
|
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
225
323
|
stop: () => node.stop(),
|
|
226
|
-
listen: async (appUrl) =>
|
|
324
|
+
listen: async (appUrl) => {
|
|
325
|
+
origins.push(...devOrigins(options, origins, appUrl));
|
|
326
|
+
return await listen(options, node, { registry, hub }, appUrl);
|
|
327
|
+
},
|
|
227
328
|
};
|
|
228
329
|
}
|
|
229
330
|
|
|
@@ -235,27 +336,20 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
235
336
|
* neighbouring port, and reversing the order would answer a second `x dev` on this checkout with
|
|
236
337
|
* "port 3001 is in use" when the fact worth printing is that 3000 is.
|
|
237
338
|
*
|
|
238
|
-
* Port 0
|
|
239
|
-
*
|
|
240
|
-
* `
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* listener's own bound address either way, never a string built from the port that was requested.
|
|
339
|
+
* Port 0 IS passed through to the kernel, web role or not. A scratch server (`x shot`, `ui.shot`,
|
|
340
|
+
* a test) asks for 0, and its pages dial `/_x/sync` on their OWN origin — the web role's mount
|
|
341
|
+
* (`sync-url.ts`) — so nothing computes this listener's port from the web port's. Deriving
|
|
342
|
+
* `PORT + 1` from the kernel's answer asked for one specific port nobody had checked, and under
|
|
343
|
+
* load another socket held it: the boot died `X_PORT_IN_USE` (role-realtime.test.ts, 6 of 20 with
|
|
344
|
+
* 12,000 loopback connections held). The reported url is the listener's own bound address.
|
|
245
345
|
*/
|
|
246
|
-
function syncPortFrom(requested: number, appUrl: string | null): number {
|
|
247
|
-
if (requested !== 0 || appUrl === null) return syncPortFor(requested);
|
|
248
|
-
const bound = Number(new URL(appUrl).port);
|
|
249
|
-
return Number.isInteger(bound) && bound > 0 ? syncPortFor(bound) : 0;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
346
|
async function listen(
|
|
253
347
|
options: StartRolesOptions,
|
|
254
348
|
node: SyncNode,
|
|
255
349
|
feeds: Pick<RunningSync, 'registry' | 'hub'>,
|
|
256
350
|
appUrl: string | null,
|
|
257
351
|
): Promise<RunningSync> {
|
|
258
|
-
const port =
|
|
352
|
+
const port = syncPortFor(options.port);
|
|
259
353
|
try {
|
|
260
354
|
// The SAME interface the web role binds, resolved from the same option and the same default.
|
|
261
355
|
// Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
|
package/src/root-env.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The `.env*` files of the APP ROOT, whatever directory `x` was started from. Bun auto-loads them
|
|
2
2
|
// from the process cwd only, while every command finds its root by walking up — so `x db migrate`
|
|
3
3
|
// run from `apps/web` found the app and none of its `.env`, and silently used the embedded
|
|
4
|
-
// database. Loaded once, after the root is known, never overriding a
|
|
4
|
+
// database. Loaded once, after the root is known, never overriding a real environment variable.
|
|
5
5
|
|
|
6
|
-
import { isAbsolute, join, resolve } from 'node:path'; // why: Bun ships no path join/resolve.
|
|
6
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'; // why: Bun ships no path join/resolve.
|
|
7
7
|
import { findAppRoot } from './app-root';
|
|
8
8
|
|
|
9
9
|
type Env = Readonly<Record<string, string | undefined>>;
|
|
@@ -25,43 +25,172 @@ export const rootEnvFiles = (envName: string): readonly string[] => [
|
|
|
25
25
|
`.env.${envName}.local`,
|
|
26
26
|
];
|
|
27
27
|
|
|
28
|
+
const QUOTES = new Set(['"', "'", '`']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The quoted value opening at `text[open]`, read the way Bun reads it: across lines until the first
|
|
32
|
+
* unescaped closing quote, a backslash keeping the character after it (so `\"` never closes), and
|
|
33
|
+
* only double quotes turning `\n` and `\r` into the characters. `undefined` when the quote never
|
|
34
|
+
* closes — Bun then keeps the line as written, opening quote and all.
|
|
35
|
+
*/
|
|
36
|
+
function quoted(text: string, open: number): { value: string; end: number } | undefined {
|
|
37
|
+
const quote = text[open];
|
|
38
|
+
let value = '';
|
|
39
|
+
for (let at = open + 1; at < text.length; at += 1) {
|
|
40
|
+
const char = text[at];
|
|
41
|
+
if (char === quote) return { value, end: at + 1 };
|
|
42
|
+
if (char === '\\' && at + 1 < text.length) {
|
|
43
|
+
const next = text[at + 1];
|
|
44
|
+
value +=
|
|
45
|
+
quote !== '"'
|
|
46
|
+
? `${char}${next}`
|
|
47
|
+
: next === 'n'
|
|
48
|
+
? '\n'
|
|
49
|
+
: next === 'r'
|
|
50
|
+
? '\r'
|
|
51
|
+
: `${char}${next}`;
|
|
52
|
+
at += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
value += char;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* belongs in the process environment, which always wins here anyway.
|
|
61
|
+
* Bun's expansion pass, the one part of it reproduced: `\$` is a literal `$` in every kind of
|
|
62
|
+
* value, quoted or not. Run after the quote pass, which is why `"a\\$x"` reads `a\$x` in Bun too.
|
|
32
63
|
*/
|
|
33
|
-
|
|
64
|
+
const literalDollars = (value: string): string => value.replaceAll('\\$', '$');
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A dotenv file, parsed to agree with Bun's own loader on the same bytes — a root `.env` must mean
|
|
68
|
+
* the same thing whether Bun read it (the process started in the root) or this did (it started
|
|
69
|
+
* below it). `KEY=value`, an optional `export `, `#` comments (an unquoted value ends at the first
|
|
70
|
+
* `#`), values quoted with `"`, `'` or a backtick and free to span lines, so a PEM key parses. A
|
|
71
|
+
* bare `\r` separates lines as `\n` does, inside a quoted value too.
|
|
72
|
+
* Bun's `$VAR` expansion is NOT reproduced: it resolves lazily across every loaded file and the
|
|
73
|
+
* process env, so a root default that needs one belongs in the process environment.
|
|
74
|
+
*/
|
|
75
|
+
export function parseDotenv(source: string): ReadonlyMap<string, string> {
|
|
76
|
+
const text = source.replace(/\r\n?/g, '\n');
|
|
34
77
|
const out = new Map<string, string>();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
78
|
+
let start = 0;
|
|
79
|
+
while (start < text.length) {
|
|
80
|
+
const newline = text.indexOf('\n', start);
|
|
81
|
+
const eol = newline === -1 ? text.length : newline;
|
|
82
|
+
const line = text.slice(start, eol);
|
|
83
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*/.exec(line);
|
|
84
|
+
const open = start + (match?.[0].length ?? 0);
|
|
85
|
+
start = eol + 1;
|
|
39
86
|
if (match === null) continue;
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
87
|
+
const key = match[1] ?? '';
|
|
88
|
+
const read = QUOTES.has(text[open] ?? '') ? quoted(text, open) : undefined;
|
|
89
|
+
if (read !== undefined) {
|
|
90
|
+
out.set(key, literalDollars(read.value));
|
|
91
|
+
// Whatever follows the closing quote on its line is a comment to Bun.
|
|
92
|
+
const after = text.indexOf('\n', read.end);
|
|
93
|
+
start = after === -1 ? text.length : after + 1;
|
|
94
|
+
continue;
|
|
47
95
|
}
|
|
96
|
+
const value = line.slice(match[0].length);
|
|
97
|
+
const hash = value.indexOf('#');
|
|
98
|
+
out.set(key, literalDollars((hash === -1 ? value : value.slice(0, hash)).trim()));
|
|
48
99
|
}
|
|
49
100
|
return out;
|
|
50
101
|
}
|
|
51
102
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
103
|
+
/** Where the command runs, and where the PROCESS started — not the same once `--cwd` is passed. */
|
|
104
|
+
export interface RootEnvInput {
|
|
105
|
+
/** The `--cwd`-resolved directory the command works in; its app root is the one loaded. */
|
|
106
|
+
readonly cwd: string;
|
|
107
|
+
/** The directory the process started in: the only one whose `.env*` Bun loaded itself. */
|
|
108
|
+
readonly processCwd: string;
|
|
109
|
+
readonly env: Env;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every `.env*` file in `dir`, merged in Bun's precedence for `envName`. */
|
|
113
|
+
async function envFilesIn(dir: string, names: readonly string[]): Promise<Map<string, string>> {
|
|
60
114
|
const merged = new Map<string, string>();
|
|
61
|
-
for (const name of
|
|
62
|
-
const file = Bun.file(join(
|
|
115
|
+
for (const name of names) {
|
|
116
|
+
const file = Bun.file(join(dir, name));
|
|
63
117
|
if (!(await file.exists())) continue;
|
|
64
118
|
for (const [key, value] of parseDotenv(await file.text())) merged.set(key, value);
|
|
65
119
|
}
|
|
66
|
-
return
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True when `dir` is `root` or below it. */
|
|
124
|
+
const isWithin = (root: string, dir: string): boolean => {
|
|
125
|
+
const rel = relative(resolve(root), resolve(dir));
|
|
126
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** What one command's env owes the app root: keys to set, and another app's keys to remove. */
|
|
130
|
+
export interface RootEnvChanges {
|
|
131
|
+
readonly set: Readonly<Record<string, string>>;
|
|
132
|
+
readonly unset: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What the root's `.env*` files change in `env`. A real environment variable always wins. Nothing
|
|
137
|
+
* when there is no app root or the process STARTED in the root (Bun loaded those files itself).
|
|
138
|
+
* Judged against `processCwd`, never `cwd`: `x --cwd <app>` run from elsewhere resolves `cwd` to
|
|
139
|
+
* the root, and Bun loaded the other directory's files, not the app's.
|
|
140
|
+
*
|
|
141
|
+
* What Bun loaded from the starting directory is not a real variable. Started below the root
|
|
142
|
+
* (`apps/web`), it is the same app's own, more specific file, and it wins. Started OUTSIDE the
|
|
143
|
+
* root — `x --cwd ../app-b db migrate` run in app-a — it is another app's: a key whose value is
|
|
144
|
+
* exactly what that directory's files say is overridden by the target's, and REMOVED when the
|
|
145
|
+
* target does not define it — left in place, app-a's DATABASE_URL migrated app-b, which relies on
|
|
146
|
+
* its default. A value that differs from the file is one Bun did not write (it never overrides a
|
|
147
|
+
* real variable), so it is real and stays.
|
|
148
|
+
*/
|
|
149
|
+
export async function rootEnvChanges(input: RootEnvInput): Promise<RootEnvChanges> {
|
|
150
|
+
const { env } = input;
|
|
151
|
+
const root = findAppRoot(input.cwd);
|
|
152
|
+
if (root === undefined || resolve(root.dir) === resolve(input.processCwd)) {
|
|
153
|
+
return { set: {}, unset: [] };
|
|
154
|
+
}
|
|
155
|
+
const outside = !isWithin(root.dir, input.processCwd);
|
|
156
|
+
// A superset of what Bun loads from a cwd, so a key from any of them is recognised as file-made.
|
|
157
|
+
const filesAt = (dir: string, name: string) =>
|
|
158
|
+
envFilesIn(dir, [...rootEnvFiles(name), '.env.local']);
|
|
159
|
+
let envName = env['NODE_ENV'] ?? 'development';
|
|
160
|
+
let foreign = outside ? await filesAt(input.processCwd, envName) : new Map<string, string>();
|
|
161
|
+
// A NODE_ENV another app's file wrote is not one the operator chose: Bun picked that directory's
|
|
162
|
+
// file set with no real NODE_ENV, so with its default — and the target's set is picked the same
|
|
163
|
+
// way, never from the foreign value (app-a's `NODE_ENV=test` loaded app-b's `.env.test`).
|
|
164
|
+
if (foreign.has('NODE_ENV') && env['NODE_ENV'] === foreign.get('NODE_ENV')) {
|
|
165
|
+
envName = 'development';
|
|
166
|
+
foreign = await filesAt(input.processCwd, envName);
|
|
167
|
+
}
|
|
168
|
+
const merged = await envFilesIn(root.dir, rootEnvFiles(envName));
|
|
169
|
+
const fromFile = (key: string): boolean => foreign.has(key) && env[key] === foreign.get(key);
|
|
170
|
+
return {
|
|
171
|
+
set: Object.fromEntries([...merged].filter(([key]) => env[key] === undefined || fromFile(key))),
|
|
172
|
+
unset: [...foreign.keys()].filter((key) => fromFile(key) && !merged.has(key)).sort(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type MutableEnv = Record<string, string | undefined>;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The env a command runs with. When `env` IS the process env, it is changed in place — a package
|
|
180
|
+
* reading `Bun.env` directly must see what the command sees, and an unset key must be GONE, not
|
|
181
|
+
* merely shadowed. An injected env is never mutated: the command gets a rebuilt copy.
|
|
182
|
+
*/
|
|
183
|
+
export function applyRootEnv(
|
|
184
|
+
env: Env,
|
|
185
|
+
changes: RootEnvChanges,
|
|
186
|
+
processEnv: MutableEnv = Bun.env,
|
|
187
|
+
): Env {
|
|
188
|
+
if (env === processEnv) {
|
|
189
|
+
for (const key of changes.unset) delete processEnv[key];
|
|
190
|
+
Object.assign(processEnv, changes.set);
|
|
191
|
+
return processEnv;
|
|
192
|
+
}
|
|
193
|
+
const rebuilt: MutableEnv = { ...env, ...changes.set };
|
|
194
|
+
for (const key of changes.unset) delete rebuilt[key];
|
|
195
|
+
return rebuilt;
|
|
67
196
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Which islands one route render collects, and the realtime page boot that render earns. Split
|
|
2
|
+
// from `runtime-render.ts` so the document builder stays one job; both `documentFrom` and the
|
|
3
|
+
// `stream` branch read the same two answers.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
import type { IslandCollector, RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import {
|
|
9
|
+
clientBootTags,
|
|
10
|
+
islandCollector,
|
|
11
|
+
islandModuleId,
|
|
12
|
+
islandModuleIds,
|
|
13
|
+
renderHead,
|
|
14
|
+
} from '@ultimat3/render';
|
|
15
|
+
import type { DocumentOptions } from './document-options';
|
|
16
|
+
import { realtimeIslandFiles } from './island-realtime';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One collector per RENDER, never module-global: two requests render different params, and a
|
|
20
|
+
* shared collector would bill one page for the other's islands. `hydrate` comes off the route, so
|
|
21
|
+
* an island never declares its own timing, and `resolve` is the build's — identity when nothing
|
|
22
|
+
* built any, which fails at the first island by name rather than emitting an unusable entry.
|
|
23
|
+
*/
|
|
24
|
+
export const collectorFor = (
|
|
25
|
+
entry: RouteEntry,
|
|
26
|
+
options: DocumentOptions,
|
|
27
|
+
scope: string | undefined,
|
|
28
|
+
): IslandCollector =>
|
|
29
|
+
islandCollector({
|
|
30
|
+
file: entry.file,
|
|
31
|
+
hydrate: entry.config.hydrate,
|
|
32
|
+
...(options.resolveIsland === undefined ? {} : { resolve: options.resolveIsland(entry.file) }),
|
|
33
|
+
// #506: on a document that carries the page boot (the same two conditions as `bootScript`), a
|
|
34
|
+
// realtime island's server markup is held off screen until it mounts over the restored store.
|
|
35
|
+
...(scope === undefined || options.sync === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { hold: (src: string) => reachesRealtime(entry, src) }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Whether `src`, as the page at `entry` wrote it, is an island the last build found realtime. */
|
|
41
|
+
const reachesRealtime = (entry: RouteEntry, src: string): boolean =>
|
|
42
|
+
realtimeIslandFiles().has(posix.join(posix.dirname(entry.file), src));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
|
|
46
|
+
* document carries a principal scope (restoring persisted records and replaying queued writes are
|
|
47
|
+
* per principal; a shareable document has neither), AND one of the islands this render emitted
|
|
48
|
+
* reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
|
|
49
|
+
* into and no write to replay). After the body, because which islands rendered is a fact the walk
|
|
50
|
+
* just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
|
|
51
|
+
*/
|
|
52
|
+
export function bootScript(
|
|
53
|
+
entry: RouteEntry,
|
|
54
|
+
islands: IslandCollector,
|
|
55
|
+
options: DocumentOptions,
|
|
56
|
+
scope: string | undefined,
|
|
57
|
+
): string {
|
|
58
|
+
if (scope === undefined || options.sync === undefined) return '';
|
|
59
|
+
const rendered = new Set(islandModuleIds(islands.directives));
|
|
60
|
+
if (rendered.size === 0) return '';
|
|
61
|
+
// An island's module id is derived from its `src`, written relative to the page that renders it:
|
|
62
|
+
// each realtime island file, spelled from THIS page, is the id its directive would carry.
|
|
63
|
+
const pageDir = posix.dirname(entry.file);
|
|
64
|
+
const reaches = [...realtimeIslandFiles()].some((file) => {
|
|
65
|
+
const src = posix.relative(pageDir, file);
|
|
66
|
+
return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
|
|
67
|
+
});
|
|
68
|
+
return reaches ? renderHead(clientBootTags(options.sync)) : '';
|
|
69
|
+
}
|
package/src/runtime-admin.ts
CHANGED
|
@@ -28,7 +28,8 @@ const declared = (): ReadonlyMap<string, AdminApp> => {
|
|
|
28
28
|
return held instanceof Map ? (held as ReadonlyMap<string, AdminApp>) : new Map();
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
export interface AdminMountOptions
|
|
31
|
+
export interface AdminMountOptions
|
|
32
|
+
extends Pick<DocumentOptions, 'themeHead' | 'brandHead' | 'origin'> {
|
|
32
33
|
readonly buildId: string;
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -103,6 +104,8 @@ async function answer(
|
|
|
103
104
|
() =>
|
|
104
105
|
routeDocument(entry, data, {
|
|
105
106
|
...(options.themeHead === undefined ? {} : { themeHead: options.themeHead }),
|
|
107
|
+
// The app's brand restyles its admin too: a screen is a document of the `app/` surface.
|
|
108
|
+
...(options.brandHead === undefined ? {} : { brandHead: options.brandHead }),
|
|
106
109
|
...(options.origin === undefined ? {} : { origin: options.origin }),
|
|
107
110
|
}),
|
|
108
111
|
{ buildId: options.buildId, status: response.status },
|
package/src/runtime-assets.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { probeImage } from '@ultimat3/core';
|
|
|
9
9
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
10
10
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
11
11
|
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
12
|
-
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
|
|
12
|
+
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery, usableWidths } from '@ultimat3/seo';
|
|
13
13
|
import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
|
|
14
|
-
import { isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
|
|
14
|
+
import { DEFAULT_QUALITY, isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
|
|
15
15
|
import { faviconRoute } from './favicon';
|
|
16
16
|
// The icon matrix's source, its base path and its renderer live in their own module so that
|
|
17
17
|
// `pwa-artifacts.ts` — which this file imports for the manifest route — can reach them without
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './runtime-storage';
|
|
30
30
|
import { siteAssetRoutes } from './site-asset-routes';
|
|
31
31
|
import { siteAssetTable } from './site-assets';
|
|
32
|
+
import { storedObjectHeaders } from './stored-object-headers';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
|
|
@@ -58,21 +59,33 @@ const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
|
|
|
58
59
|
* blast radius and does not close it: 8192 stored objects per source, per format, is amplification
|
|
59
60
|
* a tenant drives with a `for` loop.
|
|
60
61
|
*
|
|
61
|
-
* The set is `DEFAULT_WIDTHS`
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* The set is `usableWidths(intrinsic, DEFAULT_WIDTHS)` — the function that writes the `srcset`,
|
|
63
|
+
* asked rather than restated. A restatement (`DEFAULT_WIDTHS` plus the intrinsic width) disagreed
|
|
64
|
+
* with it for a source wider than `MAX_IMAGE_WIDTH`, whose widest candidate is the ceiling: that
|
|
65
|
+
* URL, minted by the framework itself, was decoded on every request and never stored.
|
|
64
66
|
* Anything outside it is still SERVED: this decides what is written, not what is answered, so no
|
|
65
67
|
* caller gains a new 4xx and the disk stops growing on a stranger's key.
|
|
66
68
|
*/
|
|
67
69
|
const isMintableWidth = (width: number | undefined, intrinsic: number): boolean =>
|
|
68
|
-
width === undefined ||
|
|
70
|
+
width === undefined || usableWidths(intrinsic, DEFAULT_WIDTHS).includes(width);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The same question for `?q=`. `@ultimat3/seo` never writes a quality into a variant URL, so the
|
|
74
|
+
* framework mints exactly one — the default — and `variantKey` already spells the default and an
|
|
75
|
+
* absent one as the same key. Every other value from 1 to 100 was its own stored object per
|
|
76
|
+
* minted width per format: a hundred writes a caller drives with a loop. Served, never stored.
|
|
77
|
+
*/
|
|
78
|
+
const isMintableQuality = (quality: number | undefined): boolean =>
|
|
79
|
+
quality === undefined || quality === DEFAULT_QUALITY;
|
|
69
80
|
|
|
70
81
|
const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
|
|
71
82
|
applyCacheHeaders(
|
|
72
83
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
73
84
|
// `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true
|
|
74
85
|
// by construction rather than by a cast that would only silence it.
|
|
75
|
-
new Response(new Uint8Array(bytes), {
|
|
86
|
+
new Response(new Uint8Array(bytes), {
|
|
87
|
+
headers: { 'content-type': contentType, ...storedObjectHeaders(contentType) },
|
|
88
|
+
}),
|
|
76
89
|
cache,
|
|
77
90
|
);
|
|
78
91
|
|
|
@@ -142,7 +155,11 @@ async function transformedVariant(
|
|
|
142
155
|
...(query.format === undefined ? {} : { format: query.format }),
|
|
143
156
|
...(query.quality === undefined ? {} : { quality: query.quality }),
|
|
144
157
|
});
|
|
145
|
-
if (
|
|
158
|
+
if (
|
|
159
|
+
cached !== undefined &&
|
|
160
|
+
isMintableWidth(query.width, intrinsic) &&
|
|
161
|
+
isMintableQuality(query.quality)
|
|
162
|
+
) {
|
|
146
163
|
await disk.put(cached, variant.bytes, { contentType: variant.contentType });
|
|
147
164
|
}
|
|
148
165
|
return imageResponse(variant.bytes, variant.contentType, cache);
|
|
@@ -174,7 +191,7 @@ export interface AssetRoutesOptions {
|
|
|
174
191
|
/** App root. The source icon is resolved against it; storage keys never are. */
|
|
175
192
|
readonly root: string;
|
|
176
193
|
readonly storage: Storage;
|
|
177
|
-
/** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG pipeline. */
|
|
194
|
+
/** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG/WebP pipeline. */
|
|
178
195
|
readonly images?: ImageTransformDriver;
|
|
179
196
|
/**
|
|
180
197
|
* `manifest.webmanifest`, resolved at boot by `pwa-artifacts.ts`. Absent when the app declares
|