@ultimat3/cli 24.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 (242) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
@@ -8,10 +8,10 @@
8
8
  import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
9
9
  import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
10
10
  import { cspHashSource } from '@ultimat3/http';
11
- import { localeConfig, routedLocales } from '@ultimat3/i18n';
11
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
12
12
  import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
13
13
  import {
14
- describeRoutes,
14
+ describePages,
15
15
  renderHead,
16
16
  routeEntries,
17
17
  speculationPattern,
@@ -67,15 +67,18 @@ export interface PageSpeculationInput {
67
67
  readonly client: readonly NavigationSurface[];
68
68
  readonly entries?: readonly RouteEntry[];
69
69
  readonly described?: readonly RouteDescriptor[];
70
- /** The NON-default routed locales' URL segments. Read from `@ultimat3/i18n` when absent. */
71
- readonly localeSegments?: readonly string[];
70
+ /**
71
+ * The NON-default routed locales' URL segments — `otherLocaleSegments(appLocaleSet)`, the set the
72
+ * manifest, the worker and the prerender read. Every caller passes it: no build reads the
73
+ * ambient locale config.
74
+ */
75
+ readonly localeSegments: readonly string[];
72
76
  }
73
77
 
74
- const otherLocaleSegments = (): readonly string[] => {
75
- const fallback = localeSegment(localeConfig().fallback);
76
- return routedLocales()
77
- .map(localeSegment)
78
- .filter((segment) => segment !== fallback);
78
+ /** Every routed locale but the default, as the URL segment a prefix is spelled in. */
79
+ export const otherLocaleSegments = (set: AppLocaleSet): readonly string[] => {
80
+ const fallback = localeSegment(set.defaultLocale);
81
+ return set.locales.map(localeSegment).filter((segment) => segment !== fallback);
79
82
  };
80
83
 
81
84
  /**
@@ -86,9 +89,9 @@ const otherLocaleSegments = (): readonly string[] => {
86
89
  export function pageSpeculation(input: PageSpeculationInput): PageSpeculation | undefined {
87
90
  if (input.config.prefetch === false) return undefined;
88
91
  const entries = input.entries ?? routeEntries();
89
- const described = new Map((input.described ?? describeRoutes()).map((one) => [one.file, one]));
92
+ const described = new Map((input.described ?? describePages()).map((one) => [one.file, one]));
90
93
  const client = new Set<string>(input.client);
91
- const segments = input.localeSegments ?? otherLocaleSegments();
94
+ const segments = input.localeSegments;
92
95
  const include = entries
93
96
  .filter((entry) => {
94
97
  const facts = described.get(entry.file);
package/src/parse.ts CHANGED
@@ -8,14 +8,6 @@ import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './err
8
8
  // anywhere while still refusing with a fix line a shell reads as one argument.
9
9
  import { quoteArg } from './shell-quote';
10
10
 
11
- /**
12
- * The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
13
- * package's exported surface and removing it would be a major for a rename. One implementation
14
- * behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
15
- * used to carry. New callers import `nearestName` from core.
16
- */
17
- export const nearest = nearestName;
18
-
19
11
  export type FlagValue = string | boolean;
20
12
 
21
13
  export interface FlagSpec {
@@ -7,7 +7,7 @@ import { listActions } from '@ultimat3/action';
7
7
  import type { Policy } from '@ultimat3/policy';
8
8
  import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
9
9
  import { listQueries } from '@ultimat3/query';
10
- import { describeRoutes, routeEntries } from '@ultimat3/render';
10
+ import { describePages, routeEntries } from '@ultimat3/render';
11
11
  import type { Finding } from './output';
12
12
  import { quoteArg } from './shell-quote';
13
13
 
@@ -46,12 +46,12 @@ export function requirements(): readonly UngrantedRequirement[] {
46
46
  // A route a package MOUNTED — the admin's — is in no file and so in no `routeEntries()` row. Its
47
47
  // screen decides on every permission of the mount, not only the coarse gate its route config
48
48
  // carries, so each one is a requirement: a hand-written entity nobody granted `<table>:read`
49
- // for was a green gate and a 403 on its first open. `describeRoutes()` is memoized by the
49
+ // for was a green gate and a 403 on its first open. `describePages()` is memoized by the
50
50
  // registry, so this loop reads a list every other step already built.
51
51
  // One requirement per permission per mount, naming the first route that asks it: `posts:write`
52
52
  // gates the create form and the edit form, and one missing grant is one finding.
53
53
  const asked = new Map<string, { permission: string; by: string; first: string; more: number }>();
54
- for (const route of describeRoutes()) {
54
+ for (const route of describePages()) {
55
55
  if (route.mount === undefined) continue;
56
56
  for (const permission of route.mount.permissions) {
57
57
  const key = `${route.mount.by} ${permission}`;
@@ -0,0 +1,30 @@
1
+ // One answer to "this host path, written with `/`". `relative()` and `Bun.Glob` answer in the
2
+ // host's separator, so on Windows a `\` reached an import specifier (`x fix` wrote
3
+ // `import '..\\foo'`, which breaks every Linux teammate), a fix line and a sort key. Every such
4
+ // result passes through here — `bun run posix-relative` refuses a `relative(` that does not.
5
+
6
+ // why: Bun ships no path API; `node:path` owns `relative`, and a test hands in its `win32` half to
7
+ // prove the Windows answer on a Linux runner.
8
+ import * as hostPath from 'node:path';
9
+
10
+ /**
11
+ * The one method `posixRelative` asks of a path module — structural, not `PlatformPath`: the
12
+ * scaffold's typecheck resolves `node:path` without that name exported (TS2305).
13
+ */
14
+ export interface RelativeHost {
15
+ readonly relative: (from: string, to: string) => string;
16
+ }
17
+
18
+ /**
19
+ * `path` with every `\` written as `/`. Unconditional rather than keyed to the host's `sep`: a key,
20
+ * a specifier and a fix line are POSIX on every host, and a Windows-shaped path handed to a Linux
21
+ * process (a CI log, a test fixture) must come out the same as it would on Windows.
22
+ */
23
+ export function toPosix(path: string): string {
24
+ return path.replaceAll('\\', '/');
25
+ }
26
+
27
+ /** `relative(from, to)` in POSIX form. `host` is the platform's own path module but for a test. */
28
+ export function posixRelative(from: string, to: string, host: RelativeHost = hostPath): string {
29
+ return toPosix(host.relative(from, to));
30
+ }
@@ -2,7 +2,8 @@
2
2
  // paths and files `renderStatic` computed; every other locale's are served at `/<locale>/<path>`
3
3
  // and written to `<locale>/<file>` — the layout every static host resolves with no rewrite rule.
4
4
 
5
- import { localeSegment, localizedPath } from '@ultimat3/i18n';
5
+ import { localeSegment, localizePath } from '@ultimat3/core';
6
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
6
7
  import type { StaticArtifact } from '@ultimat3/render/server';
7
8
 
8
9
  /** A `StaticArtifact` placed for one locale: its served path and its file under `<locale>/`. */
@@ -10,8 +11,7 @@ export type LocalizedArtifact = StaticArtifact & { readonly locale: string };
10
11
 
11
12
  /** The default's artifacts first, so a report lists the unprefixed page before its translations. */
12
13
  export async function localizedArtifacts(
13
- locales: readonly string[],
14
- defaultLocale: string,
14
+ { locales, defaultLocale }: AppLocaleSet,
15
15
  renderIn: (locale: string) => Promise<readonly StaticArtifact[]>,
16
16
  ): Promise<readonly LocalizedArtifact[]> {
17
17
  const out: LocalizedArtifact[] = [];
@@ -21,7 +21,9 @@ export async function localizedArtifacts(
21
21
  out.push({
22
22
  ...artifact,
23
23
  locale,
24
- path: isDefault ? artifact.path : localizedPath(artifact.path, locale, defaultLocale),
24
+ path: isDefault
25
+ ? artifact.path
26
+ : localizePath(artifact.path, locale, locales, defaultLocale),
25
27
  outputPath: isDefault
26
28
  ? artifact.outputPath
27
29
  : `${localeSegment(locale)}/${artifact.outputPath}`,
@@ -0,0 +1,58 @@
1
+ // What `x build --target static` reports: every page it wrote, every route it did not and why, and
2
+ // where the stats and the inventory landed. Split from `prerender.ts`, which decides what is
3
+ // written; this file only names the shapes the report carries.
4
+
5
+ import type { SkippedRoute, UnmeasuredRoute } from './static-report';
6
+
7
+ export interface PrerenderedPage {
8
+ /** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
9
+ readonly route: string;
10
+ /** The URL the page is served at — `/en/pricing` for a non-default locale. */
11
+ readonly path: string;
12
+ /** The locale the page was rendered in. */
13
+ readonly locale: string;
14
+ /** Relative to `out`, POSIX — under `<locale>/` for a non-default locale. */
15
+ readonly file: string;
16
+ readonly hash: string;
17
+ readonly bytes: number;
18
+ }
19
+
20
+ export interface PrerenderReport {
21
+ readonly out: string;
22
+ readonly buildId: string;
23
+ readonly pages: readonly PrerenderedPage[];
24
+ /**
25
+ * Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
26
+ * #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
27
+ * screenshot tool pointed at the directory filed "the island did not mount" against a route that
28
+ * had never been in the artifact. The reason is what tells an author whether an edit exists.
29
+ */
30
+ readonly skipped: readonly SkippedRoute[];
31
+ /**
32
+ * Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
33
+ * the gate then reports for each; this is the half that says WHY, which a per-route finding read
34
+ * off a stats file cannot know.
35
+ */
36
+ readonly unmeasured: readonly UnmeasuredRoute[];
37
+ /** Where the measured stats landed, for the `budgets` gate step to read. */
38
+ readonly stats: string;
39
+ /** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
40
+ readonly report: string;
41
+ /** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
42
+ readonly islands: readonly string[];
43
+ /** Surface stylesheets emitted, one file each — the CSS half of the same question. */
44
+ readonly styles: readonly string[];
45
+ /**
46
+ * What the service worker could not express, and what its precache manifest weighs too much of.
47
+ *
48
+ * `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
49
+ * `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
50
+ * that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
51
+ * has to reach the build's own report. Empty for an app with no service worker.
52
+ */
53
+ readonly serviceWorkerWarnings: readonly string[];
54
+ /** What this build could not get right on its own — today, a production build with no origin. */
55
+ readonly warnings: readonly string[];
56
+ /** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
57
+ readonly origin: string;
58
+ }
@@ -0,0 +1,52 @@
1
+ // How the build weighs an app/ page it renders only to measure: as the document a request is
2
+ // SERVED — the sync head and page boot both boots compose (`pageSync`), and the runtime chunk that
3
+ // boot makes unfetched. An unscoped render measured a runtime chunk no scoped page downloads and
4
+ // never the boot every one of them does, so `/feed` and `/runs` were budgets about nothing (#505).
5
+
6
+ import type { ClientSyncHead } from '@ultimat3/render';
7
+ import { loadAppConfig } from './app-config-load';
8
+ import type { MeasureOptions } from './budgets';
9
+ import type { IslandBundle } from './island-bundle';
10
+ import { isRuntimeChunk } from './island-runtime';
11
+ import { pageSync } from './page-sync';
12
+ import { realtimeConfigOf } from './runtime-realtime';
13
+ import { PAGE_BOOT_BASE_PATH } from './worker-bundle';
14
+
15
+ /** What a served-document measurement adds: to the document's options, and to the measurer's. */
16
+ export interface ServedMeasure {
17
+ readonly document: {
18
+ readonly sync?: ClientSyncHead;
19
+ readonly persisted?: () => readonly string[];
20
+ };
21
+ readonly measure: MeasureOptions;
22
+ }
23
+
24
+ /**
25
+ * `realtime.enabled: false` serves no sync head and no boot (`pageSync`), and then the measurement
26
+ * adds nothing: such a page's realtime islands load the runtime chunk, and are charged for it.
27
+ */
28
+ export async function servedMeasure(
29
+ root: string,
30
+ buildId: string,
31
+ islands: IslandBundle,
32
+ ): Promise<ServedMeasure> {
33
+ const sync = await pageSync(
34
+ root,
35
+ process.env,
36
+ buildId,
37
+ realtimeConfigOf(await loadAppConfig(root)),
38
+ );
39
+ return {
40
+ document: {
41
+ ...(sync.head === undefined ? {} : { sync: sync.head }),
42
+ persisted: sync.persisted,
43
+ },
44
+ measure: {
45
+ served: new Map(sync.scripts.map((script) => [script.url, script.code])),
46
+ boot: {
47
+ prefix: `${PAGE_BOOT_BASE_PATH}/`,
48
+ supplies: new Set(islands.shared.map((one) => one.url).filter(isRuntimeChunk)),
49
+ },
50
+ },
51
+ };
52
+ }
package/src/prerender.ts CHANGED
@@ -5,21 +5,21 @@
5
5
 
6
6
  import { join } from 'node:path';
7
7
  import {
8
- createContext,
8
+ ctxOf,
9
9
  DEFAULT_ENVIRONMENT,
10
10
  isUltimateError,
11
+ localizePath,
11
12
  renderThrowable,
12
13
  runWithContext,
13
14
  tryResolveEnvironment,
14
15
  } from '@ultimat3/core';
15
- import { localeConfig, localizedPath, routedLocales } from '@ultimat3/i18n';
16
16
  import type { RouteEntry } from '@ultimat3/render';
17
- import { describeRoutes, routeEntries } from '@ultimat3/render';
17
+ import { describePages, routeEntries } from '@ultimat3/render';
18
18
  import { renderStatic } from '@ultimat3/render/server';
19
19
  import { loadApp } from './app-load';
20
20
  import { appManifest } from './app-manifest';
21
21
  import type { RouteStats } from './budgets';
22
- import { measureDocumentJs, writeBuildStats } from './budgets';
22
+ import { measureDocumentJs, routeStatsRow, writeBuildStats } from './budgets';
23
23
  import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
24
24
  import { PrerenderLoadFailedError } from './errors';
25
25
  import { FAVICON_PATH, faviconBytes } from './favicon';
@@ -27,13 +27,16 @@ import type { IslandBundle } from './island-bundle';
27
27
  import { buildIslands, writeIslands } from './island-bundle';
28
28
  import { measureDatabase } from './measure-database';
29
29
  import { measurePaths } from './measure-paths';
30
- import { measureScope, withAppUrl } from './measure-scope';
30
+ import { measurementActorLabel, measureScope, withAppUrl } from './measure-scope';
31
31
  import { loadNavigation, pageNavigation } from './page-navigation';
32
- import { loadSpeculation, pageSpeculation } from './page-speculation';
32
+ import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
33
33
  import { localizedArtifacts } from './prerender-locales';
34
34
  import { clearPrerenderOut } from './prerender-out';
35
+ import type { PrerenderedPage, PrerenderReport } from './prerender-report';
36
+ import type { ServedMeasure } from './prerender-served';
37
+ import { servedMeasure } from './prerender-served';
35
38
  import { loadPwaArtifacts, writePwaIcons } from './pwa-artifacts';
36
- import { routeDocument } from './runtime-render';
39
+ import { routeDocument, servedDocument } from './runtime-render';
37
40
  import { writeSiteAssets } from './site-assets';
38
41
  import { loadSiteSettings, originWarning, publicOrigin } from './site-config';
39
42
  import type { SkippedRoute, UnmeasuredRoute } from './static-report';
@@ -48,9 +51,13 @@ import {
48
51
  serviceWorkerRegistration,
49
52
  } from './sw-artifacts';
50
53
  import { loadThemeMode, themeBoot } from './theme-boot';
54
+ import { loadThemeBrand } from './theme-brand';
55
+ import { writeErrorLine } from './write-line';
51
56
 
57
+ export type { PrerenderedPage, PrerenderReport } from './prerender-report';
52
58
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
53
- // carries it, and this file already imports that module.
59
+ // carries it, and this file already imports that module. The report's own shapes are
60
+ // `prerender-report.ts`'s, re-exported here for the same reason: callers ask this module.
54
61
  export type { UnmeasuredRoute };
55
62
 
56
63
  /**
@@ -75,59 +82,6 @@ export interface PrerenderOptions {
75
82
  readonly origin?: string;
76
83
  }
77
84
 
78
- export interface PrerenderedPage {
79
- /** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
80
- readonly route: string;
81
- /** The URL the page is served at — `/en/pricing` for a non-default locale. */
82
- readonly path: string;
83
- /** The locale the page was rendered in. */
84
- readonly locale: string;
85
- /** Relative to `out`, POSIX — under `<locale>/` for a non-default locale. */
86
- readonly file: string;
87
- readonly hash: string;
88
- readonly bytes: number;
89
- }
90
-
91
- export interface PrerenderReport {
92
- readonly out: string;
93
- readonly buildId: string;
94
- readonly pages: readonly PrerenderedPage[];
95
- /**
96
- * Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
97
- * #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
98
- * screenshot tool pointed at the directory filed "the island did not mount" against a route that
99
- * had never been in the artifact. The reason is what tells an author whether an edit exists.
100
- */
101
- readonly skipped: readonly SkippedRoute[];
102
- /**
103
- * Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
104
- * the gate then reports for each; this is the half that says WHY, which a per-route finding read
105
- * off a stats file cannot know.
106
- */
107
- readonly unmeasured: readonly UnmeasuredRoute[];
108
- /** Where the measured stats landed, for the `budgets` gate step to read. */
109
- readonly stats: string;
110
- /** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
111
- readonly report: string;
112
- /** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
113
- readonly islands: readonly string[];
114
- /** Surface stylesheets emitted, one file each — the CSS half of the same question. */
115
- readonly styles: readonly string[];
116
- /**
117
- * What the service worker could not express, and what its precache manifest weighs too much of.
118
- *
119
- * `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
120
- * `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
121
- * that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
122
- * has to reach the build's own report. Empty for an app with no service worker.
123
- */
124
- readonly serviceWorkerWarnings: readonly string[];
125
- /** What this build could not get right on its own — today, a production build with no origin. */
126
- readonly warnings: readonly string[];
127
- /** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
128
- readonly origin: string;
129
- }
130
-
131
85
  /**
132
86
  * The heaviest thing the document actually boots, named by the source file an author can open.
133
87
  * An island chunk is content-addressed, so its URL says nothing on its own — mapping it back
@@ -187,11 +141,12 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
187
141
  // Written to stderr as well as returned: an app's `prerender.ts` prints its report as ONE JSON
188
142
  // line on stdout, and a build that parses it must not meet a warning inside it.
189
143
  const warnings = originWarning(tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT, declaredOrigin);
190
- for (const warning of warnings) process.stderr.write(`warning: ${warning}\n`);
191
- // After `loadApp`: `defineCatalogs()` configures the locales on the app's own import. The
192
- // default first, so every other pass writes beside a tree that already holds the unprefixed one.
193
- const locales = routedLocales();
194
- const defaultLocale = localeConfig().fallback;
144
+ for (const warning of warnings) writeErrorLine(`warning: ${warning}`);
145
+ // The app's locales as `loadApp` read them, ONCE, off `@ultimat3/i18n`'s one reader — the same
146
+ // answer the PWA manifest and the service worker below are built from. The default first, so
147
+ // every other pass writes beside a tree that already holds the unprefixed one.
148
+ const appLocales = loaded.locales;
149
+ const { locales, defaultLocale } = appLocales;
195
150
  const pages: PrerenderedPage[] = [];
196
151
  const skipped: SkippedRoute[] = [];
197
152
  const routes: RouteStats[] = [];
@@ -238,6 +193,8 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
238
193
  // names it either.
239
194
  const pwa = await loadPwaArtifacts(options.root);
240
195
  const theme = themeBoot(await loadThemeMode(options.root));
196
+ // The brand the served process carries, so an exported page and a served one paint alike.
197
+ const brand = await loadThemeBrand(options.root);
241
198
  // The client router, for the surfaces that opted in: written BEFORE any document names it, for
242
199
  // the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
243
200
  // the route (the interactivity the app asked for), so it must be there to be weighed.
@@ -247,6 +204,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
247
204
  const speculation = pageSpeculation({
248
205
  config: await loadSpeculation(options.root),
249
206
  client: declared.surfaces,
207
+ localeSegments: otherLocaleSegments(appLocales),
250
208
  });
251
209
  if (navigation.script !== undefined) {
252
210
  await Bun.write(join(options.out, navigation.script.url.slice(1)), navigation.script.code);
@@ -292,11 +250,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
292
250
  // build. One context for the build, `role: 'web'` because that is the role serving these
293
251
  // documents, and this build's own id so a component reading `ctx.buildId` stamps the artifact
294
252
  // with the id the report and the stats carry.
295
- // ONE PER LOCALE, and the locale is the context's: `createContext` defaults it to core's `en`, so a
253
+ // ONE PER LOCALE, and the locale is the context's: `ctxOf` defaults it to core's `en`, so a
296
254
  // Spanish-default site was prerendered in English — `<html lang="en">` over English copy — while
297
255
  // the served process answered Spanish. Each `site/` page is rendered once per routed locale.
298
256
  const contexts = new Map(
299
- locales.map((locale) => [locale, createContext({ role: 'web', buildId, locale })]),
257
+ locales.map((locale) => [locale, ctxOf({ role: 'web', buildId, locale })]),
300
258
  );
301
259
  // A SECOND scope, for the branch below that renders only to weigh (`measure-scope.ts`): a
302
260
  // request context holding the app's measurement actor, with the app's own API answered in
@@ -307,23 +265,32 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
307
265
  const measure = await measureScope({ origin, buildId });
308
266
  // Started on the first route that needs weighing, and released however the loop ends.
309
267
  const database = measureDatabase(options.root);
268
+ const documentOptions = {
269
+ resolveIsland: (file: string) => islands.resolverFor(file),
270
+ themeHead: theme.head,
271
+ ...(brand === undefined ? {} : { brandHead: brand.head }),
272
+ ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
273
+ origin,
274
+ ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
275
+ ...(pwa === undefined
276
+ ? {}
277
+ : { pwaHead: (locale: string) => pwa.headFor(locale) + (swHead ?? '') }),
278
+ };
310
279
  const render = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
311
- routeDocument(entry, data, {
312
- resolveIsland: (file: string) => islands.resolverFor(file),
313
- themeHead: theme.head,
314
- ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
315
- origin,
316
- ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
317
- ...(pwa === undefined
318
- ? {}
319
- : { pwaHead: (locale: string) => pwa.headFor(locale) + (swHead ?? '') }),
320
- });
280
+ routeDocument(entry, data, documentOptions);
281
+ // The weigh-only branch measures the document a request is SERVED (`prerender-served.ts`):
282
+ // composed on the first route that needs it, so a build weighing none builds no page boot.
283
+ let weighing: Promise<ServedMeasure> | undefined;
284
+ const served = (): Promise<ServedMeasure> => {
285
+ weighing ??= servedMeasure(options.root, buildId, islands);
286
+ return weighing;
287
+ };
321
288
  const document = (
322
289
  locale: string,
323
290
  entry: RouteEntry,
324
291
  data: { url: string; params: Record<string, string> },
325
292
  ) =>
326
- runWithContext(contexts.get(locale) ?? createContext({ role: 'web', buildId, locale }), () =>
293
+ runWithContext(contexts.get(locale) ?? ctxOf({ role: 'web', buildId, locale }), () =>
327
294
  render(entry, data),
328
295
  );
329
296
 
@@ -352,16 +319,19 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
352
319
  let row: RouteStats | undefined;
353
320
  for (const { path, params } of plan.paths) {
354
321
  const data = { url: new URL(path, origin).href, params };
355
- const html = await withAppUrl(origin, () => measure.run(() => render(entry, data)));
356
- const measured = await measureDocumentJs(html, options.out);
322
+ const as = await served();
323
+ const html = await withAppUrl(origin, () =>
324
+ measure.run(() =>
325
+ servedDocument(entry, data, measure.actor, {
326
+ ...documentOptions,
327
+ ...as.document,
328
+ buildId,
329
+ }),
330
+ ),
331
+ );
332
+ const measured = await measureDocumentJs(html, options.out, as.measure);
357
333
  if (row !== undefined && measured.jsBytes <= row.jsBytes) continue;
358
- const chain = heaviestSource(islands, measured.entries);
359
- row = {
360
- path: entry.path,
361
- jsBytes: measured.jsBytes,
362
- frameworkJsBytes: measured.frameworkBytes,
363
- ...(chain === undefined ? {} : { heaviestChain: chain }),
364
- };
334
+ row = routeStatsRow(entry.path, measured, heaviestSource(islands, measured.entries));
365
335
  }
366
336
  if (row !== undefined) routes.push(row);
367
337
  } catch (error) {
@@ -377,16 +347,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
377
347
  ...(isUltimateError(error)
378
348
  ? { code: error.code, cause: error.cause, fix: error.fix }
379
349
  : {}),
350
+ actor: measurementActorLabel(measure.actor),
380
351
  });
381
352
  }
382
353
  continue;
383
354
  }
384
- const artifacts = await localizedArtifacts(locales, defaultLocale, (locale) =>
355
+ const artifacts = await localizedArtifacts(appLocales, (locale) =>
385
356
  renderStatic(
386
357
  entry,
387
358
  ({ path, params }) =>
388
359
  document(locale, entry, {
389
- url: new URL(localizedPath(path, locale, defaultLocale), origin).href,
360
+ url: new URL(localizePath(path, locale, locales, defaultLocale), origin).href,
390
361
  params,
391
362
  }),
392
363
  { buildId },
@@ -431,14 +402,8 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
431
402
  bytes,
432
403
  assets: measured.entries.map((entry) => entry.url),
433
404
  });
434
- const chain = heaviestSource(islands, measured.entries);
435
405
  if (heaviest !== undefined && heaviest.jsBytes >= measured.jsBytes) continue;
436
- heaviest = {
437
- path: entry.path,
438
- jsBytes: measured.jsBytes,
439
- frameworkJsBytes: measured.frameworkBytes,
440
- ...(chain === undefined ? {} : { heaviestChain: chain }),
441
- };
406
+ heaviest = routeStatsRow(entry.path, measured, heaviestSource(islands, measured.entries));
442
407
  }
443
408
  if (heaviest !== undefined) routes.push(heaviest);
444
409
  }
@@ -455,7 +420,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
455
420
  : serviceWorkerArtifacts({
456
421
  pwa,
457
422
  buildId,
458
- routes: describeRoutes(),
423
+ routes: describePages(),
459
424
  islands,
460
425
  styles,
461
426
  documents,
@@ -21,6 +21,7 @@ import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
21
21
  import { escapeHtml, localeSegment } from '@ultimat3/core';
22
22
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
23
23
  import { applyCacheHeaders } from '@ultimat3/http';
24
+ import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
24
25
  import {
25
26
  appleTouchLinks,
26
27
  generateWebManifest,
@@ -79,6 +80,11 @@ export interface PwaArtifacts {
79
80
  readonly offline: PwaOfflineConfig;
80
81
  readonly backgroundSync: boolean;
81
82
  readonly push: boolean;
83
+ /**
84
+ * The routed locales the manifests above were built from, default first — `appLocaleSet`'s one
85
+ * answer, carried here so the service worker prefixes exactly the locales the manifests name.
86
+ */
87
+ readonly locales: AppLocales;
82
88
  }
83
89
 
84
90
  /** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
@@ -108,7 +114,7 @@ async function loadInstallable(root: string): Promise<InstallableApp | undefined
108
114
  name: pwa.name,
109
115
  colors: pwa.colors,
110
116
  block: { ...pwa },
111
- locales: appLocales({ locales: config.locales, defaultLocale: config.defaultLocale }),
117
+ locales: appLocales(await appLocaleSet(root)),
112
118
  offline: pwa.offline,
113
119
  backgroundSync: pwa.backgroundSync,
114
120
  push: pwa.push,
@@ -161,6 +167,7 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
161
167
  offline: app.offline,
162
168
  backgroundSync: app.backgroundSync,
163
169
  push: app.push,
170
+ locales: app.locales,
164
171
  body: primary.body,
165
172
  head: primary.head,
166
173
  manifests,
@@ -3,6 +3,7 @@
3
3
  // routed locale from this; the `pwa` block is read structurally, for `colorsOf`'s reason there.
4
4
 
5
5
  import { localeSegment, localizePath } from '@ultimat3/core';
6
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
6
7
  import type {
7
8
  ManifestIcon,
8
9
  ManifestScreenshot,
@@ -10,7 +11,7 @@ import type {
10
11
  WebManifestInput,
11
12
  } from '@ultimat3/pwa';
12
13
 
13
- /** The app's locales as `app.config.ts` declares them, default first. */
14
+ /** The app's locales as its catalogs declare them (`defineCatalogs`), default first. */
14
15
  export interface AppLocales {
15
16
  readonly routed: readonly string[];
16
17
  readonly fallback: string;
@@ -23,15 +24,14 @@ const strings = (value: unknown): readonly string[] =>
23
24
  Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
24
25
 
25
26
  /**
26
- * `locales` and `defaultLocale`, read off the config object. The manifest's `lang` was the
27
- * generator's hardcoded `'en'` for an `es-co` app (notificado.co, 22.3.2). An untyped config with
28
- * neither is core's own default, `en`.
27
+ * The manifests' locales, off `appLocaleSet`'s answer (`@ultimat3/i18n/app-catalogs`) — an app
28
+ * with no catalogs is `UNDECLARED_LOCALES`, the framework default alone. The manifest's `lang` was
29
+ * the generator's hardcoded `'en'` for an `es-co` app (notificado.co, 22.3.2). Deduplicated by URL
30
+ * segment, the unit a locale prefix is spelled in.
29
31
  */
30
- export function appLocales(config: Record<string, unknown>): AppLocales {
31
- const declared = strings(config['locales']);
32
- const named = config['defaultLocale'];
33
- const fallback = typeof named === 'string' && named !== '' ? named : (declared[0] ?? 'en');
34
- const others = declared.filter((locale) => localeSegment(locale) !== localeSegment(fallback));
32
+ export function appLocales(set: AppLocaleSet): AppLocales {
33
+ const fallback = set.defaultLocale;
34
+ const others = set.locales.filter((locale) => localeSegment(locale) !== localeSegment(fallback));
35
35
  return { routed: [fallback, ...others], fallback };
36
36
  }
37
37
 
package/src/retry-memo.ts CHANGED
@@ -15,7 +15,7 @@ export interface RetryMemo<T> {
15
15
  }
16
16
 
17
17
  /**
18
- * `start` runs at most once per SUCCESS. `@ultimat3/db`'s `createPgliteClient` states the rule this
18
+ * `start` runs at most once per SUCCESS. `@ultimat3/db`'s `pgliteClient` states the rule this
19
19
  * generalises — "a failed boot must not be cached" — and the clearing handler is attached at
20
20
  * creation for the reason that matters: it therefore runs ahead of every caller's own `await`
21
21
  * continuation, so by the time anyone sees the rejection the slot is already empty and the next