@ultimat3/cli 22.2.2 → 22.3.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/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher-port.ts +7 -0
- package/src/cdp-shot-driver.ts +8 -2
- package/src/cmd-shot-matrix.ts +201 -0
- package/src/cmd-shot-spec.ts +15 -1
- package/src/cmd-shot.ts +66 -8
- package/src/cmd-test-spec.ts +13 -3
- package/src/cmd-test.ts +31 -3
- package/src/cmd-verify-spec.ts +4 -4
- package/src/cmd-verify.ts +31 -16
- package/src/dev-route-table.ts +6 -1
- package/src/error-contract.ts +5 -0
- package/src/index.ts +2 -0
- package/src/messages.ts +1 -0
- package/src/prerender-locales.ts +34 -0
- package/src/prerender.ts +67 -11
- package/src/runtime-assets.ts +10 -2
- package/src/runtime-render.ts +41 -19
- package/src/seo-routes.ts +11 -9
- package/src/serve-boot.ts +6 -1
- package/src/shot-locale.ts +67 -0
- package/src/site-asset-routes.ts +106 -0
- package/src/site-assets.ts +177 -0
- package/src/site-config.ts +79 -0
- package/src/site-seo.ts +31 -2
- package/src/templates/scaffold-entries.ts +6 -4
- package/src/test-select.ts +30 -3
- package/src/test-workers.ts +52 -12
- package/src/verify-run.ts +13 -6
- package/src/verify-step.ts +10 -7
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Serving `apps/web/site/assets/**` at the hashed URLs `asset()` mints. Mounted by `assetRoutes`,
|
|
2
|
+
// which both `x dev` and the container compose, so a picture that paints on a laptop paints in the
|
|
3
|
+
// image. Byte ranges are answered because Safari will not play an `<video>` from a server that
|
|
4
|
+
// ignores `Range`, and a film that plays everywhere but an iPhone is not a film that ships.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
8
|
+
import { applyCacheHeaders, json } from '@ultimat3/http';
|
|
9
|
+
import type { SiteAsset, SiteAssetTable } from './site-assets';
|
|
10
|
+
import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
|
|
11
|
+
|
|
12
|
+
/** The URL is the content, so the answer is `public, max-age=31536000, immutable`. */
|
|
13
|
+
export const SITE_ASSET_CACHE: CacheHint = { mode: 'immutable' };
|
|
14
|
+
|
|
15
|
+
const notFound = (code: string, cause: string, fix: string): Response =>
|
|
16
|
+
json({ ok: false, error: { code, cause, fix } }, { status: 404 });
|
|
17
|
+
|
|
18
|
+
/** One `bytes=` range, resolved against the file's size; `null` when it cannot be satisfied. */
|
|
19
|
+
export function byteRange(
|
|
20
|
+
header: string | null,
|
|
21
|
+
size: number,
|
|
22
|
+
): { readonly start: number; readonly end: number } | null | undefined {
|
|
23
|
+
if (header === null) return undefined;
|
|
24
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
25
|
+
// A multi-range or a unit we do not speak: RFC 9110 lets a server ignore Range and send it all.
|
|
26
|
+
if (match === null) return undefined;
|
|
27
|
+
const [, from = '', to = ''] = match;
|
|
28
|
+
if (from === '' && to === '') return null;
|
|
29
|
+
if (from === '') {
|
|
30
|
+
const suffix = Number(to);
|
|
31
|
+
if (suffix === 0) return null;
|
|
32
|
+
return { start: Math.max(0, size - suffix), end: size - 1 };
|
|
33
|
+
}
|
|
34
|
+
const start = Number(from);
|
|
35
|
+
const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
|
|
36
|
+
if (start >= size || end < start) return null;
|
|
37
|
+
return { start, end };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assetResponse(asset: SiteAsset, range: string | null): Response {
|
|
41
|
+
const file = Bun.file(asset.file);
|
|
42
|
+
const headers: Record<string, string> = {
|
|
43
|
+
'content-type': asset.contentType,
|
|
44
|
+
'accept-ranges': 'bytes',
|
|
45
|
+
};
|
|
46
|
+
const wanted = byteRange(range, asset.bytes);
|
|
47
|
+
if (wanted === null) {
|
|
48
|
+
return new Response(null, {
|
|
49
|
+
status: 416,
|
|
50
|
+
headers: { ...headers, 'content-range': `bytes */${String(asset.bytes)}` },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (wanted === undefined) {
|
|
54
|
+
return applyCacheHeaders(new Response(file, { headers }), SITE_ASSET_CACHE);
|
|
55
|
+
}
|
|
56
|
+
return applyCacheHeaders(
|
|
57
|
+
new Response(file.slice(wanted.start, wanted.end + 1), {
|
|
58
|
+
status: 206,
|
|
59
|
+
headers: {
|
|
60
|
+
...headers,
|
|
61
|
+
'content-range': `bytes ${String(wanted.start)}-${String(wanted.end)}/${String(asset.bytes)}`,
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
SITE_ASSET_CACHE,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Only the HASHED name is served, and only while the bytes still hash to it: an immutable answer
|
|
70
|
+
* under a URL whose bytes changed would be cached for a year by every CDN that saw it. A stale
|
|
71
|
+
* hash is a document from an earlier build, and says so.
|
|
72
|
+
*/
|
|
73
|
+
export function siteAssetRoutes(table: SiteAssetTable): readonly Route[] {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
method: 'GET',
|
|
77
|
+
path: `${SITE_ASSET_BASE_PATH}/*file`,
|
|
78
|
+
meta: { name: 'assets.site', auth: 'public', tags: ['assets'] },
|
|
79
|
+
handler: (request: UltimateRequest): Response => {
|
|
80
|
+
const named = parseHashedAssetUrl(request.pathname);
|
|
81
|
+
if (named === undefined) {
|
|
82
|
+
return notFound(
|
|
83
|
+
'X_ROUTE_NOT_FOUND',
|
|
84
|
+
`${request.pathname} is not a hashed site asset URL`,
|
|
85
|
+
"name the file with asset('assets/…') in the page, which returns the URL this route serves",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
let asset: SiteAsset;
|
|
89
|
+
try {
|
|
90
|
+
asset = table.resolve(named.path);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!isUltimateError(error)) throw error;
|
|
93
|
+
return notFound(error.code, error.cause, error.fix);
|
|
94
|
+
}
|
|
95
|
+
if (asset.hash !== named.hash) {
|
|
96
|
+
return notFound(
|
|
97
|
+
'X_ROUTE_NOT_FOUND',
|
|
98
|
+
`${named.path} is at ${asset.url} now — the document asking for ${request.pathname} was rendered against earlier bytes`,
|
|
99
|
+
'reload the page; it names the current URL',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return assetResponse(asset, request.headers.get('range'));
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The site asset table: every file under `apps/web/site/assets/`, named by a content-hashed URL.
|
|
2
|
+
// One table per app root answers all three questions — the URL `asset()` returns while a page
|
|
3
|
+
// renders, the file `/assets/*` serves, and the copy `x build --target static` writes — so the
|
|
4
|
+
// page, the container and the CDN can never name one file three ways.
|
|
5
|
+
|
|
6
|
+
// why: `asset()` is called from a synchronous render, so the table answers synchronously — and Bun
|
|
7
|
+
// ships no sync stat or sync read; `join`/`relative` because Bun ships no path API either.
|
|
8
|
+
import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path'; // why: Bun ships no path API.
|
|
10
|
+
import type { AssetExtension, AssetPath } from '@ultimat3/render';
|
|
11
|
+
import { ASSET_DIR, AssetMissingError, assetPathProblem } from '@ultimat3/render';
|
|
12
|
+
|
|
13
|
+
/** App-root-relative, beside `favicon.ico`: `apps/web/site/` is where an app's public files live. */
|
|
14
|
+
export const SITE_ASSETS_SOURCE = `apps/web/site/${ASSET_DIR}`;
|
|
15
|
+
|
|
16
|
+
/** The URL prefix every hashed asset is served under, and the export directory it is copied to. */
|
|
17
|
+
export const SITE_ASSET_BASE_PATH = `/${ASSET_DIR}`;
|
|
18
|
+
|
|
19
|
+
/** What each served extension IS. `vtt` carries a charset: captions are text a player decodes. */
|
|
20
|
+
export const ASSET_CONTENT_TYPES: Readonly<Record<AssetExtension, string>> = {
|
|
21
|
+
avif: 'image/avif',
|
|
22
|
+
webp: 'image/webp',
|
|
23
|
+
png: 'image/png',
|
|
24
|
+
jpg: 'image/jpeg',
|
|
25
|
+
jpeg: 'image/jpeg',
|
|
26
|
+
gif: 'image/gif',
|
|
27
|
+
svg: 'image/svg+xml',
|
|
28
|
+
ico: 'image/x-icon',
|
|
29
|
+
woff2: 'font/woff2',
|
|
30
|
+
mp4: 'video/mp4',
|
|
31
|
+
webm: 'video/webm',
|
|
32
|
+
vtt: 'text/vtt; charset=utf-8',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface SiteAsset {
|
|
36
|
+
/** As `asset()` names it: `assets/brand/logo.svg`. */
|
|
37
|
+
readonly path: AssetPath;
|
|
38
|
+
/** Absolute path on disk. */
|
|
39
|
+
readonly file: string;
|
|
40
|
+
/** xxHash32 of the bytes, 8 hex characters — the algorithm `contentHash` uses for CSS. */
|
|
41
|
+
readonly hash: string;
|
|
42
|
+
/** `/assets/brand/logo.<hash>.svg`. */
|
|
43
|
+
readonly url: string;
|
|
44
|
+
readonly contentType: string;
|
|
45
|
+
readonly bytes: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const splitExtension = (path: string): { readonly stem: string; readonly extension: string } => {
|
|
49
|
+
const dot = path.lastIndexOf('.');
|
|
50
|
+
return { stem: path.slice(0, dot), extension: path.slice(dot + 1) };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The hash goes before the extension, so a static host still infers the type from the name. */
|
|
54
|
+
export function hashedAssetUrl(path: AssetPath, hash: string): string {
|
|
55
|
+
const { stem, extension } = splitExtension(path);
|
|
56
|
+
return `/${stem}.${hash}.${extension}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HASHED = /^\/(assets\/.+)\.([0-9a-f]{8})\.([a-z0-9]+)$/i;
|
|
60
|
+
|
|
61
|
+
/** The inverse of `hashedAssetUrl`: which asset a request names, and at which content. */
|
|
62
|
+
export function parseHashedAssetUrl(
|
|
63
|
+
pathname: string,
|
|
64
|
+
): { readonly path: AssetPath; readonly hash: string } | undefined {
|
|
65
|
+
const match = HASHED.exec(pathname);
|
|
66
|
+
if (match === null) return undefined;
|
|
67
|
+
const path = `${match[1] ?? ''}.${match[3] ?? ''}`;
|
|
68
|
+
if (assetPathProblem(path) !== undefined) return undefined;
|
|
69
|
+
return { path: path as AssetPath, hash: match[2] ?? '' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hashBytes = (bytes: Uint8Array): string =>
|
|
73
|
+
Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0');
|
|
74
|
+
|
|
75
|
+
export interface SiteAssetTable {
|
|
76
|
+
/** The asset `asset()` named, hashed as the bytes are NOW. Throws `X_ASSET_MISSING`. */
|
|
77
|
+
resolve(path: AssetPath): SiteAsset;
|
|
78
|
+
/** Every servable file under the source directory, sorted by path. */
|
|
79
|
+
all(): readonly SiteAsset[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const missing = (path: string, cause: string): AssetMissingError =>
|
|
83
|
+
new AssetMissingError(
|
|
84
|
+
`asset(${JSON.stringify(path)}): ${cause}`,
|
|
85
|
+
`add the file at ${SITE_ASSETS_SOURCE}/${path.slice(ASSET_DIR.length + 1)}, or correct the path passed to asset(), then x build --target static --json`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A stat per call, a read only when the file changed: `x dev` sees an edited image under a new URL
|
|
90
|
+
* on the next render with no watcher, and the container — whose files never change — pays one
|
|
91
|
+
* `stat` per `asset()` call and nothing else. One behaviour in both, never a dev-only rescan.
|
|
92
|
+
*/
|
|
93
|
+
export function createSiteAssetTable(root: string): SiteAssetTable {
|
|
94
|
+
const source = join(root, SITE_ASSETS_SOURCE);
|
|
95
|
+
const cache = new Map<string, { readonly stamp: string; readonly asset: SiteAsset }>();
|
|
96
|
+
|
|
97
|
+
const resolve = (path: AssetPath): SiteAsset => {
|
|
98
|
+
const problem = assetPathProblem(path);
|
|
99
|
+
if (problem !== undefined) throw missing(path, problem);
|
|
100
|
+
const file = join(source, path.slice(ASSET_DIR.length + 1));
|
|
101
|
+
let stamp: string;
|
|
102
|
+
try {
|
|
103
|
+
const stat = statSync(file);
|
|
104
|
+
if (!stat.isFile()) throw missing(path, `${file} is not a file`);
|
|
105
|
+
stamp = `${String(stat.mtimeMs)}:${String(stat.size)}`;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof AssetMissingError) throw error;
|
|
108
|
+
throw missing(path, `no file at ${SITE_ASSETS_SOURCE}/${path.slice(ASSET_DIR.length + 1)}`);
|
|
109
|
+
}
|
|
110
|
+
const cached = cache.get(path);
|
|
111
|
+
if (cached !== undefined && cached.stamp === stamp) return cached.asset;
|
|
112
|
+
const bytes = readFileSync(file);
|
|
113
|
+
const hash = hashBytes(bytes);
|
|
114
|
+
// `assetPathProblem` already refused an unknown extension; the guard keeps `constructor` from
|
|
115
|
+
// ever answering an `Object` member if that check is loosened.
|
|
116
|
+
const extension = splitExtension(path).extension.toLowerCase();
|
|
117
|
+
const asset: SiteAsset = {
|
|
118
|
+
path,
|
|
119
|
+
file,
|
|
120
|
+
hash,
|
|
121
|
+
url: hashedAssetUrl(path, hash),
|
|
122
|
+
contentType: Object.hasOwn(ASSET_CONTENT_TYPES, extension)
|
|
123
|
+
? ASSET_CONTENT_TYPES[extension as AssetExtension]
|
|
124
|
+
: 'application/octet-stream',
|
|
125
|
+
bytes: bytes.byteLength,
|
|
126
|
+
};
|
|
127
|
+
cache.set(path, { stamp, asset });
|
|
128
|
+
return asset;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const all = (): readonly SiteAsset[] => {
|
|
132
|
+
const found: SiteAsset[] = [];
|
|
133
|
+
let files: string[];
|
|
134
|
+
try {
|
|
135
|
+
files = [...new Bun.Glob('**/*').scanSync({ cwd: source, onlyFiles: true })];
|
|
136
|
+
} catch {
|
|
137
|
+
// No `assets/` directory is an app with no assets, not an error.
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
for (const relativePath of files.sort()) {
|
|
141
|
+
const path = `${ASSET_DIR}/${relativePath.split('\\').join('/')}`;
|
|
142
|
+
// A source file beside its renditions (a `.psd`, a `README.md`) is not a served asset.
|
|
143
|
+
if (assetPathProblem(path) !== undefined) continue;
|
|
144
|
+
found.push(resolve(path as AssetPath));
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return { resolve, all };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** One table per root, so the renderer and the route read the same cache. */
|
|
153
|
+
const tables = new Map<string, SiteAssetTable>();
|
|
154
|
+
|
|
155
|
+
export function siteAssetTable(root: string): SiteAssetTable {
|
|
156
|
+
let table = tables.get(root);
|
|
157
|
+
if (table === undefined) {
|
|
158
|
+
table = createSiteAssetTable(root);
|
|
159
|
+
tables.set(root, table);
|
|
160
|
+
}
|
|
161
|
+
return table;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every asset, copied into the static export under its hashed URL — a static host serves with no
|
|
166
|
+
* process behind it, so the artifact carries every byte a document names. Returns the URLs written.
|
|
167
|
+
*/
|
|
168
|
+
export function writeSiteAssets(root: string, out: string): readonly string[] {
|
|
169
|
+
const written: string[] = [];
|
|
170
|
+
for (const asset of siteAssetTable(root).all()) {
|
|
171
|
+
const target = join(out, asset.url.slice(1));
|
|
172
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
173
|
+
copyFileSync(asset.file, target);
|
|
174
|
+
written.push(asset.url);
|
|
175
|
+
}
|
|
176
|
+
return written;
|
|
177
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The public origin and the crawler rules out of `app.config.ts` — `site.origin` and
|
|
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. Sibling of
|
|
4
|
+
// `app-auth.ts`'s `loadSignInPath`, and it imports the config for the same reason.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and the config path is app-root-relative.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { AppConfig, Environment } from '@ultimat3/core';
|
|
9
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
10
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
11
|
+
|
|
12
|
+
export interface SiteSettings {
|
|
13
|
+
/** `site.origin`, trailing slash removed; `null` when the app declares none. */
|
|
14
|
+
readonly origin: string | null;
|
|
15
|
+
/** `seo.robots.disallow` — the paths a production `robots.txt` keeps crawlers out of. */
|
|
16
|
+
readonly disallow: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const NO_SITE_SETTINGS: SiteSettings = { origin: null, disallow: [] };
|
|
20
|
+
|
|
21
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
22
|
+
typeof value === 'object' && value !== null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Structural, never `instanceof`, for `loadSignInPath`'s reason: a config that resolved through an
|
|
26
|
+
* older core simply has no `site` section, and that is the "not declared" answer, not a crash.
|
|
27
|
+
*/
|
|
28
|
+
const hasSiteSections = (value: unknown): value is Pick<AppConfig, 'site' | 'seo'> =>
|
|
29
|
+
isRecord(value) &&
|
|
30
|
+
isRecord(value['site']) &&
|
|
31
|
+
isRecord(value['seo']) &&
|
|
32
|
+
isRecord(value['seo']['robots']);
|
|
33
|
+
|
|
34
|
+
const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
|
|
35
|
+
|
|
36
|
+
export async function loadSiteSettings(root: string): Promise<SiteSettings> {
|
|
37
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
38
|
+
if (!(await Bun.file(configPath).exists())) return NO_SITE_SETTINGS;
|
|
39
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
40
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
41
|
+
if (!hasSiteSections(config)) return NO_SITE_SETTINGS;
|
|
42
|
+
const origin = config.site.origin;
|
|
43
|
+
return {
|
|
44
|
+
origin: typeof origin === 'string' && origin !== '' ? trimSlash(origin) : null,
|
|
45
|
+
disallow: [...config.seo.robots.disallow],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The declared public origin: `APP_URL` (what the runtime already names it — OAuth's redirect, the
|
|
51
|
+
* sync node's admitted origin), then `SITE_ORIGIN` (the static build's), then `site.origin`.
|
|
52
|
+
* The environment first because one image is deployed to staging and production under two
|
|
53
|
+
* origins; the config line is what a deploy that sets neither falls back to. `undefined` when
|
|
54
|
+
* nothing names one — the caller decides whether the request's own origin will do.
|
|
55
|
+
*/
|
|
56
|
+
export function publicOrigin(
|
|
57
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
58
|
+
site: SiteSettings,
|
|
59
|
+
): string | undefined {
|
|
60
|
+
for (const key of ['APP_URL', 'SITE_ORIGIN']) {
|
|
61
|
+
const declared = env[key]?.trim() ?? '';
|
|
62
|
+
if (declared !== '') return trimSlash(declared);
|
|
63
|
+
}
|
|
64
|
+
return site.origin ?? undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The sentence a production build prints when no origin was declared. Every canonical, `og:url`,
|
|
69
|
+
* hreflang and sitemap `<loc>` is then built against a placeholder, and a search engine indexes a
|
|
70
|
+
* page whose canonical names a host the site is not on. A warning, not a refusal: a production
|
|
71
|
+
* build on a laptop is how a deploy is rehearsed.
|
|
72
|
+
*/
|
|
73
|
+
export function originWarning(environment: Environment, declared: string | undefined): string[] {
|
|
74
|
+
if (environment !== 'production' || declared !== undefined) return [];
|
|
75
|
+
return [
|
|
76
|
+
'no public origin: set APP_URL (or SITE_ORIGIN) or `site: { origin }` in app.config.ts — ' +
|
|
77
|
+
'canonical, og:url, hreflang and the sitemap are absolute against a placeholder',
|
|
78
|
+
];
|
|
79
|
+
}
|
package/src/site-seo.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// serves it), so a crawler reads the same two files from a CDN and from a container.
|
|
4
4
|
|
|
5
5
|
import type { Environment } from '@ultimat3/core';
|
|
6
|
+
import { localeConfig, localizedPath, routedLocales, unlocalizedPath } from '@ultimat3/i18n';
|
|
6
7
|
import type { RouteEntry } from '@ultimat3/render';
|
|
7
8
|
import { routeEntries } from '@ultimat3/render';
|
|
8
9
|
import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
|
|
@@ -24,6 +25,11 @@ export interface SiteSeoOptions {
|
|
|
24
25
|
* the same list, enumerated by the same function the prerenderer calls.
|
|
25
26
|
*/
|
|
26
27
|
readonly pagesFor?: ((routePath: string) => readonly string[]) | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* `seo.robots.disallow` from `app.config.ts` (`loadSiteSettings`). Added to the production
|
|
30
|
+
* `User-agent: *` group; a non-production `robots.txt` still disallows everything.
|
|
31
|
+
*/
|
|
32
|
+
readonly disallow?: readonly string[] | undefined;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export interface SiteSeo {
|
|
@@ -36,8 +42,15 @@ export interface SiteSeo {
|
|
|
36
42
|
const isPublicSite = (entry: RouteEntry): boolean =>
|
|
37
43
|
entry.surface === 'site' && entry.config.policy === undefined;
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A dynamic route's pages, UNPREFIXED and once each. A static export's report lists every locale's
|
|
47
|
+
* copy (`/blog/a` and `/en/blog/a`), and the sitemap localizes each page itself — reading the
|
|
48
|
+
* prefixed copies back as pages of their own listed `/en/en/blog/a`.
|
|
49
|
+
*/
|
|
39
50
|
const pagesOf = async (entry: RouteEntry, options: SiteSeoOptions): Promise<readonly string[]> => {
|
|
40
|
-
if (options.pagesFor !== undefined)
|
|
51
|
+
if (options.pagesFor !== undefined) {
|
|
52
|
+
return [...new Set(options.pagesFor(entry.path).map((path) => unlocalizedPath(path)))];
|
|
53
|
+
}
|
|
41
54
|
const params = await enumeratePrerender(entry);
|
|
42
55
|
return params.map((set) => fillPath(entry.pattern.source, set));
|
|
43
56
|
};
|
|
@@ -65,13 +78,29 @@ async function publicSiteRoutes(options: SiteSeoOptions): Promise<readonly Route
|
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
68
|
-
|
|
81
|
+
// Every routed locale, with `xhtml:link` alternates: one `<url>` per page per locale, each
|
|
82
|
+
// naming the whole cluster and `x-default`. A single-locale app passes none and gets the plain
|
|
83
|
+
// urlset it always had — an alternates cluster of one is noise.
|
|
84
|
+
const locales = routedLocales();
|
|
85
|
+
const defaultLocale = localeConfig().fallback;
|
|
86
|
+
const sitemap = await buildSitemap(await publicSiteRoutes(options), {
|
|
87
|
+
baseUrl: options.baseUrl,
|
|
88
|
+
...(locales.length < 2
|
|
89
|
+
? {}
|
|
90
|
+
: {
|
|
91
|
+
locales,
|
|
92
|
+
defaultLocale,
|
|
93
|
+
localizePath: (path: string, locale: string) =>
|
|
94
|
+
localizedPath(path, locale, defaultLocale),
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
69
97
|
// Past 50,000 URLs `files` are `/sitemap-N.xml` and the index is `/sitemap.xml`; below it,
|
|
70
98
|
// `files` is that one file and there is no index.
|
|
71
99
|
const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
|
|
72
100
|
const robots = buildRobots({
|
|
73
101
|
baseUrl: options.baseUrl,
|
|
74
102
|
sitemaps: [SITEMAP_PATH],
|
|
103
|
+
...(options.disallow === undefined ? {} : { disallow: options.disallow }),
|
|
75
104
|
...(options.environment === undefined ? {} : { environment: options.environment }),
|
|
76
105
|
});
|
|
77
106
|
return { robots, sitemaps };
|
|
@@ -65,7 +65,7 @@ const prerender =
|
|
|
65
65
|
// landed on disk — which is what \`x build --target static --json\` reads back.
|
|
66
66
|
|
|
67
67
|
import { join } from 'node:path';
|
|
68
|
-
import {
|
|
68
|
+
import { loadSiteSettings, type PrerenderReport, prerenderSite, siteSeo } from '@ultimat3/cli';
|
|
69
69
|
|
|
70
70
|
const root = join(import.meta.dir, '..', '..');
|
|
71
71
|
const flag = Bun.argv.indexOf('--out');
|
|
@@ -90,9 +90,11 @@ const origin = Bun.env.SITE_ORIGIN;
|
|
|
90
90
|
* \`robots.txt\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
|
|
91
91
|
* \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
|
|
92
92
|
*/
|
|
93
|
-
async function writeSeoFiles(report: PrerenderReport
|
|
93
|
+
async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]> {
|
|
94
94
|
const seo = await siteSeo({
|
|
95
|
-
|
|
95
|
+
// The origin the pages were built against, so the sitemap and every canonical agree.
|
|
96
|
+
baseUrl: report.origin,
|
|
97
|
+
disallow: (await loadSiteSettings(root)).disallow,
|
|
96
98
|
pagesFor: (route) =>
|
|
97
99
|
report.pages.filter((page) => page.route === route).map((page) => page.path),
|
|
98
100
|
});
|
|
@@ -103,7 +105,7 @@ async function writeSeoFiles(report: PrerenderReport, baseUrl: string): Promise<
|
|
|
103
105
|
|
|
104
106
|
if (import.meta.main) {
|
|
105
107
|
const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
|
|
106
|
-
const seo = await writeSeoFiles(report
|
|
108
|
+
const seo = await writeSeoFiles(report);
|
|
107
109
|
await Bun.stdout.write(
|
|
108
110
|
\`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report, seo })}\\n\`,
|
|
109
111
|
);
|
package/src/test-select.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
// Bun ships no equivalent: `join` builds the host-separator path from the scan root to a hit.
|
|
7
7
|
// Sizing is Bun's own (`Bun.file().size`), so nothing here reaches for `node:fs`.
|
|
8
|
-
import { nearestName } from '@ultimat3/core';
|
|
8
|
+
import { nearestName, renderFixShellArg } from '@ultimat3/core';
|
|
9
9
|
import { BadFlagError } from './errors';
|
|
10
10
|
import type { ParsedArgs } from './parse';
|
|
11
11
|
import { flagString } from './parse';
|
|
@@ -45,17 +45,21 @@ const IGNORED = ['/dist/', '/build/', '/node_modules/', '/examples/', '/dummy/']
|
|
|
45
45
|
* File size stands in for duration: cheap to read, and it correlates far better than file count.
|
|
46
46
|
* `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — one
|
|
47
47
|
* owner per path, decided by `ownerOf` there and never a second time here.
|
|
48
|
+
*
|
|
49
|
+
* `filter` is one substring or several: a path is kept when it contains ANY of them, so an app's
|
|
50
|
+
* scoped runner hands `x test` every affected slice at once instead of one process per slice.
|
|
48
51
|
*/
|
|
49
52
|
export async function discoverTests(
|
|
50
53
|
root: string,
|
|
51
|
-
filter?: string,
|
|
54
|
+
filter?: string | readonly string[],
|
|
52
55
|
type?: TestType,
|
|
53
56
|
): Promise<readonly TestFile[]> {
|
|
57
|
+
const filters = filter === undefined ? undefined : typeof filter === 'string' ? [filter] : filter;
|
|
54
58
|
const files: TestFile[] = [];
|
|
55
59
|
for await (const found of new Bun.Glob(TEST_GLOB).scan({ cwd: root, absolute: false })) {
|
|
56
60
|
const path = found.split('\\').join('/');
|
|
57
61
|
if (IGNORED.some((part) => `/${path}`.includes(part))) continue;
|
|
58
|
-
if (
|
|
62
|
+
if (filters !== undefined && !filters.some((part) => path.includes(part))) continue;
|
|
59
63
|
if (type !== undefined && !belongsToType(path, type)) continue;
|
|
60
64
|
files.push({ path, bytes: Bun.file(join(root, path)).size });
|
|
61
65
|
}
|
|
@@ -134,6 +138,29 @@ export function readSample(args: ParsedArgs): number | undefined {
|
|
|
134
138
|
return value;
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
/**
|
|
142
|
+
* `--filter`'s value as the list `discoverTests` matches: comma-separated, each item trimmed. An
|
|
143
|
+
* empty item is refused rather than dropped — `''` is a substring of every path, so `a,,b` would
|
|
144
|
+
* quietly widen a narrowed run back to the whole suite.
|
|
145
|
+
*/
|
|
146
|
+
export function readFilters(raw: string | undefined): readonly string[] | undefined {
|
|
147
|
+
if (raw === undefined) return undefined;
|
|
148
|
+
const items = raw.split(',').map((item) => item.trim());
|
|
149
|
+
if (items.some((item) => item === '')) {
|
|
150
|
+
const kept = items.filter((item) => item !== '');
|
|
151
|
+
throw new BadFlagError({
|
|
152
|
+
flag: 'filter',
|
|
153
|
+
command: 'test',
|
|
154
|
+
reason: `"${raw}" holds an empty path, and an empty substring matches every test file`,
|
|
155
|
+
fix:
|
|
156
|
+
kept.length === 0
|
|
157
|
+
? 'x test --json'
|
|
158
|
+
: `x test --filter ${renderFixShellArg(kept.join(','), '<path,path>')}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return items;
|
|
162
|
+
}
|
|
163
|
+
|
|
137
164
|
/** The selection, as `NoTestFilesError` wants it: only the parts the caller actually asked for. */
|
|
138
165
|
export function missingSelection(
|
|
139
166
|
type: TestType | undefined,
|
package/src/test-workers.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// suite two different ways, and the `--worker N` reproduction a shard failure prints would then
|
|
4
4
|
// name a shard the gate never ran.
|
|
5
5
|
|
|
6
|
-
// Bun ships no CPU-count primitive
|
|
7
|
-
|
|
6
|
+
// Bun ships no CPU-count or free-memory primitive: `cpus()` is the fallback when navigator cannot
|
|
7
|
+
// answer, and `freemem()` is the only reader of available memory.
|
|
8
|
+
import { cpus, freemem } from 'node:os';
|
|
8
9
|
import type { TestType } from '@ultimat3/testing';
|
|
9
10
|
|
|
10
11
|
/** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
|
|
@@ -14,7 +15,7 @@ export function availableCpus(): number {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* Deliberately MORE workers than cores,
|
|
18
|
+
* Deliberately MORE workers than cores, bounded by memory rather than by a fixed count.
|
|
18
19
|
*
|
|
19
20
|
* `cpus - 1` is the intuitive default and it was measured to be worthless exactly where it has to
|
|
20
21
|
* pay off. On a 4-core `ubuntu-latest` — the runner this repo commits to — the `unit` step:
|
|
@@ -32,23 +33,62 @@ export function availableCpus(): number {
|
|
|
32
33
|
* resolution, on `--isolate` rebuilding a registry per file, and on waiting for its database.
|
|
33
34
|
* Oversubscribing fills those stalls.
|
|
34
35
|
*
|
|
35
|
-
* The
|
|
36
|
-
* graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* The bound is memory, not cores. A worker is a whole Bun process with the framework's module
|
|
37
|
+
* graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite. Until
|
|
38
|
+
* 22.3 that was a FIXED ceiling of 8, which held a 12-core box to 8 workers with 30 GB free: the
|
|
39
|
+
* notificado.co `unit` step (381 files) sat at 51s on 8 workers against a 90s gate budget. The
|
|
40
|
+
* ceiling is now what the machine can actually hold — `os.freemem()` (MemAvailable on Linux, so
|
|
41
|
+
* reclaimable page cache counts as free) divided by `WORKER_BYTES` — which still binds on a small
|
|
42
|
+
* CI runner and on a loaded laptop, the two places an unbounded count would swap.
|
|
39
43
|
*
|
|
40
44
|
* The floor of 2 keeps a 1-core box sharding rather than silently reverting to serial.
|
|
41
45
|
*/
|
|
42
|
-
export const WORKER_CEILING = 8;
|
|
43
|
-
|
|
44
|
-
/** Oversubscription factor. See the table above — it is measured, not chosen for roundness. */
|
|
45
46
|
export const WORKER_OVERSUBSCRIBE = 1.5;
|
|
46
47
|
|
|
47
48
|
/** The floor the paragraph above names: a 1-core box shards rather than reverting to serial. */
|
|
48
49
|
export const WORKER_FLOOR = 2;
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* What one test worker is budgeted at, for the memory bound above. MEASURED — peak RSS of the whole
|
|
53
|
+
* `x test unit` process tree on the notificado.co corpus (381 files, PGlite per worker), 12-core
|
|
54
|
+
* box, `As of 2026-09-25`:
|
|
55
|
+
*
|
|
56
|
+
* | workers | peak tree RSS |
|
|
57
|
+
* |---------|---------------|
|
|
58
|
+
* | 8 | 20.7 GB |
|
|
59
|
+
* | 12 | 22.5 GB |
|
|
60
|
+
* | 16 | 24.3 GB |
|
|
61
|
+
*
|
|
62
|
+
* The MARGINAL worker costs ~0.45 GB (the slope); the ~17 GB intercept is the corpus itself —
|
|
63
|
+
* every file's module graph and database, retained per worker — and does not shrink with fewer
|
|
64
|
+
* workers, so it is not this bound's to budget. The constant is the slope doubled and rounded to a
|
|
65
|
+
* power of two, because free memory is read once, before a single worker has started.
|
|
66
|
+
*/
|
|
67
|
+
export const WORKER_BYTES = 1024 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The most `--workers` accepts, on either command. Not a default and not a memory rule — a sanity
|
|
71
|
+
* bound: without one `--workers 5000` parsed, the run clamped only to the file count, and it
|
|
72
|
+
* started one Bun process per test FILE. An explicit width below it is the caller's call.
|
|
73
|
+
*/
|
|
74
|
+
export const WORKER_CEILING = 64;
|
|
75
|
+
|
|
76
|
+
/** Bun ships no memory primitive; `freemem()` is libuv's MemAvailable on Linux. */
|
|
77
|
+
export const availableMemory = (): number => freemem();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `ceil(cpus x 1.5)`, held to what free memory can carry and never below the floor. The file-count
|
|
81
|
+
* clamp is `test-passes.ts`'s (every pass is clamped to its own file list), because only the
|
|
82
|
+
* caller knows the selection.
|
|
83
|
+
*/
|
|
84
|
+
export const defaultWorkers = (
|
|
85
|
+
available: number = availableCpus(),
|
|
86
|
+
freeBytes: number = availableMemory(),
|
|
87
|
+
): number => {
|
|
88
|
+
const byCpu = Math.ceil(available * WORKER_OVERSUBSCRIBE);
|
|
89
|
+
const byMemory = Math.floor(freeBytes / WORKER_BYTES);
|
|
90
|
+
return Math.max(WORKER_FLOOR, Math.min(byCpu, byMemory, WORKER_CEILING));
|
|
91
|
+
};
|
|
52
92
|
|
|
53
93
|
/**
|
|
54
94
|
* Which types run across worker processes, and why the other two cannot.
|