@ultimat3/cli 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/schema-drift.ts
CHANGED
|
@@ -15,10 +15,15 @@ import { existsSync } from 'node:fs';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
17
17
|
import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
|
|
18
|
-
import { declaredSchema, snapshotOf, unrenderedOf } from '@ultimat3/db';
|
|
18
|
+
import { declaredSchema, shellInertIdentifier, snapshotOf, unrenderedOf } from '@ultimat3/db';
|
|
19
19
|
import { describeEntities } from '@ultimat3/entity';
|
|
20
20
|
import { loadApp } from './app-load';
|
|
21
21
|
import { checkSourceDrift, DB_PACKAGE } from './drift';
|
|
22
|
+
import {
|
|
23
|
+
appReplicaIdentity,
|
|
24
|
+
type ReplicaIdentityWanted,
|
|
25
|
+
wantedReplicaIdentity,
|
|
26
|
+
} from './drift-replica-identity';
|
|
22
27
|
import { MIGRATIONS_DIR, readMigrations } from './migrations';
|
|
23
28
|
import type { Finding } from './output';
|
|
24
29
|
import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
|
|
@@ -50,10 +55,17 @@ const appEntities: DeclaredEntities = async (root) => {
|
|
|
50
55
|
* silently and the result is green. In that state the unrendered entry's own `fix:` is the
|
|
51
56
|
* instruction: `@ultimat3/db` owns that wording, and a second one here would drift from it.
|
|
52
57
|
*/
|
|
58
|
+
interface Repair {
|
|
59
|
+
/** One runnable command or one edit — never a second option behind a shell comment. */
|
|
60
|
+
readonly fix: string;
|
|
61
|
+
/** Why this fix and not the obvious one, appended to the cause where the reader sees it. */
|
|
62
|
+
readonly why?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
function repairFix(
|
|
54
66
|
unrendered: readonly UnrenderedDeclaration[],
|
|
55
67
|
difference: SchemaDifference,
|
|
56
|
-
):
|
|
68
|
+
): Repair {
|
|
57
69
|
// The entry ABOUT this difference first, then any entry at all. Reading `unrendered[0]`
|
|
58
70
|
// unconditionally meant that in any app carrying an unrendered DEFAULT, every difference —
|
|
59
71
|
// a dropped CHECK included — was answered with the default's edit, which is an instruction for
|
|
@@ -62,7 +74,7 @@ function repairFix(
|
|
|
62
74
|
(entry) => entry.table === difference.table && entry.name === difference.name,
|
|
63
75
|
);
|
|
64
76
|
// The entry ABOUT this difference carries the edit that repairs it, so it is the whole answer.
|
|
65
|
-
if (named !== undefined) return named.fix;
|
|
77
|
+
if (named !== undefined) return { fix: named.fix };
|
|
66
78
|
// Otherwise `x db gen` is still unsafe — it would drop what the other entries name — but the
|
|
67
79
|
// reader must NOT be handed a different declaration's edit as the instruction for this one.
|
|
68
80
|
// Reading `unrendered[0]` did exactly that: a column default and an index each got
|
|
@@ -72,16 +84,26 @@ function repairFix(
|
|
|
72
84
|
// app — not only for the one the entry names.
|
|
73
85
|
const blocker = unrendered[0];
|
|
74
86
|
if (blocker !== undefined) {
|
|
75
|
-
return
|
|
87
|
+
return {
|
|
88
|
+
fix: blocker.fix,
|
|
89
|
+
why: `${blocker.table}.${blocker.name} reaches no SQL, so regenerating would drop it — repair that before recording ${difference.name}`,
|
|
90
|
+
};
|
|
76
91
|
}
|
|
77
92
|
// "record", not "add": one finding covers a declaration the migrations never carried AND one they
|
|
78
|
-
// carry differently, so `add` would be a wrong migration name for half of them. `undeclared`
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
// carry differently, so `add` would be a wrong migration name for half of them. `undeclared` runs
|
|
94
|
+
// the DROP, and the other branch — the declaration may have been LOST rather than removed, which
|
|
95
|
+
// is the whole reason this direction is its own finding — rides in the cause: a `fix:` is one
|
|
96
|
+
// command, and a `# or …` after it was a shell comment nobody's paste would run.
|
|
97
|
+
if (difference.direction === 'unmigrated') return { fix: `x db gen "record ${difference.name}"` };
|
|
98
|
+
// The name was read off a sidecar on disk and lands inside double quotes, where `$(…)` and a
|
|
99
|
+
// backtick still run: one the shell would act on is named in the cause only, never the command.
|
|
100
|
+
const inert = shellInertIdentifier(difference.name) !== null;
|
|
101
|
+
return {
|
|
102
|
+
fix: inert
|
|
103
|
+
? `x db gen "drop ${difference.name}"`
|
|
104
|
+
: 'x db gen "drop what the entities no longer declare"',
|
|
105
|
+
why: `if ${difference.name} was lost rather than removed, re-declare it on the entity instead — this command drops it`,
|
|
106
|
+
};
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
/**
|
|
@@ -95,8 +117,10 @@ export const schemaDifferenceCause = (difference: SchemaDifference): string =>
|
|
|
95
117
|
? `table "${difference.table}" ${difference.detail}`
|
|
96
118
|
: `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
|
|
97
119
|
|
|
98
|
-
function findingFor(difference: SchemaDifference,
|
|
99
|
-
const
|
|
120
|
+
function findingFor(difference: SchemaDifference, repair: Repair): Finding {
|
|
121
|
+
const { fix } = repair;
|
|
122
|
+
const stated = schemaDifferenceCause(difference);
|
|
123
|
+
const cause = repair.why === undefined ? stated : `${stated} — ${repair.why}`;
|
|
100
124
|
return difference.direction === 'unmigrated'
|
|
101
125
|
? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
|
|
102
126
|
: { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
|
|
@@ -114,6 +138,7 @@ function findingFor(difference: SchemaDifference, fix: string): Finding {
|
|
|
114
138
|
export async function checkSnapshotDrift(
|
|
115
139
|
root: string,
|
|
116
140
|
declared: DeclaredEntities = appEntities,
|
|
141
|
+
identity: ReplicaIdentityWanted = appReplicaIdentity,
|
|
117
142
|
): Promise<readonly Finding[]> {
|
|
118
143
|
if (!existsSync(join(root, DB_PACKAGE))) return [];
|
|
119
144
|
const entities = await declared(root);
|
|
@@ -122,7 +147,11 @@ export async function checkSnapshotDrift(
|
|
|
122
147
|
if (migrations.length === 0) return [];
|
|
123
148
|
const recorded = declaredSchema(migrations);
|
|
124
149
|
if (recorded === undefined) return [];
|
|
125
|
-
|
|
150
|
+
// Read only over a WHOLE registry (`entities` is `undefined` otherwise), the rule `x db gen`
|
|
151
|
+
// states for the same call: a short one makes every subscribed name in the missing module a typo.
|
|
152
|
+
const wanted = wantedReplicaIdentity(identity, new Set(entities.map((entity) => entity.table)));
|
|
153
|
+
if ('finding' in wanted) return [wanted.finding];
|
|
154
|
+
const differences = diffDeclaredSchema(snapshotOf(entities, wanted.tables), recorded);
|
|
126
155
|
if (differences.length === 0) return [];
|
|
127
156
|
// `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
|
|
128
157
|
// migration recorded a CHECK for it, which only the sidecar knows.
|
|
@@ -145,8 +174,9 @@ export async function checkMigrationDrift(
|
|
|
145
174
|
root: string,
|
|
146
175
|
declared: DeclaredEntities = appEntities,
|
|
147
176
|
hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
|
|
177
|
+
identity: ReplicaIdentityWanted = appReplicaIdentity,
|
|
148
178
|
): Promise<readonly Finding[]> {
|
|
149
|
-
const snapshot = await checkSnapshotDrift(root, declared);
|
|
179
|
+
const snapshot = await checkSnapshotDrift(root, declared, identity);
|
|
150
180
|
if (snapshot.length > 0) return snapshot;
|
|
151
181
|
return hashDrift(root);
|
|
152
182
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The `drift` step's fourth rail: is the committed schema dump what the migrations produce, and
|
|
2
|
+
// does a database loaded from it come back the same? The other three rails read files and open
|
|
3
|
+
// nothing; this one replays the migrations on a scratch embedded database, because "what the
|
|
4
|
+
// migrations produce" is a fact only a database can state.
|
|
5
|
+
|
|
6
|
+
// why: Bun has no directory probe — `Bun.file().exists()` answers about a file.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
// why: Bun ships no path joiner.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
11
|
+
import type { Migration } from '@ultimat3/db';
|
|
12
|
+
import type { SchemaDumpDifference } from '@ultimat3/db/schema-dump';
|
|
13
|
+
import { compareSchemaDump, schemaDumpDrift } from '@ultimat3/db/schema-dump';
|
|
14
|
+
import {
|
|
15
|
+
hasSchemaDump,
|
|
16
|
+
type ReplayedSchema,
|
|
17
|
+
readSchemaDump,
|
|
18
|
+
replayFor,
|
|
19
|
+
SCHEMA_DUMP_DIR,
|
|
20
|
+
} from './db-schema-dump';
|
|
21
|
+
import { dumpNeverGenerated, replayFailure } from './db-schema-refresh';
|
|
22
|
+
import { DB_PACKAGE } from './drift';
|
|
23
|
+
import { readMigrations } from './migrations';
|
|
24
|
+
import type { Finding } from './output';
|
|
25
|
+
import { findingFrom } from './output';
|
|
26
|
+
|
|
27
|
+
/** Injected so this module's rules are testable without booting a database per case. */
|
|
28
|
+
export type SchemaReplay = (migrations: readonly Migration[]) => Promise<ReplayedSchema>;
|
|
29
|
+
|
|
30
|
+
const findingFor = (difference: SchemaDumpDifference): Finding => ({
|
|
31
|
+
...findingFrom(schemaDumpDrift(difference)),
|
|
32
|
+
docs: ERROR_DOCS_URL,
|
|
33
|
+
at: `${SCHEMA_DUMP_DIR}/${difference.path}`,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Empty result = the dump is the migrations' own.
|
|
38
|
+
*
|
|
39
|
+
* An app with no migration and no dump owes none — zero recorded against zero dumped is agreement,
|
|
40
|
+
* the rule `checkSourceDrift` states for entities, and what keeps `x new --no-example` green. From
|
|
41
|
+
* the first migration on the dump is owed: a missing directory is one finding naming the command
|
|
42
|
+
* that writes it, reported without booting a database.
|
|
43
|
+
*
|
|
44
|
+
* Both halves are reported together. A stale file and a dump that does not round-trip are two
|
|
45
|
+
* repairs, and hiding the second behind the first hands out `x db gen` for a dump regenerating
|
|
46
|
+
* cannot fix.
|
|
47
|
+
*/
|
|
48
|
+
export async function checkSchemaDump(
|
|
49
|
+
root: string,
|
|
50
|
+
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
51
|
+
replay: SchemaReplay = replayFor(root, env, true),
|
|
52
|
+
): Promise<readonly Finding[]> {
|
|
53
|
+
if (!existsSync(join(root, DB_PACKAGE))) return [];
|
|
54
|
+
const migrations = await readMigrations(root);
|
|
55
|
+
if (!hasSchemaDump(root)) return migrations.length === 0 ? [] : [dumpNeverGenerated()];
|
|
56
|
+
const committed = await readSchemaDump(root);
|
|
57
|
+
let replayed: ReplayedSchema;
|
|
58
|
+
try {
|
|
59
|
+
replayed = await replay(migrations);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return [replayFailure(error)];
|
|
62
|
+
}
|
|
63
|
+
return [...compareSchemaDump(committed, replayed.files), ...replayed.reload].map(findingFor);
|
|
64
|
+
}
|
package/src/seo-routes.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// `GET /robots.txt
|
|
2
|
-
//
|
|
3
|
-
// `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
|
|
1
|
+
// `GET /robots.txt`, `GET /sitemap.xml` and its parts (`/sitemaps/<n>.xml`) from a running web
|
|
2
|
+
// role — the files the static export writes, for a deploy that serves its pages from a container.
|
|
3
|
+
// Mounted by `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
|
|
4
4
|
|
|
5
5
|
import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
|
|
6
6
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
7
|
-
import { applyCacheHeaders } from '@ultimat3/http';
|
|
7
|
+
import { applyCacheHeaders, json, NO_STORE } from '@ultimat3/http';
|
|
8
|
+
import { SITEMAP_PARTS_DIR } from '@ultimat3/seo';
|
|
9
|
+
import { quoteArg } from './shell-quote';
|
|
8
10
|
import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
|
|
11
|
+
import type { SiteSeo } from './site-seo';
|
|
9
12
|
import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
|
|
10
13
|
|
|
11
14
|
export interface SeoRoutesOptions {
|
|
@@ -26,22 +29,57 @@ function originOf(options: SeoRoutesOptions, request: UltimateRequest): string {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
32
|
+
* An hour of shared cache is what a CDN keeps of either file, and what this process keeps of the
|
|
33
|
+
* answer behind both. `prerender()` may enumerate rows that change, so the memo is never longer
|
|
34
|
+
* than the response's own `max-age` — a crawler that bypasses the CDN sees what one behind it
|
|
35
|
+
* would. Before it, every `/robots.txt` hit recomputed the whole sitemap, each dynamic route's
|
|
36
|
+
* `prerender()` included.
|
|
31
37
|
*/
|
|
32
38
|
const SEO_CACHE = { mode: 'public', maxAgeSeconds: 3600 } as const;
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Origins remembered at once. With no declared origin the request's own is the key, and a `Host`
|
|
42
|
+
* header is the caller's to choose — so the memo is bounded, oldest out first.
|
|
43
|
+
*/
|
|
44
|
+
const SEO_MEMO_ORIGINS = 8;
|
|
45
|
+
|
|
46
|
+
interface Memo {
|
|
47
|
+
readonly at: number;
|
|
48
|
+
readonly answer: Promise<SiteSeo>;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
35
52
|
const environment: Environment =
|
|
36
53
|
tryResolveEnvironment({ env: options.env }) ?? DEFAULT_ENVIRONMENT;
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
baseUrl
|
|
54
|
+
const compute = (baseUrl: string): Promise<SiteSeo> =>
|
|
55
|
+
siteSeo({
|
|
56
|
+
baseUrl,
|
|
40
57
|
environment,
|
|
41
58
|
disallow: options.site?.disallow ?? [],
|
|
42
59
|
...(options.site === undefined ? {} : { sitemap: options.site.sitemap }),
|
|
43
60
|
...(options.root === undefined ? {} : { root: options.root }),
|
|
44
61
|
});
|
|
62
|
+
// Single-flight per origin: concurrent requests share the one computation in progress. A failed
|
|
63
|
+
// one is dropped, so the next request asks again rather than serving the failure for an hour.
|
|
64
|
+
const memo = new Map<string, Memo>();
|
|
65
|
+
const answer = (request: UltimateRequest): Promise<SiteSeo> => {
|
|
66
|
+
const origin = originOf(options, request);
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const kept = memo.get(origin);
|
|
69
|
+
if (kept !== undefined && now - kept.at < SEO_CACHE.maxAgeSeconds * 1000) return kept.answer;
|
|
70
|
+
memo.delete(origin);
|
|
71
|
+
if (memo.size >= SEO_MEMO_ORIGINS) {
|
|
72
|
+
const oldest = memo.keys().next().value;
|
|
73
|
+
if (oldest !== undefined) memo.delete(oldest);
|
|
74
|
+
}
|
|
75
|
+
const computed = compute(origin);
|
|
76
|
+
memo.set(origin, { at: now, answer: computed });
|
|
77
|
+
// Voided: the request awaits `computed` itself and reports its failure; this branch only forgets it.
|
|
78
|
+
void computed.catch(() => {
|
|
79
|
+
if (memo.get(origin)?.answer === computed) memo.delete(origin);
|
|
80
|
+
});
|
|
81
|
+
return computed;
|
|
82
|
+
};
|
|
45
83
|
|
|
46
84
|
return [
|
|
47
85
|
{
|
|
@@ -69,5 +107,38 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
|
69
107
|
SEO_CACHE,
|
|
70
108
|
),
|
|
71
109
|
},
|
|
110
|
+
{
|
|
111
|
+
method: 'GET',
|
|
112
|
+
path: `${SITEMAP_PARTS_DIR}/:file`,
|
|
113
|
+
meta: { name: 'seo.sitemapPart', auth: 'public', tags: ['seo'] },
|
|
114
|
+
// A part of a split sitemap, from the same answer the index came from — so a part the index
|
|
115
|
+
// names always exists here, and the static export writes the same paths.
|
|
116
|
+
handler: async (request: UltimateRequest): Promise<Response> => {
|
|
117
|
+
const seo = await answer(request);
|
|
118
|
+
const part = seo.sitemaps.find((file) => file.path === request.pathname);
|
|
119
|
+
if (part !== undefined && part.path !== SITEMAP_PATH) {
|
|
120
|
+
return applyCacheHeaders(
|
|
121
|
+
new Response(part.xml, {
|
|
122
|
+
headers: { 'content-type': 'application/xml; charset=utf-8' },
|
|
123
|
+
}),
|
|
124
|
+
SEO_CACHE,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const index = new URL(SITEMAP_PATH, originOf(options, request)).href;
|
|
128
|
+
const missing = json(
|
|
129
|
+
{
|
|
130
|
+
ok: false,
|
|
131
|
+
error: {
|
|
132
|
+
code: 'X_ROUTE_NOT_FOUND',
|
|
133
|
+
cause: `${request.pathname} is no part of this site's sitemap: the index names ${String(Math.max(seo.sitemaps.length - 1, 0))} part(s)`,
|
|
134
|
+
fix: `curl -sS ${quoteArg(index)}`,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{ status: 404 },
|
|
138
|
+
);
|
|
139
|
+
// `no-store`: the part may exist once the site grows, and a cached miss would hide it.
|
|
140
|
+
return applyCacheHeaders(missing, NO_STORE);
|
|
141
|
+
},
|
|
142
|
+
},
|
|
72
143
|
];
|
|
73
144
|
}
|
package/src/serve-boot.ts
CHANGED
|
@@ -1,46 +1,89 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
// that file's header states — the SAME boot `x dev` runs, minus the watcher,
|
|
4
|
-
// — are this file's.
|
|
1
|
+
// The serving roles' boot: the services started, the app loaded, its route table built, the roles
|
|
2
|
+
// started. Behind ONE `await import()` in `serve.ts` (the entry points), so `ROLE=migrate` loads
|
|
3
|
+
// none of it; the rules that file's header states — the SAME boot `x dev` runs, minus the watcher,
|
|
4
|
+
// `/_x` and `dev: true` — are this file's. A role imports what it runs: the web surface
|
|
5
|
+
// (`serve-web.ts`) and the manifest projection are behind `await import()` in turn, so a worker's
|
|
6
|
+
// module graph holds neither.
|
|
5
7
|
|
|
6
|
-
import type { Role } from '@ultimat3/core';
|
|
7
|
-
import { configureLifecycle } from '@ultimat3/core';
|
|
8
|
-
import type { Route } from '@ultimat3/http';
|
|
9
|
-
import { describeRoutes } from '@ultimat3/render';
|
|
10
|
-
import { createIsrController } from '@ultimat3/render/server';
|
|
11
|
-
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
12
|
-
import { loadSignInPath } from './app-auth';
|
|
13
|
-
import { loadApp } from './app-load';
|
|
14
|
-
import { appManifest } from './app-manifest';
|
|
15
|
-
import { mountAppMcp } from './app-mcp';
|
|
16
|
-
import { errorPageStyleSources } from './error-page-csp';
|
|
17
|
-
import { islandRoutes } from './island-routes';
|
|
18
|
-
import { loadOrBuildIslands } from './island-store';
|
|
8
|
+
import type { DrainConfig, LifecycleOptions, Role } from '@ultimat3/core';
|
|
9
|
+
import { configureLifecycle, logger } from '@ultimat3/core';
|
|
19
10
|
import type { MetricsEndpoint } from './metrics-endpoint';
|
|
20
11
|
import { startOtlpExport } from './otlp-export';
|
|
21
|
-
import {
|
|
22
|
-
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
23
|
-
import { pageSync } from './page-sync';
|
|
24
|
-
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
12
|
+
import { loadAppForRole, roleLoadFor } from './role-load';
|
|
25
13
|
import { startRoles } from './role-start';
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
14
|
+
import { resolveServices } from './runtime-bindings';
|
|
15
|
+
import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
|
|
16
|
+
import { warnUnsealedMfaSecrets } from './runtime-mfa-warning';
|
|
28
17
|
import { replicaOverrides } from './runtime-replica';
|
|
29
18
|
import type { RunningServices } from './runtime-services';
|
|
30
|
-
import {
|
|
31
|
-
import { seoRoutes } from './seo-routes';
|
|
19
|
+
import { startServices } from './runtime-services';
|
|
32
20
|
import { loadDrainConfig, loadHealthConfig } from './serve-drain';
|
|
33
21
|
import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
|
|
22
|
+
import { adoptPrebuiltStyles, reportBootBuilds, watchBootBuilds } from './serve-prebuilt';
|
|
34
23
|
import type { ServedApp, ServeOptions } from './serve-types';
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
|
|
27
|
+
* answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only when
|
|
28
|
+
* unstamped, because a stamped image already paid for it at build time and a replica's boot should
|
|
29
|
+
* not repeat it. The projection describes the WHOLE app — routes included — so an unstamped worker
|
|
30
|
+
* imports everything its own load left out, and says so.
|
|
31
|
+
*/
|
|
32
|
+
async function buildIdFor(options: ServeOptions, role: Role): Promise<string> {
|
|
33
|
+
const stamped = options.env['BUILD_ID'];
|
|
34
|
+
if (stamped !== undefined && stamped.length > 0) return stamped;
|
|
35
|
+
if (roleLoadFor(role) === 'background') {
|
|
36
|
+
logger.warn('ultimate build id unstamped', {
|
|
37
|
+
role,
|
|
38
|
+
cause:
|
|
39
|
+
'BUILD_ID is unset, so this role imported every page of the app to compute the build id the web role serves',
|
|
40
|
+
fix: 'x build --target docker',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const { appManifest } = await import('./app-manifest');
|
|
44
|
+
return (await appManifest(options.root)).manifest.buildId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The roles that open an HTTP listener something routes to — the only ones a readiness grace serves. */
|
|
48
|
+
const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `app.config.ts`'s `drain`, applied to the WHOLE process rather than only to the web server that
|
|
52
|
+
* `createServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
|
|
53
|
+
* holds `/readyz` at 503 with the listener open so the endpoints catch up, and a worker has neither
|
|
54
|
+
* — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
|
|
55
|
+
*/
|
|
56
|
+
export function lifecycleForRole(
|
|
57
|
+
role: Role,
|
|
58
|
+
drain: Partial<DrainConfig> | undefined,
|
|
59
|
+
): LifecycleOptions {
|
|
60
|
+
if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0 };
|
|
61
|
+
return drain?.readinessGraceMs === undefined ? {} : { readinessGraceMs: drain.readinessGraceMs };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Everything `serveApp` does after the scrape listener is open: the services, then the roles over
|
|
66
|
+
* them. One export, so `serve.ts` reaches the whole serving graph through ONE `await import()`.
|
|
67
|
+
*/
|
|
68
|
+
export async function bootServing(boot: {
|
|
69
|
+
readonly options: ServeOptions;
|
|
70
|
+
readonly role: Role;
|
|
71
|
+
readonly acquired: (() => void | Promise<void>)[];
|
|
72
|
+
readonly metrics: MetricsEndpoint;
|
|
73
|
+
}): Promise<ServedApp> {
|
|
74
|
+
const { options } = boot;
|
|
75
|
+
const services = resolveServices(options.root, options.env);
|
|
76
|
+
// A serving role on a real database runs no DDL: `ROLE=migrate` applied this build's framework
|
|
77
|
+
// tables first, and this boot only verifies the stamp it left (`framework-schema-apply.ts`).
|
|
78
|
+
// The embedded database has no migrate step to have run, so it applies its own.
|
|
79
|
+
const schema = services.db.mode === 'external' ? 'verify' : 'apply';
|
|
80
|
+
const runtime = await startServices(services, options.env, options.runtime, schema);
|
|
81
|
+
boot.acquired.push(() => runtime.stop());
|
|
82
|
+
return bootRoles({ ...boot, runtime });
|
|
83
|
+
}
|
|
41
84
|
|
|
42
85
|
/** The half of `serveApp` whose every acquisition is registered for rollback. */
|
|
43
|
-
|
|
86
|
+
async function bootRoles(boot: {
|
|
44
87
|
readonly options: ServeOptions;
|
|
45
88
|
readonly role: Role;
|
|
46
89
|
readonly runtime: RunningServices;
|
|
@@ -49,18 +92,21 @@ export async function bootRoles(boot: {
|
|
|
49
92
|
readonly metrics: MetricsEndpoint;
|
|
50
93
|
}): Promise<ServedApp> {
|
|
51
94
|
const { options, role, runtime, acquired, metrics } = boot;
|
|
95
|
+
// The image's compiled stylesheets, before the first page is imported — importing one IS
|
|
96
|
+
// compiling its sheet — and the count of what this boot compiles anyway.
|
|
97
|
+
adoptPrebuiltStyles(options.root);
|
|
98
|
+
const bootBuilds = watchBootBuilds();
|
|
52
99
|
// Importing the app's modules IS the registration: every route, action and job below is
|
|
53
|
-
// whatever this call put in the registries.
|
|
54
|
-
await
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
: (await appManifest(options.root)).manifest.buildId;
|
|
100
|
+
// whatever this call put in the registries — for THIS role (`role-load.ts`).
|
|
101
|
+
const loaded = await loadAppForRole(options.root, role);
|
|
102
|
+
// A module that would not import registered nothing, and a process that only logs the roles it
|
|
103
|
+
// started gives no sign of it: each one is a line an operator can act on.
|
|
104
|
+
for (const finding of loaded.findings) {
|
|
105
|
+
logger.error('ultimate app module failed to load', { role, ...finding });
|
|
106
|
+
}
|
|
107
|
+
// After the app's modules: an app swaps the store at import time, after `startServices`.
|
|
108
|
+
warnIfIdempotencyProcessScoped();
|
|
109
|
+
const buildId = await buildIdFor(options, role);
|
|
64
110
|
// Before the first socket opens: everything above this line fails loudly into the container's
|
|
65
111
|
// own logs, everything below it is a served request, a claimed job or a routed frame.
|
|
66
112
|
configureReporting(options.env, buildId);
|
|
@@ -72,14 +118,26 @@ export async function bootRoles(boot: {
|
|
|
72
118
|
acquired.push(stopOtlp);
|
|
73
119
|
// Only the web role serves pages, so only it builds islands and assembles a route table: a
|
|
74
120
|
// worker, scheduler, sync or replicator pod compiled every island of the app on every boot for
|
|
75
|
-
// nothing it would ever serve (plan 101, slice 12 e).
|
|
76
|
-
|
|
121
|
+
// nothing it would ever serve (plan 101, slice 12 e). The MODULE is the web role's as well —
|
|
122
|
+
// imported here, it is render, PWA, SEO and MCP code no other role evaluates.
|
|
123
|
+
const web =
|
|
124
|
+
role === 'web'
|
|
125
|
+
? await (await import('./serve-web')).webSurface(options, runtime, buildId)
|
|
126
|
+
: undefined;
|
|
127
|
+
if (web !== undefined) acquired.push(web.release);
|
|
128
|
+
// The web role alone — it is where a second factor is asked for — and once per pod boot.
|
|
129
|
+
if (role === 'web') await warnUnsealedMfaSecrets(runtime.db);
|
|
130
|
+
// Islands and stylesheets are the image build's to make (`x build --target prebuilt`). A role
|
|
131
|
+
// that made either here served correctly and paid for it — seconds of CPU and a compiler's
|
|
132
|
+
// memory, on every start of every replica — so it says so once, with the Dockerfile line.
|
|
133
|
+
reportBootBuilds(role, bootBuilds(web?.islandsBuilt));
|
|
77
134
|
const port = options.port ?? portFromEnv(options.env);
|
|
78
135
|
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
79
136
|
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
80
137
|
// still wins — that is the deploy talking.
|
|
81
138
|
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
82
139
|
const drain = await loadDrainConfig(options.root);
|
|
140
|
+
configureLifecycle(lifecycleForRole(role, drain));
|
|
83
141
|
// Process-wide, so every role's `/readyz` — web, sync, worker — answers in the declared mode.
|
|
84
142
|
const health = await loadHealthConfig(options.root);
|
|
85
143
|
if (health?.readiness !== undefined) configureLifecycle({ readiness: health.readiness });
|
|
@@ -93,13 +151,15 @@ export async function bootRoles(boot: {
|
|
|
93
151
|
runtime,
|
|
94
152
|
routes: web?.routes ?? [],
|
|
95
153
|
env: options.env,
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
154
|
+
// The three facts only a process serving documents has (`WebSurface`); `startWeb` is their
|
|
155
|
+
// one reader, so every other role hands none.
|
|
156
|
+
...(web === undefined
|
|
157
|
+
? {}
|
|
158
|
+
: {
|
|
159
|
+
signInPath: web.signInPath,
|
|
160
|
+
inlineStyles: web.inlineStyles,
|
|
161
|
+
inlineScripts: web.inlineScripts,
|
|
162
|
+
}),
|
|
103
163
|
// The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
|
|
104
164
|
// process and `x dev` cannot answer a browser differently.
|
|
105
165
|
root: options.root,
|
|
@@ -122,6 +182,8 @@ export async function bootRoles(boot: {
|
|
|
122
182
|
runtime,
|
|
123
183
|
async stop() {
|
|
124
184
|
await running.stop();
|
|
185
|
+
// After the server drained: a bust landing mid-drain still marks the pages it is serving.
|
|
186
|
+
web?.release();
|
|
125
187
|
await runtime.stop();
|
|
126
188
|
// Last: the exporters outlive the roles they were recording, so the drain's own spans and
|
|
127
189
|
// the final counter snapshot still have somewhere to go.
|
|
@@ -129,109 +191,3 @@ export async function bootRoles(boot: {
|
|
|
129
191
|
},
|
|
130
192
|
};
|
|
131
193
|
}
|
|
132
|
-
|
|
133
|
-
/** What only the web role serves: its islands, its documents and every route beside them. */
|
|
134
|
-
async function webSurface(
|
|
135
|
-
options: ServeOptions,
|
|
136
|
-
runtime: RunningServices,
|
|
137
|
-
buildId: string,
|
|
138
|
-
): Promise<{ readonly routes: readonly Route[]; readonly inlineScripts: readonly string[] }> {
|
|
139
|
-
// Read from the store `x build --target docker` wrote and VERIFIED against this app and this
|
|
140
|
-
// runtime (`island-store.ts`); built here only when there is no store to trust, which is correct
|
|
141
|
-
// and slower, and logged with its reason.
|
|
142
|
-
const islands = await loadOrBuildIslands(options.root);
|
|
143
|
-
// The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
|
|
144
|
-
// on a laptop and absent in the image is exactly the dev/prod difference this file exists to
|
|
145
|
-
// prevent, and it is the one an operator cannot see without installing the app.
|
|
146
|
-
const pwa = await loadPwaArtifacts(options.root);
|
|
147
|
-
const theme = themeBoot(await loadThemeMode(options.root));
|
|
148
|
-
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
149
|
-
const site = await loadSiteSettings(options.root);
|
|
150
|
-
const origin = publicOrigin(options.env, site);
|
|
151
|
-
// The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
|
|
152
|
-
// Before the service worker, which precaches those scripts.
|
|
153
|
-
const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
|
|
154
|
-
// The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
|
|
155
|
-
const declared = await loadNavigation(options.root);
|
|
156
|
-
const navigation = await pageNavigation(options.root, declared, buildId);
|
|
157
|
-
// The browser's own prefetch, for the documents that carry no router (`navigation.speculation`).
|
|
158
|
-
const speculation = pageSpeculation({
|
|
159
|
-
config: await loadSpeculation(options.root),
|
|
160
|
-
client: declared.surfaces,
|
|
161
|
-
});
|
|
162
|
-
// The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
|
|
163
|
-
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
164
|
-
// route added here cannot be missing from the precache manifest.
|
|
165
|
-
const serviceWorker =
|
|
166
|
-
pwa === undefined
|
|
167
|
-
? undefined
|
|
168
|
-
: serviceWorkerArtifacts({
|
|
169
|
-
pwa,
|
|
170
|
-
buildId,
|
|
171
|
-
routes: describeRoutes(),
|
|
172
|
-
islands,
|
|
173
|
-
styles: styleBundle(),
|
|
174
|
-
scripts: [
|
|
175
|
-
...sync.scripts,
|
|
176
|
-
...(navigation.script === undefined ? [] : [navigation.script]),
|
|
177
|
-
],
|
|
178
|
-
});
|
|
179
|
-
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
180
|
-
const mcpMount = await mountAppMcp(options.root);
|
|
181
|
-
// The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
|
|
182
|
-
const served = servedStorage(runtime.storage);
|
|
183
|
-
const routes: readonly Route[] = [
|
|
184
|
-
...apiRoutes(),
|
|
185
|
-
// The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
|
|
186
|
-
// on the store this boot's limiter uses, so one token's allowance is one number fleet-wide.
|
|
187
|
-
...apiMountRoutes(options.runtime?.rateLimitStore ?? runtime.rateLimitStore),
|
|
188
|
-
...mcpMount.routes,
|
|
189
|
-
// The app's own plain routes (`apps/<app>/runtime.ts` `routes`), before any page can shadow one.
|
|
190
|
-
...(options.runtime?.routes ?? []),
|
|
191
|
-
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
192
|
-
...assetRoutes({
|
|
193
|
-
root: options.root,
|
|
194
|
-
storage: served,
|
|
195
|
-
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
196
|
-
...(pwa === undefined ? {} : { pwa }),
|
|
197
|
-
}),
|
|
198
|
-
...storageRoutes({ storage: served }),
|
|
199
|
-
...islandRoutes(() => islands),
|
|
200
|
-
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
201
|
-
// filled, so this process serves exactly the CSS it renders against.
|
|
202
|
-
...styleRoutes(() => styleBundle()),
|
|
203
|
-
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
204
|
-
...seoRoutes({ env: options.env, site, root: options.root }),
|
|
205
|
-
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
206
|
-
...sync.routes,
|
|
207
|
-
...navigation.routes,
|
|
208
|
-
// A page's `POST`, bound to an action (`defineRoute({ post })`) — beside the page's `GET`.
|
|
209
|
-
...pagePostRoutes(),
|
|
210
|
-
...appRoutes({
|
|
211
|
-
buildId,
|
|
212
|
-
resolveIsland: (file) => islands.resolverFor(file),
|
|
213
|
-
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
214
|
-
persisted: sync.persisted,
|
|
215
|
-
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
216
|
-
themeHead: theme.head,
|
|
217
|
-
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
218
|
-
// A `static` page is the same bytes for every request of this process: rendered once.
|
|
219
|
-
memoStatic: true,
|
|
220
|
-
...(origin === undefined ? {} : { origin }),
|
|
221
|
-
...(pwa === undefined
|
|
222
|
-
? {}
|
|
223
|
-
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
224
|
-
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
225
|
-
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
226
|
-
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
227
|
-
...(options.runtime?.isrStore === undefined
|
|
228
|
-
? {}
|
|
229
|
-
: { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
|
|
230
|
-
}),
|
|
231
|
-
];
|
|
232
|
-
return {
|
|
233
|
-
routes,
|
|
234
|
-
// The theme boot and the speculation rules: the two inline bodies every document may carry.
|
|
235
|
-
inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
|
|
236
|
-
};
|
|
237
|
-
}
|