@ultimat3/cli 11.1.0 → 11.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/src/messages.ts CHANGED
@@ -196,6 +196,20 @@ const CATALOG = {
196
196
  'cli.shot.verdict': ' verdict {path}',
197
197
  'cli.shot.blind.status':
198
198
  'HTTP response status is not observed — the port records requests, never responses',
199
+ // `--island`. A component capture reports per STATE, so the summary counts pictures and the
200
+ // lines name one state each; nothing here restates a fact `--json` does not carry.
201
+ 'cli.shot.island.ok':
202
+ '{island} clean — {pictures} picture(s), every one mounted, nothing logged and nothing threw',
203
+ 'cli.shot.island.failed':
204
+ '{island}: {taken} of {expected} declared picture(s) taken — verdict.json names each refusal',
205
+ 'cli.shot.island.state': ' {state} {theme} {width}x{height} {file}',
206
+ 'cli.shot.island.missing': ' missing {file} — no picture was taken for this declared state',
207
+ 'cli.shot.island.picture': ' pictures {path}',
208
+ 'cli.shot.island.verdict': ' verdict {path}',
209
+ 'cli.shot.island.blind.crop':
210
+ 'the picture is the viewport, not a crop — the browser port takes no clip rectangle, so a state sizes its own frame with viewport',
211
+ 'cli.shot.island.blind.locale':
212
+ 'toLocaleString() on a Date resolves its zone inside the engine — only an explicit timeZone is pinned by this harness',
199
213
  'cli.ci.failed':
200
214
  '{failed} of {runs} workflow run(s) on {branch} failed — {findings} finding(s) recovered from the log',
201
215
  'cli.ci.green': 'every one of {runs} workflow run(s) on {branch} passed',
@@ -0,0 +1,62 @@
1
+ // Whether a file is a pure re-export manifest — every statement in it an `import` or an `export`
2
+ // that declares nothing. The line ceiling is a rule about REVIEWABLE LOGIC, and such a file has
3
+ // none: its length is a function of the package's API size, so the ceiling measures the wrong
4
+ // thing there. One added statement of logic disqualifies it and re-arms the ceiling on the spot.
5
+
6
+ import { CLOSERS, maskLiterals, OPENERS } from './ts-scan';
7
+
8
+ /** Every statement a manifest may hold begins with one of these two words. */
9
+ const IMPORT_OR_EXPORT = /^(?:import|export)\b/;
10
+
11
+ /**
12
+ * An `export` that DECLARES rather than re-exports. `export const LIMIT = 1` is a value with an
13
+ * initialiser, `export function` is logic outright, and both are exactly what the ceiling is for —
14
+ * so a file holding one is an ordinary source file that happens to start with re-exports.
15
+ */
16
+ const DECLARES =
17
+ /^export\s+(?:default|declare|abstract|async|const|let|var|function|class|enum|namespace|module|interface)\b/;
18
+
19
+ /**
20
+ * `export type { Ctx } from './ctx'` is a re-export; `export type Ctx = { … }` is a type alias, and
21
+ * an alias is a declaration a reviewer reads. The brace is the whole distinction.
22
+ */
23
+ const TYPE_ALIAS = /^export\s+type\s+[A-Za-z_$]/;
24
+
25
+ /**
26
+ * Top-level statements, split at the `;` that ends each one at bracket depth 0, over MASKED source
27
+ * — comments and string contents blanked — so a `;` inside a specifier or a comment is not read as
28
+ * a boundary. `undefined` when the file ends in something this cannot read as a statement: a scan
29
+ * that guesses would exempt a file on the strength of not understanding it.
30
+ */
31
+ function topLevelStatements(masked: string): readonly string[] | undefined {
32
+ const statements: string[] = [];
33
+ let depth = 0;
34
+ let start = 0;
35
+ for (let i = 0; i < masked.length; i += 1) {
36
+ const ch = masked[i] as string;
37
+ if (OPENERS.has(ch)) depth += 1;
38
+ // Clamped, because an unbalanced closer would otherwise put every later `;` at a negative
39
+ // depth and the whole file would read as one unterminated statement.
40
+ else if (CLOSERS.has(ch)) depth = Math.max(0, depth - 1);
41
+ else if (ch === ';' && depth === 0) {
42
+ statements.push(masked.slice(start, i));
43
+ start = i + 1;
44
+ }
45
+ }
46
+ return masked.slice(start).trim() === '' ? statements : undefined;
47
+ }
48
+
49
+ /**
50
+ * True when every statement in `source` is an import or a declaration-free export. A file with no
51
+ * statement at all is NOT a manifest: an exemption has to be earned by what a file holds, and
52
+ * "this scan found nothing" is the one answer that must never grant one.
53
+ */
54
+ export function isReExportManifest(source: string): boolean {
55
+ const statements = topLevelStatements(maskLiterals(source));
56
+ if (statements === undefined || statements.length === 0) return false;
57
+ return statements.every((statement) => {
58
+ const text = statement.trim();
59
+ if (text === '') return true;
60
+ return IMPORT_OR_EXPORT.test(text) && !DECLARES.test(text) && !TYPE_ALIAS.test(text);
61
+ });
62
+ }
@@ -0,0 +1,84 @@
1
+ // Which browser a `x shot` run gets: start one in this container, or ATTACH to one somebody else is
2
+ // running. Three rules over plain inputs and no `ParsedArgs`, so each is testable without a boot —
3
+ // the `cmd-jobs.ts` / `jobs-report.ts` split, repeated for the one decision that is easy to get
4
+ // silently wrong.
5
+
6
+ import {
7
+ browserBinaryExists,
8
+ cdpUrlFrom,
9
+ cdpUrlProblem,
10
+ executablePathFrom,
11
+ } from './browser-launcher';
12
+ import { BadFlagError } from './errors';
13
+
14
+ /** A runnable example, not a placeholder: every refusal below hands one of these back. */
15
+ const CDP_FIX = 'x shot / --cdp-url wss://cdp.example.com/session/abc';
16
+
17
+ /**
18
+ * Exactly one of these is set. `undefined` on both is the ordinary local run where the library
19
+ * finds its own Chrome — which is why neither is required rather than a union of two shapes.
20
+ */
21
+ export interface ShotBrowserChoice {
22
+ /** Attach here. When set, nothing about a local executable was read. */
23
+ readonly cdpUrl?: string | undefined;
24
+ /** Launch this. Already proved to exist on disk. */
25
+ readonly executablePath?: string | undefined;
26
+ }
27
+
28
+ export interface ShotBrowserInput {
29
+ /** `--cdp-url` as typed. The env fallback is applied here, not by the caller. */
30
+ readonly cdpFlag?: string | undefined;
31
+ /** `--browser` as typed, before `PUPPETEER_EXECUTABLE_PATH` / `CHROME_PATH`. */
32
+ readonly browserFlag?: string | undefined;
33
+ readonly env: Readonly<Record<string, string | undefined>>;
34
+ }
35
+
36
+ /**
37
+ * Decided before anything boots, because a typo must not cost an embedded Postgres — and, on the
38
+ * attach path, a provider session — to report.
39
+ *
40
+ * The three rules, each chosen against a silent failure rather than for symmetry:
41
+ *
42
+ * 1. **Both FLAGS is refused, never ranked.** One names a Chrome to START and the other says the
43
+ * browser is somebody else's, so honouring either ignores what was typed.
44
+ * 2. **An exported `SCRAPE_CDP_URL` loses to `--browser`.** A shell-wide default is not a typed
45
+ * intent. The alternative is a flag that parses, reports nothing and quietly attaches somewhere
46
+ * else — the `--critical` defect class `flag-reads.ts` exists for and cannot see here, because
47
+ * the flag IS read.
48
+ * 3. **On an attach, no executable is read at all.** Checking the filesystem for a binary this run
49
+ * will never execute is how a correct remote capture gets refused on a box with no Chrome.
50
+ */
51
+ export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
52
+ if (input.cdpFlag !== undefined && input.browserFlag !== undefined) {
53
+ throw new BadFlagError({
54
+ flag: 'cdp-url',
55
+ command: 'shot',
56
+ reason:
57
+ '--browser names a Chrome to launch here and --cdp-url attaches to one already running',
58
+ fix: CDP_FIX,
59
+ });
60
+ }
61
+ const cdpUrl = input.browserFlag === undefined ? cdpUrlFrom(input.cdpFlag, input.env) : undefined;
62
+ if (cdpUrl !== undefined) {
63
+ const problem = cdpUrlProblem(cdpUrl);
64
+ if (problem !== undefined) {
65
+ throw new BadFlagError({
66
+ flag: 'cdp-url',
67
+ command: 'shot',
68
+ reason: `"${cdpUrl}" ${problem}`,
69
+ fix: CDP_FIX,
70
+ });
71
+ }
72
+ return { cdpUrl };
73
+ }
74
+ const executablePath = executablePathFrom(input.browserFlag, input.env);
75
+ if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
76
+ throw new BadFlagError({
77
+ flag: 'browser',
78
+ command: 'shot',
79
+ reason: `no executable at "${executablePath}"`,
80
+ fix: 'x shot / --browser /usr/bin/chromium',
81
+ });
82
+ }
83
+ return executablePath === undefined ? {} : { executablePath };
84
+ }
@@ -0,0 +1,90 @@
1
+ // The server a shot is taken against, and the hosts the page may reach while it is: the two
2
+ // facts a ROUTE capture and a COMPONENT capture share, and the only ones. Its own file so
3
+ // `island-shot.ts` can have them without importing the command that photographs a route, which
4
+ // would be a cycle between two files that otherwise have nothing to say to each other.
5
+
6
+ // why: no Bun native joins a path; `.x/shot` is a path both capture paths write under.
7
+ import { join } from 'node:path';
8
+ import { startDev } from './cmd-dev';
9
+ import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
10
+ import { DEV_BINDING } from './dev-roles';
11
+ import { resolveServices } from './dev-services';
12
+
13
+ export const SHOT_DIR = join('.x', 'shot');
14
+
15
+ /**
16
+ * How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
17
+ * call, for the reason every `Runner` in this package is one: the failure path below — a boot that
18
+ * throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
19
+ */
20
+ export type BootDevServer = (input: {
21
+ readonly root: string;
22
+ readonly port: number;
23
+ readonly env: Readonly<Record<string, string | undefined>>;
24
+ }) => Promise<{ readonly url: string; stop(): Promise<void> }>;
25
+
26
+ export interface ShotServer {
27
+ readonly url: string;
28
+ /** Which server the picture is of. Reported, because the two have different failure modes. */
29
+ readonly origin: 'booted' | 'reused';
30
+ stop(): Promise<void>;
31
+ }
32
+
33
+ /**
34
+ * One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
35
+ * Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
36
+ * on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
37
+ */
38
+ export async function devServerFor(
39
+ root: string,
40
+ env: Readonly<Record<string, string | undefined>>,
41
+ port: number,
42
+ boot: BootDevServer = (input) => startDev(input),
43
+ ): Promise<ShotServer> {
44
+ const services = resolveServices(root, env);
45
+ const file = Bun.file(lockPath(services.stateDir));
46
+ if (await file.exists()) {
47
+ const lock = parseLock(await file.text());
48
+ if (lock !== null && isProcessAlive(lock.pid)) {
49
+ return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
50
+ }
51
+ }
52
+ const { release } = await preflight({
53
+ stateDir: services.stateDir,
54
+ port,
55
+ hostname: DEV_BINDING.hostname,
56
+ embeddedDb: services.db.mode === 'embedded',
57
+ });
58
+ // The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
59
+ // looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
60
+ // same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
61
+ // checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
62
+ // teardown must never replace the failure it is cleaning up after.
63
+ const dev = await boot({ root, port, env }).catch((error: unknown) => {
64
+ release();
65
+ throw error;
66
+ });
67
+ await writeLock(services.stateDir, {
68
+ pid: process.pid,
69
+ port,
70
+ url: dev.url,
71
+ startedAt: new Date().toISOString(),
72
+ });
73
+ return {
74
+ url: dev.url,
75
+ origin: 'booted',
76
+ async stop() {
77
+ clearLock(services.stateDir);
78
+ await dev.stop();
79
+ },
80
+ };
81
+ }
82
+
83
+ /** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
84
+ export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
85
+ const named = (extra ?? '')
86
+ .split(',')
87
+ .map((host) => host.trim())
88
+ .filter((host) => host.length > 0);
89
+ return [new URL(url).hostname, ...named];
90
+ };
@@ -1,7 +1,8 @@
1
- // When a shot may be TAKEN: the two rules that decide whether the page has finished hydrating,
2
- // separated from both the command that drives a browser and the verdict that judges what came
3
- // back. Plain values and an injected sleep, so the whole loop is proved with neither.
1
+ // When a shot may be TAKEN: the rules that decide whether the page has finished, separated from
2
+ // both the command that drives a browser and the verdict that judges what came back. Plain values
3
+ // and an injected sleep, so every loop here is proved with neither.
4
4
 
5
+ import type { IslandReadiness } from './island-verdict';
5
6
  import type { IslandCount } from './shot-verdict';
6
7
 
7
8
  /**
@@ -55,3 +56,32 @@ export async function settleIslands(
55
56
  }
56
57
  return answer;
57
58
  }
59
+
60
+ /**
61
+ * Read the harness's readiness until the page goes QUIET or the window runs out.
62
+ *
63
+ * Quiet and not idle, which is the whole rule: the page's own watcher sets `ready` after N
64
+ * consecutive frames in which no request started and none settled, so a state whose fixture is
65
+ * deliberately `pending` still reaches it. Waiting for nothing in flight would hang on exactly the
66
+ * state an author declared on purpose, and a fixed sleep would photograph whatever a slow machine
67
+ * had painted by then.
68
+ *
69
+ * A `null` answer never overwrites a real one, for `settleIslands`' reason: `null` is "the page
70
+ * answered no probe", and a probe that fails once must not turn a ready page into an unready one.
71
+ */
72
+ export async function settleReadiness(
73
+ probe: () => Promise<IslandReadiness | null>,
74
+ options: SettleOptions,
75
+ ): Promise<IslandReadiness | null> {
76
+ const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
77
+ let answer = await probe();
78
+ let waited = 0;
79
+ while (answer?.ready !== true && waited < options.windowMs) {
80
+ // At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
81
+ const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
82
+ await sleep(step);
83
+ waited += step;
84
+ answer = (await probe()) ?? answer;
85
+ }
86
+ return answer;
87
+ }
package/src/ts-scan.ts CHANGED
@@ -161,10 +161,18 @@ export function lineIndex(text: string): (index: number) => number {
161
161
  }
162
162
 
163
163
  /**
164
- * Every string literal in the value expression starting at `from`, at the expression's own bracket
165
- * depth. Spanning the expression is what makes `cond ? 'a' : 'b'` and `table[k] ?? 'c'` checkable
166
- * instead of silently skipped; the depth rule is what keeps `command.join(' ')`'s separator and
167
- * `table['key']`'s key out — an argument is not a fix.
164
+ * Every string literal the value expression starting at `from` can EVALUATE TO, at the
165
+ * expression's own bracket depth. Spanning the expression is what makes `cond ? 'a' : 'b'` and
166
+ * `table[k] ?? 'c'` checkable instead of silently skipped; the depth rule is what keeps
167
+ * `command.join(' ')`'s separator and `table['key']`'s key out — an argument is not a fix.
168
+ *
169
+ * A ternary's CONDITION is dropped, which is the difference between reading the expression and
170
+ * reading every literal in it. `fix: input.slug === '' ? 'x g …' : 'x g …'` published the empty
171
+ * string as a fix line — `X_ERROR_FIX_INVALID`, "the fix line is empty", against source whose two
172
+ * real fixes are both correct — and `input.key === 'timeZone' ? … : …` published `timeZone`, a
173
+ * string then judged for banned phrases and cited paths that is not a fix at all. Costly enough
174
+ * that `@ultimat3/testing`'s island-state errors carry two classes under one code rather than one
175
+ * class with a ternary in it.
168
176
  */
169
177
  export function valueLiterals(
170
178
  masked: string,
@@ -173,21 +181,42 @@ export function valueLiterals(
173
181
  lineAt: (index: number) => number,
174
182
  ): readonly FixSite[] {
175
183
  const found: { value: string; index: number }[] = [];
184
+ // The literals of the segment being read. A segment ended by `?` is a condition and is dropped
185
+ // whole; one ended by `:` or by the end of the expression is a value the fix can evaluate to.
186
+ let segment: { value: string; index: number }[] = [];
187
+ const keep = (): void => {
188
+ found.push(...segment);
189
+ segment = [];
190
+ };
176
191
  let depth = 0;
192
+ /** Open `?`s still waiting for their `:`, so a `:` outside a ternary stays an ordinary char. */
193
+ let conditionals = 0;
177
194
  for (let i = from; i < masked.length; i += 1) {
178
195
  const ch = masked[i] as string;
179
196
  if (QUOTES.has(ch)) {
180
197
  const end = endOfLiteral(masked, i);
181
198
  // A quote that never closes is one character of code, not an empty literal to report.
182
199
  if (end === i + 1) continue;
183
- if (depth === 0) found.push({ value: source.slice(i + 1, end - 1), index: i });
200
+ if (depth === 0) segment.push({ value: source.slice(i + 1, end - 1), index: i });
184
201
  i = end - 1;
185
202
  } else if (OPENERS.has(ch)) depth += 1;
186
203
  else if (CLOSERS.has(ch)) {
187
204
  if (depth === 0) break;
188
205
  depth -= 1;
206
+ } else if (depth === 0 && ch === '?') {
207
+ // `??` and `?.` are operators and end no segment: `input?.fix ?? 'x help'` evaluates to the
208
+ // literal, and dropping what came before it would drop the only answer the expression has.
209
+ if (masked[i + 1] === '?') i += 1;
210
+ else if (masked[i + 1] !== '.') {
211
+ segment = [];
212
+ conditionals += 1;
213
+ }
214
+ } else if (depth === 0 && ch === ':' && conditionals > 0) {
215
+ keep();
216
+ conditionals -= 1;
189
217
  } else if (depth === 0 && (ch === ',' || ch === ';')) break;
190
218
  }
219
+ keep();
191
220
  return found.map((literal) => ({
192
221
  at: '',
193
222
  line: lineAt(literal.index),
@@ -1,12 +1,20 @@
1
- // Four shape rules the gate owns: one file, one job (a hard line ceiling), every workspace
2
- // package shipping the same contract files, every published package's tarball matching what its
3
- // manifest promises, and every published package being in the root build graph. All report
4
- // findings — a shape rule that is only written down is not a rule (axiom 3).
1
+ // Four shape rules the gate owns: one file, one job (a hard line ceiling on REVIEWABLE LOGIC),
2
+ // every workspace package shipping the same contract files, every published package's tarball
3
+ // matching what its manifest promises, and every published package being in the root build graph.
4
+ // All report findings — a shape rule that is only written down is not a rule (axiom 3).
5
+ //
6
+ // The ceiling exempts a pure re-export manifest, and only that. Such a file has one job by
7
+ // construction and its length is a function of the package's API size rather than of its
8
+ // complexity: `@ultimat3/core`'s `src/index.ts` reached 514 lines with 513 statements and not one
9
+ // of them logic, so the ceiling had stopped protecting anything and started refusing every new
10
+ // public subject. `isReExportManifest` is the whole carve-out — one statement of logic in such a
11
+ // file re-arms the ceiling on the same save, which is what keeps it from being a hole.
5
12
 
6
13
  import { existsSync } from 'node:fs';
7
14
  import { join } from 'node:path';
8
15
  import { ERROR_DOCS_URL, renderCauseValue } from '@ultimat3/core';
9
16
  import type { Finding } from './output';
17
+ import { isReExportManifest } from './reexport-manifest';
10
18
  import { eachSourceFile, isGenerated } from './source-files';
11
19
  import { checkRootReferences } from './tsconfig-references';
12
20
 
@@ -30,13 +38,21 @@ export const tooLongFinding = (path: string, lines: number): Finding => ({
30
38
  export const countLines = (text: string): number =>
31
39
  text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
32
40
 
33
- /** Files are the unit of review: one file, one job, hard ceiling 500 lines. */
41
+ /**
42
+ * Files are the unit of review: one file, one job, hard ceiling 500 lines of reviewable logic.
43
+ *
44
+ * The source is read before the count is judged rather than after, because the exemption is a
45
+ * question about CONTENTS: a 3,000-line file of re-exports is one job and a 501-line file with one
46
+ * statement of logic in it is not, and only reading tells them apart.
47
+ */
34
48
  export async function checkFileSizes(root: string): Promise<readonly Finding[]> {
35
49
  const findings: Finding[] = [];
36
50
  for await (const path of eachSourceFile(root)) {
37
51
  if (isGenerated(path)) continue;
38
- const lines = countLines(await Bun.file(join(root, path)).text());
39
- if (lines > LINE_CEILING) findings.push(tooLongFinding(path, lines));
52
+ const source = await Bun.file(join(root, path)).text();
53
+ const lines = countLines(source);
54
+ if (lines <= LINE_CEILING || isReExportManifest(source)) continue;
55
+ findings.push(tooLongFinding(path, lines));
40
56
  }
41
57
  return findings;
42
58
  }
@@ -1,129 +0,0 @@
1
- // Every `solid-js` import in an island chunk resolves to Solid's PRODUCTION browser build.
2
- // `Bun.build({ target: 'browser' })` always adds the `development` export condition and offers no
3
- // option that removes it — `conditions`, `production`, `env` and `define` were each measured under
4
- // Bun 1.4 and none of them does — so without this seam an island ships the dev bundle silently.
5
-
6
- // `node:path` by necessity: Bun ships no path API, and this file resolves a package entry back
7
- // to the directory its `exports` map is relative to.
8
- import { dirname, join } from 'node:path';
9
- import type { BunPlugin } from 'bun';
10
- import { IslandBuildFailedError } from './errors';
11
-
12
- /**
13
- * The conditions an island's `solid-js` subpath is resolved under. `development` is the one NOT in
14
- * the set, which is the whole point of the file; `production` is in it because an island chunk is
15
- * only ever built to be shipped — `x dev` serves the same chunk the container does, so a second
16
- * answer here would be a bundle the byte budget never measured.
17
- */
18
- const ISLAND_CONDITIONS: ReadonlySet<string> = new Set([
19
- 'production',
20
- 'browser',
21
- 'module',
22
- 'import',
23
- 'default',
24
- ]);
25
-
26
- /** `solid-js` and its subpaths, and nothing else: Solid is the runtime an island is compiled for. */
27
- const SOLID_SPECIFIER = /^solid-js(?:\/|$)/;
28
-
29
- /**
30
- * Node's conditional-exports walk, restricted to what this file needs: the first key of the object
31
- * that the build's condition set contains, depth-first, with an array as an ordered fallback list.
32
- * Written out rather than delegated to `Bun.resolveSync` because the ONE thing it has to do
33
- * differently from Bun's resolver is refuse `development` — and `types` with it, which would
34
- * otherwise win on Solid's map and hand the bundler a `.d.ts`.
35
- */
36
- export function selectCondition(node: unknown, conditions: ReadonlySet<string>): string | null {
37
- if (typeof node === 'string') return node;
38
- if (Array.isArray(node)) {
39
- for (const alternative of node as readonly unknown[]) {
40
- const picked = selectCondition(alternative, conditions);
41
- if (picked !== null) return picked;
42
- }
43
- return null;
44
- }
45
- if (typeof node !== 'object' || node === null) return null;
46
- for (const [condition, value] of Object.entries(node)) {
47
- if (!conditions.has(condition)) continue;
48
- const picked = selectCondition(value, conditions);
49
- if (picked !== null) return picked;
50
- }
51
- return null;
52
- }
53
-
54
- /** One parse per manifest: an island imports Solid from several files, and every file asks again. */
55
- const exportsCache = new Map<string, unknown>();
56
-
57
- async function exportsOf(manifest: string): Promise<unknown> {
58
- const hit = exportsCache.get(manifest);
59
- if (hit !== undefined || exportsCache.has(manifest)) return hit;
60
- const parsed: unknown = JSON.parse(await Bun.file(manifest).text());
61
- const field =
62
- typeof parsed === 'object' && parsed !== null && 'exports' in parsed
63
- ? (parsed as { readonly exports?: unknown }).exports
64
- : undefined;
65
- exportsCache.set(manifest, field);
66
- return field;
67
- }
68
-
69
- /** Test seam: the cache is process-global because `x dev` rebuilds in one process. */
70
- export function clearSolidExportsCache(): void {
71
- exportsCache.clear();
72
- }
73
-
74
- /**
75
- * The absolute file `specifier` must resolve to, or `null` for "Bun's own answer is already the
76
- * right one" — which is every subpath Solid declares as a plain string or a pattern, since a
77
- * declaration with no conditions on it cannot select the development build.
78
- */
79
- export async function solidProductionEntry(
80
- specifier: string,
81
- resolveDir: string,
82
- importer: string,
83
- ): Promise<string | null> {
84
- let manifest: string;
85
- try {
86
- // `solid-js/package.json` is an `exports` entry of Solid's own map, so this reaches the exact
87
- // copy the island would have imported — not a hoisted sibling at a different version.
88
- manifest = Bun.resolveSync('solid-js/package.json', resolveDir);
89
- } catch {
90
- // Solid is not installed here. Bun's resolver says so, in its own words, naming the importer.
91
- return null;
92
- }
93
- const field = await exportsOf(manifest);
94
- const subpath = specifier === 'solid-js' ? '.' : `.${specifier.slice('solid-js'.length)}`;
95
- if (typeof field !== 'object' || field === null || !(subpath in field)) return null;
96
-
97
- const entry = selectCondition((field as Record<string, unknown>)[subpath], ISLAND_CONDITIONS);
98
- const file = entry === null ? null : join(dirname(manifest), entry);
99
- if (file === null || !(await Bun.file(file).exists())) {
100
- throw new IslandBuildFailedError({
101
- file: importer.length > 0 ? importer : specifier,
102
- logs:
103
- `${specifier} has no production browser entry: ${manifest} answers ` +
104
- `${entry === null ? 'nothing' : JSON.stringify(entry)} under ` +
105
- `[${[...ISLAND_CONDITIONS].join(', ')}], and an island may not ship Solid's ` +
106
- 'development build',
107
- });
108
- }
109
- return file;
110
- }
111
-
112
- /**
113
- * The plugin `island-bundle.ts` hands `Bun.build`, beside `solidJsxPlugin`. Stateless apart from
114
- * the manifest cache, so one frozen descriptor serves every concurrent island build.
115
- *
116
- * The absolute path it answers with no longer matches `SOLID_SPECIFIER`, so Solid's own internal
117
- * `import … from 'solid-js'` is the only re-entry — and that one is wanted: it is how `web.js`
118
- * reaches `solid.js` rather than `dev.js`.
119
- */
120
- export const solidProductionPlugin: BunPlugin = {
121
- name: 'ultimate-island-solid-production',
122
- setup(build): void {
123
- build.onResolve({ filter: SOLID_SPECIFIER }, async ({ path, importer, resolveDir }) => {
124
- const from = resolveDir.length > 0 ? resolveDir : dirname(importer);
125
- const entry = await solidProductionEntry(path, from, importer);
126
- return entry === null ? undefined : { path: entry };
127
- });
128
- },
129
- };