@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.
@@ -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) return options.pagesFor(entry.path);
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
- const sitemap = await buildSitemap(await publicSiteRoutes(options), { baseUrl: options.baseUrl });
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 { DEFAULT_ORIGIN, type PrerenderReport, prerenderSite, siteSeo } from '@ultimat3/cli';
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, baseUrl: string): Promise<readonly string[]> {
93
+ async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]> {
94
94
  const seo = await siteSeo({
95
- baseUrl,
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, origin ?? DEFAULT_ORIGIN);
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
  );
@@ -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 (filter !== undefined && !path.includes(filter)) continue;
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,
@@ -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; `cpus()` is the fallback when navigator cannot answer.
7
- import { cpus } from 'node:os';
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, with a ceiling.
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 ceiling is memory, not cores. A worker is a whole Bun process with the framework's module
36
- * graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite, so
37
- * width costs hundreds of MB per step. It binds on a developer's 12- or 32-core machine, which is
38
- * exactly where an unbounded count would swap.
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
- export const defaultWorkers = (available: number = availableCpus()): number =>
51
- Math.max(WORKER_FLOOR, Math.min(WORKER_CEILING, Math.round(available * WORKER_OVERSUBSCRIBE)));
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.