@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,257 @@
1
+ /**
2
+ * Shell tokens: words (with their quoting, as segments) and operators. Here-
3
+ * documents are read here too — `<<EOF` is followed, after the end of its line,
4
+ * by body lines up to the delimiter — so the parser sees each body attached to
5
+ * its redirect.
6
+ */
7
+ import type { Segment, Word } from './ast.js';
8
+
9
+ export type Token =
10
+ | { type: 'word'; word: Word; raw: string }
11
+ | { type: 'op'; op: string }
12
+ | { type: 'heredoc'; body?: Word };
13
+
14
+ export class ShellSyntaxError extends Error {
15
+ /** The input ended inside a quote, a block or a here-document: an interactive shell asks for more. */
16
+ constructor(message: string, readonly incomplete = false) {
17
+ super(message);
18
+ }
19
+ }
20
+
21
+ const OPERATORS = ['2>&1', '>&2', '2>>', '<<-', '&&', '||', ';;', '>>', '<<', '2>', '&>', '|', '&', ';', '>', '<', '(', ')', '\n'];
22
+ const SPECIAL_VARS = /^[?$#@*!0-9]/;
23
+ /** Unquoted characters that end a word. */
24
+ const WORD_BREAK = /[;|&<>()]/;
25
+
26
+ /** Find the `)` closing a `$(` at `start` (just after the paren), honouring quotes and nesting. */
27
+ function matchParen(input: string, start: number): number {
28
+ let depth = 1;
29
+ for (let i = start; i < input.length; i++) {
30
+ const ch = input[i];
31
+ if (ch === '\\') i++;
32
+ else if (ch === "'") i = input.indexOf("'", i + 1) < 0 ? input.length : input.indexOf("'", i + 1);
33
+ else if (ch === '"') {
34
+ for (i++; i < input.length && input[i] !== '"'; i++) if (input[i] === '\\') i++;
35
+ } else if (ch === '(') depth++;
36
+ else if (ch === ')' && --depth === 0) return i;
37
+ }
38
+ throw new ShellSyntaxError('unexpected end of input: missing )', true);
39
+ }
40
+
41
+ /** Parse `$…` at `i` (pointing at `$`); returns the segment and the index after it. */
42
+ function readDollar(input: string, i: number, quoted: boolean): [Segment, number] {
43
+ const next = input[i + 1];
44
+ if (next === '(') {
45
+ const end = matchParen(input, i + 2);
46
+ return [{ kind: 'cmd', source: input.slice(i + 2, end), quoted }, end + 1];
47
+ }
48
+ if (next === '{') {
49
+ const end = input.indexOf('}', i + 2);
50
+ if (end < 0) throw new ShellSyntaxError('unexpected end of input: missing }', true);
51
+ const body = input.slice(i + 2, end);
52
+ const m = /^([A-Za-z_][\w]*|[?$#@*!0-9])(?:(:-|-|:=|:\+)(.*))?$/s.exec(body);
53
+ if (!m) throw new ShellSyntaxError(`bad substitution: \${${body}}`);
54
+ const seg: Segment = { kind: 'var', name: m[1]!, quoted };
55
+ if (m[2]) {
56
+ seg.op = m[2] as ':-';
57
+ seg.arg = tokenizeWord(m[3] ?? '', quoted);
58
+ }
59
+ return [seg, end + 1];
60
+ }
61
+ if (next && SPECIAL_VARS.test(next)) return [{ kind: 'var', name: next, quoted }, i + 2];
62
+ const m = /^[A-Za-z_]\w*/.exec(input.slice(i + 1));
63
+ if (!m) return [{ kind: 'text', value: '$', quoted }, i + 1];
64
+ return [{ kind: 'var', name: m[0], quoted }, i + 1 + m[0].length];
65
+ }
66
+
67
+ /** Segments of a word body (used for `${x:-default}` arguments). */
68
+ function tokenizeWord(text: string, quoted: boolean): Word {
69
+ const tokens = tokenize(text, true);
70
+ const word: Word = [];
71
+ for (const t of tokens) if (t.type === 'word') word.push(...t.word.map((s) => (s.kind === 'tilde' ? s : { ...s, quoted: quoted || ('quoted' in s && s.quoted) } as Segment)));
72
+ return word;
73
+ }
74
+
75
+ /** A here-document body as a word: `$VAR`, `$(…)` and backticks expand, nothing splits or globs. */
76
+ function heredocWord(body: string): Word {
77
+ const word: Word = [];
78
+ let text = '';
79
+ const flush = () => {
80
+ if (text) word.push({ kind: 'text', value: text, quoted: true });
81
+ text = '';
82
+ };
83
+ for (let i = 0; i < body.length;) {
84
+ const c = body[i]!;
85
+ if (c === '\\' && /[$`\\\n]/.test(body[i + 1] ?? '')) {
86
+ if (body[i + 1] !== '\n') text += body[i + 1];
87
+ i += 2;
88
+ } else if (c === '$') {
89
+ flush();
90
+ const [seg, next] = readDollar(body, i, true);
91
+ word.push(seg);
92
+ i = next;
93
+ } else if (c === '`') {
94
+ flush();
95
+ const end = body.indexOf('`', i + 1);
96
+ if (end < 0) throw new ShellSyntaxError('unterminated ` in here-document');
97
+ word.push({ kind: 'cmd', source: body.slice(i + 1, end), quoted: true });
98
+ i = end + 1;
99
+ } else {
100
+ text += c;
101
+ i++;
102
+ }
103
+ }
104
+ flush();
105
+ return word.length ? word : [{ kind: 'text', value: '', quoted: true }];
106
+ }
107
+
108
+ interface PendingHeredoc {
109
+ token: { type: 'heredoc'; body?: Word };
110
+ delimiter: string;
111
+ strip: boolean;
112
+ quoted: boolean;
113
+ }
114
+
115
+ /** Read the bodies of pending here-documents starting at `i` (the start of a line); returns the index after them. */
116
+ function readHeredocs(input: string, i: number, pending: PendingHeredoc[]): number {
117
+ for (const doc of pending) {
118
+ const lines: string[] = [];
119
+ for (;;) {
120
+ if (i >= input.length) throw new ShellSyntaxError(`unexpected end of input: here-document delimited by ${doc.delimiter}`, true);
121
+ const end = input.indexOf('\n', i);
122
+ let line = input.slice(i, end < 0 ? input.length : end).replace(/\r$/, '');
123
+ i = end < 0 ? input.length : end + 1;
124
+ if (doc.strip) line = line.replace(/^\t+/, '');
125
+ if (line === doc.delimiter) break;
126
+ if (end < 0) throw new ShellSyntaxError(`unexpected end of input: here-document delimited by ${doc.delimiter}`, true);
127
+ lines.push(line);
128
+ }
129
+ const body = lines.map((l) => l + '\n').join('');
130
+ doc.token.body = doc.quoted ? [{ kind: 'text', value: body, quoted: true }] : heredocWord(body);
131
+ }
132
+ pending.length = 0;
133
+ return i;
134
+ }
135
+
136
+ export function tokenize(input: string, wordOnly = false): Token[] {
137
+ const tokens: Token[] = [];
138
+ const pending: PendingHeredoc[] = [];
139
+ let i = 0;
140
+ while (i < input.length) {
141
+ const ch = input[i]!;
142
+ if (ch === ' ' || ch === '\t' || ch === '\r') {
143
+ i++;
144
+ continue;
145
+ }
146
+ if (ch === '\\' && input[i + 1] === '\n') {
147
+ i += 2;
148
+ continue;
149
+ }
150
+ if (ch === '#') {
151
+ while (i < input.length && input[i] !== '\n') i++;
152
+ continue;
153
+ }
154
+ if (!wordOnly) {
155
+ const op = OPERATORS.find((o) => input.startsWith(o, i));
156
+ if (op) {
157
+ tokens.push({ type: 'op', op });
158
+ i += op.length;
159
+ if (op === '\n' && pending.length) i = readHeredocs(input, i, pending);
160
+ if (op === '<<' || op === '<<-') {
161
+ while (input[i] === ' ' || input[i] === '\t') i++;
162
+ const start = i;
163
+ while (i < input.length && !/[\s;|&<>()]/.test(input[i]!)) i++;
164
+ const raw = input.slice(start, i);
165
+ if (!raw) throw new ShellSyntaxError('syntax error: here-document needs a delimiter');
166
+ const token: PendingHeredoc['token'] = { type: 'heredoc' };
167
+ tokens.push(token);
168
+ pending.push({ token, delimiter: raw.replace(/["'\\]/g, ''), strip: op === '<<-', quoted: /["'\\]/.test(raw) });
169
+ }
170
+ continue;
171
+ }
172
+ }
173
+ // A word: runs until unquoted whitespace or an operator character.
174
+ const word: Word = [];
175
+ const start = i;
176
+ let text = '';
177
+ const flush = () => {
178
+ if (text) word.push({ kind: 'text', value: text, quoted: false });
179
+ text = '';
180
+ };
181
+ if (input[i] === '~' && (i + 1 >= input.length || /[\s/;|&<>()]/.test(input[i + 1]!))) {
182
+ word.push({ kind: 'tilde' });
183
+ i++;
184
+ }
185
+ while (i < input.length) {
186
+ const c = input[i]!;
187
+ if (/[\s]/.test(c) && !wordOnly) break;
188
+ if (!wordOnly && WORD_BREAK.test(c)) break;
189
+ if (c === '\\') {
190
+ text += input[i + 1] ?? '';
191
+ i += 2;
192
+ } else if (c === "'") {
193
+ const end = input.indexOf("'", i + 1);
194
+ if (end < 0) throw new ShellSyntaxError('unexpected end of input: unterminated quote', true);
195
+ flush();
196
+ word.push({ kind: 'text', value: input.slice(i + 1, end), quoted: true });
197
+ i = end + 1;
198
+ } else if (c === '"') {
199
+ flush();
200
+ i++;
201
+ let inner = '';
202
+ const pushInner = () => {
203
+ if (inner) word.push({ kind: 'text', value: inner, quoted: true });
204
+ inner = '';
205
+ };
206
+ let closed = false;
207
+ while (i < input.length) {
208
+ const d = input[i]!;
209
+ if (d === '"') {
210
+ closed = true;
211
+ i++;
212
+ break;
213
+ }
214
+ if (d === '\\' && /["\\$`\n]/.test(input[i + 1] ?? '')) {
215
+ inner += input[i + 1];
216
+ i += 2;
217
+ } else if (d === '$') {
218
+ pushInner();
219
+ const [seg, next] = readDollar(input, i, true);
220
+ word.push(seg);
221
+ i = next;
222
+ } else if (d === '`') {
223
+ pushInner();
224
+ const end = input.indexOf('`', i + 1);
225
+ if (end < 0) throw new ShellSyntaxError('unexpected end of input: unterminated `', true);
226
+ word.push({ kind: 'cmd', source: input.slice(i + 1, end), quoted: true });
227
+ i = end + 1;
228
+ } else {
229
+ inner += d;
230
+ i++;
231
+ }
232
+ }
233
+ if (!closed) throw new ShellSyntaxError('unexpected end of input: unterminated quote', true);
234
+ pushInner();
235
+ if (!word.length || word[word.length - 1]!.kind !== 'text') word.push({ kind: 'text', value: '', quoted: true });
236
+ } else if (c === '$') {
237
+ flush();
238
+ const [seg, next] = readDollar(input, i, false);
239
+ word.push(seg);
240
+ i = next;
241
+ } else if (c === '`') {
242
+ flush();
243
+ const end = input.indexOf('`', i + 1);
244
+ if (end < 0) throw new ShellSyntaxError('unexpected end of input: unterminated `', true);
245
+ word.push({ kind: 'cmd', source: input.slice(i + 1, end), quoted: false });
246
+ i = end + 1;
247
+ } else {
248
+ text += c;
249
+ i++;
250
+ }
251
+ }
252
+ flush();
253
+ tokens.push({ type: 'word', word, raw: input.slice(start, i) });
254
+ }
255
+ if (pending.length) throw new ShellSyntaxError(`unexpected end of input: here-document delimited by ${pending[0]!.delimiter}`, true);
256
+ return tokens;
257
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Cookies for virtual servers. A service worker cannot hand `Set-Cookie` to
3
+ * the browser (it is a forbidden header on a synthesized response), so the
4
+ * host keeps the jar itself: it stores what a server sets and sends it back as
5
+ * the `Cookie` header on later requests — per preview and per port, which is
6
+ * the origin a server would have had. Paths, `Max-Age`/`Expires` and deletion
7
+ * follow RFC 6265; `HttpOnly` cookies are stored and sent but never shown to
8
+ * page script; `Domain`, `Secure` and `SameSite` have nothing to choose between
9
+ * (every request goes to the one server) and are kept only as metadata.
10
+ */
11
+
12
+ export interface StoredCookie {
13
+ name: string;
14
+ value: string;
15
+ path: string;
16
+ /** Epoch ms; undefined for a session cookie (lives as long as the runtime). */
17
+ expires?: number;
18
+ httpOnly: boolean;
19
+ secure: boolean;
20
+ sameSite?: string;
21
+ }
22
+
23
+ /** RFC 6265 §5.1.4: the directory of the request path. */
24
+ export function defaultCookiePath(requestPath: string): string {
25
+ const path = requestPath.split('?')[0] ?? '/';
26
+ if (!path.startsWith('/')) return '/';
27
+ const slash = path.lastIndexOf('/');
28
+ return slash <= 0 ? '/' : path.slice(0, slash);
29
+ }
30
+
31
+ /** RFC 6265 §5.1.4 path-match. */
32
+ export function cookiePathMatches(cookiePath: string, requestPath: string): boolean {
33
+ const path = requestPath.split('?')[0] || '/';
34
+ if (path === cookiePath) return true;
35
+ if (!path.startsWith(cookiePath)) return false;
36
+ return cookiePath.endsWith('/') || path[cookiePath.length] === '/';
37
+ }
38
+
39
+ /** Parse one `Set-Cookie` value; undefined when malformed. `deleted` marks an already-expired cookie (a deletion). */
40
+ export function parseSetCookie(header: string, requestPath: string, now = Date.now()): (StoredCookie & { deleted: boolean }) | undefined {
41
+ const [pair = '', ...attributes] = header.split(';');
42
+ const eq = pair.indexOf('=');
43
+ if (eq < 0) return undefined;
44
+ const name = pair.slice(0, eq).trim();
45
+ if (!name) return undefined;
46
+ const cookie: StoredCookie & { deleted: boolean } = { name, value: pair.slice(eq + 1).trim(), path: defaultCookiePath(requestPath), httpOnly: false, secure: false, deleted: false };
47
+ let maxAge: number | undefined;
48
+ let expires: number | undefined;
49
+ for (const attribute of attributes) {
50
+ const i = attribute.indexOf('=');
51
+ const key = (i < 0 ? attribute : attribute.slice(0, i)).trim().toLowerCase();
52
+ const value = i < 0 ? '' : attribute.slice(i + 1).trim();
53
+ if (key === 'path' && value.startsWith('/')) cookie.path = value;
54
+ else if (key === 'max-age' && /^-?\d+$/.test(value)) maxAge = Number(value);
55
+ else if (key === 'expires') {
56
+ const at = Date.parse(value);
57
+ if (!Number.isNaN(at)) expires = at;
58
+ } else if (key === 'httponly') cookie.httpOnly = true;
59
+ else if (key === 'secure') cookie.secure = true;
60
+ else if (key === 'samesite') cookie.sameSite = value;
61
+ }
62
+ // Max-Age wins over Expires (§5.3 step 3).
63
+ if (maxAge !== undefined) cookie.expires = maxAge <= 0 ? 0 : now + maxAge * 1000;
64
+ else if (expires !== undefined) cookie.expires = expires;
65
+ cookie.deleted = cookie.expires !== undefined && cookie.expires <= now;
66
+ return cookie;
67
+ }
68
+
69
+ export class CookieJar {
70
+ private cookies: StoredCookie[] = [];
71
+ private readonly listeners = new Set<() => void>();
72
+
73
+ /** Store `Set-Cookie` values from a response to `requestPath`; returns whether anything changed. */
74
+ store(setCookie: string | readonly string[] | undefined, requestPath: string, now = Date.now()): boolean {
75
+ if (!setCookie) return false;
76
+ let changed = false;
77
+ for (const header of typeof setCookie === 'string' ? [setCookie] : setCookie) {
78
+ const cookie = parseSetCookie(header, requestPath, now);
79
+ if (cookie) changed = this.put(cookie, now, true) || changed;
80
+ }
81
+ if (changed) this.notify();
82
+ return changed;
83
+ }
84
+
85
+ /** A `document.cookie = '…'` assignment from a page at `pagePath`. Script cannot create or replace an `HttpOnly` cookie. */
86
+ setFromDocument(assignment: string, pagePath: string, now = Date.now()): boolean {
87
+ const cookie = parseSetCookie(assignment, pagePath, now);
88
+ if (!cookie || cookie.httpOnly) return false;
89
+ const changed = this.put(cookie, now, false);
90
+ if (changed) this.notify();
91
+ return changed;
92
+ }
93
+
94
+ /** The `Cookie` header for a request to `path`; undefined when no cookie applies. Longer paths first. */
95
+ header(path: string, now = Date.now()): string | undefined {
96
+ const text = this.matching(path, now, true);
97
+ return text || undefined;
98
+ }
99
+
100
+ /** What `document.cookie` reads on a page at `path`: the same cookies, minus `HttpOnly` ones. */
101
+ documentCookie(path: string, now = Date.now()): string {
102
+ return this.matching(path, now, false);
103
+ }
104
+
105
+ /** Every live cookie. */
106
+ list(now = Date.now()): StoredCookie[] {
107
+ this.expire(now);
108
+ return this.cookies.map((c) => ({ ...c }));
109
+ }
110
+
111
+ clear(): void {
112
+ if (!this.cookies.length) return;
113
+ this.cookies = [];
114
+ this.notify();
115
+ }
116
+
117
+ /** The jar changed (a server or a page set or deleted a cookie). */
118
+ onChange(listener: () => void): () => void {
119
+ this.listeners.add(listener);
120
+ return () => this.listeners.delete(listener);
121
+ }
122
+
123
+ private put(cookie: StoredCookie & { deleted: boolean }, now: number, fromHttp: boolean): boolean {
124
+ this.expire(now);
125
+ const index = this.cookies.findIndex((c) => c.name === cookie.name && c.path === cookie.path);
126
+ const existing = index < 0 ? undefined : this.cookies[index];
127
+ if (existing?.httpOnly && !fromHttp) return false;
128
+ const { deleted, ...stored } = cookie;
129
+ if (deleted) {
130
+ if (index < 0) return false;
131
+ this.cookies.splice(index, 1);
132
+ return true;
133
+ }
134
+ if (existing && existing.value === stored.value && existing.expires === stored.expires && existing.httpOnly === stored.httpOnly) return false;
135
+ if (index < 0) this.cookies.push(stored);
136
+ else this.cookies[index] = stored;
137
+ return true;
138
+ }
139
+
140
+ private matching(path: string, now: number, includeHttpOnly: boolean): string {
141
+ this.expire(now);
142
+ return this.cookies
143
+ .filter((c) => (includeHttpOnly || !c.httpOnly) && cookiePathMatches(c.path, path))
144
+ .sort((a, b) => b.path.length - a.path.length)
145
+ .map((c) => `${c.name}=${c.value}`)
146
+ .join('; ');
147
+ }
148
+
149
+ private expire(now: number): void {
150
+ const live = this.cookies.filter((c) => c.expires === undefined || c.expires > now);
151
+ if (live.length === this.cookies.length) return;
152
+ this.cookies = live;
153
+ this.notify();
154
+ }
155
+
156
+ private notify(): void {
157
+ for (const listener of this.listeners) listener();
158
+ }
159
+ }
160
+
161
+ /** One jar per virtual server port — what the preview host keeps for its runtime. */
162
+ export class PortCookieJars {
163
+ private readonly jars = new Map<number, CookieJar>();
164
+ private readonly listeners = new Set<(port: number) => void>();
165
+
166
+ jar(port: number): CookieJar {
167
+ let jar = this.jars.get(port);
168
+ if (!jar) {
169
+ jar = new CookieJar();
170
+ jar.onChange(() => {
171
+ for (const listener of this.listeners) listener(port);
172
+ });
173
+ this.jars.set(port, jar);
174
+ }
175
+ return jar;
176
+ }
177
+
178
+ /** Some port's jar changed. */
179
+ onChange(listener: (port: number) => void): () => void {
180
+ this.listeners.add(listener);
181
+ return () => this.listeners.delete(listener);
182
+ }
183
+
184
+ clear(): void {
185
+ for (const jar of this.jars.values()) jar.clear();
186
+ }
187
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Building blocks for streamed HTTP between programs and hosts: a body stream
3
+ * fed by pushes (a server's `res.write`, chunks arriving over postMessage),
4
+ * buffering helpers for callers that want the whole response, and the in-memory
5
+ * byte pipe an upgraded connection runs over.
6
+ */
7
+ import type { ByteChannel, HttpResponseData, HttpResponseHead, HttpStreamResponse } from './types.js';
8
+
9
+ export interface PushStream {
10
+ readonly stream: ReadableStream<Uint8Array>;
11
+ /** Queue a chunk; ignored once the stream is closed or cancelled. */
12
+ push(chunk: Uint8Array): void;
13
+ end(): void;
14
+ /** End with an error: the reader sees a failed read (a truncated response). */
15
+ fail(error: unknown): void;
16
+ /** True once ended, failed or cancelled. */
17
+ readonly done: boolean;
18
+ }
19
+
20
+ /** A body stream fed from outside. `onCancel` runs when the reader hangs up before the end. */
21
+ export function pushStream(onCancel?: (reason: unknown) => void): PushStream {
22
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
23
+ let done = false;
24
+ const stream = new ReadableStream<Uint8Array>({
25
+ start: (c) => void (controller = c),
26
+ cancel: (reason) => {
27
+ if (done) return;
28
+ done = true;
29
+ onCancel?.(reason);
30
+ },
31
+ });
32
+ return {
33
+ stream,
34
+ push(chunk) {
35
+ if (!done && chunk.byteLength) controller.enqueue(chunk);
36
+ },
37
+ end() {
38
+ if (done) return;
39
+ done = true;
40
+ controller.close();
41
+ },
42
+ fail(error) {
43
+ if (done) return;
44
+ done = true;
45
+ controller.error(error);
46
+ },
47
+ get done() {
48
+ return done;
49
+ },
50
+ };
51
+ }
52
+
53
+ /** A stream holding exactly `bytes`. */
54
+ export function bytesStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
55
+ return new ReadableStream<Uint8Array>({
56
+ start(controller) {
57
+ if (bytes.byteLength) controller.enqueue(bytes);
58
+ controller.close();
59
+ },
60
+ });
61
+ }
62
+
63
+ /** A complete plain-text response (errors: nothing listening, the server exited). */
64
+ export function textResponse(status: number, text: string): HttpStreamResponse {
65
+ return { status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: bytesStream(new TextEncoder().encode(text)) };
66
+ }
67
+
68
+ /** Read a body stream to the end. */
69
+ export async function readBody(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
70
+ const chunks: Uint8Array[] = [];
71
+ let length = 0;
72
+ const reader = stream.getReader();
73
+ for (;;) {
74
+ const { value, done } = await reader.read();
75
+ if (done) break;
76
+ chunks.push(value);
77
+ length += value.byteLength;
78
+ }
79
+ if (chunks.length === 1) return chunks[0]!;
80
+ const out = new Uint8Array(length);
81
+ let offset = 0;
82
+ for (const chunk of chunks) {
83
+ out.set(chunk, offset);
84
+ offset += chunk.byteLength;
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Wait for a streamed response's whole body. */
90
+ export async function bufferResponse(response: HttpStreamResponse): Promise<HttpResponseData> {
91
+ return { ...headOf(response), body: await readBody(response.body) };
92
+ }
93
+
94
+ /** The status line and headers alone (plain data, safe to post). */
95
+ export function headOf(response: HttpResponseHead): HttpResponseHead {
96
+ return { status: response.status, statusText: response.statusText, headers: response.headers };
97
+ }
98
+
99
+ /**
100
+ * Feed a body stream to `sink` chunk by chunk. Returns a cancel that hangs up
101
+ * on the producer; `sink.end` gets the error message when the stream failed.
102
+ */
103
+ export function drainBody(stream: ReadableStream<Uint8Array>, sink: { chunk(data: Uint8Array): void; end(error?: string): void }): () => void {
104
+ const reader = stream.getReader();
105
+ let cancelled = false;
106
+ void (async () => {
107
+ try {
108
+ for (;;) {
109
+ const { value, done } = await reader.read();
110
+ if (done || cancelled) break;
111
+ sink.chunk(value);
112
+ }
113
+ if (!cancelled) sink.end();
114
+ } catch (error) {
115
+ if (!cancelled) sink.end(error instanceof Error ? error.message : String(error));
116
+ }
117
+ })();
118
+ return () => {
119
+ if (cancelled) return;
120
+ cancelled = true;
121
+ void reader.cancel().catch(() => undefined);
122
+ };
123
+ }
124
+
125
+ class ChannelEnd implements ByteChannel {
126
+ peer!: ChannelEnd;
127
+ closed = false;
128
+ private readonly data = new Set<(data: Uint8Array) => void>();
129
+ private readonly closes = new Set<() => void>();
130
+
131
+ send(data: Uint8Array): void {
132
+ if (this.closed) return;
133
+ const copy = new Uint8Array(data); // a copy (a Buffer's slice would be a view)
134
+ queueMicrotask(() => {
135
+ if (!this.peer.closed) for (const listener of this.peer.data) listener(copy);
136
+ });
137
+ }
138
+
139
+ close(): void {
140
+ if (this.closed) return;
141
+ this.closed = true;
142
+ // Delivered after any data already sent, like a FIN after the last segment.
143
+ queueMicrotask(() => {
144
+ if (this.peer.closed) return; // both hung up at once: neither needs telling
145
+ this.peer.closed = true;
146
+ for (const listener of this.peer.closes) listener();
147
+ });
148
+ }
149
+
150
+ onData(listener: (data: Uint8Array) => void): () => void {
151
+ this.data.add(listener);
152
+ return () => this.data.delete(listener);
153
+ }
154
+
155
+ onClose(listener: () => void): () => void {
156
+ this.closes.add(listener);
157
+ return () => this.closes.delete(listener);
158
+ }
159
+ }
160
+
161
+ /** Two connected ends: bytes sent on one arrive (asynchronously, in order) on the other. */
162
+ export function createChannelPair(): [ByteChannel, ByteChannel] {
163
+ const a = new ChannelEnd();
164
+ const b = new ChannelEnd();
165
+ a.peer = b;
166
+ b.peer = a;
167
+ return [a, b];
168
+ }
@@ -1,7 +1,30 @@
1
1
  export { Kernel, DEFAULT_ENV } from './kernel.js';
2
2
  export type { KernelOptions } from './kernel.js';
3
- export type { System, Program, ProgramContext, ProgramIO, TerminalPort, ProcessOptions, ChildHandle, HttpRequestData, HttpResponseData, HttpHandler } from './types.js';
3
+ export type {
4
+ System,
5
+ Program,
6
+ ProgramContext,
7
+ ProgramIO,
8
+ TerminalPort,
9
+ ProcessOptions,
10
+ ChildHandle,
11
+ HttpRequestData,
12
+ HttpResponseHead,
13
+ HttpResponseData,
14
+ HttpStreamResponse,
15
+ HttpHandler,
16
+ ByteChannel,
17
+ UpgradeHandler,
18
+ } from './types.js';
4
19
  export { PortRegistry, parsePortPath } from './ports.js';
20
+ export { pushStream, bytesStream, textResponse, readBody, bufferResponse, headOf, drainBody, createChannelPair } from './httpStream.js';
21
+ export type { PushStream } from './httpStream.js';
22
+ export { connectWebSocket } from './webSocketClient.js';
23
+ export type { WebSocketConnection, WebSocketConnectOptions, WebSocketCloseInfo } from './webSocketClient.js';
24
+ export { FrameDecoder, Opcode, encodeFrame, encodeClosePayload, decodeClosePayload, webSocketAccept, webSocketKey, WEBSOCKET_GUID } from './webSocketFrames.js';
25
+ export type { WebSocketFrame } from './webSocketFrames.js';
26
+ export { CookieJar, PortCookieJars, parseSetCookie, defaultCookiePath, cookiePathMatches } from './cookieJar.js';
27
+ export type { StoredCookie } from './cookieJar.js';
5
28
  export type { PortEvent } from './ports.js';
6
29
  export { runProgram, runArguments, toRuntimeProcess, allocatePid } from './process.js';
7
30
  export type { RuntimeProcess, RunProgramOptions } from './process.js';
@@ -7,7 +7,7 @@
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, ProcessOptions, System } from './types.js';
10
+ import type { ByteChannel, ChildHandle, HttpRequestData, HttpResponseData, HttpStreamResponse, Program, ProcessOptions, System } from './types.js';
11
11
  import { PortRegistry } from './ports.js';
12
12
  import { ServerEvents } from './serverEvents.js';
13
13
  import { commandNotFound, resolveCommand } from './commands.js';
@@ -55,7 +55,7 @@ export class Kernel {
55
55
  fs: this.fs,
56
56
  fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
57
57
  spawn: (command, args, spawnOptions) => this.spawnChild(command, args, spawnOptions),
58
- listen: (port, handler) => this.ports.listen(port, handler),
58
+ listen: (port, handler, upgrade) => this.ports.listen(port, handler, upgrade),
59
59
  loopback: (port, request) => this.ports.request(port, request),
60
60
  serverUrl,
61
61
  announceServer: (port, url) => this.servers.announce({ port, url }),
@@ -82,8 +82,20 @@ export class Kernel {
82
82
  return runProgram({ program: resolved.program, args: resolved.args, system: this.system, cwd, env, terminal: options.terminal });
83
83
  }
84
84
 
85
- /** Send an HTTP request to a virtual server (what the preview service worker does in the browser). */
85
+ /** Send an HTTP request to a virtual server (what the preview service worker does in the browser); resolves with the whole response. */
86
86
  request(port: number, request: Partial<HttpRequestData> & { url: string }): Promise<HttpResponseData | undefined> {
87
- return this.ports.request(port, { method: 'GET', headers: {}, body: null, ...request });
87
+ return this.ports.requestBuffered(port, withDefaults(request));
88
+ }
89
+
90
+ /** {@link request}, resolving at the head with the body streaming (Server-Sent Events, chunked progress). */
91
+ requestStream(port: number, request: Partial<HttpRequestData> & { url: string }): Promise<HttpStreamResponse | undefined> {
92
+ return this.ports.request(port, withDefaults(request));
93
+ }
94
+
95
+ /** Open an upgraded connection (a WebSocket, via `connectWebSocket`); undefined when nothing on `port` takes upgrades. */
96
+ upgrade(port: number, request: Partial<HttpRequestData> & { url: string }): ByteChannel | undefined {
97
+ return this.ports.upgrade(port, withDefaults(request));
88
98
  }
89
99
  }
100
+
101
+ const withDefaults = (request: Partial<HttpRequestData> & { url: string }): HttpRequestData => ({ method: 'GET', headers: {}, body: null, ...request });