@ultimat3/cli 11.2.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/CLAUDE.md CHANGED
@@ -166,7 +166,7 @@ change, and CI does not install one.
166
166
 
167
167
  | | Reaches for | Never |
168
168
  |---|---|---|
169
- | `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
169
+ | `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` — launching Chrome here, or **attaching** to one over `--cdp-url` / `SCRAPE_CDP_URL`, which is what every stealth provider sells and what `remoteBrowser()` has called its primary path since it shipped | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
170
170
  | `x shot --island <name>` | the same server and the same browser, plus the app's own `*.island.states.ts` | a second command — photographing a route and photographing a component are one job with two subjects, and `--island` with a route positional is refused by name |
171
171
  | `x pr review\|resolve\|reply` | `gh api graphql`, through the injected `Runner` | `gh pr view --comments`, which shows *issue* comments and not the line-anchored threads that carry the findings |
172
172
  | `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
@@ -187,6 +187,7 @@ ones a running app will not produce on request. `--island` takes them, one addre
187
187
  | `island-harness-script.ts` | what runs before the chunk does: the sealed network, the pinned clock, the readiness watch |
188
188
  | `island-harness-route.ts` | `GET /_x/island`, mounted by `x dev` |
189
189
  | `island-shot.ts` | the capture loop, the assertions before each shutter, the missing-shot gate |
190
+ | `shot-browser.ts` | which browser a run gets — launch one here, or attach over `--cdp-url` / `SCRAPE_CDP_URL` — as three rules over plain inputs |
190
191
  | `island-verdict.ts` | the per-state verdict — a PNG cannot say the component threw or logged |
191
192
  | `cmd-shot-island.ts` | the flags, and the one browser per declared viewport |
192
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "11.2.0",
3
+ "version": "11.3.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,33 +37,33 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "11.2.0",
41
- "@ultimat3/admin": "11.2.0",
42
- "@ultimat3/ai": "11.2.0",
43
- "@ultimat3/auth": "11.2.0",
44
- "@ultimat3/cache": "11.2.0",
45
- "@ultimat3/core": "11.2.0",
46
- "@ultimat3/db": "11.2.0",
47
- "@ultimat3/entity": "11.2.0",
48
- "@ultimat3/flags": "11.2.0",
49
- "@ultimat3/http": "11.2.0",
50
- "@ultimat3/i18n": "11.2.0",
51
- "@ultimat3/jobs": "11.2.0",
52
- "@ultimat3/mail": "11.2.0",
53
- "@ultimat3/manifest": "11.2.0",
54
- "@ultimat3/mcp": "11.2.0",
55
- "@ultimat3/money": "11.2.0",
56
- "@ultimat3/policy": "11.2.0",
57
- "@ultimat3/pwa": "11.2.0",
58
- "@ultimat3/query": "11.2.0",
59
- "@ultimat3/realtime": "11.2.0",
60
- "@ultimat3/render": "11.2.0",
61
- "@ultimat3/schema": "11.2.0",
62
- "@ultimat3/scraping": "11.2.0",
63
- "@ultimat3/seo": "11.2.0",
64
- "@ultimat3/storage": "11.2.0",
65
- "@ultimat3/testing": "11.2.0",
66
- "@ultimat3/time": "11.2.0",
40
+ "@ultimat3/action": "11.3.0",
41
+ "@ultimat3/admin": "11.3.0",
42
+ "@ultimat3/ai": "11.3.0",
43
+ "@ultimat3/auth": "11.3.0",
44
+ "@ultimat3/cache": "11.3.0",
45
+ "@ultimat3/core": "11.3.0",
46
+ "@ultimat3/db": "11.3.0",
47
+ "@ultimat3/entity": "11.3.0",
48
+ "@ultimat3/flags": "11.3.0",
49
+ "@ultimat3/http": "11.3.0",
50
+ "@ultimat3/i18n": "11.3.0",
51
+ "@ultimat3/jobs": "11.3.0",
52
+ "@ultimat3/mail": "11.3.0",
53
+ "@ultimat3/manifest": "11.3.0",
54
+ "@ultimat3/mcp": "11.3.0",
55
+ "@ultimat3/money": "11.3.0",
56
+ "@ultimat3/policy": "11.3.0",
57
+ "@ultimat3/pwa": "11.3.0",
58
+ "@ultimat3/query": "11.3.0",
59
+ "@ultimat3/realtime": "11.3.0",
60
+ "@ultimat3/render": "11.3.0",
61
+ "@ultimat3/schema": "11.3.0",
62
+ "@ultimat3/scraping": "11.3.0",
63
+ "@ultimat3/seo": "11.3.0",
64
+ "@ultimat3/storage": "11.3.0",
65
+ "@ultimat3/testing": "11.3.0",
66
+ "@ultimat3/time": "11.3.0",
67
67
  "babel-preset-solid": "^1.9.15"
68
68
  }
69
69
  }
@@ -2,11 +2,17 @@
2
2
  // of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
3
3
  // precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
4
4
  // command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
5
+ //
6
+ // Two ways to get a browser, and the second is the one production uses. `localBrowser()` starts
7
+ // Chrome in this container; `remoteBrowser({ cdpUrl })` ATTACHES to one somebody else is running,
8
+ // which is what every stealth provider sells — a session created over their API answers with a
9
+ // `wss://` CDP endpoint and a real, unfingerprintable Chromium behind it. `driver-cdp.ts` has
10
+ // called attach its primary path since it shipped, and until now no CLI command could reach it.
5
11
 
6
12
  import { existsSync } from 'node:fs';
7
13
  import { UltimateError } from '@ultimat3/core';
8
14
  import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
9
- import { localBrowser } from '@ultimat3/scraping';
15
+ import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
10
16
 
11
17
  /**
12
18
  * The one library this works against. Playwright is not an alternative and is not a flag:
@@ -18,6 +24,17 @@ export const BROWSER_PACKAGE = 'puppeteer-core';
18
24
  /** Where a browser binary is named when the flag does not name one. Read in this order. */
19
25
  export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
20
26
 
27
+ /**
28
+ * Where a CDP endpoint is named when `--cdp-url` does not name one. `SCRAPE_CDP_URL` and not a
29
+ * name of this command's own: `@ultimat3/scraping`'s `remoteRequired` refusal already tells its
30
+ * reader `remoteBrowser({ cdpUrl: env.SCRAPE_CDP_URL })`, and a second spelling would make the
31
+ * package's own instruction wrong for the CLI that follows it.
32
+ */
33
+ export const BROWSER_CDP_URL_VAR = 'SCRAPE_CDP_URL';
34
+
35
+ /** The schemes a CDP endpoint can arrive as: a provider's `wss://`, a sidecar's `http://`. */
36
+ const CDP_SCHEMES = ['ws:', 'wss:', 'http:', 'https:'] as const;
37
+
21
38
  /**
22
39
  * A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
23
40
  * shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
@@ -27,6 +44,8 @@ export class ShotBrowserMissingError extends UltimateError {
27
44
  constructor(input: { root: string; detail: string }) {
28
45
  super({
29
46
  code: 'X_SHOT_BROWSER_MISSING',
47
+ // The same install either way: attaching over CDP needs no Chrome on this box, but it still
48
+ // needs a client that speaks the protocol, and `puppeteer-core` is that client.
30
49
  cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
31
50
  // One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
32
51
  // one literal, and a fix line the gate cannot read is a fix line nothing holds to the
@@ -40,23 +59,34 @@ export class ShotBrowserMissingError extends UltimateError {
40
59
  /**
41
60
  * Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
42
61
  * transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
43
- * question decides — is there a `launch` to call?
62
+ * question decides — is the method this run needs there to call?
63
+ *
64
+ * WHICH method is the run's, not this function's. `cdp-port.ts` declares `launch` and `connect`
65
+ * both optional precisely so an attach-only provider SDK satisfies the port, and asking for
66
+ * `launch` when the run is going to `connect` would refuse exactly the library that works.
44
67
  */
45
- const launcherIn = (module: unknown): CdpLauncherLike | undefined => {
68
+ const launcherIn = (module: unknown, method: 'launch' | 'connect'): CdpLauncherLike | undefined => {
46
69
  if (typeof module !== 'object' || module === null) return undefined;
47
- const candidate = module as { launch?: unknown; default?: unknown };
48
- if (typeof candidate.launch === 'function') return candidate as CdpLauncherLike;
70
+ const candidate = module as Record<string, unknown>;
71
+ if (typeof candidate[method] === 'function') return candidate as CdpLauncherLike;
49
72
  // `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
50
73
  // refused rather than followed: one unbounded recursion here is a stack overflow instead of the
51
74
  // instruction this whole function exists to produce.
52
- if (candidate.default === undefined || candidate.default === candidate) return undefined;
53
- return launcherIn(candidate.default);
75
+ const inner = candidate['default'];
76
+ if (inner === undefined || inner === candidate) return undefined;
77
+ return launcherIn(inner, method);
54
78
  };
55
79
 
56
80
  export interface AppBrowserOptions {
57
81
  readonly root: string;
58
82
  /** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
59
83
  readonly executablePath?: string | undefined;
84
+ /**
85
+ * A CDP endpoint to ATTACH to. When it is set nothing is launched here and `executablePath` is
86
+ * not read: the browser is somebody else's, and closing it ends their session too — which
87
+ * `remoteBrowser()` does deliberately, so a provider stops billing for a run that ended.
88
+ */
89
+ readonly cdpUrl?: string | undefined;
60
90
  /**
61
91
  * The page size this browser lays out at. A LAUNCH option and not a per-capture one, because
62
92
  * that is the only place the shipped port has for it: `CaptureRequest` is `fullPage` alone
@@ -85,6 +115,33 @@ export const executablePathFrom = (
85
115
  /** True when a named executable is really there — a bad `--browser` is refused before a boot. */
86
116
  export const browserBinaryExists = (path: string): boolean => existsSync(path);
87
117
 
118
+ /** The endpoint a run will attach to, or `undefined` for "launch one here". */
119
+ export const cdpUrlFrom = (
120
+ flag: string | undefined,
121
+ env: Readonly<Record<string, string | undefined>>,
122
+ ): string | undefined => {
123
+ if (flag !== undefined && flag.length > 0) return flag;
124
+ const value = env[BROWSER_CDP_URL_VAR];
125
+ return value !== undefined && value.length > 0 ? value : undefined;
126
+ };
127
+
128
+ /**
129
+ * The scheme, checked here rather than at `connect()`. A `--cdp-url` naming an https page or a
130
+ * bare host is a typo, and a typo must not cost an embedded Postgres and a provider session to
131
+ * report — the same rule `--browser` follows against the filesystem one line above.
132
+ */
133
+ export const cdpUrlProblem = (url: string): string | undefined => {
134
+ let parsed: URL;
135
+ try {
136
+ parsed = new URL(url);
137
+ } catch {
138
+ return 'is not a URL';
139
+ }
140
+ return CDP_SCHEMES.includes(parsed.protocol as (typeof CDP_SCHEMES)[number])
141
+ ? undefined
142
+ : `has scheme "${parsed.protocol}", and a CDP endpoint is ${CDP_SCHEMES.join(', ')}`;
143
+ };
144
+
88
145
  /**
89
146
  * The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
90
147
  * this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
@@ -93,17 +150,30 @@ export const browserBinaryExists = (path: string): boolean => existsSync(path);
93
150
  export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
94
151
  const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
95
152
  const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
153
+ const method = options.cdpUrl === undefined ? 'launch' : 'connect';
96
154
  let entry: string;
97
155
  try {
98
156
  entry = resolve(BROWSER_PACKAGE, options.root);
99
157
  } catch {
100
158
  throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
101
159
  }
102
- const launcher = launcherIn(await load(entry));
160
+ const launcher = launcherIn(await load(entry), method);
103
161
  if (launcher === undefined) {
104
162
  throw new ShotBrowserMissingError({
105
163
  root: options.root,
106
- detail: `resolved to ${entry}, which exports no launch()`,
164
+ detail: `resolved to ${entry}, which exports no ${method}()`,
165
+ });
166
+ }
167
+ if (options.cdpUrl !== undefined) {
168
+ return remoteBrowser({
169
+ launcher,
170
+ cdpUrl: options.cdpUrl,
171
+ // The viewport reaches `connect()` the same way it reaches `launch()` — through the
172
+ // pass-through slot — so `x shot --island`'s one-browser-per-viewport loop is unchanged by
173
+ // which half of the port a run took.
174
+ ...(options.viewport === undefined
175
+ ? {}
176
+ : { options: { defaultViewport: { ...options.viewport } } }),
107
177
  });
108
178
  }
109
179
  return localBrowser({
@@ -27,6 +27,7 @@ import { SHOT_DIR } from './shot-server';
27
27
  export function islandBrowser(input: {
28
28
  readonly root: string;
29
29
  readonly executablePath?: string | undefined;
30
+ readonly cdpUrl?: string | undefined;
30
31
  }): IslandBrowser {
31
32
  const byViewport = new Map<string, Promise<ScrapeDriver>>();
32
33
  return (viewport: IslandViewport): Promise<ScrapeDriver> => {
@@ -36,6 +37,7 @@ export function islandBrowser(input: {
36
37
  const started = appBrowser({
37
38
  root: input.root,
38
39
  ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
40
+ ...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
39
41
  viewport: { width: viewport.width, height: viewport.height },
40
42
  });
41
43
  byViewport.set(key, started);
@@ -86,6 +88,8 @@ export interface IslandShotInput {
86
88
  readonly timeoutMs: number;
87
89
  readonly extraHosts?: string | undefined;
88
90
  readonly executablePath?: string | undefined;
91
+ /** A provider's session, or a sidecar. One attach per viewport, memoised like a launch. */
92
+ readonly cdpUrl?: string | undefined;
89
93
  readonly boot: () => Promise<ShotServer>;
90
94
  /** Injected by a test, so the whole path is proved on a machine with no Chrome. */
91
95
  readonly driver?: IslandBrowser | undefined;
@@ -124,6 +128,7 @@ export async function islandShot(input: IslandShotInput): Promise<IslandArtifact
124
128
  islandBrowser({
125
129
  root: input.root,
126
130
  ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
131
+ ...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
127
132
  }),
128
133
  boot: input.boot,
129
134
  settleMs: input.settleMs,
package/src/cmd-shot.ts CHANGED
@@ -11,7 +11,7 @@ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
11
11
  import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
12
12
  import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
13
13
  import { requireAppRoot } from './app-root';
14
- import { appBrowser, browserBinaryExists, executablePathFrom } from './browser-launcher';
14
+ import { appBrowser } from './browser-launcher';
15
15
  import { islandShot, islandShotResult, refuseRouteWithIsland } from './cmd-shot-island';
16
16
  import type { CliCommand, CommandContext } from './command';
17
17
  import { BadFlagError, MissingPositionalError } from './errors';
@@ -19,6 +19,7 @@ import { intFlagOr, PORT_RANGE } from './flag-number';
19
19
  import type { CommandResult } from './output';
20
20
  import type { ParsedArgs } from './parse';
21
21
  import { flagBool, flagString } from './parse';
22
+ import { shotBrowserChoice } from './shot-browser';
22
23
  import type { BootDevServer, ShotServer } from './shot-server';
23
24
  import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
24
25
  import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
@@ -285,6 +286,11 @@ export const shotCommand: CliCommand = {
285
286
  { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
286
287
  { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
287
288
  { name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
289
+ {
290
+ name: 'cdp-url',
291
+ type: 'string',
292
+ summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
293
+ },
288
294
  { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
289
295
  // A FLAG on `x shot` and never a second command: photographing a route and photographing a
290
296
  // component are one job with two subjects, and a parallel command would be the second path
@@ -314,15 +320,14 @@ export const shotCommand: CliCommand = {
314
320
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
315
321
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
316
322
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
317
- const executablePath = executablePathFrom(flagString(ctx.args, 'browser'), ctx.env);
318
- if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
319
- throw new BadFlagError({
320
- flag: 'browser',
321
- command: 'shot',
322
- reason: `no executable at "${executablePath}"`,
323
- fix: 'x shot / --browser /usr/bin/chromium',
324
- });
325
- }
323
+ // Which browser this run gets — start one here, or attach to one somebody else is running.
324
+ // Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
325
+ // provider session exists to pay for a typo.
326
+ const { cdpUrl, executablePath } = shotBrowserChoice({
327
+ cdpFlag: flagString(ctx.args, 'cdp-url'),
328
+ browserFlag: flagString(ctx.args, 'browser'),
329
+ env: ctx.env,
330
+ });
326
331
  const out = flagString(ctx.args, 'out');
327
332
  const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
328
333
  if (island !== undefined && island !== '') {
@@ -337,6 +342,7 @@ export const shotCommand: CliCommand = {
337
342
  settleMs,
338
343
  timeoutMs,
339
344
  ...(executablePath === undefined ? {} : { executablePath }),
345
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
340
346
  ...(flagString(ctx.args, 'allow-hosts') === undefined
341
347
  ? {}
342
348
  : { extraHosts: flagString(ctx.args, 'allow-hosts') }),
@@ -349,6 +355,7 @@ export const shotCommand: CliCommand = {
349
355
  const driver = await appBrowser({
350
356
  root,
351
357
  ...(executablePath === undefined ? {} : { executablePath }),
358
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
352
359
  });
353
360
  return shotResult(
354
361
  await runShot({
@@ -8,10 +8,13 @@
8
8
  * `package.json` inside a `catch` — and both sit in `loadCtsDefault`, which only runs while Babel
9
9
  * loads a `.cts` CONFIG FILE. `solid-loader.ts` passes `babelrc: false, configFile: false`, so no
10
10
  * config file is ever loaded and neither line is reachable at run time. The bundler walks them
11
- * anyway, and that is the whole failure: Bun 1.3 (what CI pins and what `docker/Dockerfile` builds
12
- * on) refuses the build with `Could not resolve: "@babel/preset-typescript/package.json"`, while
13
- * Bun 1.4 bundles the unresolvable `require` as a runtime throw — so one tree compiled on a laptop
14
- * and did not in CI.
11
+ * anyway, and that is the whole failure: Bun 1.3 refuses the build with
12
+ * `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
13
+ * unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
14
+ * That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
15
+ * `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
16
+ * 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
17
+ * `--compile` graph that must not reach an unresolvable `require`, on either Bun.
15
18
  *
16
19
  * Marking the dead specifier external rather than the two live ones: `serve.ts` calls
17
20
  * `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
@@ -10,7 +10,6 @@ import { renderThrowable } from '@ultimat3/core';
10
10
  import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
11
11
  import { contentHash } from '@ultimat3/render/server';
12
12
  import { IslandBuildFailedError } from './errors';
13
- import { solidProductionPlugin } from './island-solid-production';
14
13
  import { islandStylesPlugin } from './island-styles';
15
14
  import { solidJsxPlugin } from './solid-loader';
16
15
 
@@ -84,12 +83,24 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
84
83
  // classic `React.createElement` — emitted into a browser chunk that imports no React, with
85
84
  // `success: true` and no log. Every island shipped that way through five majors.
86
85
  //
87
- // The other two close the same shape of failure — a wrong answer `Bun.build` reports as
88
- // `success: true`: without the second, `target: 'browser'` resolves the `development`
89
- // export condition and the chunk carries Solid's dev build; without the third, Bun's file
90
- // loader resolves a `.module.scss` to its asset PATH, so `styles['x']` is `undefined` and
91
- // every element renders unclassed.
92
- plugins: [solidJsxPlugin, solidProductionPlugin, islandStylesPlugin],
86
+ // The second closes the same shape of failure — a wrong answer `Bun.build` reports as
87
+ // `success: true`: without it, Bun's file loader resolves a `.module.scss` to its asset
88
+ // PATH, so `styles['x']` is `undefined` and every element renders unclassed.
89
+ plugins: [solidJsxPlugin, islandStylesPlugin],
90
+ // The third one, and it is a `define` rather than the plugin this used to be: Bun selects
91
+ // the `development`/`production` export condition from the BUILD PROCESS's own `NODE_ENV`,
92
+ // and a defined `process.env.NODE_ENV` overrides it. Measured on 1.4.0, `solid-js` plus
93
+ // `solid-js/web` plus `solid-js/store`: unset → dev build, `test` → dev build, `production`
94
+ // → production build, this line → production build in all three, byte for byte.
95
+ //
96
+ // So without it a chunk built anywhere a container did not run — `x dev`, `x build` on a
97
+ // laptop, `bun test` — ships Solid's development build, and the island's own
98
+ // `process.env.NODE_ENV` reads `"development"` in the file a browser downloads.
99
+ //
100
+ // Pinned rather than inherited, because an island chunk is only ever built to be shipped:
101
+ // `x dev` serves the same chunk the container does, and bytes that depend on the ambient
102
+ // NODE_ENV are a content hash and a byte budget measured on a build nobody ships.
103
+ define: { 'process.env.NODE_ENV': '"production"' },
93
104
  });
94
105
  } catch (error) {
95
106
  throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
@@ -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
+ }
@@ -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
- };