@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.
Files changed (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. 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 { createContext, logger, UltimateError } from '@ultimat3/core';
6
- import type { WebSocketMount } from '@ultimat3/http';
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, for the line below that names both doors.
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. A deployment that wants the tenant cap passes both:
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 = createContext({ role: 'sync', buildId: options.buildId });
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 node = createSyncNode({
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
- // The page's origin when it is served on another host than this node; the node's own host is
206
- // admitted without it. Anything else is refused before `authenticate` (X_SOCKET_ORIGIN_REFUSED).
207
- allowedOrigins: syncOriginsFrom(options.env),
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) => await listen(options, node, { registry, hub }, 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 is NOT passed through to the kernel. It cannot be incremented (`+ 1` would ask for port
239
- * 1), but the web role has bound by the time this runs and `appUrl` carries the port it got — and
240
- * `PORT + 1` is the contract every scaffolded `sync-url.ts` computes from and the wiki states. A
241
- * scratch server (`x shot`, `ui.shot`) always asks for 0, so before this every one of its
242
- * pictures carried `WebSocket … ERR_CONNECTION_REFUSED` for every live island, on the framework's
243
- * own account. With no web role to follow, 0 still goes to the kernel. The reported url is the
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 = syncPortFrom(options.port, appUrl);
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 variable already set.
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
- * One dotenv line: `KEY=value`, an optional `export `, a `#` comment, a value in single or double
30
- * quotes (double quotes read `\n`). No `${VAR}` expansion — a root-level default that needs one
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
- export function parseDotenv(text: string): ReadonlyMap<string, string> {
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
- for (const raw of text.split(/\r?\n/)) {
36
- const line = raw.trim();
37
- if (line === '' || line.startsWith('#')) continue;
38
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/.exec(line);
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 value = (match[2] ?? '').trim();
41
- const quote = value[0];
42
- if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0) {
43
- const inner = value.slice(1, value.lastIndexOf(quote));
44
- out.set(match[1] ?? '', quote === '"' ? inner.replaceAll('\\n', '\n') : inner);
45
- } else {
46
- out.set(match[1] ?? '', value.replace(/\s+#.*$/, ''));
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
- * The variables the root's `.env*` files add to `env`: only keys `env` does not already hold, so a
54
- * real environment variable — and anything Bun already loaded from the cwd — always wins. Empty
55
- * when there is no app root or the cwd IS the root (Bun loaded those files itself).
56
- */
57
- export async function rootEnvAdditions(cwd: string, env: Env): Promise<Record<string, string>> {
58
- const root = findAppRoot(cwd);
59
- if (root === undefined || resolve(root.dir) === resolve(cwd)) return {};
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 rootEnvFiles(env['NODE_ENV'] ?? 'development')) {
62
- const file = Bun.file(join(root.dir, name));
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 Object.fromEntries([...merged].filter(([key]) => env[key] === undefined));
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
+ }
@@ -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 extends Pick<DocumentOptions, 'themeHead' | 'origin'> {
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 },
@@ -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` **plus the source's intrinsic width**, which is exactly what
62
- * `usableWidths` puts in a `srcset` — clamping to the constant alone would refuse the widest entry
63
- * of every image whose intrinsic width is not one of the eight, a URL the framework mints itself.
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 || width === intrinsic || DEFAULT_WIDTHS.includes(width);
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), { headers: { 'content-type': contentType } }),
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 (cached !== undefined && isMintableWidth(query.width, intrinsic)) {
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