@seanhogg/builderforce-webcontainers-core 2026.10.5 → 2026.10.7
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/README.md +1 -1
- package/dist/archive.d.ts +11 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +51 -0
- package/dist/archive.js.map +1 -0
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -1
- package/dist/build.js.map +1 -1
- package/dist/check/typecheck.d.ts +2 -2
- package/dist/check/typecheck.d.ts.map +1 -1
- package/dist/check/typecheck.js +1 -1
- package/dist/check/typecheck.js.map +1 -1
- package/dist/devServer.d.ts +5 -0
- package/dist/devServer.d.ts.map +1 -1
- package/dist/devServer.js +11 -2
- package/dist/devServer.js.map +1 -1
- package/dist/html.d.ts +10 -3
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +29 -5
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/installer/index.d.ts +2 -0
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +1 -0
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/install.d.ts +3 -0
- package/dist/installer/install.d.ts.map +1 -1
- package/dist/installer/install.js +1 -1
- package/dist/installer/install.js.map +1 -1
- package/dist/installer/npmrc.d.ts +26 -0
- package/dist/installer/npmrc.d.ts.map +1 -0
- package/dist/installer/npmrc.js +55 -0
- package/dist/installer/npmrc.js.map +1 -0
- package/dist/installer/registry.d.ts +3 -0
- package/dist/installer/registry.d.ts.map +1 -1
- package/dist/installer/registry.js +10 -5
- package/dist/installer/registry.js.map +1 -1
- package/dist/node/childProcess.js +1 -1
- package/dist/node/childProcess.js.map +1 -1
- package/dist/node/misc.js +1 -1
- package/dist/node/misc.js.map +1 -1
- package/dist/shell/builtins.d.ts.map +1 -1
- package/dist/shell/builtins.js +2 -2
- package/dist/shell/builtins.js.map +1 -1
- package/dist/shell/devHandoff.d.ts +1 -1
- package/dist/shell/exec.js +4 -4
- package/dist/shell/exec.js.map +1 -1
- package/dist/shell/expand.js +1 -1
- package/dist/shell/expand.js.map +1 -1
- package/dist/shell/npm.d.ts.map +1 -1
- package/dist/shell/npm.js +5 -1
- package/dist/shell/npm.js.map +1 -1
- package/dist/shell/program.d.ts +1 -1
- package/dist/shell/program.js +3 -3
- package/dist/shell/program.js.map +1 -1
- package/dist/system/commands.d.ts +4 -4
- package/dist/system/commands.d.ts.map +1 -1
- package/dist/system/commands.js +3 -3
- package/dist/system/commands.js.map +1 -1
- package/dist/system/index.d.ts +6 -4
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +2 -1
- package/dist/system/index.js.map +1 -1
- package/dist/system/kernel.d.ts +9 -16
- package/dist/system/kernel.d.ts.map +1 -1
- package/dist/system/kernel.js +10 -23
- package/dist/system/kernel.js.map +1 -1
- package/dist/system/process.d.ts +18 -14
- package/dist/system/process.d.ts.map +1 -1
- package/dist/system/process.js +12 -10
- package/dist/system/process.js.map +1 -1
- package/dist/system/programs.js +1 -1
- package/dist/system/programs.js.map +1 -1
- package/dist/system/serverEvents.d.ts +27 -0
- package/dist/system/serverEvents.d.ts.map +1 -0
- package/dist/system/serverEvents.js +33 -0
- package/dist/system/serverEvents.js.map +1 -0
- package/dist/system/types.d.ts +4 -4
- package/dist/system/types.d.ts.map +1 -1
- package/dist/vfs.d.ts +27 -17
- package/dist/vfs.d.ts.map +1 -1
- package/dist/vfs.js +48 -11
- package/dist/vfs.js.map +1 -1
- package/package.json +1 -1
- package/src/archive.ts +59 -0
- package/src/build.ts +4 -4
- package/src/check/typecheck.ts +4 -4
- package/src/devServer.ts +12 -2
- package/src/html.ts +32 -5
- package/src/index.ts +5 -2
- package/src/installer/index.ts +2 -0
- package/src/installer/install.ts +4 -1
- package/src/installer/npmrc.ts +69 -0
- package/src/installer/registry.ts +12 -5
- package/src/node/childProcess.ts +1 -1
- package/src/node/misc.ts +1 -1
- package/src/shell/builtins.ts +2 -2
- package/src/shell/devHandoff.ts +1 -1
- package/src/shell/exec.ts +4 -4
- package/src/shell/expand.ts +1 -1
- package/src/shell/npm.ts +5 -1
- package/src/shell/program.ts +4 -4
- package/src/system/commands.ts +6 -6
- package/src/system/index.ts +6 -4
- package/src/system/kernel.ts +15 -32
- package/src/system/process.ts +28 -20
- package/src/system/programs.ts +1 -1
- package/src/system/serverEvents.ts +44 -0
- package/src/system/types.ts +4 -4
- package/src/vfs.ts +58 -15
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.npmrc`: the default registry, per-scope registries (`@acme:registry=…`) and
|
|
3
|
+
* auth tokens keyed by registry path (`//npm.acme.dev/:_authToken=${NPM_TOKEN}`).
|
|
4
|
+
* Project and user files are read like npm does — the project's wins — and
|
|
5
|
+
* `${VAR}` is filled from the process environment.
|
|
6
|
+
*
|
|
7
|
+
* A private registry is reached with the browser's `fetch`, so it must answer
|
|
8
|
+
* CORS requests that carry an `Authorization` header.
|
|
9
|
+
*/
|
|
10
|
+
import type { VirtualFileSystem } from '../vfs.js';
|
|
11
|
+
|
|
12
|
+
export interface NpmConfig {
|
|
13
|
+
registry?: string;
|
|
14
|
+
/** `@scope` → registry URL. */
|
|
15
|
+
scopes: Record<string, string>;
|
|
16
|
+
/** `//host/path/` → bearer token. */
|
|
17
|
+
tokens: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emptyNpmConfig(): NpmConfig {
|
|
21
|
+
return { scopes: {}, tokens: {} };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const trimSlash = (url: string) => url.replace(/\/+$/, '');
|
|
25
|
+
|
|
26
|
+
export function parseNpmrc(text: string, env: Record<string, string | undefined> = {}, into: NpmConfig = emptyNpmConfig()): NpmConfig {
|
|
27
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
28
|
+
const line = raw.trim();
|
|
29
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
30
|
+
const eq = line.indexOf('=');
|
|
31
|
+
if (eq < 0) continue;
|
|
32
|
+
const key = line.slice(0, eq).trim();
|
|
33
|
+
const value = line.slice(eq + 1).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\$\{([^}]+)\}/g, (_m, name: string) => env[name] ?? '');
|
|
34
|
+
if (key === 'registry') into.registry = trimSlash(value);
|
|
35
|
+
else if (/^@[^:]+:registry$/.test(key)) into.scopes[key.slice(0, key.indexOf(':'))] = trimSlash(value);
|
|
36
|
+
else if (key.startsWith('//') && key.endsWith(':_authToken')) into.tokens[key.slice(0, -':_authToken'.length).replace(/\/?$/, '/')] = value;
|
|
37
|
+
}
|
|
38
|
+
return into;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read `.npmrc` files, lowest precedence first (user, then project). Missing files are skipped. */
|
|
42
|
+
export function readNpmConfig(fs: VirtualFileSystem, paths: string[], env: Record<string, string | undefined> = {}): NpmConfig {
|
|
43
|
+
const config = emptyNpmConfig();
|
|
44
|
+
for (const path of paths) {
|
|
45
|
+
const text = fs.readText(path);
|
|
46
|
+
if (text !== undefined) parseNpmrc(text, env, config);
|
|
47
|
+
}
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The registry a package is fetched from: its scope's, else the default. */
|
|
52
|
+
export function registryFor(config: NpmConfig | undefined, name: string, fallback: string): string {
|
|
53
|
+
if (name.startsWith('@')) {
|
|
54
|
+
const scoped = config?.scopes[name.slice(0, name.indexOf('/'))];
|
|
55
|
+
if (scoped) return scoped;
|
|
56
|
+
}
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `Authorization` for a URL: the token whose registry path is the longest prefix of it. */
|
|
61
|
+
export function authHeaders(config: NpmConfig | undefined, url: string): Record<string, string> {
|
|
62
|
+
if (!config) return {};
|
|
63
|
+
const target = url.replace(/^https?:/, '');
|
|
64
|
+
let best = '';
|
|
65
|
+
for (const prefix of Object.keys(config.tokens)) {
|
|
66
|
+
if ((target.startsWith(prefix) || target + '/' === prefix) && prefix.length > best.length) best = prefix;
|
|
67
|
+
}
|
|
68
|
+
return best ? { authorization: `Bearer ${config.tokens[best]}` } : {};
|
|
69
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* caller injects one, which is what keeps this testable against a fake registry.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { authHeaders, registryFor, type NpmConfig } from './npmrc.js';
|
|
9
|
+
|
|
8
10
|
export type FetchLike = (url: string, init?: { headers?: Record<string, string>; signal?: AbortSignal }) => Promise<{
|
|
9
11
|
ok: boolean;
|
|
10
12
|
status: number;
|
|
@@ -60,6 +62,8 @@ export interface RegistryOptions {
|
|
|
60
62
|
fetch: FetchLike;
|
|
61
63
|
registry?: string;
|
|
62
64
|
cache?: PackageCache;
|
|
65
|
+
/** `.npmrc` settings: scoped registries and auth tokens. */
|
|
66
|
+
npmrc?: NpmConfig;
|
|
63
67
|
/** How long a cached packument is trusted. Default 10 minutes. */
|
|
64
68
|
packumentMaxAgeMs?: number;
|
|
65
69
|
signal?: AbortSignal;
|
|
@@ -136,7 +140,8 @@ export class RegistryClient {
|
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
private async loadPackument(name: string): Promise<Packument> {
|
|
139
|
-
const
|
|
143
|
+
const registry = registryFor(this.options.npmrc, name, this.registry);
|
|
144
|
+
const key = `packument:${registry}/${name}`;
|
|
140
145
|
const maxAge = this.options.packumentMaxAgeMs ?? 10 * 60_000;
|
|
141
146
|
const cached = await this.options.cache?.get(key);
|
|
142
147
|
if (cached) {
|
|
@@ -147,15 +152,16 @@ export class RegistryClient {
|
|
|
147
152
|
// a corrupt entry is just a miss
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
|
-
const url = `${
|
|
151
|
-
const bytes = await this.get(url, { accept: ABBREVIATED }, name);
|
|
155
|
+
const url = `${registry}/${name.startsWith('@') ? '@' + encodeURIComponent(name.slice(1)) : encodeURIComponent(name)}`;
|
|
156
|
+
const bytes = await this.get(url, { accept: ABBREVIATED }, name, registry);
|
|
152
157
|
const packument = JSON.parse(decoder.decode(bytes)) as Packument;
|
|
153
158
|
if (!packument.versions) throw new Error(`The registry returned no versions for "${name}".`);
|
|
154
159
|
await this.options.cache?.put(key, encoder.encode(JSON.stringify({ t: Date.now(), p: packument })));
|
|
155
160
|
return packument;
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
private async get(url: string, headers: Record<string, string>, packageName?: string): Promise<Uint8Array> {
|
|
163
|
+
private async get(url: string, headers: Record<string, string>, packageName?: string, registry = this.registry): Promise<Uint8Array> {
|
|
164
|
+
headers = { ...headers, ...authHeaders(this.options.npmrc, url) };
|
|
159
165
|
let lastError: unknown;
|
|
160
166
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
161
167
|
let response: Awaited<ReturnType<FetchLike>>;
|
|
@@ -166,7 +172,8 @@ export class RegistryClient {
|
|
|
166
172
|
lastError = error; // network blip: retry once
|
|
167
173
|
continue;
|
|
168
174
|
}
|
|
169
|
-
if (response.status === 404 && packageName) throw new Error(`Package "${packageName}" was not found in ${
|
|
175
|
+
if (response.status === 404 && packageName) throw new Error(`Package "${packageName}" was not found in ${registry}.`);
|
|
176
|
+
if ((response.status === 401 || response.status === 403) && packageName) throw new Error(`${registry} refused "${packageName}" (HTTP ${response.status}). Add an auth token to .npmrc.`);
|
|
170
177
|
if (!response.ok) throw new Error(`GET ${url} failed with HTTP ${response.status}.`);
|
|
171
178
|
return new Uint8Array(await response.arrayBuffer());
|
|
172
179
|
}
|
package/src/node/childProcess.ts
CHANGED
|
@@ -68,7 +68,7 @@ class ChildProcess extends EventEmitterBase {
|
|
|
68
68
|
|
|
69
69
|
const env: Record<string, string> = {};
|
|
70
70
|
for (const [k, v] of Object.entries(options.env ?? deps.env())) if (v !== undefined) env[k] = String(v);
|
|
71
|
-
const [file, argv] = options.shell ? ['
|
|
71
|
+
const [file, argv] = options.shell ? ['bfsh', ['-c', [command, ...args].join(' ')]] : [command, args];
|
|
72
72
|
const release = deps.loop.hold();
|
|
73
73
|
try {
|
|
74
74
|
this.handle = deps.system.spawn(file, argv, { cwd: options.cwd ?? deps.cwd(), env });
|
package/src/node/misc.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function createOsModule(env: () => Record<string, string | undefined>): R
|
|
|
27
27
|
hostname: () => 'localhost',
|
|
28
28
|
homedir: () => env().HOME ?? '/home/user',
|
|
29
29
|
tmpdir: () => env().TMPDIR ?? '/tmp',
|
|
30
|
-
userInfo: () => ({ uid: 1000, gid: 1000, username: env().USER ?? 'user', homedir: env().HOME ?? '/home/user', shell: '/bin/
|
|
30
|
+
userInfo: () => ({ uid: 1000, gid: 1000, username: env().USER ?? 'user', homedir: env().HOME ?? '/home/user', shell: '/bin/bfsh' }),
|
|
31
31
|
cpus: () => cpus,
|
|
32
32
|
availableParallelism: () => cpus.length,
|
|
33
33
|
totalmem: () => 4 * 1024 ** 3,
|
package/src/shell/builtins.ts
CHANGED
|
@@ -31,9 +31,9 @@ export const COREUTILS: Record<string, Command> = {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
/** Programs the runtime provides besides coreutils (reported by `which`/`type`). */
|
|
34
|
-
export const SYSTEM_PROGRAMS = ['node', 'npm', 'npx', '
|
|
34
|
+
export const SYSTEM_PROGRAMS = ['node', 'npm', 'npx', 'bfsh', 'sh', 'bash'];
|
|
35
35
|
|
|
36
|
-
const HELP = `
|
|
36
|
+
const HELP = `bfsh — BuilderForce WebContainers shell
|
|
37
37
|
|
|
38
38
|
Builtins: cd, pwd, export, unset, exit, source, history, type, which, help
|
|
39
39
|
Files: ls [-la], cat, mkdir [-p], rm [-rf], cp [-r], mv, touch
|
package/src/shell/devHandoff.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* dev` / `npm start` usually run) cannot run here as-is — Vite itself needs
|
|
4
4
|
* esbuild's native binary — but the runtime already IS a Vite-style dev server:
|
|
5
5
|
* the in-browser DevServer serving the preview. So these commands announce that
|
|
6
|
-
* preview as their server (
|
|
6
|
+
* preview as their server (a listening announcement) and stay "running" until Ctrl-C,
|
|
7
7
|
* which is exactly what a host waiting on `npm run dev` expects.
|
|
8
8
|
*/
|
|
9
9
|
import type { CommandContext } from './context.js';
|
package/src/shell/exec.ts
CHANGED
|
@@ -73,7 +73,7 @@ export class Shell implements ShellState {
|
|
|
73
73
|
script = parse(source);
|
|
74
74
|
} catch (error) {
|
|
75
75
|
if (!(error instanceof ShellSyntaxError)) throw error;
|
|
76
|
-
io.stderr(`
|
|
76
|
+
io.stderr(`bfsh: ${error.message}\n`);
|
|
77
77
|
this.lastStatus = 2;
|
|
78
78
|
return 2;
|
|
79
79
|
}
|
|
@@ -176,7 +176,7 @@ export class Shell implements ShellState {
|
|
|
176
176
|
if (redirect.op === '<') {
|
|
177
177
|
const text = this.system.fs.readText(path);
|
|
178
178
|
if (text === undefined) {
|
|
179
|
-
io.stderr(`
|
|
179
|
+
io.stderr(`bfsh: ${target}: No such file or directory\n`);
|
|
180
180
|
return 1;
|
|
181
181
|
}
|
|
182
182
|
const queue = new InputQueue();
|
|
@@ -186,7 +186,7 @@ export class Shell implements ShellState {
|
|
|
186
186
|
continue;
|
|
187
187
|
}
|
|
188
188
|
if (this.system.fs.isDirectory(path)) {
|
|
189
|
-
io.stderr(`
|
|
189
|
+
io.stderr(`bfsh: ${target}: Is a directory\n`);
|
|
190
190
|
return 1;
|
|
191
191
|
}
|
|
192
192
|
const append = redirect.op === '>>' || redirect.op === '2>>';
|
|
@@ -240,7 +240,7 @@ export class Shell implements ShellState {
|
|
|
240
240
|
child = this.system.spawn(name, args, { cwd: this.cwd, env, terminal: size });
|
|
241
241
|
} catch (error) {
|
|
242
242
|
if ((error as { code?: string }).code !== 'ENOENT') throw error;
|
|
243
|
-
io.stderr(`
|
|
243
|
+
io.stderr(`bfsh: command not found: ${name}\n`);
|
|
244
244
|
return 127;
|
|
245
245
|
}
|
|
246
246
|
child.onStdout(io.stdout);
|
package/src/shell/expand.ts
CHANGED
|
@@ -29,7 +29,7 @@ async function paramValue(seg: Extract<Segment, { kind: 'var' }>, ctx: ExpandCon
|
|
|
29
29
|
case '@':
|
|
30
30
|
case '*': value = ctx.positional.join(' '); break;
|
|
31
31
|
case '!': value = ''; break;
|
|
32
|
-
case '0': value = '
|
|
32
|
+
case '0': value = 'bfsh'; break;
|
|
33
33
|
default: value = /^\d$/.test(seg.name) ? ctx.positional[Number(seg.name) - 1] : ctx.get(seg.name);
|
|
34
34
|
}
|
|
35
35
|
if (!seg.op) return value ?? '';
|
package/src/shell/npm.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { join } from '../paths.js';
|
|
9
9
|
import { installPackages, type InstallOptions } from '../installer/install.js';
|
|
10
|
+
import { readNpmConfig } from '../installer/npmrc.js';
|
|
10
11
|
import type { PackageJson } from '../installer/spec.js';
|
|
11
12
|
import { binDirectories, findExecutable } from '../system/commands.js';
|
|
12
13
|
import { findPackageScope } from '../node/resolve.js';
|
|
@@ -34,10 +35,13 @@ async function install(ctx: CommandContext, options: Partial<InstallOptions>, di
|
|
|
34
35
|
const started = performance.now();
|
|
35
36
|
const progress = ctx.io.isTerminal;
|
|
36
37
|
try {
|
|
38
|
+
// User file first, then the project's: later files win, as with npm.
|
|
39
|
+
const npmrc = readNpmConfig(ctx.fs, [join(ctx.env.HOME ?? '/home/user', '.npmrc'), join(dir, '.npmrc')], ctx.env);
|
|
37
40
|
const result = await installPackages({
|
|
38
41
|
fs: ctx.fs,
|
|
39
42
|
fetch: (url, init) => ctx.system.fetch(url, init as RequestInit),
|
|
40
|
-
registry: ctx.env.npm_config_registry ?? ctx.system.registry,
|
|
43
|
+
registry: ctx.env.npm_config_registry ?? npmrc.registry ?? ctx.system.registry,
|
|
44
|
+
npmrc,
|
|
41
45
|
cache: ctx.system.packageCache,
|
|
42
46
|
cwd: dir,
|
|
43
47
|
signal: ctx.signal,
|
package/src/shell/program.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `
|
|
2
|
+
* The `bfsh` program: `bfsh -c "…"`, `bfsh script.sh`, a script on stdin, or —
|
|
3
3
|
* attached to a terminal — the interactive shell. Interactively the shell owns
|
|
4
4
|
* the keyboard (raw mode) while it edits a line, and hands it to the running
|
|
5
5
|
* job otherwise: straight through to a terminal-attached child (which has its
|
|
@@ -22,7 +22,7 @@ function createShell(ctx: ProgramContext, positional: string[] = []): Shell {
|
|
|
22
22
|
return new Shell({
|
|
23
23
|
system: ctx.system,
|
|
24
24
|
cwd: ctx.cwd,
|
|
25
|
-
env: { ...ctx.env, SHELL: '/bin/
|
|
25
|
+
env: { ...ctx.env, SHELL: '/bin/bfsh' },
|
|
26
26
|
positional,
|
|
27
27
|
terminalSize: () => (ctx.io.terminal ? { cols: ctx.io.terminal.cols, rows: ctx.io.terminal.rows } : undefined),
|
|
28
28
|
commands: SHELL_COMMANDS,
|
|
@@ -131,7 +131,7 @@ async function interactive(ctx: ProgramContext): Promise<number> {
|
|
|
131
131
|
try {
|
|
132
132
|
await shell.exec(line, io, current.input, controller.signal);
|
|
133
133
|
} catch (error) {
|
|
134
|
-
ctx.io.stderr(`
|
|
134
|
+
ctx.io.stderr(`bfsh: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
135
135
|
}
|
|
136
136
|
job = undefined;
|
|
137
137
|
if (shell.exitRequested !== undefined) return finish(shell.exitRequested);
|
|
@@ -206,7 +206,7 @@ export const shellProgram: Program = async (ctx) => {
|
|
|
206
206
|
const path = args[0].startsWith('/') ? args[0] : join(ctx.cwd, args[0]);
|
|
207
207
|
const source = ctx.system.fs.readText(path);
|
|
208
208
|
if (source === undefined) {
|
|
209
|
-
ctx.io.stderr(`
|
|
209
|
+
ctx.io.stderr(`bfsh: ${args[0]}: No such file or directory\n`);
|
|
210
210
|
return 127;
|
|
211
211
|
}
|
|
212
212
|
return runOnce(ctx, createShell(ctx, args.slice(1)), source.replace(/^#!.*\n/, ''));
|
package/src/system/commands.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Command lookup, shared by the kernel, the browser host and the shell:
|
|
3
|
-
* registered programs first (node, npm,
|
|
3
|
+
* registered programs first (node, npm, bfsh, coreutils), then a path or a
|
|
4
4
|
* `PATH`/`node_modules/.bin` lookup. A found file runs under `node` (a `.bin`
|
|
5
|
-
* shim runs its target directly) or, with a shell shebang, under `
|
|
5
|
+
* shim runs its target directly) or, with a shell shebang, under `bfsh`.
|
|
6
6
|
*/
|
|
7
7
|
import type { VirtualFileSystem } from '../vfs.js';
|
|
8
8
|
import { dirname, extname, join, normalizePath } from '../paths.js';
|
|
@@ -10,7 +10,7 @@ import { readBinLink } from '../installer/bins.js';
|
|
|
10
10
|
import type { Program } from './types.js';
|
|
11
11
|
|
|
12
12
|
export interface ResolvedCommand {
|
|
13
|
-
/** The registered program that will run (`node`, `
|
|
13
|
+
/** The registered program that will run (`node`, `bfsh`, `ls`…). */
|
|
14
14
|
name: string;
|
|
15
15
|
program: Program;
|
|
16
16
|
args: string[];
|
|
@@ -40,12 +40,12 @@ export function findExecutable(fs: VirtualFileSystem, command: string, cwd: stri
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** How to run a script file: its interpreter and arguments. */
|
|
43
|
-
export function interpreterFor(fs: VirtualFileSystem, path: string): { name: 'node' | '
|
|
43
|
+
export function interpreterFor(fs: VirtualFileSystem, path: string): { name: 'node' | 'bfsh'; args: string[] } {
|
|
44
44
|
const target = readBinLink(fs, path);
|
|
45
45
|
if (target) return { name: 'node', args: [target] };
|
|
46
46
|
const firstLine = (fs.readText(path) ?? '').split('\n', 1)[0] ?? '';
|
|
47
|
-
if (/^#!.*\b(?:sh|bash|
|
|
48
|
-
if (['.sh', '.bash'].includes(extname(path))) return { name: '
|
|
47
|
+
if (/^#!.*\b(?:sh|bash|bfsh|zsh|dash)\b/.test(firstLine)) return { name: 'bfsh', args: [path] };
|
|
48
|
+
if (['.sh', '.bash'].includes(extname(path))) return { name: 'bfsh', args: [path] };
|
|
49
49
|
return { name: 'node', args: [path] };
|
|
50
50
|
}
|
|
51
51
|
|
package/src/system/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export { Kernel, DEFAULT_ENV } from './kernel.js';
|
|
2
|
-
export type { KernelOptions
|
|
3
|
-
export type { System, Program, ProgramContext, ProgramIO, TerminalPort,
|
|
2
|
+
export type { KernelOptions } from './kernel.js';
|
|
3
|
+
export type { System, Program, ProgramContext, ProgramIO, TerminalPort, ProcessOptions, ChildHandle, HttpRequestData, HttpResponseData, HttpHandler } from './types.js';
|
|
4
4
|
export { PortRegistry, parsePortPath } from './ports.js';
|
|
5
5
|
export type { PortEvent } from './ports.js';
|
|
6
|
-
export { runProgram,
|
|
7
|
-
export type {
|
|
6
|
+
export { runProgram, runArguments, toRuntimeProcess, allocatePid } from './process.js';
|
|
7
|
+
export type { RuntimeProcess, RunProgramOptions } from './process.js';
|
|
8
|
+
export { ServerEvents } from './serverEvents.js';
|
|
9
|
+
export type { ServerAddress, PortChange } from './serverEvents.js';
|
|
8
10
|
export { resolveCommand, findExecutable, binDirectories, interpreterFor, commandNotFound } from './commands.js';
|
|
9
11
|
export type { ResolvedCommand } from './commands.js';
|
|
10
12
|
export { InputQueue, LineDiscipline, toTerminal } from './terminal.js';
|
package/src/system/kernel.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { VirtualFileSystem } from '../vfs.js';
|
|
9
9
|
import type { PackageCache } from '../installer/registry.js';
|
|
10
|
-
import type { ChildHandle, HttpRequestData, HttpResponseData, Program,
|
|
10
|
+
import type { ChildHandle, HttpRequestData, HttpResponseData, Program, ProcessOptions, System } from './types.js';
|
|
11
11
|
import { PortRegistry } from './ports.js';
|
|
12
|
+
import { ServerEvents } from './serverEvents.js';
|
|
12
13
|
import { commandNotFound, resolveCommand } from './commands.js';
|
|
13
|
-
import { runProgram,
|
|
14
|
+
import { runArguments, runProgram, toRuntimeProcess, type RuntimeProcess } from './process.js';
|
|
14
15
|
|
|
15
16
|
export interface KernelOptions {
|
|
16
17
|
fs?: VirtualFileSystem;
|
|
@@ -26,19 +27,11 @@ export interface KernelOptions {
|
|
|
26
27
|
packageCache?: PackageCache;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export interface KernelSpawnOptions extends SpawnOptions {
|
|
30
|
-
/** Kept for `@webcontainer/api` parity; output is always delivered. */
|
|
31
|
-
output?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
type ServerReadyListener = (port: number, url: string) => void;
|
|
35
|
-
type PortListener = (port: number, type: 'open' | 'close', url: string) => void;
|
|
36
|
-
|
|
37
30
|
export const DEFAULT_ENV: Record<string, string> = {
|
|
38
31
|
PATH: '/usr/local/bin:/usr/bin:/bin',
|
|
39
32
|
HOME: '/home/user',
|
|
40
33
|
USER: 'user',
|
|
41
|
-
SHELL: '/bin/
|
|
34
|
+
SHELL: '/bin/bfsh',
|
|
42
35
|
TERM: 'xterm-256color',
|
|
43
36
|
COLORTERM: 'truecolor',
|
|
44
37
|
LANG: 'en_US.UTF-8',
|
|
@@ -48,20 +41,16 @@ export const DEFAULT_ENV: Record<string, string> = {
|
|
|
48
41
|
export class Kernel {
|
|
49
42
|
readonly fs: VirtualFileSystem;
|
|
50
43
|
readonly ports = new PortRegistry();
|
|
44
|
+
/** Servers listening, ports opening and closing. */
|
|
45
|
+
readonly servers = new ServerEvents();
|
|
51
46
|
readonly env: Record<string, string>;
|
|
52
|
-
private readonly serverReady = new Set<ServerReadyListener>();
|
|
53
|
-
private readonly portListeners = new Set<PortListener>();
|
|
54
47
|
private readonly system: System;
|
|
55
48
|
|
|
56
49
|
constructor(private readonly options: KernelOptions) {
|
|
57
50
|
this.fs = options.fs ?? new VirtualFileSystem();
|
|
58
51
|
this.env = { ...DEFAULT_ENV, ...options.env };
|
|
59
52
|
const serverUrl = (port: number) => options.serverUrl?.(port) ?? `http://localhost:${port}/`;
|
|
60
|
-
this.ports.watch(({ type, port }) => {
|
|
61
|
-
const url = serverUrl(port);
|
|
62
|
-
for (const l of this.portListeners) l(port, type, url);
|
|
63
|
-
if (type === 'open') for (const l of this.serverReady) l(port, url);
|
|
64
|
-
});
|
|
53
|
+
this.ports.watch(({ type, port }) => this.servers.portChanged({ type, port, url: serverUrl(port) }));
|
|
65
54
|
this.system = {
|
|
66
55
|
fs: this.fs,
|
|
67
56
|
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
@@ -69,21 +58,23 @@ export class Kernel {
|
|
|
69
58
|
listen: (port, handler) => this.ports.listen(port, handler),
|
|
70
59
|
loopback: (port, request) => this.ports.request(port, request),
|
|
71
60
|
serverUrl,
|
|
72
|
-
announceServer: (port, url) => this.
|
|
61
|
+
announceServer: (port, url) => this.servers.announce({ port, url }),
|
|
73
62
|
previewUrl: options.previewUrl,
|
|
74
63
|
registry: options.registry,
|
|
75
64
|
packageCache: options.packageCache,
|
|
76
65
|
};
|
|
77
66
|
}
|
|
78
67
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
68
|
+
/** Start a command — `run('npm', ['install'])`, `run('bfsh', { terminal })` — as a stream-based {@link RuntimeProcess}. */
|
|
69
|
+
run(command: string, args?: string[], options?: ProcessOptions): RuntimeProcess;
|
|
70
|
+
run(command: string, options?: ProcessOptions): RuntimeProcess;
|
|
71
|
+
run(command: string, argsOrOptions?: string[] | ProcessOptions, maybeOptions?: ProcessOptions): RuntimeProcess {
|
|
72
|
+
const [args, options] = runArguments(argsOrOptions, maybeOptions);
|
|
73
|
+
return toRuntimeProcess(this.spawnChild(command, args, options), !!options.terminal);
|
|
83
74
|
}
|
|
84
75
|
|
|
85
76
|
/** A child as a program's parent sees it — what `System.spawn` returns. */
|
|
86
|
-
spawnChild(command: string, args: string[] = [], options:
|
|
77
|
+
spawnChild(command: string, args: string[] = [], options: ProcessOptions = {}): ChildHandle {
|
|
87
78
|
const cwd = options.cwd ?? '/';
|
|
88
79
|
const env = { ...this.env, ...options.env };
|
|
89
80
|
const resolved = resolveCommand(this.fs, this.options.programs, command, args, cwd, env);
|
|
@@ -95,12 +86,4 @@ export class Kernel {
|
|
|
95
86
|
request(port: number, request: Partial<HttpRequestData> & { url: string }): Promise<HttpResponseData | undefined> {
|
|
96
87
|
return this.ports.request(port, { method: 'GET', headers: {}, body: null, ...request });
|
|
97
88
|
}
|
|
98
|
-
|
|
99
|
-
on(event: 'server-ready', listener: ServerReadyListener): () => void;
|
|
100
|
-
on(event: 'port', listener: PortListener): () => void;
|
|
101
|
-
on(event: 'server-ready' | 'port', listener: ServerReadyListener | PortListener): () => void {
|
|
102
|
-
const set = (event === 'server-ready' ? this.serverReady : this.portListeners) as Set<typeof listener>;
|
|
103
|
-
set.add(listener);
|
|
104
|
-
return () => set.delete(listener);
|
|
105
|
-
}
|
|
106
89
|
}
|
package/src/system/process.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Running one program in-process: its stdin queue, TTY line discipline,
|
|
3
3
|
* output listeners and kill signal, packaged as a {@link ChildHandle}. The core
|
|
4
4
|
* kernel uses it for every process; the browser worker uses it for the one
|
|
5
|
-
* program it hosts. Also the
|
|
6
|
-
*
|
|
5
|
+
* program it hosts. Also {@link RuntimeProcess}, the stream-based handle the
|
|
6
|
+
* public `run()` returns.
|
|
7
7
|
*/
|
|
8
|
-
import type { ChildHandle, Program, ProgramIO,
|
|
8
|
+
import type { ChildHandle, Program, ProgramIO, ProcessOptions, System } from './types.js';
|
|
9
9
|
import { InputQueue, LineDiscipline, toTerminal } from './terminal.js';
|
|
10
10
|
|
|
11
11
|
let nextPid = 1000;
|
|
@@ -14,7 +14,7 @@ export function allocatePid(): number {
|
|
|
14
14
|
return nextPid++;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export interface RunProgramOptions extends
|
|
17
|
+
export interface RunProgramOptions extends ProcessOptions {
|
|
18
18
|
program: Program;
|
|
19
19
|
args: string[];
|
|
20
20
|
system: System;
|
|
@@ -122,18 +122,28 @@ export function runProgram(options: RunProgramOptions): ChildHandle {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/**
|
|
126
|
-
export interface
|
|
125
|
+
/** A running process as a host page sees it: streams for xterm.js or a log, and its exit code. */
|
|
126
|
+
export interface RuntimeProcess {
|
|
127
|
+
readonly pid: number;
|
|
127
128
|
/** stdout and stderr, interleaved as written (`\r\n` line endings when attached to a terminal). */
|
|
128
|
-
readonly
|
|
129
|
-
|
|
130
|
-
readonly
|
|
129
|
+
readonly combinedOutput: ReadableStream<string>;
|
|
130
|
+
/** Keystrokes or piped input; closing it is end-of-file. */
|
|
131
|
+
readonly stdin: WritableStream<string>;
|
|
132
|
+
/** Resolves with the exit code (128 + signal number when killed). */
|
|
133
|
+
wait(): Promise<number>;
|
|
134
|
+
/** Default `SIGTERM`; `SIGKILL` cannot be caught. */
|
|
131
135
|
kill(signal?: string): void;
|
|
132
|
-
|
|
136
|
+
/** Resize the attached terminal. A no-op for a process started without one. */
|
|
137
|
+
resizeTerminal(cols: number, rows: number): void;
|
|
133
138
|
}
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
/** `run(command, args?, options?)` and `run(command, options)` — the same call either way. */
|
|
141
|
+
export function runArguments(args: string[] | ProcessOptions = [], options: ProcessOptions = {}): [string[], ProcessOptions] {
|
|
142
|
+
return Array.isArray(args) ? [args, options] : [[], args];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function toRuntimeProcess(child: ChildHandle, terminal: boolean): RuntimeProcess {
|
|
146
|
+
const combinedOutput = new ReadableStream<string>({
|
|
137
147
|
start(controller) {
|
|
138
148
|
const push = (chunk: string) => controller.enqueue(terminal ? toTerminal(chunk) : chunk);
|
|
139
149
|
child.onStdout(push);
|
|
@@ -141,18 +151,16 @@ export function toWebContainerProcess(child: ChildHandle, terminal: boolean): We
|
|
|
141
151
|
void child.exit.then(() => controller.close());
|
|
142
152
|
},
|
|
143
153
|
});
|
|
144
|
-
const
|
|
154
|
+
const stdin = new WritableStream<string>({
|
|
145
155
|
write: (chunk) => child.write(chunk),
|
|
146
156
|
close: () => child.closeStdin(),
|
|
147
157
|
});
|
|
148
158
|
return {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
pid: child.pid,
|
|
160
|
+
combinedOutput,
|
|
161
|
+
stdin,
|
|
162
|
+
wait: () => child.exit,
|
|
152
163
|
kill: (signal) => child.kill(signal),
|
|
153
|
-
resize(
|
|
154
|
-
const size = typeof dimensions === 'number' ? { cols: dimensions, rows: rows ?? 24 } : dimensions;
|
|
155
|
-
child.resize?.(size.cols, size.rows);
|
|
156
|
-
},
|
|
164
|
+
resizeTerminal: (cols, rows) => child.resize?.(cols, rows),
|
|
157
165
|
};
|
|
158
166
|
}
|
package/src/system/programs.ts
CHANGED
|
@@ -20,7 +20,7 @@ function viaShell(command: string): Program {
|
|
|
20
20
|
export function defaultPrograms(): Record<string, Program> {
|
|
21
21
|
const programs: Record<string, Program> = {
|
|
22
22
|
node: nodeProgram,
|
|
23
|
-
|
|
23
|
+
bfsh: shellProgram,
|
|
24
24
|
sh: shellProgram,
|
|
25
25
|
bash: shellProgram,
|
|
26
26
|
npm: viaShell('npm'),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who hears about servers in a runtime: a port opening or closing, and a
|
|
3
|
+
* server becoming reachable (which a dev-server handoff announces without
|
|
4
|
+
* opening a port). One instance per runtime — the core `Kernel` and the
|
|
5
|
+
* browser's process host both own one, so the listener contract is the same.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ServerAddress {
|
|
9
|
+
port: number;
|
|
10
|
+
/** Where a browser reaches it (the preview URL for that port). */
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PortChange extends ServerAddress {
|
|
15
|
+
type: 'open' | 'close';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ServerEvents {
|
|
19
|
+
private readonly listening = new Set<(server: ServerAddress) => void>();
|
|
20
|
+
private readonly changes = new Set<(change: PortChange) => void>();
|
|
21
|
+
|
|
22
|
+
/** A server started listening (or a dev script handed off to the preview); `url` loads it in an iframe. */
|
|
23
|
+
onListening(listener: (server: ServerAddress) => void): () => void {
|
|
24
|
+
this.listening.add(listener);
|
|
25
|
+
return () => this.listening.delete(listener);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A virtual server opened or closed its port. */
|
|
29
|
+
onPortChange(listener: (change: PortChange) => void): () => void {
|
|
30
|
+
this.changes.add(listener);
|
|
31
|
+
return () => this.changes.delete(listener);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Report a port table change; an opened port is also a server listening. */
|
|
35
|
+
portChanged(change: PortChange): void {
|
|
36
|
+
for (const listener of this.changes) listener(change);
|
|
37
|
+
if (change.type === 'open') this.announce({ port: change.port, url: change.url });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Report a reachable server that did not open a port of its own. */
|
|
41
|
+
announce(server: ServerAddress): void {
|
|
42
|
+
for (const listener of this.listening) listener(server);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/system/types.ts
CHANGED
|
@@ -24,14 +24,14 @@ export interface ProgramIO {
|
|
|
24
24
|
terminal?: TerminalPort;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export interface
|
|
27
|
+
export interface ProcessOptions {
|
|
28
28
|
cwd?: string;
|
|
29
29
|
env?: Record<string, string>;
|
|
30
30
|
/** Attach to a terminal of this size (`isTTY`, line discipline, `\r\n` output). */
|
|
31
31
|
terminal?: { cols: number; rows: number };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** A child as its parent sees it (separate stdout/stderr, unlike the public
|
|
34
|
+
/** A child as its parent sees it (separate stdout/stderr listeners, unlike the streams of the public `RuntimeProcess`). */
|
|
35
35
|
export interface ChildHandle {
|
|
36
36
|
readonly pid: number;
|
|
37
37
|
onStdout(listener: (chunk: string) => void): () => void;
|
|
@@ -64,14 +64,14 @@ export interface System {
|
|
|
64
64
|
fs: VirtualFileSystem;
|
|
65
65
|
/** Outbound network (registry, `http.request`, `fetch`). */
|
|
66
66
|
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
67
|
-
spawn(command: string, args: string[], options?:
|
|
67
|
+
spawn(command: string, args: string[], options?: ProcessOptions): ChildHandle;
|
|
68
68
|
/** Register a virtual server; throws `EADDRINUSE`. Port 0 picks a free one. Returns the port and an unlisten. */
|
|
69
69
|
listen(port: number, handler: HttpHandler): { port: number; close(): void };
|
|
70
70
|
/** Send a request to a virtual server in this runtime (`http.get('http://localhost:3000')`); undefined when nothing listens. */
|
|
71
71
|
loopback(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined>;
|
|
72
72
|
/** Where a browser reaches a server on `port` (the preview URL), if known. */
|
|
73
73
|
serverUrl(port: number): string | undefined;
|
|
74
|
-
/** Tell the host a server is
|
|
74
|
+
/** Tell the host a server is reachable without opening a port; used by the dev-server handoff. */
|
|
75
75
|
announceServer(port: number, url: string): void;
|
|
76
76
|
/** The in-browser dev server's preview URL, for `vite`/`react-scripts start` handoff. */
|
|
77
77
|
previewUrl?: string;
|