@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/serve-drain.ts
CHANGED
|
@@ -1,41 +1,17 @@
|
|
|
1
|
-
// `app.config.ts`'s `drain` and `health` sections
|
|
2
|
-
//
|
|
3
|
-
// module. Both shape what `/readyz` tells the load balancer.
|
|
1
|
+
// `app.config.ts`'s `drain` and `health` sections for the production boot, off the one loader
|
|
2
|
+
// (`app-config-load.ts`). Both shape what `/readyz` tells the load balancer.
|
|
4
3
|
|
|
5
4
|
import type { DrainConfig, HealthConfig } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
7
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
5
|
+
import { loadAppConfig } from './app-config-load';
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
typeof value === 'object' && value !== null;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* The declared section, or `undefined` when the app has none (a config not built by
|
|
14
|
-
* `defineConfig`, or no config file): `@ultimat3/http` then keeps core's own default. A value that
|
|
15
|
-
* is not a number is left for core to refuse, where the rule lives, rather than dropped here.
|
|
16
|
-
*/
|
|
7
|
+
/** The declared section, or `undefined` for a root with no config file — core keeps its default. */
|
|
17
8
|
export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const module = (await import(path)) as Record<string, unknown>;
|
|
21
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
22
|
-
const drain = isRecord(config) ? config['drain'] : undefined;
|
|
23
|
-
if (!isRecord(drain) || !Object.hasOwn(drain, 'readinessGraceMs')) return undefined;
|
|
24
|
-
return { readinessGraceMs: drain['readinessGraceMs'] as number };
|
|
9
|
+
const drain = (await loadAppConfig(root))?.drain;
|
|
10
|
+
return drain === undefined ? undefined : { readinessGraceMs: drain.readinessGraceMs };
|
|
25
11
|
}
|
|
26
12
|
|
|
27
|
-
/**
|
|
28
|
-
* `health.readiness`, or `undefined` when the app declares none (core keeps `'dependencies'`). The
|
|
29
|
-
* value is handed to `configureLifecycle` as written, so a bad one is refused by core's own rule.
|
|
30
|
-
*/
|
|
13
|
+
/** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
|
|
31
14
|
export async function loadHealthConfig(root: string): Promise<Partial<HealthConfig> | undefined> {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const module = (await import(path)) as Record<string, unknown>;
|
|
35
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
36
|
-
const health = isRecord(config)
|
|
37
|
-
? (config['health'] as Partial<HealthConfig> | undefined)
|
|
38
|
-
: undefined;
|
|
39
|
-
if (!isRecord(health) || health.readiness === undefined) return undefined;
|
|
40
|
-
return { readiness: health.readiness };
|
|
15
|
+
const health = (await loadAppConfig(root))?.health;
|
|
16
|
+
return health === undefined ? undefined : { readiness: health.readiness };
|
|
41
17
|
}
|
package/src/serve-env.ts
CHANGED
|
@@ -42,22 +42,25 @@ export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
|
|
|
42
42
|
/**
|
|
43
43
|
* `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
|
|
44
44
|
* to `web` would start a process that serves nothing the operator asked for and reports healthy.
|
|
45
|
+
* Empty or blank is unset, read the way `PORT` and `HOST` are — not a role name nobody typed.
|
|
45
46
|
*/
|
|
46
47
|
export function roleFromEnv(env: Env): Role {
|
|
47
|
-
const raw = env['ROLE']
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const raw = env['ROLE']?.trim();
|
|
49
|
+
const role = raw === undefined || raw.length === 0 ? 'web' : raw;
|
|
50
|
+
if (!isRole(role)) throw new RoleUnknownError({ role, known: ROLES });
|
|
51
|
+
return role;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
|
-
* `Number.parseInt` would read `80abc` as 80,
|
|
54
|
-
*
|
|
55
|
+
* `Number.parseInt` would read `80abc` as 80, and `Number` reads `0x1F90` as 8080 and `8e3` as
|
|
56
|
+
* 8000, so the whole string has to be decimal digits — a port spelled any other way is a deploy
|
|
57
|
+
* that binds somewhere nobody wrote down.
|
|
55
58
|
*/
|
|
56
59
|
function portValue(env: Env, name: string, fallback: number): number {
|
|
57
60
|
const raw = env[name];
|
|
58
61
|
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
59
62
|
const port = Number(raw.trim());
|
|
60
|
-
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
63
|
+
if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(port) || port < 0 || port > 65_535)
|
|
61
64
|
throw new PortInvalidError({ value: raw, name });
|
|
62
65
|
return port;
|
|
63
66
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Where the image's prebuilt store lives and the one command that writes it. A leaf with no
|
|
2
|
+
// import, so the Dockerfile template, the build command and the container boot all spell them
|
|
3
|
+
// from here without loading each other.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* App-root-relative. NOT under `.x/`: that is the state directory, which the scaffolded compose
|
|
7
|
+
* topology mounts a tmpfs over and every ignore file drops, so a store written there never reached
|
|
8
|
+
* a running container. Under `node_modules` because that is the one directory every tool already
|
|
9
|
+
* leaves alone — git, the linter, every source walker and the image context — and the one the
|
|
10
|
+
* image build has just written, so it is in a layer and readable under a read-only root.
|
|
11
|
+
*/
|
|
12
|
+
export const PREBUILT_DIR = 'node_modules/.cache/ultimate';
|
|
13
|
+
|
|
14
|
+
export const PREBUILT_ISLANDS_DIR = `${PREBUILT_DIR}/islands`;
|
|
15
|
+
|
|
16
|
+
export const PREBUILT_SASS_DIR = `${PREBUILT_DIR}/sass`;
|
|
17
|
+
|
|
18
|
+
/** The Dockerfile line that writes the store — one spelling, for the template and every `fix:`. */
|
|
19
|
+
export const PREBUILT_COMMAND = 'bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The image's prebuilt store, as a container sees it: where `x build --target prebuilt` wrote the
|
|
2
|
+
// island chunks and the compiled stylesheets during `docker build`, and the one log line a role
|
|
3
|
+
// owes when it had to build either at boot instead. A leaf of the boot graph — the half that
|
|
4
|
+
// WRITES the store (`image-prepare.ts`) carries the island builder and is never loaded here.
|
|
5
|
+
|
|
6
|
+
// why: the store is adopted before the first app module is imported, and that import path is
|
|
7
|
+
// synchronous end to end (`compileStylesheet` runs inside Bun's `onLoad`); Bun has no sync exists.
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
// why: Bun ships no path-join primitive.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { Role } from '@ultimat3/core';
|
|
12
|
+
import { logger, UltimateError } from '@ultimat3/core';
|
|
13
|
+
import { sassCompilations, setSassCacheDir } from '@ultimat3/render/server';
|
|
14
|
+
import { PREBUILT_COMMAND, PREBUILT_SASS_DIR } from './serve-prebuilt-paths';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Points the Sass cache at the image's own entries, when the image has any. Before the app is
|
|
18
|
+
* imported, because importing a page IS compiling its stylesheet. An entry is keyed by the
|
|
19
|
+
* sheet's absolute path, which is why the store is written inside the image build: same `WORKDIR`,
|
|
20
|
+
* same paths. Answers whether there was a store to adopt.
|
|
21
|
+
*/
|
|
22
|
+
export function adoptPrebuiltStyles(root: string): boolean {
|
|
23
|
+
const dir = join(root, PREBUILT_SASS_DIR);
|
|
24
|
+
if (!existsSync(dir)) return false;
|
|
25
|
+
setSassCacheDir(dir);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a role built at boot that the image build exists to have built once. */
|
|
30
|
+
export interface BootBuilds {
|
|
31
|
+
/** Island chunks built by this boot, and the one sentence saying why the store was not served. */
|
|
32
|
+
readonly islands?: { readonly chunks: number; readonly reason: string };
|
|
33
|
+
/** Stylesheets this boot ran Sass for. */
|
|
34
|
+
readonly stylesheets: number;
|
|
35
|
+
readonly elapsedMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Logged, never thrown: a build at boot is correct and only slow, so the role serves what it
|
|
40
|
+
* would have served — it pays seconds of CPU and a compiler's worth of memory on every start.
|
|
41
|
+
*/
|
|
42
|
+
export class ImageNotPrebuiltError extends UltimateError {
|
|
43
|
+
constructor(input: { readonly role: Role } & BootBuilds) {
|
|
44
|
+
const islands = input.islands;
|
|
45
|
+
const built = [
|
|
46
|
+
...(islands === undefined
|
|
47
|
+
? []
|
|
48
|
+
: [`built ${String(islands.chunks)} island chunk(s) because ${islands.reason}`]),
|
|
49
|
+
...(input.stylesheets === 0
|
|
50
|
+
? []
|
|
51
|
+
: [`compiled ${String(input.stylesheets)} stylesheet(s) the image holds no result for`]),
|
|
52
|
+
];
|
|
53
|
+
super({
|
|
54
|
+
code: 'X_IMAGE_NOT_PREBUILT',
|
|
55
|
+
cause: `the ${input.role} role ${built.join(' and ')}, ${String(input.elapsedMs)} ms into its boot — work this image repeats on every start of every replica`,
|
|
56
|
+
fix: `edit docker/Dockerfile: add RUN ${PREBUILT_COMMAND} after COPY . . in the runtime stage, then rebuild the image`,
|
|
57
|
+
meta: {
|
|
58
|
+
role: input.role,
|
|
59
|
+
islands: islands?.chunks ?? 0,
|
|
60
|
+
stylesheets: input.stylesheets,
|
|
61
|
+
elapsedMs: input.elapsedMs,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Starts counting what a boot compiles; the answer is what `reportBootBuilds` is handed. */
|
|
68
|
+
export function watchBootBuilds(): (islands?: BootBuilds['islands']) => BootBuilds {
|
|
69
|
+
const started = Bun.nanoseconds();
|
|
70
|
+
const compiled = sassCompilations();
|
|
71
|
+
return (islands) => ({
|
|
72
|
+
...(islands === undefined || islands.chunks === 0 ? {} : { islands }),
|
|
73
|
+
stylesheets: sassCompilations() - compiled,
|
|
74
|
+
elapsedMs: Math.round((Bun.nanoseconds() - started) / 1e6),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One `error` line when the boot built anything, and nothing when it built nothing. */
|
|
79
|
+
export function reportBootBuilds(role: Role, builds: BootBuilds): boolean {
|
|
80
|
+
if (builds.islands === undefined && builds.stylesheets === 0) return false;
|
|
81
|
+
const error = new ImageNotPrebuiltError({ role, ...builds });
|
|
82
|
+
logger.error(error.format(), { code: error.code, ...error.meta });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
package/src/serve-web.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// What only the web role serves: its islands, its documents and every route beside them. A file
|
|
2
|
+
// of its own so the roles that render nothing never import it — `serve-boot.ts` loads it with
|
|
3
|
+
// `await import()` for `ROLE=web` alone, and `serve-graph.test.ts` pins what every other role is
|
|
4
|
+
// spared: render's server half, the PWA, SEO, MCP and island subsystems.
|
|
5
|
+
|
|
6
|
+
import type { Route } from '@ultimat3/http';
|
|
7
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
8
|
+
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
9
|
+
import { loadSignInPath } from './app-auth';
|
|
10
|
+
import { mountAppMcp } from './app-mcp';
|
|
11
|
+
import { errorPageStyleSources } from './error-page-csp';
|
|
12
|
+
import { islandRoutes } from './island-routes';
|
|
13
|
+
import type { LoadedIslands } from './island-store';
|
|
14
|
+
import { loadOrBuildIslands } from './island-store';
|
|
15
|
+
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
16
|
+
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
17
|
+
import { pageSync } from './page-sync';
|
|
18
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
19
|
+
import { adminMountRoutes } from './runtime-admin';
|
|
20
|
+
import { assetRoutes } from './runtime-assets';
|
|
21
|
+
import { attachedIsr } from './runtime-isr';
|
|
22
|
+
import { appRoutes } from './runtime-render';
|
|
23
|
+
import type { RunningServices } from './runtime-services';
|
|
24
|
+
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
25
|
+
import { seoRoutes } from './seo-routes';
|
|
26
|
+
import type { ServeOptions } from './serve-types';
|
|
27
|
+
import { loadSiteSettings, publicOrigin } from './site-config';
|
|
28
|
+
import { styleBundle } from './style-bundle';
|
|
29
|
+
import { styleRoutes } from './style-routes';
|
|
30
|
+
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
31
|
+
import { serviceWorkerRoutes } from './sw-routes';
|
|
32
|
+
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
33
|
+
|
|
34
|
+
/** Everything `startRoles` is handed that only a process serving documents has. */
|
|
35
|
+
export interface WebSurface {
|
|
36
|
+
readonly routes: readonly Route[];
|
|
37
|
+
/** The theme boot and the speculation rules: the two inline bodies every document may carry. */
|
|
38
|
+
readonly inlineScripts: readonly string[];
|
|
39
|
+
/** The app's own error pages' `<style>` bodies, which the enforced policy must admit. */
|
|
40
|
+
readonly inlineStyles: readonly string[];
|
|
41
|
+
/** Where a browser that opened a guarded page is sent — the declaration `x dev` reads. */
|
|
42
|
+
readonly signInPath: string | null;
|
|
43
|
+
/** Present when this boot built the islands itself: how many chunks, and why not the store. */
|
|
44
|
+
readonly islandsBuilt?: LoadedIslands['built'];
|
|
45
|
+
/** Detaches the ISR controller from `invalidateTags` — on the boot's stop list. */
|
|
46
|
+
readonly release: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function webSurface(
|
|
50
|
+
options: ServeOptions,
|
|
51
|
+
runtime: RunningServices,
|
|
52
|
+
buildId: string,
|
|
53
|
+
): Promise<WebSurface> {
|
|
54
|
+
// Read from the store the image build wrote and VERIFIED against this app and this runtime
|
|
55
|
+
// (`island-store.ts`); built here only when there is no store to trust, which is correct and
|
|
56
|
+
// slower — the reason travels back to the boot, which reports it.
|
|
57
|
+
const { bundle: islands, built: islandsBuilt } = await loadOrBuildIslands(options.root);
|
|
58
|
+
// The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
|
|
59
|
+
// on a laptop and absent in the image is exactly the dev/prod difference this file exists to
|
|
60
|
+
// prevent, and it is the one an operator cannot see without installing the app.
|
|
61
|
+
const pwa = await loadPwaArtifacts(options.root);
|
|
62
|
+
const theme = themeBoot(await loadThemeMode(options.root));
|
|
63
|
+
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
64
|
+
const site = await loadSiteSettings(options.root);
|
|
65
|
+
const origin = publicOrigin(options.env, site);
|
|
66
|
+
// The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
|
|
67
|
+
// Before the service worker, which precaches those scripts.
|
|
68
|
+
const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
|
|
69
|
+
// The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
|
|
70
|
+
const declared = await loadNavigation(options.root);
|
|
71
|
+
const navigation = await pageNavigation(options.root, declared, buildId);
|
|
72
|
+
// The browser's own prefetch, for the documents that carry no router (`navigation.speculation`).
|
|
73
|
+
const speculation = pageSpeculation({
|
|
74
|
+
config: await loadSpeculation(options.root),
|
|
75
|
+
client: declared.surfaces,
|
|
76
|
+
});
|
|
77
|
+
// The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
|
|
78
|
+
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
79
|
+
// route added here cannot be missing from the precache manifest.
|
|
80
|
+
const serviceWorker =
|
|
81
|
+
pwa === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: serviceWorkerArtifacts({
|
|
84
|
+
pwa,
|
|
85
|
+
buildId,
|
|
86
|
+
routes: describeRoutes(),
|
|
87
|
+
islands,
|
|
88
|
+
styles: styleBundle(),
|
|
89
|
+
scripts: [
|
|
90
|
+
...sync.scripts,
|
|
91
|
+
...(navigation.script === undefined ? [] : [navigation.script]),
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
95
|
+
const mcpMount = await mountAppMcp(options.root);
|
|
96
|
+
// The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
|
|
97
|
+
const served = servedStorage(runtime.storage);
|
|
98
|
+
// Attached, so a tag bust marks this process's ISR pages stale. The store is the deployment's
|
|
99
|
+
// when it supplied one: the default is per process, so twelve replicas hold twelve of them and a
|
|
100
|
+
// purge regenerates one twelfth of the fleet while the other eleven keep the invalidated page.
|
|
101
|
+
const isr = attachedIsr({
|
|
102
|
+
buildId,
|
|
103
|
+
...(options.runtime?.isrStore === undefined ? {} : { store: options.runtime.isrStore }),
|
|
104
|
+
});
|
|
105
|
+
const routes: readonly Route[] = [
|
|
106
|
+
...apiRoutes(),
|
|
107
|
+
// The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
|
|
108
|
+
// on the store this boot's limiter uses, so one token's allowance is one number fleet-wide.
|
|
109
|
+
...apiMountRoutes(options.runtime?.rateLimitStore ?? runtime.rateLimitStore),
|
|
110
|
+
...mcpMount.routes,
|
|
111
|
+
// The app's own plain routes (`apps/<app>/runtime.ts` `routes`), before any page can shadow one.
|
|
112
|
+
...(options.runtime?.routes ?? []),
|
|
113
|
+
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
114
|
+
...assetRoutes({
|
|
115
|
+
root: options.root,
|
|
116
|
+
storage: served,
|
|
117
|
+
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
118
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
119
|
+
}),
|
|
120
|
+
...storageRoutes({ storage: served }),
|
|
121
|
+
...islandRoutes(() => islands),
|
|
122
|
+
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
123
|
+
// filled, so this process serves exactly the CSS it renders against.
|
|
124
|
+
...styleRoutes(() => styleBundle()),
|
|
125
|
+
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
126
|
+
...seoRoutes({ env: options.env, site, root: options.root }),
|
|
127
|
+
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
128
|
+
...sync.routes,
|
|
129
|
+
...navigation.routes,
|
|
130
|
+
// A page's `POST`, bound to an action (`defineRoute({ post })`) — beside the page's `GET`.
|
|
131
|
+
...pagePostRoutes(),
|
|
132
|
+
// The app's generated admin, when it declared one (`defineAdmin`): every screen under its base
|
|
133
|
+
// path, served through the same document builder as the pages below. Nothing for an app with
|
|
134
|
+
// no admin — the registry is asked, the package is not imported.
|
|
135
|
+
...adminMountRoutes({
|
|
136
|
+
buildId: buildId,
|
|
137
|
+
themeHead: theme.head,
|
|
138
|
+
...(origin === undefined ? {} : { origin }),
|
|
139
|
+
}),
|
|
140
|
+
...appRoutes({
|
|
141
|
+
buildId,
|
|
142
|
+
resolveIsland: (file) => islands.resolverFor(file),
|
|
143
|
+
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
144
|
+
persisted: sync.persisted,
|
|
145
|
+
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
146
|
+
themeHead: theme.head,
|
|
147
|
+
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
148
|
+
// A `static` page is the same bytes for every request of this process: rendered once.
|
|
149
|
+
memoStatic: true,
|
|
150
|
+
...(origin === undefined ? {} : { origin }),
|
|
151
|
+
...(pwa === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
154
|
+
isr: isr.isr,
|
|
155
|
+
}),
|
|
156
|
+
];
|
|
157
|
+
return {
|
|
158
|
+
routes,
|
|
159
|
+
inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
|
|
160
|
+
// The enforced policy this process sends must admit the app's own error pages' `<style>` and
|
|
161
|
+
// the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
|
|
162
|
+
inlineStyles: await errorPageStyleSources(options.root),
|
|
163
|
+
// Same declaration `x dev` reads. Without it a container answers a browser that opened a
|
|
164
|
+
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
165
|
+
signInPath: await loadSignInPath(options.root),
|
|
166
|
+
...(islandsBuilt === undefined ? {} : { islandsBuilt }),
|
|
167
|
+
release: isr.release,
|
|
168
|
+
};
|
|
169
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -13,8 +13,6 @@ import { startMetricsEndpoint } from './metrics-endpoint';
|
|
|
13
13
|
import { readMigrations } from './migrations';
|
|
14
14
|
import { resolveServices } from './runtime-bindings';
|
|
15
15
|
import { startQueue } from './runtime-queue';
|
|
16
|
-
import { startServices } from './runtime-services';
|
|
17
|
-
import { bootRoles } from './serve-boot';
|
|
18
16
|
import { containerBinding, metricsPortFor, portFromEnv, roleFromEnv } from './serve-env';
|
|
19
17
|
import type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-types';
|
|
20
18
|
|
|
@@ -144,17 +142,16 @@ export async function serveApp(input: ServeOptions): Promise<ServedApp> {
|
|
|
144
142
|
const metrics = startMetricsEndpoint({
|
|
145
143
|
port: metricsPortFor(options.env, port, options.metricsPort),
|
|
146
144
|
hostname: containerBinding(options.env, options.hostname).hostname,
|
|
145
|
+
role,
|
|
147
146
|
});
|
|
148
147
|
// Everything acquired from here down, in order, so a throw anywhere below gives it all back.
|
|
149
148
|
const acquired: (() => void | Promise<void>)[] = [() => metrics.stop()];
|
|
150
149
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
);
|
|
156
|
-
acquired.push(() => runtime.stop());
|
|
157
|
-
return await bootRoles({ options, role, runtime, acquired, metrics });
|
|
150
|
+
// Behind `await import()`: `ROLE=migrate` runs in this module too, applies SQL files and
|
|
151
|
+
// exits, and the services and the role boot are ~240 modules it never calls
|
|
152
|
+
// (`serve-graph.test.ts` pins the count each role is left with).
|
|
153
|
+
const { bootServing } = await import('./serve-boot');
|
|
154
|
+
return await bootServing({ options, role, acquired, metrics });
|
|
158
155
|
} catch (error) {
|
|
159
156
|
await releaseBoot(acquired);
|
|
160
157
|
throw error;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A package barrel a browser must not import, and the entry it imports instead. `@ultimat3/entity`'s
|
|
2
|
+
// barrel reaches the SQL renderer and `@ultimat3/db` — about a megabyte in an island — while
|
|
3
|
+
// `@ultimat3/entity/record` is the browser half. DERIVED from each manifest's `exports`: a package
|
|
4
|
+
// that publishes a `./record`, `./browser` or `./client` subpath has said its barrel is the server
|
|
5
|
+
// half, so a new browser entry enters this rule by being published, never by being listed here.
|
|
6
|
+
|
|
7
|
+
import { stripComments } from '@ultimat3/core';
|
|
8
|
+
import { lineIndex } from './ts-scan';
|
|
9
|
+
|
|
10
|
+
/** The subpath names that declare "this is the browser entry of the package". */
|
|
11
|
+
export const BROWSER_ENTRY_SUBPATHS: ReadonlySet<string> = new Set(['record', 'browser', 'client']);
|
|
12
|
+
|
|
13
|
+
/** Barrel specifier → the browser entry that replaces it, read off the published specifiers. */
|
|
14
|
+
export function serverBarrels(specifiers: Iterable<string>): ReadonlyMap<string, string> {
|
|
15
|
+
const barrels = new Map<string, string>();
|
|
16
|
+
for (const spec of specifiers) {
|
|
17
|
+
const match = /^(@[\w-]+\/[\w-]+)\/([\w-]+)$/.exec(spec);
|
|
18
|
+
if (match?.[1] === undefined || !BROWSER_ENTRY_SUBPATHS.has(match[2] ?? '')) continue;
|
|
19
|
+
barrels.set(match[1], spec);
|
|
20
|
+
}
|
|
21
|
+
return barrels;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BarrelImport {
|
|
25
|
+
readonly line: number;
|
|
26
|
+
readonly barrel: string;
|
|
27
|
+
readonly entry: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `import` / `export … from` / `import()` of a VALUE from the barrel. `import type` runs nothing. */
|
|
31
|
+
const IMPORTS =
|
|
32
|
+
/\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)/g;
|
|
33
|
+
|
|
34
|
+
export function barrelImports(
|
|
35
|
+
source: string,
|
|
36
|
+
barrels: ReadonlyMap<string, string>,
|
|
37
|
+
): readonly BarrelImport[] {
|
|
38
|
+
// Most files import no barrel at all; say so before paying for a comment-stripped copy.
|
|
39
|
+
if (![...barrels.keys()].some((barrel) => source.includes(barrel))) return [];
|
|
40
|
+
const code = stripComments(source);
|
|
41
|
+
const lineAt = lineIndex(code);
|
|
42
|
+
const found: BarrelImport[] = [];
|
|
43
|
+
for (const match of code.matchAll(IMPORTS)) {
|
|
44
|
+
const spec = match[2] ?? match[4] ?? '';
|
|
45
|
+
const entry = barrels.get(spec);
|
|
46
|
+
if (entry === undefined) continue;
|
|
47
|
+
// `import { type A, type B } from …` binds nothing at runtime either.
|
|
48
|
+
const clause = /\{([^}]*)\}/.exec(match[0])?.[1];
|
|
49
|
+
const typesOnly = clause?.split(',').every((part) => /^\s*(?:type\s|$)/.test(part));
|
|
50
|
+
if (typesOnly === true) continue;
|
|
51
|
+
found.push({ line: lineAt(match.index), barrel: spec, entry });
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
package/src/shot-locale.ts
CHANGED
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
// on the language of the machine's Chrome. A non-default locale is also a URL prefix (`/en/…`),
|
|
4
4
|
// because on a `site/` route the unprefixed path is always the default locale whatever the header.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
8
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
6
|
+
import { loadAppConfig } from './app-config-load';
|
|
9
7
|
import { BadFlagError } from './errors';
|
|
10
8
|
|
|
11
9
|
export interface ShotLocales {
|
|
@@ -16,27 +14,11 @@ export interface ShotLocales {
|
|
|
16
14
|
/** An app that declares nothing is `en` only — the framework's own default. */
|
|
17
15
|
export const FALLBACK_SHOT_LOCALES: ShotLocales = { locales: ['en'], defaultLocale: 'en' };
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
typeof value === 'object' && value !== null;
|
|
21
|
-
|
|
22
|
-
/** `app.config.ts`'s `locales` and `defaultLocale`, read the way `loadThemeMode` reads `theme`. */
|
|
17
|
+
/** `app.config.ts`'s `locales` and `defaultLocale`, off the one loader (`app-config-load.ts`). */
|
|
23
18
|
export async function loadShotLocales(root: string): Promise<ShotLocales> {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
28
|
-
if (!isRecord(config)) return FALLBACK_SHOT_LOCALES;
|
|
29
|
-
const declared = config['locales'];
|
|
30
|
-
const locales = Array.isArray(declared)
|
|
31
|
-
? declared.filter((locale): locale is string => typeof locale === 'string')
|
|
32
|
-
: [];
|
|
33
|
-
const fallback = config['defaultLocale'];
|
|
34
|
-
const defaultLocale =
|
|
35
|
-
typeof fallback === 'string' && fallback !== '' ? fallback : (locales[0] ?? 'en');
|
|
36
|
-
return {
|
|
37
|
-
locales: locales.length === 0 ? [defaultLocale] : locales,
|
|
38
|
-
defaultLocale,
|
|
39
|
-
};
|
|
19
|
+
const config = await loadAppConfig(root);
|
|
20
|
+
if (config === undefined) return FALLBACK_SHOT_LOCALES;
|
|
21
|
+
return { locales: config.locales, defaultLocale: config.defaultLocale };
|
|
40
22
|
}
|
|
41
23
|
|
|
42
24
|
/** `--locale <l>`, refused by name when the app does not declare it — a typo costs no browser. */
|
package/src/site-assets.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
9
9
|
import { dirname, join } from 'node:path'; // why: Bun ships no path API.
|
|
10
10
|
import type { AssetExtension, AssetPath } from '@ultimat3/render';
|
|
11
11
|
import { ASSET_DIR, AssetMissingError, assetPathProblem } from '@ultimat3/render';
|
|
12
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
12
13
|
|
|
13
14
|
/** App-root-relative, beside `favicon.ico`: `apps/web/site/` is where an app's public files live. */
|
|
14
15
|
export const SITE_ASSETS_SOURCE = `apps/web/site/${ASSET_DIR}`;
|
|
@@ -37,7 +38,7 @@ export interface SiteAsset {
|
|
|
37
38
|
readonly path: AssetPath;
|
|
38
39
|
/** Absolute path on disk. */
|
|
39
40
|
readonly file: string;
|
|
40
|
-
/**
|
|
41
|
+
/** `@ultimat3/render`'s `contentHash` of the bytes, 8 hex characters — the one CSS and HTML get. */
|
|
41
42
|
readonly hash: string;
|
|
42
43
|
/** `/assets/brand/logo.<hash>.svg`. */
|
|
43
44
|
readonly url: string;
|
|
@@ -69,9 +70,6 @@ export function parseHashedAssetUrl(
|
|
|
69
70
|
return { path: path as AssetPath, hash: match[2] ?? '' };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
const hashBytes = (bytes: Uint8Array): string =>
|
|
73
|
-
Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0');
|
|
74
|
-
|
|
75
73
|
export interface SiteAssetTable {
|
|
76
74
|
/** The asset `asset()` named, hashed as the bytes are NOW. Throws `X_ASSET_MISSING`. */
|
|
77
75
|
resolve(path: AssetPath): SiteAsset;
|
|
@@ -110,7 +108,7 @@ export function createSiteAssetTable(root: string): SiteAssetTable {
|
|
|
110
108
|
const cached = cache.get(path);
|
|
111
109
|
if (cached !== undefined && cached.stamp === stamp) return cached.asset;
|
|
112
110
|
const bytes = readFileSync(file);
|
|
113
|
-
const hash =
|
|
111
|
+
const hash = contentHash(bytes);
|
|
114
112
|
// `assetPathProblem` already refused an unknown extension; the guard keeps `constructor` from
|
|
115
113
|
// ever answering an `Object` member if that check is loosened.
|
|
116
114
|
const extension = splitExtension(path).extension.toLowerCase();
|
package/src/site-config.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// The public origin and the crawler rules out of `app.config.ts` — `site.origin` and
|
|
2
2
|
// `seo.robots.disallow` — and the one resolution of "which origin is this site served on" every
|
|
3
|
-
// absolute URL a document, a sitemap or a robots file carries is built against.
|
|
4
|
-
// `app-
|
|
3
|
+
// absolute URL a document, a sitemap or a robots file carries is built against. Read off the one
|
|
4
|
+
// loader (`app-config-load.ts`).
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import type { AppConfig, Environment, SeoSitemapConfig, SitemapLastmod } from '@ultimat3/core';
|
|
9
|
-
import { SITEMAP_LASTMOD_SOURCES } from '@ultimat3/core';
|
|
10
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
6
|
+
import type { Environment, SeoSitemapConfig } from '@ultimat3/core';
|
|
7
|
+
import { loadAppConfig } from './app-config-load';
|
|
12
8
|
|
|
13
9
|
export interface SiteSettings {
|
|
14
10
|
/** `site.origin`, trailing slash removed; `null` when the app declares none. */
|
|
@@ -25,51 +21,16 @@ export const NO_SITE_SETTINGS: SiteSettings = {
|
|
|
25
21
|
sitemap: { extra: [], lastmod: 'none' },
|
|
26
22
|
};
|
|
27
23
|
|
|
28
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
29
|
-
typeof value === 'object' && value !== null;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Structural, never `instanceof`, for `loadSignInPath`'s reason: a config that resolved through an
|
|
33
|
-
* older core simply has no `site` section, and that is the "not declared" answer, not a crash.
|
|
34
|
-
*/
|
|
35
|
-
const hasSiteSections = (value: unknown): value is Pick<AppConfig, 'site' | 'seo'> =>
|
|
36
|
-
isRecord(value) &&
|
|
37
|
-
isRecord(value['site']) &&
|
|
38
|
-
isRecord(value['seo']) &&
|
|
39
|
-
isRecord(value['seo']['robots']);
|
|
40
|
-
|
|
41
24
|
const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
|
|
42
25
|
|
|
43
26
|
export async function loadSiteSettings(root: string): Promise<SiteSettings> {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
const
|
|
47
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
48
|
-
if (!hasSiteSections(config)) return NO_SITE_SETTINGS;
|
|
49
|
-
const origin = config.site.origin;
|
|
27
|
+
const config = await loadAppConfig(root);
|
|
28
|
+
if (config === undefined) return NO_SITE_SETTINGS;
|
|
29
|
+
const { origin } = config.site;
|
|
50
30
|
return {
|
|
51
|
-
origin:
|
|
31
|
+
origin: origin === null ? null : trimSlash(origin),
|
|
52
32
|
disallow: [...config.seo.robots.disallow],
|
|
53
|
-
sitemap:
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Structural for `hasSiteSections`' reason: a config resolved through a core older than
|
|
59
|
-
* `seo.sitemap` has no such key, and that is the default, not a crash.
|
|
60
|
-
*/
|
|
61
|
-
function sitemapOf(seo: unknown): SeoSitemapConfig {
|
|
62
|
-
const declared = isRecord(seo) ? seo['sitemap'] : undefined;
|
|
63
|
-
if (!isRecord(declared)) return NO_SITE_SETTINGS.sitemap;
|
|
64
|
-
const extra = declared['extra'];
|
|
65
|
-
const lastmod = declared['lastmod'];
|
|
66
|
-
return {
|
|
67
|
-
extra: Array.isArray(extra)
|
|
68
|
-
? extra.filter((path): path is string => typeof path === 'string')
|
|
69
|
-
: [],
|
|
70
|
-
lastmod: SITEMAP_LASTMOD_SOURCES.includes(lastmod as SitemapLastmod)
|
|
71
|
-
? (lastmod as SitemapLastmod)
|
|
72
|
-
: 'none',
|
|
33
|
+
sitemap: config.seo.sitemap,
|
|
73
34
|
};
|
|
74
35
|
}
|
|
75
36
|
|
package/src/site-seo.ts
CHANGED
|
@@ -173,7 +173,7 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
|
173
173
|
localizedPath(path, locale, defaultLocale),
|
|
174
174
|
}),
|
|
175
175
|
});
|
|
176
|
-
// Past 50,000 URLs `files` are `/
|
|
176
|
+
// Past 50,000 URLs `files` are `/sitemaps/<n>.xml` and the index is `/sitemap.xml`; below it,
|
|
177
177
|
// `files` is that one file and there is no index.
|
|
178
178
|
const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
|
|
179
179
|
const robots = buildRobots({
|
package/src/sitemap-lastmod.ts
CHANGED
|
@@ -14,6 +14,9 @@ let builtAt: string | undefined;
|
|
|
14
14
|
|
|
15
15
|
const cache = new Map<string, string | undefined>();
|
|
16
16
|
|
|
17
|
+
/** One `git log -1` over one path; a synchronous wait past this blocks the request serving it. */
|
|
18
|
+
const GIT_DATE_TIMEOUT_MS = 5_000;
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* The `<lastmod>` for one route file, W3C Datetime (ISO 8601, UTC), or `undefined` when the source
|
|
19
22
|
* yields none — a sitemap entry without `<lastmod>` is valid, a wrong one misleads a crawler.
|
|
@@ -46,6 +49,8 @@ function gitDate(file: string, root: string): string | undefined {
|
|
|
46
49
|
cwd: root,
|
|
47
50
|
stdout: 'pipe',
|
|
48
51
|
stderr: 'ignore',
|
|
52
|
+
// A wedged `git` (a lock, a credential prompt) answers no date: the mtime fallback below.
|
|
53
|
+
timeout: GIT_DATE_TIMEOUT_MS,
|
|
49
54
|
});
|
|
50
55
|
if (run.exitCode !== 0) return undefined;
|
|
51
56
|
return isoOf(run.stdout.toString().trim());
|