@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.
Files changed (114) hide show
  1. package/README.md +1 -1
  2. package/dist/archive.d.ts +11 -0
  3. package/dist/archive.d.ts.map +1 -0
  4. package/dist/archive.js +51 -0
  5. package/dist/archive.js.map +1 -0
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +1 -1
  9. package/dist/build.js.map +1 -1
  10. package/dist/check/typecheck.d.ts +2 -2
  11. package/dist/check/typecheck.d.ts.map +1 -1
  12. package/dist/check/typecheck.js +1 -1
  13. package/dist/check/typecheck.js.map +1 -1
  14. package/dist/devServer.d.ts +5 -0
  15. package/dist/devServer.d.ts.map +1 -1
  16. package/dist/devServer.js +11 -2
  17. package/dist/devServer.js.map +1 -1
  18. package/dist/html.d.ts +10 -3
  19. package/dist/html.d.ts.map +1 -1
  20. package/dist/html.js +29 -5
  21. package/dist/html.js.map +1 -1
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/installer/index.d.ts +2 -0
  27. package/dist/installer/index.d.ts.map +1 -1
  28. package/dist/installer/index.js +1 -0
  29. package/dist/installer/index.js.map +1 -1
  30. package/dist/installer/install.d.ts +3 -0
  31. package/dist/installer/install.d.ts.map +1 -1
  32. package/dist/installer/install.js +1 -1
  33. package/dist/installer/install.js.map +1 -1
  34. package/dist/installer/npmrc.d.ts +26 -0
  35. package/dist/installer/npmrc.d.ts.map +1 -0
  36. package/dist/installer/npmrc.js +55 -0
  37. package/dist/installer/npmrc.js.map +1 -0
  38. package/dist/installer/registry.d.ts +3 -0
  39. package/dist/installer/registry.d.ts.map +1 -1
  40. package/dist/installer/registry.js +10 -5
  41. package/dist/installer/registry.js.map +1 -1
  42. package/dist/node/childProcess.js +1 -1
  43. package/dist/node/childProcess.js.map +1 -1
  44. package/dist/node/misc.js +1 -1
  45. package/dist/node/misc.js.map +1 -1
  46. package/dist/shell/builtins.d.ts.map +1 -1
  47. package/dist/shell/builtins.js +2 -2
  48. package/dist/shell/builtins.js.map +1 -1
  49. package/dist/shell/devHandoff.d.ts +1 -1
  50. package/dist/shell/exec.js +4 -4
  51. package/dist/shell/exec.js.map +1 -1
  52. package/dist/shell/expand.js +1 -1
  53. package/dist/shell/expand.js.map +1 -1
  54. package/dist/shell/npm.d.ts.map +1 -1
  55. package/dist/shell/npm.js +5 -1
  56. package/dist/shell/npm.js.map +1 -1
  57. package/dist/shell/program.d.ts +1 -1
  58. package/dist/shell/program.js +3 -3
  59. package/dist/shell/program.js.map +1 -1
  60. package/dist/system/commands.d.ts +4 -4
  61. package/dist/system/commands.d.ts.map +1 -1
  62. package/dist/system/commands.js +3 -3
  63. package/dist/system/commands.js.map +1 -1
  64. package/dist/system/index.d.ts +6 -4
  65. package/dist/system/index.d.ts.map +1 -1
  66. package/dist/system/index.js +2 -1
  67. package/dist/system/index.js.map +1 -1
  68. package/dist/system/kernel.d.ts +9 -16
  69. package/dist/system/kernel.d.ts.map +1 -1
  70. package/dist/system/kernel.js +10 -23
  71. package/dist/system/kernel.js.map +1 -1
  72. package/dist/system/process.d.ts +18 -14
  73. package/dist/system/process.d.ts.map +1 -1
  74. package/dist/system/process.js +12 -10
  75. package/dist/system/process.js.map +1 -1
  76. package/dist/system/programs.js +1 -1
  77. package/dist/system/programs.js.map +1 -1
  78. package/dist/system/serverEvents.d.ts +27 -0
  79. package/dist/system/serverEvents.d.ts.map +1 -0
  80. package/dist/system/serverEvents.js +33 -0
  81. package/dist/system/serverEvents.js.map +1 -0
  82. package/dist/system/types.d.ts +4 -4
  83. package/dist/system/types.d.ts.map +1 -1
  84. package/dist/vfs.d.ts +27 -17
  85. package/dist/vfs.d.ts.map +1 -1
  86. package/dist/vfs.js +48 -11
  87. package/dist/vfs.js.map +1 -1
  88. package/package.json +1 -1
  89. package/src/archive.ts +59 -0
  90. package/src/build.ts +4 -4
  91. package/src/check/typecheck.ts +4 -4
  92. package/src/devServer.ts +12 -2
  93. package/src/html.ts +32 -5
  94. package/src/index.ts +5 -2
  95. package/src/installer/index.ts +2 -0
  96. package/src/installer/install.ts +4 -1
  97. package/src/installer/npmrc.ts +69 -0
  98. package/src/installer/registry.ts +12 -5
  99. package/src/node/childProcess.ts +1 -1
  100. package/src/node/misc.ts +1 -1
  101. package/src/shell/builtins.ts +2 -2
  102. package/src/shell/devHandoff.ts +1 -1
  103. package/src/shell/exec.ts +4 -4
  104. package/src/shell/expand.ts +1 -1
  105. package/src/shell/npm.ts +5 -1
  106. package/src/shell/program.ts +4 -4
  107. package/src/system/commands.ts +6 -6
  108. package/src/system/index.ts +6 -4
  109. package/src/system/kernel.ts +15 -32
  110. package/src/system/process.ts +28 -20
  111. package/src/system/programs.ts +1 -1
  112. package/src/system/serverEvents.ts +44 -0
  113. package/src/system/types.ts +4 -4
  114. 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 key = `packument:${this.registry}/${name}`;
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 = `${this.registry}/${name.startsWith('@') ? '@' + encodeURIComponent(name.slice(1)) : encodeURIComponent(name)}`;
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 ${this.registry}.`);
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
  }
@@ -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 ? ['jsh', ['-c', [command, ...args].join(' ')]] : [command, args];
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/jsh' }),
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,
@@ -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', 'jsh', 'sh', 'bash'];
34
+ export const SYSTEM_PROGRAMS = ['node', 'npm', 'npx', 'bfsh', 'sh', 'bash'];
35
35
 
36
- const HELP = `jsh — BuilderForce WebContainers shell
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
@@ -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 (`server-ready`) and stay "running" until Ctrl-C,
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(`jsh: ${error.message}\n`);
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(`jsh: ${target}: No such file or directory\n`);
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(`jsh: ${target}: Is a directory\n`);
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(`jsh: command not found: ${name}\n`);
243
+ io.stderr(`bfsh: command not found: ${name}\n`);
244
244
  return 127;
245
245
  }
246
246
  child.onStdout(io.stdout);
@@ -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 = 'jsh'; break;
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,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `jsh` program: `jsh -c "…"`, `jsh script.sh`, a script on stdin, or —
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/jsh' },
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(`jsh: ${error instanceof Error ? error.message : String(error)}\n`);
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(`jsh: ${args[0]}: No such file or directory\n`);
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/, ''));
@@ -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, jsh, coreutils), then a path or a
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 `jsh`.
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`, `jsh`, `ls`…). */
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' | 'jsh'; args: string[] } {
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|jsh|zsh|dash)\b/.test(firstLine)) return { name: 'jsh', args: [path] };
48
- if (['.sh', '.bash'].includes(extname(path))) return { name: 'jsh', args: [path] };
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
 
@@ -1,10 +1,12 @@
1
1
  export { Kernel, DEFAULT_ENV } from './kernel.js';
2
- export type { KernelOptions, KernelSpawnOptions } from './kernel.js';
3
- export type { System, Program, ProgramContext, ProgramIO, TerminalPort, SpawnOptions, ChildHandle, HttpRequestData, HttpResponseData, HttpHandler } from './types.js';
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, toWebContainerProcess, allocatePid } from './process.js';
7
- export type { WebContainerProcess, RunProgramOptions } from './process.js';
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';
@@ -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, SpawnOptions, System } from './types.js';
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, toWebContainerProcess, type WebContainerProcess } from './process.js';
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/jsh',
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.serverReady.forEach((l) => l(port, url)),
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
- /** Same shape as `WebContainer#spawn`. */
80
- spawn(command: string, args: string[] | KernelSpawnOptions = [], options: KernelSpawnOptions = {}): WebContainerProcess {
81
- if (!Array.isArray(args)) [options, args] = [args, []];
82
- return toWebContainerProcess(this.spawnChild(command, args, options), !!options.terminal);
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: SpawnOptions = {}): ChildHandle {
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
  }
@@ -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 WebContainer-shaped wrapper (`output`/`input`
6
- * streams) that public `spawn` returns.
5
+ * program it hosts. Also {@link RuntimeProcess}, the stream-based handle the
6
+ * public `run()` returns.
7
7
  */
8
- import type { ChildHandle, Program, ProgramIO, SpawnOptions, System } from './types.js';
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 SpawnOptions {
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
- /** The public process shape, deliberately the same as `@webcontainer/api`'s `WebContainerProcess`. */
126
- export interface WebContainerProcess {
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 output: ReadableStream<string>;
129
- readonly input: WritableStream<string>;
130
- readonly exit: Promise<number>;
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
- resize(dimensions: { cols: number; rows: number } | number, rows?: number): void;
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
- export function toWebContainerProcess(child: ChildHandle, terminal: boolean): WebContainerProcess {
136
- const output = new ReadableStream<string>({
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 input = new WritableStream<string>({
154
+ const stdin = new WritableStream<string>({
145
155
  write: (chunk) => child.write(chunk),
146
156
  close: () => child.closeStdin(),
147
157
  });
148
158
  return {
149
- output,
150
- input,
151
- exit: child.exit,
159
+ pid: child.pid,
160
+ combinedOutput,
161
+ stdin,
162
+ wait: () => child.exit,
152
163
  kill: (signal) => child.kill(signal),
153
- resize(dimensions, rows) {
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
  }
@@ -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
- jsh: shellProgram,
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
+ }
@@ -24,14 +24,14 @@ export interface ProgramIO {
24
24
  terminal?: TerminalPort;
25
25
  }
26
26
 
27
- export interface SpawnOptions {
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 API). */
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?: SpawnOptions): ChildHandle;
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 ready (`server-ready`); used by the dev-server handoff. */
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;