@seanhogg/builderforce-webcontainers-core 2026.10.6 → 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 (184) hide show
  1. package/dist/archive.d.ts +13 -0
  2. package/dist/archive.d.ts.map +1 -0
  3. package/dist/archive.js +53 -0
  4. package/dist/archive.js.map +1 -0
  5. package/dist/devServer.d.ts +5 -0
  6. package/dist/devServer.d.ts.map +1 -1
  7. package/dist/devServer.js +11 -2
  8. package/dist/devServer.js.map +1 -1
  9. package/dist/html.d.ts +12 -3
  10. package/dist/html.d.ts.map +1 -1
  11. package/dist/html.js +31 -5
  12. package/dist/html.js.map +1 -1
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/installer/index.d.ts +2 -0
  18. package/dist/installer/index.d.ts.map +1 -1
  19. package/dist/installer/index.js +1 -0
  20. package/dist/installer/index.js.map +1 -1
  21. package/dist/installer/install.d.ts +3 -0
  22. package/dist/installer/install.d.ts.map +1 -1
  23. package/dist/installer/install.js +1 -1
  24. package/dist/installer/install.js.map +1 -1
  25. package/dist/installer/npmrc.d.ts +26 -0
  26. package/dist/installer/npmrc.d.ts.map +1 -0
  27. package/dist/installer/npmrc.js +55 -0
  28. package/dist/installer/npmrc.js.map +1 -0
  29. package/dist/installer/registry.d.ts +3 -0
  30. package/dist/installer/registry.d.ts.map +1 -1
  31. package/dist/installer/registry.js +10 -5
  32. package/dist/installer/registry.js.map +1 -1
  33. package/dist/node/http/client.d.ts +3 -1
  34. package/dist/node/http/client.d.ts.map +1 -1
  35. package/dist/node/http/client.js +74 -5
  36. package/dist/node/http/client.js.map +1 -1
  37. package/dist/node/http/index.d.ts.map +1 -1
  38. package/dist/node/http/index.js +2 -1
  39. package/dist/node/http/index.js.map +1 -1
  40. package/dist/node/http/messages.d.ts +87 -0
  41. package/dist/node/http/messages.d.ts.map +1 -0
  42. package/dist/node/http/messages.js +146 -0
  43. package/dist/node/http/messages.js.map +1 -0
  44. package/dist/node/http/server.d.ts +13 -98
  45. package/dist/node/http/server.d.ts.map +1 -1
  46. package/dist/node/http/server.js +34 -202
  47. package/dist/node/http/server.js.map +1 -1
  48. package/dist/node/http/serverResponse.d.ts +42 -0
  49. package/dist/node/http/serverResponse.d.ts.map +1 -0
  50. package/dist/node/http/serverResponse.js +89 -0
  51. package/dist/node/http/serverResponse.js.map +1 -0
  52. package/dist/node/http/upgradeSocket.d.ts +34 -0
  53. package/dist/node/http/upgradeSocket.d.ts.map +1 -0
  54. package/dist/node/http/upgradeSocket.js +73 -0
  55. package/dist/node/http/upgradeSocket.js.map +1 -0
  56. package/dist/node/runtime.js +2 -2
  57. package/dist/node/runtime.js.map +1 -1
  58. package/dist/previewBridge.d.ts +42 -0
  59. package/dist/previewBridge.d.ts.map +1 -0
  60. package/dist/previewBridge.js +192 -0
  61. package/dist/previewBridge.js.map +1 -0
  62. package/dist/shell/ast.d.ts +101 -0
  63. package/dist/shell/ast.d.ts.map +1 -0
  64. package/dist/shell/ast.js +7 -0
  65. package/dist/shell/ast.js.map +1 -0
  66. package/dist/shell/builtins.d.ts +14 -0
  67. package/dist/shell/builtins.d.ts.map +1 -1
  68. package/dist/shell/builtins.js +87 -7
  69. package/dist/shell/builtins.js.map +1 -1
  70. package/dist/shell/compound.d.ts +30 -0
  71. package/dist/shell/compound.d.ts.map +1 -0
  72. package/dist/shell/compound.js +73 -0
  73. package/dist/shell/compound.js.map +1 -0
  74. package/dist/shell/exec.d.ts +27 -6
  75. package/dist/shell/exec.d.ts.map +1 -1
  76. package/dist/shell/exec.js +127 -41
  77. package/dist/shell/exec.js.map +1 -1
  78. package/dist/shell/expand.d.ts +5 -1
  79. package/dist/shell/expand.d.ts.map +1 -1
  80. package/dist/shell/expand.js +7 -5
  81. package/dist/shell/expand.js.map +1 -1
  82. package/dist/shell/lineReader.d.ts +9 -0
  83. package/dist/shell/lineReader.d.ts.map +1 -0
  84. package/dist/shell/lineReader.js +42 -0
  85. package/dist/shell/lineReader.js.map +1 -0
  86. package/dist/shell/npm.d.ts.map +1 -1
  87. package/dist/shell/npm.js +5 -1
  88. package/dist/shell/npm.js.map +1 -1
  89. package/dist/shell/packageManagers.d.ts +20 -0
  90. package/dist/shell/packageManagers.d.ts.map +1 -0
  91. package/dist/shell/packageManagers.js +74 -0
  92. package/dist/shell/packageManagers.js.map +1 -0
  93. package/dist/shell/parse.d.ts +16 -66
  94. package/dist/shell/parse.d.ts.map +1 -1
  95. package/dist/shell/parse.js +244 -243
  96. package/dist/shell/parse.js.map +1 -1
  97. package/dist/shell/program.d.ts.map +1 -1
  98. package/dist/shell/program.js +2 -1
  99. package/dist/shell/program.js.map +1 -1
  100. package/dist/shell/testCommand.d.ts +9 -0
  101. package/dist/shell/testCommand.d.ts.map +1 -0
  102. package/dist/shell/testCommand.js +105 -0
  103. package/dist/shell/testCommand.js.map +1 -0
  104. package/dist/shell/tokenize.d.ts +25 -0
  105. package/dist/shell/tokenize.d.ts.map +1 -0
  106. package/dist/shell/tokenize.js +279 -0
  107. package/dist/shell/tokenize.js.map +1 -0
  108. package/dist/system/cookieJar.d.ts +59 -0
  109. package/dist/system/cookieJar.d.ts.map +1 -0
  110. package/dist/system/cookieJar.js +187 -0
  111. package/dist/system/cookieJar.js.map +1 -0
  112. package/dist/system/httpStream.d.ts +40 -0
  113. package/dist/system/httpStream.d.ts.map +1 -0
  114. package/dist/system/httpStream.js +156 -0
  115. package/dist/system/httpStream.js.map +1 -0
  116. package/dist/system/index.d.ts +9 -1
  117. package/dist/system/index.d.ts.map +1 -1
  118. package/dist/system/index.js +4 -0
  119. package/dist/system/index.js.map +1 -1
  120. package/dist/system/kernel.d.ts +10 -2
  121. package/dist/system/kernel.d.ts.map +1 -1
  122. package/dist/system/kernel.js +12 -3
  123. package/dist/system/kernel.js.map +1 -1
  124. package/dist/system/ports.d.ts +9 -4
  125. package/dist/system/ports.d.ts.map +1 -1
  126. package/dist/system/ports.js +21 -2
  127. package/dist/system/ports.js.map +1 -1
  128. package/dist/system/programs.d.ts +1 -1
  129. package/dist/system/programs.d.ts.map +1 -1
  130. package/dist/system/programs.js +2 -0
  131. package/dist/system/programs.js.map +1 -1
  132. package/dist/system/types.d.ts +36 -6
  133. package/dist/system/types.d.ts.map +1 -1
  134. package/dist/system/webSocketClient.d.ts +37 -0
  135. package/dist/system/webSocketClient.d.ts.map +1 -0
  136. package/dist/system/webSocketClient.js +152 -0
  137. package/dist/system/webSocketClient.js.map +1 -0
  138. package/dist/system/webSocketFrames.d.ts +39 -0
  139. package/dist/system/webSocketFrames.d.ts.map +1 -0
  140. package/dist/system/webSocketFrames.js +117 -0
  141. package/dist/system/webSocketFrames.js.map +1 -0
  142. package/dist/vfs.d.ts +16 -1
  143. package/dist/vfs.d.ts.map +1 -1
  144. package/dist/vfs.js +45 -3
  145. package/dist/vfs.js.map +1 -1
  146. package/package.json +1 -1
  147. package/src/archive.ts +63 -0
  148. package/src/devServer.ts +12 -2
  149. package/src/html.ts +34 -5
  150. package/src/index.ts +5 -1
  151. package/src/installer/index.ts +2 -0
  152. package/src/installer/install.ts +4 -1
  153. package/src/installer/npmrc.ts +69 -0
  154. package/src/installer/registry.ts +12 -5
  155. package/src/node/http/client.ts +69 -6
  156. package/src/node/http/index.ts +2 -1
  157. package/src/node/http/messages.ts +150 -0
  158. package/src/node/http/server.ts +37 -202
  159. package/src/node/http/serverResponse.ts +98 -0
  160. package/src/node/http/upgradeSocket.ts +79 -0
  161. package/src/node/runtime.ts +2 -2
  162. package/src/previewBridge.ts +194 -0
  163. package/src/shell/ast.ts +85 -0
  164. package/src/shell/builtins.ts +97 -7
  165. package/src/shell/compound.ts +89 -0
  166. package/src/shell/exec.ts +128 -40
  167. package/src/shell/expand.ts +8 -6
  168. package/src/shell/lineReader.ts +46 -0
  169. package/src/shell/npm.ts +5 -1
  170. package/src/shell/packageManagers.ts +75 -0
  171. package/src/shell/parse.ts +233 -236
  172. package/src/shell/program.ts +2 -1
  173. package/src/shell/testCommand.ts +105 -0
  174. package/src/shell/tokenize.ts +257 -0
  175. package/src/system/cookieJar.ts +187 -0
  176. package/src/system/httpStream.ts +168 -0
  177. package/src/system/index.ts +24 -1
  178. package/src/system/kernel.ts +16 -4
  179. package/src/system/ports.ts +23 -4
  180. package/src/system/programs.ts +3 -1
  181. package/src/system/types.ts +40 -6
  182. package/src/system/webSocketClient.ts +177 -0
  183. package/src/system/webSocketFrames.ts +118 -0
  184. package/src/vfs.ts +46 -3
@@ -4,7 +4,8 @@
4
4
  * in one process is reachable from another (`fetch('http://localhost:3000')`)
5
5
  * and from the preview service worker (`/__bfwc/<id>/__port/3000/`).
6
6
  */
7
- import type { HttpHandler, HttpRequestData, HttpResponseData } from './types.js';
7
+ import type { ByteChannel, HttpHandler, HttpRequestData, HttpResponseData, HttpStreamResponse, UpgradeHandler } from './types.js';
8
+ import { bufferResponse, createChannelPair } from './httpStream.js';
8
9
 
9
10
  export interface PortEvent {
10
11
  type: 'open' | 'close';
@@ -15,10 +16,11 @@ const EPHEMERAL_START = 49152;
15
16
 
16
17
  export class PortRegistry {
17
18
  private readonly handlers = new Map<number, HttpHandler>();
19
+ private readonly upgrades = new Map<number, UpgradeHandler>();
18
20
  private readonly listeners = new Set<(event: PortEvent) => void>();
19
21
  private nextEphemeral = EPHEMERAL_START;
20
22
 
21
- listen(port: number, handler: HttpHandler): { port: number; close(): void } {
23
+ listen(port: number, handler: HttpHandler, upgrade?: UpgradeHandler): { port: number; close(): void } {
22
24
  let actual = port;
23
25
  if (!actual) {
24
26
  while (this.handlers.has(this.nextEphemeral)) this.nextEphemeral++;
@@ -28,6 +30,7 @@ export class PortRegistry {
28
30
  throw Object.assign(new Error(`listen EADDRINUSE: address already in use :::${actual}`), { code: 'EADDRINUSE', errno: -98, syscall: 'listen', address: '::', port: actual });
29
31
  }
30
32
  this.handlers.set(actual, handler);
33
+ if (upgrade) this.upgrades.set(actual, upgrade);
31
34
  this.emit({ type: 'open', port: actual });
32
35
  let open = true;
33
36
  return {
@@ -36,6 +39,7 @@ export class PortRegistry {
36
39
  if (!open || this.handlers.get(actual) !== handler) return;
37
40
  open = false;
38
41
  this.handlers.delete(actual);
42
+ this.upgrades.delete(actual);
39
43
  this.emit({ type: 'close', port: actual });
40
44
  },
41
45
  };
@@ -49,12 +53,27 @@ export class PortRegistry {
49
53
  return [...this.handlers.keys()].sort((a, b) => a - b);
50
54
  }
51
55
 
52
- /** Dispatch to the server on `port`; undefined when nothing listens there. */
53
- async request(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined> {
56
+ /** Dispatch to the server on `port`; resolves with the head as soon as it is written, the body streaming. Undefined when nothing listens there. */
57
+ async request(port: number, request: HttpRequestData): Promise<HttpStreamResponse | undefined> {
54
58
  const handler = this.handlers.get(port);
55
59
  return handler ? handler(request) : undefined;
56
60
  }
57
61
 
62
+ /** {@link request}, waiting for the whole body. */
63
+ async requestBuffered(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined> {
64
+ const response = await this.request(port, request);
65
+ return response && bufferResponse(response);
66
+ }
67
+
68
+ /** Open an upgraded connection to the server on `port`; returns the client end, or undefined when nothing there takes upgrades. */
69
+ upgrade(port: number, request: HttpRequestData): ByteChannel | undefined {
70
+ const upgrade = this.upgrades.get(port);
71
+ if (!upgrade) return undefined;
72
+ const [client, server] = createChannelPair();
73
+ upgrade(request, server);
74
+ return client;
75
+ }
76
+
58
77
  watch(listener: (event: PortEvent) => void): () => void {
59
78
  this.listeners.add(listener);
60
79
  return () => this.listeners.delete(listener);
@@ -2,7 +2,7 @@
2
2
  * The command table: every program a process can be. Hosts start from
3
3
  * `defaultPrograms()` and may add or replace entries (a custom `git`, say).
4
4
  *
5
- * `npm`/`npx` live in the shell (they run scripts through it), so spawning them
5
+ * `npm`/`npx`/`pnpm`/`yarn` live in the shell (they run scripts through it), so spawning them
6
6
  * directly runs them through a one-shot shell; coreutils run standalone.
7
7
  */
8
8
  import type { Program } from './types.js';
@@ -25,6 +25,8 @@ export function defaultPrograms(): Record<string, Program> {
25
25
  bash: shellProgram,
26
26
  npm: viaShell('npm'),
27
27
  npx: viaShell('npx'),
28
+ pnpm: viaShell('pnpm'),
29
+ yarn: viaShell('yarn'),
28
30
  };
29
31
  for (const [name, command] of Object.entries(COREUTILS)) programs[name] = asProgram(command, name);
30
32
  return programs;
@@ -51,24 +51,58 @@ export interface HttpRequestData {
51
51
  body: Uint8Array | null;
52
52
  }
53
53
 
54
- export interface HttpResponseData {
54
+ export interface HttpResponseHead {
55
55
  status: number;
56
56
  statusText?: string;
57
57
  headers: Record<string, string | string[]>;
58
+ }
59
+
60
+ /** A whole response, body buffered (`Kernel.request`, tests). */
61
+ export interface HttpResponseData extends HttpResponseHead {
58
62
  body: Uint8Array;
59
63
  }
60
64
 
61
- export type HttpHandler = (request: HttpRequestData) => Promise<HttpResponseData>;
65
+ /**
66
+ * A response as the server produces it: the head as soon as it is written, then
67
+ * the body chunk by chunk (Server-Sent Events, chunked progress). Cancelling the
68
+ * body hangs up, which the server sees as the client closing the connection.
69
+ */
70
+ export interface HttpStreamResponse extends HttpResponseHead {
71
+ body: ReadableStream<Uint8Array>;
72
+ }
73
+
74
+ export type HttpHandler = (request: HttpRequestData) => Promise<HttpStreamResponse>;
75
+
76
+ /**
77
+ * One end of a raw byte pipe — an upgraded connection. The server end carries
78
+ * whatever the server writes (a WebSocket server writes its `101` reply and then
79
+ * frames), so packages like `ws` run their own framing over it unchanged.
80
+ */
81
+ export interface ByteChannel {
82
+ send(data: Uint8Array): void;
83
+ /** Hang up; the other end hears `onClose`. */
84
+ close(): void;
85
+ onData(listener: (data: Uint8Array) => void): () => void;
86
+ /** The other end hung up. */
87
+ onClose(listener: () => void): () => void;
88
+ readonly closed: boolean;
89
+ }
90
+
91
+ /** Take over a connection a client asked to upgrade (`Upgrade: websocket`); `socket` is the server end. */
92
+ export type UpgradeHandler = (request: HttpRequestData, socket: ByteChannel) => void;
62
93
 
63
94
  export interface System {
64
95
  fs: VirtualFileSystem;
65
96
  /** Outbound network (registry, `http.request`, `fetch`). */
66
97
  fetch(input: string, init?: RequestInit): Promise<Response>;
67
98
  spawn(command: string, args: string[], options?: ProcessOptions): ChildHandle;
68
- /** Register a virtual server; throws `EADDRINUSE`. Port 0 picks a free one. Returns the port and an unlisten. */
69
- listen(port: number, handler: HttpHandler): { port: number; close(): void };
70
- /** Send a request to a virtual server in this runtime (`http.get('http://localhost:3000')`); undefined when nothing listens. */
71
- loopback(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined>;
99
+ /**
100
+ * Register a virtual server; throws `EADDRINUSE`. Port 0 picks a free one.
101
+ * `upgrade` takes over connections that ask to upgrade (WebSockets). Returns the port and an unlisten.
102
+ */
103
+ listen(port: number, handler: HttpHandler, upgrade?: UpgradeHandler): { port: number; close(): void };
104
+ /** Send a request to a virtual server in this runtime (`http.get('http://localhost:3000')`); undefined when nothing listens. The body streams. */
105
+ loopback(port: number, request: HttpRequestData): Promise<HttpStreamResponse | undefined>;
72
106
  /** Where a browser reaches a server on `port` (the preview URL), if known. */
73
107
  serverUrl(port: number): string | undefined;
74
108
  /** Tell the host a server is reachable without opening a port; used by the dev-server handoff. */
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The client end of a WebSocket to a virtual server, over an upgraded
3
+ * {@link ByteChannel}: sends the opening handshake as an HTTP request, checks
4
+ * the server's `101` reply (whatever wrote it — `ws`, socket.io's engine, a
5
+ * hand-written `upgrade` handler), then speaks RFC 6455 frames. The host page
6
+ * uses it to carry a preview's `new WebSocket('ws://localhost:3000')`.
7
+ */
8
+ import type { ByteChannel, HttpRequestData } from './types.js';
9
+ import { FrameDecoder, Opcode, decodeClosePayload, encodeClosePayload, encodeFrame, webSocketAccept, webSocketKey } from './webSocketFrames.js';
10
+
11
+ export interface WebSocketCloseInfo {
12
+ code: number;
13
+ reason: string;
14
+ wasClean: boolean;
15
+ }
16
+
17
+ export interface WebSocketConnection {
18
+ /** 0 connecting, 1 open, 2 closing, 3 closed — `WebSocket.readyState`'s values. */
19
+ readonly readyState: number;
20
+ onOpen(listener: (info: { protocol: string }) => void): () => void;
21
+ onMessage(listener: (data: string | Uint8Array) => void): () => void;
22
+ /** Fires once; `wasClean` false and code 1006 when the connection dropped or the handshake failed. */
23
+ onClose(listener: (info: WebSocketCloseInfo) => void): () => void;
24
+ send(data: string | Uint8Array): void;
25
+ close(code?: number, reason?: string): void;
26
+ }
27
+
28
+ export interface WebSocketConnectOptions {
29
+ /** Path and query on the server (`/socket?room=1`). */
30
+ path: string;
31
+ protocols?: string[];
32
+ /** Extra request headers (`origin`, `cookie`). */
33
+ headers?: Record<string, string>;
34
+ /** Give up on a close handshake the server never answers. Default 5000ms. */
35
+ closeTimeoutMs?: number;
36
+ }
37
+
38
+ const HEAD_END = [13, 10, 13, 10];
39
+
40
+ function indexOfHeadEnd(bytes: Uint8Array): number {
41
+ outer: for (let i = 0; i + 3 < bytes.length; i++) {
42
+ for (let j = 0; j < 4; j++) if (bytes[i + j] !== HEAD_END[j]) continue outer;
43
+ return i;
44
+ }
45
+ return -1;
46
+ }
47
+
48
+ function parseHead(text: string): { status: number; headers: Record<string, string> } {
49
+ const [statusLine = '', ...lines] = text.split('\r\n');
50
+ const headers: Record<string, string> = {};
51
+ for (const line of lines) {
52
+ const colon = line.indexOf(':');
53
+ if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
54
+ }
55
+ return { status: Number(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/.exec(statusLine)?.[1] ?? 0), headers };
56
+ }
57
+
58
+ /** Open a WebSocket through `upgrade` (`kernel.upgrade` / `processHost.upgrade` bound to a port). */
59
+ export function connectWebSocket(upgrade: (request: HttpRequestData) => ByteChannel | undefined, options: WebSocketConnectOptions): WebSocketConnection {
60
+ const opens = new Set<(info: { protocol: string }) => void>();
61
+ const messages = new Set<(data: string | Uint8Array) => void>();
62
+ const closes = new Set<(info: WebSocketCloseInfo) => void>();
63
+ const key = webSocketKey();
64
+ const decoder = new FrameDecoder();
65
+ let state = 0;
66
+ let head: Uint8Array = new Uint8Array(0);
67
+ let fragments: Uint8Array[] = [];
68
+ let fragmentOpcode = 0;
69
+ let closeSent = false;
70
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
71
+
72
+ const headers: Record<string, string> = {
73
+ ...options.headers,
74
+ connection: 'Upgrade',
75
+ upgrade: 'websocket',
76
+ 'sec-websocket-version': '13',
77
+ 'sec-websocket-key': key,
78
+ };
79
+ if (options.protocols?.length) headers['sec-websocket-protocol'] = options.protocols.join(', ');
80
+ const channel = upgrade({ method: 'GET', url: options.path, headers, body: null });
81
+
82
+ const finish = (info: WebSocketCloseInfo) => {
83
+ if (state === 3) return;
84
+ state = 3;
85
+ clearTimeout(closeTimer);
86
+ channel?.close();
87
+ for (const listener of closes) listener(info);
88
+ };
89
+ const fail = () => finish({ code: 1006, reason: '', wasClean: false });
90
+ const sendFrame = (opcode: number, payload: Uint8Array) => channel?.send(encodeFrame(opcode, payload, { mask: true }));
91
+
92
+ const onFrame = (opcode: number, fin: boolean, payload: Uint8Array) => {
93
+ if (opcode === Opcode.ping) return sendFrame(Opcode.pong, payload);
94
+ if (opcode === Opcode.pong) return;
95
+ if (opcode === Opcode.close) {
96
+ const { code, reason } = decodeClosePayload(payload);
97
+ if (!closeSent) {
98
+ closeSent = true;
99
+ sendFrame(Opcode.close, code === 1005 ? new Uint8Array() : encodeClosePayload(code));
100
+ }
101
+ return finish({ code, reason, wasClean: true });
102
+ }
103
+ if (opcode !== Opcode.continuation) {
104
+ fragmentOpcode = opcode;
105
+ fragments = [];
106
+ }
107
+ fragments.push(payload);
108
+ if (!fin) return;
109
+ const whole = fragments.length === 1 ? fragments[0]! : joinChunks(fragments);
110
+ fragments = [];
111
+ const data = fragmentOpcode === Opcode.text ? new TextDecoder().decode(whole) : whole;
112
+ for (const listener of messages) listener(data);
113
+ };
114
+
115
+ const onBytes = (bytes: Uint8Array) => {
116
+ if (state === 3) return;
117
+ let rest = bytes;
118
+ if (state === 0) {
119
+ head = joinChunks([head, bytes]);
120
+ const end = indexOfHeadEnd(head);
121
+ if (end < 0) return;
122
+ const reply = parseHead(new TextDecoder().decode(head.subarray(0, end)));
123
+ rest = head.subarray(end + 4);
124
+ if (reply.status !== 101 || reply.headers['sec-websocket-accept'] !== webSocketAccept(key)) return fail();
125
+ state = 1;
126
+ const protocol = reply.headers['sec-websocket-protocol'] ?? '';
127
+ for (const listener of opens) listener({ protocol });
128
+ }
129
+ for (const frame of decoder.push(rest)) {
130
+ onFrame(frame.opcode, frame.fin, frame.payload);
131
+ if (state === 3) return;
132
+ }
133
+ };
134
+
135
+ if (!channel) queueMicrotask(fail);
136
+ else {
137
+ channel.onData(onBytes);
138
+ channel.onClose(fail);
139
+ }
140
+
141
+ return {
142
+ get readyState() {
143
+ return state;
144
+ },
145
+ onOpen: (listener) => subscribe(opens, listener),
146
+ onMessage: (listener) => subscribe(messages, listener),
147
+ onClose: (listener) => subscribe(closes, listener),
148
+ send(data) {
149
+ if (state !== 1) return;
150
+ if (typeof data === 'string') sendFrame(Opcode.text, new TextEncoder().encode(data));
151
+ else sendFrame(Opcode.binary, data);
152
+ },
153
+ close(code, reason) {
154
+ if (state === 0) return fail();
155
+ if (state !== 1) return;
156
+ state = 2;
157
+ closeSent = true;
158
+ sendFrame(Opcode.close, encodeClosePayload(code ?? 1000, reason));
159
+ closeTimer = setTimeout(fail, options.closeTimeoutMs ?? 5000);
160
+ },
161
+ };
162
+ }
163
+
164
+ function subscribe<T>(set: Set<T>, listener: T): () => void {
165
+ set.add(listener);
166
+ return () => set.delete(listener);
167
+ }
168
+
169
+ function joinChunks(chunks: Uint8Array[]): Uint8Array {
170
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.byteLength, 0));
171
+ let offset = 0;
172
+ for (const chunk of chunks) {
173
+ out.set(chunk, offset);
174
+ offset += chunk.byteLength;
175
+ }
176
+ return out;
177
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The WebSocket wire format (RFC 6455): the opening handshake's accept key and
3
+ * frame encoding/decoding. Runtime-agnostic, so the same codec drives the
4
+ * client end in the host page (bridging a preview's `WebSocket`) and tests that
5
+ * speak to a server's `upgrade` handler byte for byte.
6
+ */
7
+ import { hashAlgorithm } from '../node/hashes.js';
8
+
9
+ export const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10
+
11
+ export const Opcode = { continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa } as const;
12
+
13
+ export interface WebSocketFrame {
14
+ fin: boolean;
15
+ opcode: number;
16
+ /** Unmasked. */
17
+ payload: Uint8Array;
18
+ }
19
+
20
+ export function bytesToBase64(bytes: Uint8Array): string {
21
+ let binary = '';
22
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
23
+ return btoa(binary);
24
+ }
25
+
26
+ /** `Sec-WebSocket-Accept` for a client's `Sec-WebSocket-Key`. */
27
+ export function webSocketAccept(key: string): string {
28
+ return bytesToBase64(hashAlgorithm('sha1').digest(new TextEncoder().encode(key + WEBSOCKET_GUID)));
29
+ }
30
+
31
+ /** A fresh `Sec-WebSocket-Key` (16 random bytes, base64). */
32
+ export function webSocketKey(): string {
33
+ return bytesToBase64(globalThis.crypto.getRandomValues(new Uint8Array(16)));
34
+ }
35
+
36
+ /** One frame. Clients must mask (`mask: true` picks a random key); servers must not. */
37
+ export function encodeFrame(opcode: number, payload: Uint8Array, options: { mask?: boolean | Uint8Array; fin?: boolean } = {}): Uint8Array {
38
+ const length = payload.byteLength;
39
+ const maskKey = options.mask === true ? globalThis.crypto.getRandomValues(new Uint8Array(4)) : options.mask || undefined;
40
+ const extended = length < 126 ? 0 : length <= 0xffff ? 2 : 8;
41
+ const headerLength = 2 + extended + (maskKey ? 4 : 0);
42
+ const frame = new Uint8Array(headerLength + length);
43
+ const view = new DataView(frame.buffer);
44
+ frame[0] = (options.fin === false ? 0 : 0x80) | (opcode & 0x0f);
45
+ frame[1] = (maskKey ? 0x80 : 0) | (extended === 0 ? length : extended === 2 ? 126 : 127);
46
+ if (extended === 2) view.setUint16(2, length);
47
+ else if (extended === 8) {
48
+ view.setUint32(2, Math.floor(length / 2 ** 32));
49
+ view.setUint32(6, length >>> 0);
50
+ }
51
+ if (maskKey) {
52
+ frame.set(maskKey, 2 + extended);
53
+ for (let i = 0; i < length; i++) frame[headerLength + i] = payload[i]! ^ maskKey[i & 3]!;
54
+ } else frame.set(payload, headerLength);
55
+ return frame;
56
+ }
57
+
58
+ /** A close frame's payload: the status code and a UTF-8 reason. */
59
+ export function encodeClosePayload(code?: number, reason = ''): Uint8Array {
60
+ if (code === undefined) return new Uint8Array();
61
+ const text = new TextEncoder().encode(reason);
62
+ const out = new Uint8Array(2 + text.byteLength);
63
+ new DataView(out.buffer).setUint16(0, code);
64
+ out.set(text, 2);
65
+ return out;
66
+ }
67
+
68
+ /** Status 1005 ("no status received") when the payload is empty. */
69
+ export function decodeClosePayload(payload: Uint8Array): { code: number; reason: string } {
70
+ if (payload.byteLength < 2) return { code: 1005, reason: '' };
71
+ return { code: new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint16(0), reason: new TextDecoder().decode(payload.subarray(2)) };
72
+ }
73
+
74
+ /** Splits a byte stream into frames, whatever the chunking. */
75
+ export class FrameDecoder {
76
+ private buffer = new Uint8Array(0);
77
+
78
+ push(bytes: Uint8Array): WebSocketFrame[] {
79
+ if (bytes.byteLength) {
80
+ const joined = new Uint8Array(this.buffer.byteLength + bytes.byteLength);
81
+ joined.set(this.buffer);
82
+ joined.set(bytes, this.buffer.byteLength);
83
+ this.buffer = joined;
84
+ }
85
+ const frames: WebSocketFrame[] = [];
86
+ for (;;) {
87
+ const frame = this.next();
88
+ if (!frame) return frames;
89
+ frames.push(frame);
90
+ }
91
+ }
92
+
93
+ private next(): WebSocketFrame | undefined {
94
+ const b = this.buffer;
95
+ if (b.byteLength < 2) return undefined;
96
+ const masked = (b[1]! & 0x80) !== 0;
97
+ let length = b[1]! & 0x7f;
98
+ let offset = 2;
99
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
100
+ if (length === 126) {
101
+ if (b.byteLength < 4) return undefined;
102
+ length = view.getUint16(2);
103
+ offset = 4;
104
+ } else if (length === 127) {
105
+ if (b.byteLength < 10) return undefined;
106
+ length = view.getUint32(2) * 2 ** 32 + view.getUint32(6);
107
+ offset = 10;
108
+ }
109
+ const maskKey = masked ? b.subarray(offset, offset + 4) : undefined;
110
+ if (masked) offset += 4;
111
+ if (b.byteLength < offset + length) return undefined;
112
+ const payload = b.slice(offset, offset + length);
113
+ if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ maskKey[i & 3]!;
114
+ const frame = { fin: (b[0]! & 0x80) !== 0, opcode: b[0]! & 0x0f, payload };
115
+ this.buffer = b.slice(offset + length);
116
+ return frame;
117
+ }
118
+ }
package/src/vfs.ts CHANGED
@@ -14,6 +14,11 @@ export interface FileTree {
14
14
  [name: string]: FileContent | FileTree;
15
15
  }
16
16
 
17
+ export interface DirectoryEntry {
18
+ name: string;
19
+ kind: 'file' | 'directory';
20
+ }
21
+
17
22
  export interface FsChange {
18
23
  /** `mkdir` is an explicitly created (possibly empty) directory. */
19
24
  type: 'write' | 'remove' | 'mkdir';
@@ -98,6 +103,39 @@ export class VirtualFileSystem {
98
103
  return [...(this.children.get(normalizePath(path)) ?? [])].sort();
99
104
  }
100
105
 
106
+ /** Direct children with their kind, sorted by name — `readdir` for callers that render a tree. */
107
+ entries(path: string): DirectoryEntry[] {
108
+ const dir = normalizePath(path);
109
+ return this.readdir(dir).map((name) => ({ name, kind: this.children.has(join(dir, name)) ? 'directory' : 'file' }));
110
+ }
111
+
112
+ /**
113
+ * Move a file or a directory (with everything under it). Throws when `from` is
114
+ * missing or `to` already exists. Watchers see the old paths removed and the
115
+ * new ones written, which is what every consumer (dev server, process sync) needs.
116
+ */
117
+ rename(from: string, to: string): void {
118
+ const source = normalizePath(from);
119
+ const target = normalizePath(to);
120
+ if (source === target) return;
121
+ if (!this.exists(source)) throw new Error(`ENOENT: no such file or directory, rename '${source}' -> '${target}'`);
122
+ if (this.exists(target)) throw new Error(`EEXIST: file already exists, rename '${source}' -> '${target}'`);
123
+ if (target.startsWith(source + '/')) throw new Error(`EINVAL: cannot move '${source}' into itself`);
124
+ if (this.files.has(source)) {
125
+ const contents = this.files.get(source)!;
126
+ this.removeFile(source);
127
+ this.writeFile(target, contents);
128
+ return;
129
+ }
130
+ const prefix = source + '/';
131
+ const moved = [...this.files].filter(([path]) => path.startsWith(prefix));
132
+ const dirs = [...this.explicit].filter((dir) => dir === source || dir.startsWith(prefix));
133
+ this.rm(source);
134
+ this.mkdir(target);
135
+ for (const dir of dirs) this.mkdir(target + dir.slice(source.length));
136
+ for (const [path, contents] of moved) this.writeFile(target + path.slice(source.length), contents);
137
+ }
138
+
101
139
  /** Every file path, sorted. */
102
140
  list(): string[] {
103
141
  return [...this.files.keys()].sort();
@@ -152,9 +190,14 @@ export class VirtualFileSystem {
152
190
  return out;
153
191
  }
154
192
 
155
- watch(listener: (change: FsChange) => void): () => void {
156
- this.listeners.add(listener);
157
- return () => this.listeners.delete(listener);
193
+ /** Every change, or with `under` only changes at or below that path. */
194
+ watch(listener: (change: FsChange) => void, options: { under?: string } = {}): () => void {
195
+ const root = options.under === undefined ? undefined : normalizePath(options.under);
196
+ const filtered = root === undefined || root === '/'
197
+ ? listener
198
+ : (change: FsChange) => (change.path === root || change.path.startsWith(root + '/')) && listener(change);
199
+ this.listeners.add(filtered);
200
+ return () => this.listeners.delete(filtered);
158
201
  }
159
202
 
160
203
  private removeFile(key: string): void {