@seanhogg/builderforce-webcontainers-core 2026.10.7 → 2026.10.8

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 (150) hide show
  1. package/dist/archive.d.ts +2 -0
  2. package/dist/archive.d.ts.map +1 -1
  3. package/dist/archive.js +6 -4
  4. package/dist/archive.js.map +1 -1
  5. package/dist/html.d.ts +2 -0
  6. package/dist/html.d.ts.map +1 -1
  7. package/dist/html.js +5 -3
  8. package/dist/html.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/node/http/client.d.ts +3 -1
  14. package/dist/node/http/client.d.ts.map +1 -1
  15. package/dist/node/http/client.js +74 -5
  16. package/dist/node/http/client.js.map +1 -1
  17. package/dist/node/http/index.d.ts.map +1 -1
  18. package/dist/node/http/index.js +2 -1
  19. package/dist/node/http/index.js.map +1 -1
  20. package/dist/node/http/messages.d.ts +87 -0
  21. package/dist/node/http/messages.d.ts.map +1 -0
  22. package/dist/node/http/messages.js +146 -0
  23. package/dist/node/http/messages.js.map +1 -0
  24. package/dist/node/http/server.d.ts +13 -98
  25. package/dist/node/http/server.d.ts.map +1 -1
  26. package/dist/node/http/server.js +34 -202
  27. package/dist/node/http/server.js.map +1 -1
  28. package/dist/node/http/serverResponse.d.ts +42 -0
  29. package/dist/node/http/serverResponse.d.ts.map +1 -0
  30. package/dist/node/http/serverResponse.js +89 -0
  31. package/dist/node/http/serverResponse.js.map +1 -0
  32. package/dist/node/http/upgradeSocket.d.ts +34 -0
  33. package/dist/node/http/upgradeSocket.d.ts.map +1 -0
  34. package/dist/node/http/upgradeSocket.js +73 -0
  35. package/dist/node/http/upgradeSocket.js.map +1 -0
  36. package/dist/node/runtime.js +2 -2
  37. package/dist/node/runtime.js.map +1 -1
  38. package/dist/previewBridge.d.ts +42 -0
  39. package/dist/previewBridge.d.ts.map +1 -0
  40. package/dist/previewBridge.js +192 -0
  41. package/dist/previewBridge.js.map +1 -0
  42. package/dist/shell/ast.d.ts +101 -0
  43. package/dist/shell/ast.d.ts.map +1 -0
  44. package/dist/shell/ast.js +7 -0
  45. package/dist/shell/ast.js.map +1 -0
  46. package/dist/shell/builtins.d.ts +14 -0
  47. package/dist/shell/builtins.d.ts.map +1 -1
  48. package/dist/shell/builtins.js +87 -7
  49. package/dist/shell/builtins.js.map +1 -1
  50. package/dist/shell/compound.d.ts +30 -0
  51. package/dist/shell/compound.d.ts.map +1 -0
  52. package/dist/shell/compound.js +73 -0
  53. package/dist/shell/compound.js.map +1 -0
  54. package/dist/shell/exec.d.ts +27 -6
  55. package/dist/shell/exec.d.ts.map +1 -1
  56. package/dist/shell/exec.js +127 -41
  57. package/dist/shell/exec.js.map +1 -1
  58. package/dist/shell/expand.d.ts +5 -1
  59. package/dist/shell/expand.d.ts.map +1 -1
  60. package/dist/shell/expand.js +7 -5
  61. package/dist/shell/expand.js.map +1 -1
  62. package/dist/shell/lineReader.d.ts +9 -0
  63. package/dist/shell/lineReader.d.ts.map +1 -0
  64. package/dist/shell/lineReader.js +42 -0
  65. package/dist/shell/lineReader.js.map +1 -0
  66. package/dist/shell/packageManagers.d.ts +20 -0
  67. package/dist/shell/packageManagers.d.ts.map +1 -0
  68. package/dist/shell/packageManagers.js +74 -0
  69. package/dist/shell/packageManagers.js.map +1 -0
  70. package/dist/shell/parse.d.ts +16 -66
  71. package/dist/shell/parse.d.ts.map +1 -1
  72. package/dist/shell/parse.js +244 -243
  73. package/dist/shell/parse.js.map +1 -1
  74. package/dist/shell/program.d.ts.map +1 -1
  75. package/dist/shell/program.js +2 -1
  76. package/dist/shell/program.js.map +1 -1
  77. package/dist/shell/testCommand.d.ts +9 -0
  78. package/dist/shell/testCommand.d.ts.map +1 -0
  79. package/dist/shell/testCommand.js +105 -0
  80. package/dist/shell/testCommand.js.map +1 -0
  81. package/dist/shell/tokenize.d.ts +25 -0
  82. package/dist/shell/tokenize.d.ts.map +1 -0
  83. package/dist/shell/tokenize.js +279 -0
  84. package/dist/shell/tokenize.js.map +1 -0
  85. package/dist/system/cookieJar.d.ts +59 -0
  86. package/dist/system/cookieJar.d.ts.map +1 -0
  87. package/dist/system/cookieJar.js +187 -0
  88. package/dist/system/cookieJar.js.map +1 -0
  89. package/dist/system/httpStream.d.ts +40 -0
  90. package/dist/system/httpStream.d.ts.map +1 -0
  91. package/dist/system/httpStream.js +156 -0
  92. package/dist/system/httpStream.js.map +1 -0
  93. package/dist/system/index.d.ts +9 -1
  94. package/dist/system/index.d.ts.map +1 -1
  95. package/dist/system/index.js +4 -0
  96. package/dist/system/index.js.map +1 -1
  97. package/dist/system/kernel.d.ts +10 -2
  98. package/dist/system/kernel.d.ts.map +1 -1
  99. package/dist/system/kernel.js +12 -3
  100. package/dist/system/kernel.js.map +1 -1
  101. package/dist/system/ports.d.ts +9 -4
  102. package/dist/system/ports.d.ts.map +1 -1
  103. package/dist/system/ports.js +21 -2
  104. package/dist/system/ports.js.map +1 -1
  105. package/dist/system/programs.d.ts +1 -1
  106. package/dist/system/programs.d.ts.map +1 -1
  107. package/dist/system/programs.js +2 -0
  108. package/dist/system/programs.js.map +1 -1
  109. package/dist/system/types.d.ts +36 -6
  110. package/dist/system/types.d.ts.map +1 -1
  111. package/dist/system/webSocketClient.d.ts +37 -0
  112. package/dist/system/webSocketClient.d.ts.map +1 -0
  113. package/dist/system/webSocketClient.js +152 -0
  114. package/dist/system/webSocketClient.js.map +1 -0
  115. package/dist/system/webSocketFrames.d.ts +39 -0
  116. package/dist/system/webSocketFrames.d.ts.map +1 -0
  117. package/dist/system/webSocketFrames.js +117 -0
  118. package/dist/system/webSocketFrames.js.map +1 -0
  119. package/package.json +1 -1
  120. package/src/archive.ts +8 -4
  121. package/src/html.ts +5 -3
  122. package/src/index.ts +2 -1
  123. package/src/node/http/client.ts +69 -6
  124. package/src/node/http/index.ts +2 -1
  125. package/src/node/http/messages.ts +150 -0
  126. package/src/node/http/server.ts +37 -202
  127. package/src/node/http/serverResponse.ts +98 -0
  128. package/src/node/http/upgradeSocket.ts +79 -0
  129. package/src/node/runtime.ts +2 -2
  130. package/src/previewBridge.ts +194 -0
  131. package/src/shell/ast.ts +85 -0
  132. package/src/shell/builtins.ts +97 -7
  133. package/src/shell/compound.ts +89 -0
  134. package/src/shell/exec.ts +128 -40
  135. package/src/shell/expand.ts +8 -6
  136. package/src/shell/lineReader.ts +46 -0
  137. package/src/shell/packageManagers.ts +75 -0
  138. package/src/shell/parse.ts +233 -236
  139. package/src/shell/program.ts +2 -1
  140. package/src/shell/testCommand.ts +105 -0
  141. package/src/shell/tokenize.ts +257 -0
  142. package/src/system/cookieJar.ts +187 -0
  143. package/src/system/httpStream.ts +168 -0
  144. package/src/system/index.ts +24 -1
  145. package/src/system/kernel.ts +16 -4
  146. package/src/system/ports.ts +23 -4
  147. package/src/system/programs.ts +3 -1
  148. package/src/system/types.ts +40 -6
  149. package/src/system/webSocketClient.ts +177 -0
  150. package/src/system/webSocketFrames.ts +118 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * ServerResponse, streamed: the head goes out on `writeHead`/`flushHeaders` or
3
+ * the first write, each `write` is a body chunk, `end` ends the body. So
4
+ * Server-Sent Events and chunked progress reach the client as they happen. A
5
+ * client that hangs up mid-response is seen the way Node reports it.
6
+ */
7
+ import type { HttpResponseHead } from '../../system/types.js';
8
+ import { STATUS_CODES } from './status.js';
9
+ import { FakeSocket, IncomingMessage, OutgoingHeaders } from './messages.js';
10
+
11
+ /** Where a response goes as it is written. */
12
+ export interface ResponseSink {
13
+ head(head: HttpResponseHead): void;
14
+ chunk(data: Uint8Array): void;
15
+ end(): void;
16
+ }
17
+
18
+ /** Statuses that never carry a body. */
19
+ const NO_BODY = new Set([101, 204, 304]);
20
+
21
+ export class ServerResponse extends OutgoingHeaders {
22
+ statusCode = 200;
23
+ statusMessage = '';
24
+ req: IncomingMessage;
25
+ socket: FakeSocket;
26
+ connection: FakeSocket;
27
+ chunkedEncoding = false;
28
+ shouldKeepAlive = false;
29
+ private headWritten = false;
30
+ private hungUp = false;
31
+
32
+ constructor(req: IncomingMessage, private readonly sink: ResponseSink) {
33
+ super({ decodeStrings: true });
34
+ this.req = req;
35
+ this.socket = req.socket;
36
+ this.connection = req.socket;
37
+ }
38
+
39
+ get finished(): boolean {
40
+ return this.writableEnded;
41
+ }
42
+
43
+ writeHead(status: number, message?: string | Record<string, unknown> | unknown[], headers?: Record<string, unknown> | unknown[]): this {
44
+ if (typeof message !== 'string') [headers, message] = [message, undefined];
45
+ this.statusCode = status;
46
+ if (message) this.statusMessage = message;
47
+ if (Array.isArray(headers)) {
48
+ for (let i = 0; i + 1 < headers.length; i += 2) this.setHeader(String(headers[i]), headers[i + 1] as string);
49
+ } else if (headers) {
50
+ for (const [k, v] of Object.entries(headers)) if (v !== undefined) this.setHeader(k, v as string);
51
+ }
52
+ this.sendHead();
53
+ return this;
54
+ }
55
+ override flushHeaders(): void {
56
+ this.sendHead();
57
+ }
58
+ writeContinue(): void {}
59
+ writeProcessing(): void {}
60
+ writeEarlyHints(): void {}
61
+ addTrailers(): void {}
62
+ assignSocket(): void {}
63
+ detachSocket(): void {}
64
+
65
+ override _write(chunk: Uint8Array, _encoding: string, callback: () => void): void {
66
+ this.sendHead();
67
+ if (!this.hungUp && this.req.method !== 'HEAD' && !NO_BODY.has(this.statusCode)) this.sink.chunk(new Uint8Array(chunk));
68
+ callback();
69
+ }
70
+
71
+ override _final(callback: () => void): void {
72
+ this.sendHead();
73
+ if (!this.hungUp) this.sink.end();
74
+ callback();
75
+ }
76
+
77
+ /** The client went away (closed its EventSource, navigated off): `aborted`/`close` on the request, `close` here; later writes are dropped. */
78
+ _hangUp(): void {
79
+ if (this.hungUp || this.writableFinished) return;
80
+ this.hungUp = true;
81
+ this._writableState.emitClose = false;
82
+ this.req.aborted = true;
83
+ this.req.emit('aborted');
84
+ this.req.emit('close');
85
+ this.socket.destroyed = true;
86
+ this.socket.emit('close', false);
87
+ this.emit('close');
88
+ }
89
+
90
+ private sendHead(): void {
91
+ this.headersSent = true;
92
+ if (this.headWritten) return;
93
+ this.headWritten = true;
94
+ const headers: Record<string, string | string[]> = {};
95
+ for (const { name, value } of this._headers.values()) headers[name] = value;
96
+ this.sink.head({ status: this.statusCode, statusText: this.statusMessage || STATUS_CODES[this.statusCode] || '', headers });
97
+ }
98
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The socket an `http.Server` hands its `upgrade` listeners: a real Duplex
3
+ * over the upgraded connection's byte pipe, with the slice of net.Socket that
4
+ * WebSocket servers touch (`ws` writes its `101` reply and frames with
5
+ * `write(data, cb)`, corks, pauses, reads `_readableState`, and calls
6
+ * `setNoDelay`/`setTimeout`). Raw bytes only — framing is the package's job.
7
+ */
8
+ import { Buffer } from '../buffer.js';
9
+ import { Duplex } from '../stream/index.js';
10
+ import type { ByteChannel } from '../../system/types.js';
11
+
12
+ type Callback = (error?: Error | null) => void;
13
+
14
+ export class UpgradeSocket extends Duplex {
15
+ remoteAddress = '127.0.0.1';
16
+ remotePort = 50000 + Math.floor(Math.random() * 10000);
17
+ remoteFamily = 'IPv4';
18
+ localAddress = '127.0.0.1';
19
+ localPort: number;
20
+ encrypted = false;
21
+ connecting = false;
22
+ bytesRead = 0;
23
+ bytesWritten = 0;
24
+ private release?: () => void;
25
+
26
+ /** `hold` keeps the process alive while the connection is open, as a TCP socket would. */
27
+ constructor(private readonly channel: ByteChannel, port: number, hold?: () => () => void) {
28
+ super({ allowHalfOpen: false });
29
+ this.localPort = port;
30
+ this.release = hold?.();
31
+ channel.onData((data) => {
32
+ this.bytesRead += data.byteLength;
33
+ this.push(Buffer.from(data.buffer as ArrayBuffer, data.byteOffset, data.byteLength));
34
+ });
35
+ channel.onClose(() => {
36
+ this.push(null);
37
+ this.end();
38
+ });
39
+ this.once('close', () => {
40
+ this.release?.();
41
+ this.release = undefined;
42
+ });
43
+ }
44
+
45
+ override _read(): void {
46
+ // bytes are pushed as they arrive
47
+ }
48
+
49
+ _write(chunk: Uint8Array, _encoding: string, callback: Callback): void {
50
+ this.bytesWritten += chunk.byteLength;
51
+ this.channel.send(chunk);
52
+ callback();
53
+ }
54
+
55
+ _final(callback: Callback): void {
56
+ this.channel.close();
57
+ this.push(null);
58
+ callback();
59
+ }
60
+
61
+ override _destroy(error: Error | null, callback: Callback): void {
62
+ this.channel.close();
63
+ callback(error);
64
+ }
65
+
66
+ setTimeout(_ms: number, cb?: () => void): this {
67
+ if (cb) this.once('timeout', cb);
68
+ return this;
69
+ }
70
+ setNoDelay(): this { return this; }
71
+ setKeepAlive(): this { return this; }
72
+ address() { return { address: this.localAddress, family: 'IPv4', port: this.localPort }; }
73
+ ref(): this { return this; }
74
+ unref(): this {
75
+ this.release?.();
76
+ this.release = undefined;
77
+ return this;
78
+ }
79
+ }
@@ -57,8 +57,8 @@ export async function startNode(options: NodeOptions): Promise<NodeInstance> {
57
57
  // Servers die with the process: wrap listen so exit can close what is left open.
58
58
  const processSystem: System = {
59
59
  ...system,
60
- listen(port, handler) {
61
- const registration = system.listen(port, handler);
60
+ listen(port, handler, upgrade) {
61
+ const registration = system.listen(port, handler, upgrade);
62
62
  const tracked = {
63
63
  port: registration.port,
64
64
  close() {
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Scripts injected into preview pages that let them reach virtual servers the
3
+ * way a page reaches a real one, where a service worker cannot help:
4
+ *
5
+ * • WebSockets. A service worker never sees a WebSocket, so the page's
6
+ * `WebSocket` is replaced by one that, for URLs naming a virtual server
7
+ * (`ws://localhost:3000/…`, `/__bfwc/<id>/__port/3000/…`, or a path on the
8
+ * page's own host from a page that server served), opens a MessageChannel
9
+ * to the runtime's host page (`parent`, else `opener`). The host connects
10
+ * to the server and relays frames both ways. Every other URL gets the real
11
+ * `WebSocket`, untouched.
12
+ * • `document.cookie` on pages a virtual server served. The host keeps those
13
+ * cookies (a service worker cannot set `Set-Cookie`); this mirrors the
14
+ * non-HttpOnly ones into `document.cookie` and sends assignments back.
15
+ *
16
+ * Messages on a socket's channel — page → host: `{ kind: 'send', data }`,
17
+ * `{ kind: 'close', code, reason }`; host → page: `{ kind: 'open', protocol }`,
18
+ * `{ kind: 'message', data }` (string or ArrayBuffer), `{ kind: 'close', code, reason, wasClean }`.
19
+ * On the cookie channel — page → host `{ kind: 'set-cookie', value }`, host → page `{ kind: 'cookies', value }`.
20
+ */
21
+
22
+ /** page → host, with a MessagePort: "connect a WebSocket to `port` at `path`". */
23
+ export const SOCKET_MESSAGE_TYPE = 'bfwc:socket';
24
+ /** page → host, with a MessagePort: "keep my `document.cookie` in sync" (pages served by a virtual server). */
25
+ export const COOKIE_MESSAGE_TYPE = 'bfwc:cookies';
26
+ /** How long a page waits for the host to answer a socket before failing it (code 1006). */
27
+ export const SOCKET_CONNECT_TIMEOUT_MS = 15_000;
28
+
29
+ /** The `WebSocket` replacement; runs in every preview page (dev-server documents and virtual-server HTML). */
30
+ export function previewSocketScript(): string {
31
+ return `<script>(function(){
32
+ var Native = window.WebSocket;
33
+ if (!Native || Native.__bfwc) return;
34
+ var runtime = window.parent !== window ? window.parent : window.opener;
35
+ var m = /^(.*\\/__bfwc\\/[A-Za-z0-9_-]+\\/)(?:__port\\/(\\d{1,5})(?:\\/|$))?/.exec(location.pathname);
36
+ if (!runtime || !m || typeof MessageChannel === 'undefined') return;
37
+ var base = m[1], ownPort = m[2] ? Number(m[2]) : 0;
38
+ var LOCAL = /^(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\])$/i;
39
+ function target(url) {
40
+ var u;
41
+ try { u = new URL(String(url), location.href); } catch (_) { return null; }
42
+ if (!/^(wss?|https?):$/.test(u.protocol)) return null;
43
+ if (u.host === location.host) {
44
+ if (u.pathname.indexOf(base) === 0) {
45
+ var p = /^__port\\/(\\d{1,5})(\\/.*)?$/.exec(u.pathname.slice(base.length));
46
+ return p ? { port: Number(p[1]), path: (p[2] || '/') + u.search } : null;
47
+ }
48
+ return ownPort ? { port: ownPort, path: u.pathname + u.search } : null;
49
+ }
50
+ if (LOCAL.test(u.hostname)) return { port: Number(u.port || (/^(wss|https):$/.test(u.protocol) ? 443 : 80)), path: u.pathname + u.search };
51
+ return null;
52
+ }
53
+ var CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3;
54
+ class VirtualWebSocket extends EventTarget {
55
+ constructor(url, protocols) {
56
+ var t = target(url);
57
+ if (!t) return protocols === undefined ? new Native(url) : new Native(url, protocols);
58
+ super();
59
+ var self = this, channel = new MessageChannel();
60
+ this._url = new URL(String(url), location.href).href.replace(/^http/, 'ws');
61
+ this._state = CONNECTING;
62
+ this._protocol = '';
63
+ this._binaryType = 'blob';
64
+ this._queue = Promise.resolve();
65
+ this._port = channel.port1;
66
+ this._port.onmessage = function (e) { self._receive(e.data); };
67
+ this._timer = setTimeout(function () { self._receive({ kind: 'close', code: 1006, reason: '', wasClean: false }); }, ${SOCKET_CONNECT_TIMEOUT_MS});
68
+ var list = protocols === undefined ? [] : typeof protocols === 'string' ? [protocols] : Array.from(protocols, String);
69
+ runtime.postMessage({ type: ${JSON.stringify(SOCKET_MESSAGE_TYPE)}, href: location.href, port: t.port, path: t.path, protocols: list }, '*', [channel.port2]);
70
+ }
71
+ get url() { return this._url; }
72
+ get readyState() { return this._state; }
73
+ get protocol() { return this._protocol; }
74
+ get extensions() { return ''; }
75
+ get bufferedAmount() { return 0; }
76
+ get binaryType() { return this._binaryType; }
77
+ set binaryType(v) { if (v === 'blob' || v === 'arraybuffer') this._binaryType = v; }
78
+ send(data) {
79
+ if (this._state === CONNECTING) throw new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", 'InvalidStateError');
80
+ if (this._state !== OPEN) return;
81
+ var port = this._port;
82
+ var value = typeof data === 'string' ? data
83
+ : data instanceof Blob ? data.arrayBuffer()
84
+ : ArrayBuffer.isView(data) ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
85
+ : data instanceof ArrayBuffer ? data.slice(0) : String(data);
86
+ this._queue = this._queue.then(function () { return value; }).then(function (v) { port.postMessage({ kind: 'send', data: v }); });
87
+ }
88
+ close(code, reason) {
89
+ if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) throw new DOMException("Failed to execute 'close' on 'WebSocket': The code must be either 1000, or between 3000 and 4999.", 'InvalidAccessError');
90
+ if (this._state === CLOSING || this._state === CLOSED) return;
91
+ this._state = CLOSING;
92
+ var port = this._port;
93
+ this._queue.then(function () { port.postMessage({ kind: 'close', code: code, reason: reason === undefined ? '' : String(reason) }); });
94
+ }
95
+ _receive(m) {
96
+ if (!m || this._state === CLOSED) return;
97
+ if (m.kind === 'open' && this._state === CONNECTING) {
98
+ clearTimeout(this._timer);
99
+ this._state = OPEN;
100
+ this._protocol = m.protocol || '';
101
+ this._emit(new Event('open'));
102
+ } else if (m.kind === 'message' && this._state !== CONNECTING) {
103
+ var d = m.data;
104
+ if (typeof d !== 'string' && this._binaryType === 'blob') d = new Blob([d]);
105
+ this._emit(new MessageEvent('message', { data: d, origin: new URL(this._url).origin }));
106
+ } else if (m.kind === 'close') {
107
+ clearTimeout(this._timer);
108
+ this._state = CLOSED;
109
+ this._port.close();
110
+ if (!m.wasClean) this._emit(new Event('error'));
111
+ this._emit(new CloseEvent('close', { code: m.code, reason: m.reason || '', wasClean: !!m.wasClean }));
112
+ }
113
+ }
114
+ _emit(event) {
115
+ var handler = this['on' + event.type];
116
+ if (typeof handler === 'function') handler.call(this, event);
117
+ this.dispatchEvent(event);
118
+ }
119
+ }
120
+ [['CONNECTING', CONNECTING], ['OPEN', OPEN], ['CLOSING', CLOSING], ['CLOSED', CLOSED]].forEach(function (c) {
121
+ Object.defineProperty(VirtualWebSocket, c[0], { value: c[1] });
122
+ Object.defineProperty(VirtualWebSocket.prototype, c[0], { value: c[1] });
123
+ });
124
+ Object.defineProperty(VirtualWebSocket, Symbol.hasInstance, { value: function (o) { return o instanceof Native || Function.prototype[Symbol.hasInstance].call(VirtualWebSocket, o); } });
125
+ VirtualWebSocket.__bfwc = true;
126
+ window.WebSocket = VirtualWebSocket;
127
+ })();</script>`;
128
+ }
129
+
130
+ /** `JSON.stringify`, safe inside an inline script. */
131
+ const scriptLiteral = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
132
+
133
+ /**
134
+ * `document.cookie` for a page a virtual server served: starts from the host's
135
+ * jar (`initial`: the non-HttpOnly cookies for this page's path), stays in sync
136
+ * over a channel, and sends assignments to the host's jar.
137
+ */
138
+ export function previewCookieScript(initial: string): string {
139
+ return `<script>(function(){
140
+ var runtime = window.parent !== window ? window.parent : window.opener;
141
+ var cookies = ${scriptLiteral(initial)};
142
+ var port = null;
143
+ if (runtime && typeof MessageChannel !== 'undefined') {
144
+ var channel = new MessageChannel();
145
+ port = channel.port1;
146
+ port.onmessage = function (e) { if (e.data && e.data.kind === 'cookies') cookies = String(e.data.value); };
147
+ runtime.postMessage({ type: ${JSON.stringify(COOKIE_MESSAGE_TYPE)}, href: location.href }, '*', [channel.port2]);
148
+ }
149
+ function apply(value) {
150
+ var parts = value.split(';'), pair = parts[0], eq = pair.indexOf('=');
151
+ if (eq < 0) return;
152
+ var name = pair.slice(0, eq).trim(), remove = false;
153
+ if (!name) return;
154
+ for (var i = 1; i < parts.length; i++) {
155
+ var a = parts[i].trim(), k = a.split('=')[0].trim().toLowerCase(), v = a.slice(a.indexOf('=') + 1).trim();
156
+ if (k === 'httponly') return;
157
+ if (k === 'max-age' && /^-?\\d+$/.test(v) && Number(v) <= 0) remove = true;
158
+ if (k === 'expires' && Date.parse(v) <= Date.now()) remove = true;
159
+ }
160
+ var list = cookies ? cookies.split('; ').filter(function (c) { return c.slice(0, c.indexOf('=')) !== name; }) : [];
161
+ if (!remove) list.push(name + '=' + pair.slice(eq + 1).trim());
162
+ cookies = list.join('; ');
163
+ }
164
+ try {
165
+ Object.defineProperty(document, 'cookie', {
166
+ configurable: true,
167
+ get: function () { return cookies; },
168
+ set: function (value) { value = String(value); apply(value); if (port) port.postMessage({ kind: 'set-cookie', value: value }); },
169
+ });
170
+ } catch (_) {}
171
+ })();</script>`;
172
+ }
173
+
174
+ const HEAD_OPEN = /<head(?:\s[^>]*)?>/i;
175
+ const HTML_OPEN = /<html(?:\s[^>]*)?>/i;
176
+ const DOCTYPE = /<!doctype[^>]*>/i;
177
+ /** Content past which `<head>` can no longer appear. */
178
+ const BODY_STARTED = /<(?:body|script|link|meta|style|title|div|p)[\s>/]/i;
179
+
180
+ /**
181
+ * Where to inject the bridge into a streamed document, from its first bytes:
182
+ * just after `<head …>`, else after `<html …>` or the doctype (never before the
183
+ * doctype, which would switch the page to quirks mode), else the start.
184
+ * Undefined while the prefix is too short to tell; pass `complete` at the end of the body.
185
+ */
186
+ export function bridgeInsertionPoint(prefix: string, complete = false): number | undefined {
187
+ const head = HEAD_OPEN.exec(prefix);
188
+ if (head) return head.index + head[0].length;
189
+ if (!complete && !BODY_STARTED.test(prefix) && prefix.length < 4096) return undefined;
190
+ const html = HTML_OPEN.exec(prefix);
191
+ if (html) return html.index + html[0].length;
192
+ const doctype = DOCTYPE.exec(prefix);
193
+ return doctype ? doctype.index + doctype[0].length : 0;
194
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The shell's syntax tree. Words keep their quoting as segments, because
3
+ * expansion (variables, `$(…)`, `~`, globs, field splitting) happens at run
4
+ * time and depends on it.
5
+ */
6
+
7
+ export type Segment =
8
+ | { kind: 'text'; value: string; quoted: boolean }
9
+ | { kind: 'var'; name: string; quoted: boolean; op?: ':-' | '-' | ':=' | ':+'; arg?: Word }
10
+ | { kind: 'cmd'; source: string; quoted: boolean }
11
+ | { kind: 'tilde' };
12
+ export type Word = Segment[];
13
+
14
+ export type RedirectOp = '>' | '>>' | '<' | '2>' | '2>>' | '&>' | '2>&1' | '>&2' | '<<' | '<<-';
15
+ export interface Redirect {
16
+ op: RedirectOp;
17
+ target?: Word;
18
+ /** A here-document's body; expanded like a double-quoted string unless the delimiter was quoted. */
19
+ heredoc?: { body: Word };
20
+ }
21
+
22
+ export interface SimpleCommand {
23
+ type: 'simple';
24
+ assignments: Array<{ name: string; value: Word }>;
25
+ words: Word[];
26
+ redirects: Redirect[];
27
+ }
28
+
29
+ export interface IfCommand {
30
+ type: 'if';
31
+ clauses: Array<{ condition: Script; body: Script }>;
32
+ otherwise?: Script;
33
+ redirects: Redirect[];
34
+ }
35
+
36
+ export interface LoopCommand {
37
+ type: 'while' | 'until';
38
+ condition: Script;
39
+ body: Script;
40
+ redirects: Redirect[];
41
+ }
42
+
43
+ export interface ForCommand {
44
+ type: 'for';
45
+ name: string;
46
+ /** Absent: iterate the positional parameters (`for x; do …`). */
47
+ items?: Word[];
48
+ body: Script;
49
+ redirects: Redirect[];
50
+ }
51
+
52
+ export interface CaseCommand {
53
+ type: 'case';
54
+ subject: Word;
55
+ arms: Array<{ patterns: Word[]; body: Script }>;
56
+ redirects: Redirect[];
57
+ }
58
+
59
+ /** `{ list; }` runs in this shell; `( list )` in a copy of it. */
60
+ export interface GroupCommand {
61
+ type: 'group' | 'subshell';
62
+ body: Script;
63
+ redirects: Redirect[];
64
+ }
65
+
66
+ export interface FunctionDefinition {
67
+ type: 'function';
68
+ name: string;
69
+ body: CompoundCommand;
70
+ redirects: Redirect[];
71
+ }
72
+
73
+ export type CompoundCommand = IfCommand | LoopCommand | ForCommand | CaseCommand | GroupCommand;
74
+ export type Command = SimpleCommand | CompoundCommand | FunctionDefinition;
75
+
76
+ export interface Pipeline {
77
+ negate: boolean;
78
+ commands: Command[];
79
+ }
80
+ export interface AndOrList {
81
+ first: Pipeline;
82
+ rest: Array<{ op: '&&' | '||'; pipeline: Pipeline }>;
83
+ background: boolean;
84
+ }
85
+ export type Script = AndOrList[];
@@ -8,6 +8,15 @@ import type { CommandContext, Command } from './context.js';
8
8
  import { resolvePath } from './context.js';
9
9
  import { cat, cp, ls, mkdir, mv, pwd, rm, touch } from './fileCommands.js';
10
10
  import { basename, clear, dirname, echo, env, falseCommand, grep, head, printf, sleep, tail, trueCommand, wc } from './textCommands.js';
11
+ import { bracket, test } from './testCommand.js';
12
+ import { readLine } from './lineReader.js';
13
+ import type { Flow } from './compound.js';
14
+
15
+ /** A command started with `&`. */
16
+ export interface BackgroundJob {
17
+ id: number;
18
+ done: Promise<number>;
19
+ }
11
20
 
12
21
  /** The slice of the shell a builtin may change. */
13
22
  export interface ShellState {
@@ -17,6 +26,14 @@ export interface ShellState {
17
26
  lastStatus: number;
18
27
  /** Set by `exit`: the shell stops after the current command. */
19
28
  exitRequested?: number;
29
+ /** Set by `break`, `continue` and `return`; the loop or function it targets clears it. */
30
+ flow?: Flow;
31
+ /** `$1…`, `$@`; a function call swaps them for its arguments. */
32
+ positional: string[];
33
+ readonly functions: ReadonlyMap<string, unknown>;
34
+ readonly jobs: ReadonlyMap<number, BackgroundJob>;
35
+ /** One frame per running function (for `local`). */
36
+ readonly localFrames: Array<Map<string, string | undefined>>;
20
37
  /** Run a script in this shell (for `source`). */
21
38
  run(source: string): Promise<number>;
22
39
  }
@@ -25,21 +42,25 @@ export type Builtin = (ctx: CommandContext, shell: ShellState) => Promise<number
25
42
 
26
43
  /** Coreutils: plain commands, also spawnable as standalone programs. */
27
44
  export const COREUTILS: Record<string, Command> = {
28
- ls, cat, mkdir, rm, cp, mv, touch, pwd, echo, printf, head, tail, grep, wc, env, clear, sleep, basename, dirname,
45
+ ls, cat, mkdir, rm, cp, mv, touch, pwd, echo, printf, head, tail, grep, wc, env, clear, sleep, basename, dirname, test,
46
+ '[': bracket,
29
47
  true: trueCommand,
30
48
  false: falseCommand,
31
49
  };
32
50
 
33
51
  /** Programs the runtime provides besides coreutils (reported by `which`/`type`). */
34
- export const SYSTEM_PROGRAMS = ['node', 'npm', 'npx', 'bfsh', 'sh', 'bash'];
52
+ export const SYSTEM_PROGRAMS = ['node', 'npm', 'npx', 'pnpm', 'yarn', 'bfsh', 'sh', 'bash'];
35
53
 
36
54
  const HELP = `bfsh — BuilderForce WebContainers shell
37
55
 
38
- Builtins: cd, pwd, export, unset, exit, source, history, type, which, help
39
- Files: ls [-la], cat, mkdir [-p], rm [-rf], cp [-r], mv, touch
56
+ Builtins: cd, pwd, export, unset, exit, source, history, type, which, help,
57
+ read, local, shift, return, break, continue, jobs, wait
58
+ Files: ls [-la], cat, mkdir [-p], rm [-rf], cp [-r], mv, touch, test / [ … ]
40
59
  Text: echo, printf, head, tail, grep, wc, env, clear, sleep, basename, dirname
41
- Node: node <file>, npm install|ci|run|start|test, npx <bin>
60
+ Node: node <file>, npm install|ci|run|start|test, npx <bin>, pnpm, yarn
42
61
  Syntax: 'quotes' "\$VARS" a && b || c; a | b > out.txt >> log 2>&1 2> err; FOO=bar cmd; $(cmd); *.js
62
+ if …; then …; elif …; else …; fi while|until …; do …; done for x in …; do …; done
63
+ case $x in a|b) …;; *) …;; esac name() { …; } { …; } ( … ) cat <<EOF … EOF
43
64
 
44
65
  `;
45
66
 
@@ -116,10 +137,11 @@ export const BUILTINS: Record<string, Builtin> = {
116
137
  }
117
138
  return status;
118
139
  },
119
- async type(ctx) {
140
+ async type(ctx, shell) {
120
141
  let status = 0;
121
142
  for (const name of ctx.args) {
122
- if (name in BUILTINS) ctx.io.stdout(`${name} is a shell builtin\n`);
143
+ if (shell.functions.has(name)) ctx.io.stdout(`${name} is a function\n`);
144
+ else if (name in BUILTINS) ctx.io.stdout(`${name} is a shell builtin\n`);
123
145
  else if (name in COREUTILS || SYSTEM_PROGRAMS.includes(name)) ctx.io.stdout(`${name} is /usr/local/bin/${name}\n`);
124
146
  else {
125
147
  const file = findExecutable(ctx.fs, name, ctx.cwd, ctx.env);
@@ -135,7 +157,75 @@ export const BUILTINS: Record<string, Builtin> = {
135
157
  async set() {
136
158
  return 0; // options (-e, -x) are accepted and ignored
137
159
  },
160
+ async break(ctx, shell) {
161
+ return loopControl(ctx, shell, 'break');
162
+ },
163
+ async continue(ctx, shell) {
164
+ return loopControl(ctx, shell, 'continue');
165
+ },
166
+ async return(ctx, shell) {
167
+ if (!shell.localFrames.length) {
168
+ ctx.io.stderr('return: can only `return\' from a function or sourced script\n');
169
+ return 1;
170
+ }
171
+ const status = ctx.args[0] === undefined ? shell.lastStatus : Number(ctx.args[0]) & 255;
172
+ shell.flow = { kind: 'return', status };
173
+ return status;
174
+ },
175
+ async local(ctx, shell) {
176
+ const frame = shell.localFrames[shell.localFrames.length - 1];
177
+ if (!frame) {
178
+ ctx.io.stderr('local: can only be used in a function\n');
179
+ return 1;
180
+ }
181
+ for (const arg of ctx.args) {
182
+ const eq = arg.indexOf('=');
183
+ const name = eq > 0 ? arg.slice(0, eq) : arg;
184
+ if (!frame.has(name)) frame.set(name, shell.env[name]);
185
+ shell.env[name] = eq > 0 ? arg.slice(eq + 1) : '';
186
+ }
187
+ return 0;
188
+ },
189
+ async shift(ctx, shell) {
190
+ const count = ctx.args[0] === undefined ? 1 : Number(ctx.args[0]);
191
+ if (!Number.isInteger(count) || count < 0 || count > shell.positional.length) return 1;
192
+ shell.positional = shell.positional.slice(count);
193
+ return 0;
194
+ },
195
+ async read(ctx, shell) {
196
+ const raw = ctx.args.includes('-r');
197
+ const names = ctx.args.filter((a) => !a.startsWith('-'));
198
+ let line = await readLine(ctx.io.stdin, ctx.signal);
199
+ if (line === null) return 1;
200
+ if (!raw) line = line.replace(/\\(.)/g, '$1');
201
+ const targets = names.length ? names : ['REPLY'];
202
+ const fields = names.length ? line.trim().split(/\s+/) : [line];
203
+ targets.forEach((name, i) => {
204
+ shell.env[name] = i === targets.length - 1 ? fields.slice(i).join(' ') : fields[i] ?? '';
205
+ });
206
+ return 0;
207
+ },
208
+ async jobs(ctx, shell) {
209
+ for (const job of shell.jobs.values()) ctx.io.stdout(`[${job.id}] Running\n`);
210
+ return 0;
211
+ },
212
+ async wait(ctx, shell) {
213
+ const ids = ctx.args.map((a) => Number(a.replace(/^%/, '')));
214
+ const jobs = ids.length ? ids.map((id) => shell.jobs.get(id)).filter((j): j is BackgroundJob => !!j) : [...shell.jobs.values()];
215
+ const statuses = await Promise.all(jobs.map((job) => job.done));
216
+ return statuses.length ? statuses[statuses.length - 1]! : 0;
217
+ },
138
218
  };
219
+
220
+ function loopControl(ctx: CommandContext, shell: ShellState, kind: 'break' | 'continue'): number {
221
+ const levels = ctx.args[0] === undefined ? 1 : Number(ctx.args[0]);
222
+ if (!Number.isInteger(levels) || levels < 1) {
223
+ ctx.io.stderr(`${kind}: ${ctx.args[0]}: loop count out of range\n`);
224
+ return 1;
225
+ }
226
+ shell.flow = { kind, levels };
227
+ return 0;
228
+ }
139
229
  BUILTINS['.'] = BUILTINS.source!;
140
230
  BUILTINS[':'] = async () => 0;
141
231