@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 +2 -1
- package/package.json +28 -28
- package/src/browser-launcher.ts +79 -9
- package/src/cmd-shot-island.ts +5 -0
- package/src/cmd-shot.ts +17 -10
- package/src/compile-externals.ts +7 -4
- package/src/island-bundle.ts +18 -7
- package/src/shot-browser.ts +84 -0
- package/src/island-solid-production.ts +0 -129
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.
|
|
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.
|
|
41
|
-
"@ultimat3/admin": "11.
|
|
42
|
-
"@ultimat3/ai": "11.
|
|
43
|
-
"@ultimat3/auth": "11.
|
|
44
|
-
"@ultimat3/cache": "11.
|
|
45
|
-
"@ultimat3/core": "11.
|
|
46
|
-
"@ultimat3/db": "11.
|
|
47
|
-
"@ultimat3/entity": "11.
|
|
48
|
-
"@ultimat3/flags": "11.
|
|
49
|
-
"@ultimat3/http": "11.
|
|
50
|
-
"@ultimat3/i18n": "11.
|
|
51
|
-
"@ultimat3/jobs": "11.
|
|
52
|
-
"@ultimat3/mail": "11.
|
|
53
|
-
"@ultimat3/manifest": "11.
|
|
54
|
-
"@ultimat3/mcp": "11.
|
|
55
|
-
"@ultimat3/money": "11.
|
|
56
|
-
"@ultimat3/policy": "11.
|
|
57
|
-
"@ultimat3/pwa": "11.
|
|
58
|
-
"@ultimat3/query": "11.
|
|
59
|
-
"@ultimat3/realtime": "11.
|
|
60
|
-
"@ultimat3/render": "11.
|
|
61
|
-
"@ultimat3/schema": "11.
|
|
62
|
-
"@ultimat3/scraping": "11.
|
|
63
|
-
"@ultimat3/seo": "11.
|
|
64
|
-
"@ultimat3/storage": "11.
|
|
65
|
-
"@ultimat3/testing": "11.
|
|
66
|
-
"@ultimat3/time": "11.
|
|
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
|
}
|
package/src/browser-launcher.ts
CHANGED
|
@@ -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
|
|
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
|
|
48
|
-
if (typeof candidate
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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({
|
package/src/cmd-shot-island.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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({
|
package/src/compile-externals.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
package/src/island-bundle.ts
CHANGED
|
@@ -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
|
|
88
|
-
// `success: true`: without
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
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
|
-
};
|