@cloudflare/sandbox 0.4.12 → 0.4.15

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 (79) hide show
  1. package/.turbo/turbo-build.log +13 -47
  2. package/CHANGELOG.md +46 -16
  3. package/Dockerfile +78 -31
  4. package/README.md +9 -2
  5. package/dist/index.d.ts +1889 -9
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +3144 -65
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/clients/base-client.ts +39 -24
  11. package/src/clients/command-client.ts +8 -8
  12. package/src/clients/file-client.ts +31 -26
  13. package/src/clients/git-client.ts +3 -4
  14. package/src/clients/index.ts +12 -16
  15. package/src/clients/interpreter-client.ts +51 -47
  16. package/src/clients/port-client.ts +10 -10
  17. package/src/clients/process-client.ts +11 -8
  18. package/src/clients/sandbox-client.ts +2 -4
  19. package/src/clients/types.ts +6 -2
  20. package/src/clients/utility-client.ts +10 -6
  21. package/src/errors/adapter.ts +90 -32
  22. package/src/errors/classes.ts +189 -64
  23. package/src/errors/index.ts +9 -5
  24. package/src/file-stream.ts +11 -6
  25. package/src/index.ts +22 -15
  26. package/src/interpreter.ts +50 -41
  27. package/src/request-handler.ts +24 -21
  28. package/src/sandbox.ts +339 -149
  29. package/src/security.ts +21 -6
  30. package/src/sse-parser.ts +4 -3
  31. package/src/version.ts +1 -1
  32. package/tests/base-client.test.ts +116 -80
  33. package/tests/command-client.test.ts +149 -112
  34. package/tests/file-client.test.ts +309 -197
  35. package/tests/file-stream.test.ts +24 -20
  36. package/tests/get-sandbox.test.ts +10 -10
  37. package/tests/git-client.test.ts +188 -101
  38. package/tests/port-client.test.ts +100 -108
  39. package/tests/process-client.test.ts +204 -179
  40. package/tests/request-handler.test.ts +117 -65
  41. package/tests/sandbox.test.ts +219 -67
  42. package/tests/sse-parser.test.ts +17 -16
  43. package/tests/utility-client.test.ts +79 -72
  44. package/tsdown.config.ts +12 -0
  45. package/vitest.config.ts +6 -6
  46. package/dist/chunk-BFVUNTP4.js +0 -104
  47. package/dist/chunk-BFVUNTP4.js.map +0 -1
  48. package/dist/chunk-EKSWCBCA.js +0 -86
  49. package/dist/chunk-EKSWCBCA.js.map +0 -1
  50. package/dist/chunk-JXZMAU2C.js +0 -559
  51. package/dist/chunk-JXZMAU2C.js.map +0 -1
  52. package/dist/chunk-UJ3TV4M6.js +0 -7
  53. package/dist/chunk-UJ3TV4M6.js.map +0 -1
  54. package/dist/chunk-YE265ASX.js +0 -2484
  55. package/dist/chunk-YE265ASX.js.map +0 -1
  56. package/dist/chunk-Z532A7QC.js +0 -78
  57. package/dist/chunk-Z532A7QC.js.map +0 -1
  58. package/dist/file-stream.d.ts +0 -43
  59. package/dist/file-stream.js +0 -9
  60. package/dist/file-stream.js.map +0 -1
  61. package/dist/interpreter.d.ts +0 -33
  62. package/dist/interpreter.js +0 -8
  63. package/dist/interpreter.js.map +0 -1
  64. package/dist/request-handler.d.ts +0 -18
  65. package/dist/request-handler.js +0 -13
  66. package/dist/request-handler.js.map +0 -1
  67. package/dist/sandbox-CLZWpfGc.d.ts +0 -613
  68. package/dist/sandbox.d.ts +0 -4
  69. package/dist/sandbox.js +0 -13
  70. package/dist/sandbox.js.map +0 -1
  71. package/dist/security.d.ts +0 -31
  72. package/dist/security.js +0 -13
  73. package/dist/security.js.map +0 -1
  74. package/dist/sse-parser.d.ts +0 -28
  75. package/dist/sse-parser.js +0 -11
  76. package/dist/sse-parser.js.map +0 -1
  77. package/dist/version.d.ts +0 -8
  78. package/dist/version.js +0 -7
  79. package/dist/version.js.map +0 -1
@@ -1,14 +1,21 @@
1
1
  import { Container } from '@cloudflare/containers';
2
2
  import type { DurableObjectState } from '@cloudflare/workers-types';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { Sandbox } from '../src/sandbox';
4
+ import { connect, Sandbox } from '../src/sandbox';
5
5
 
6
6
  // Mock dependencies before imports
7
7
  vi.mock('./interpreter', () => ({
8
- CodeInterpreter: vi.fn().mockImplementation(() => ({})),
8
+ CodeInterpreter: vi.fn().mockImplementation(() => ({}))
9
9
  }));
10
10
 
11
11
  vi.mock('@cloudflare/containers', () => {
12
+ const mockSwitchPort = vi.fn((request: Request, port: number) => {
13
+ // Create a new request with the port in the URL path
14
+ const url = new URL(request.url);
15
+ url.pathname = `/proxy/${port}${url.pathname}`;
16
+ return new Request(url, request);
17
+ });
18
+
12
19
  const MockContainer = class Container {
13
20
  ctx: any;
14
21
  env: any;
@@ -18,6 +25,17 @@ vi.mock('@cloudflare/containers', () => {
18
25
  }
19
26
  async fetch(request: Request): Promise<Response> {
20
27
  // Mock implementation - will be spied on in tests
28
+ const upgradeHeader = request.headers.get('Upgrade');
29
+ if (upgradeHeader?.toLowerCase() === 'websocket') {
30
+ return new Response('WebSocket Upgraded', {
31
+ status: 200,
32
+ headers: {
33
+ 'X-WebSocket-Upgraded': 'true',
34
+ Upgrade: 'websocket',
35
+ Connection: 'Upgrade'
36
+ }
37
+ });
38
+ }
21
39
  return new Response('Mock Container fetch');
22
40
  }
23
41
  async containerFetch(request: Request, port: number): Promise<Response> {
@@ -29,12 +47,13 @@ vi.mock('@cloudflare/containers', () => {
29
47
  return {
30
48
  Container: MockContainer,
31
49
  getContainer: vi.fn(),
50
+ switchPort: mockSwitchPort
32
51
  };
33
52
  });
34
53
 
35
54
  describe('Sandbox - Automatic Session Management', () => {
36
55
  let sandbox: Sandbox;
37
- let mockCtx: Partial<DurableObjectState>;
56
+ let mockCtx: Partial<DurableObjectState<{}>>;
38
57
  let mockEnv: any;
39
58
 
40
59
  beforeEach(async () => {
@@ -46,31 +65,39 @@ describe('Sandbox - Automatic Session Management', () => {
46
65
  get: vi.fn().mockResolvedValue(null),
47
66
  put: vi.fn().mockResolvedValue(undefined),
48
67
  delete: vi.fn().mockResolvedValue(undefined),
49
- list: vi.fn().mockResolvedValue(new Map()),
68
+ list: vi.fn().mockResolvedValue(new Map())
50
69
  } as any,
51
- blockConcurrencyWhile: vi.fn().mockImplementation(<T>(callback: () => Promise<T>): Promise<T> => callback()),
70
+ blockConcurrencyWhile: vi
71
+ .fn()
72
+ .mockImplementation(
73
+ <T>(callback: () => Promise<T>): Promise<T> => callback()
74
+ ),
52
75
  id: {
53
76
  toString: () => 'test-sandbox-id',
54
77
  equals: vi.fn(),
55
- name: 'test-sandbox',
56
- } as any,
78
+ name: 'test-sandbox'
79
+ } as any
57
80
  };
58
81
 
59
82
  mockEnv = {};
60
83
 
61
84
  // Create Sandbox instance - SandboxClient is created internally
62
- sandbox = new Sandbox(mockCtx as DurableObjectState, mockEnv);
85
+ const stub = new Sandbox(mockCtx, mockEnv);
63
86
 
64
87
  // Wait for blockConcurrencyWhile to complete
65
88
  await vi.waitFor(() => {
66
89
  expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled();
67
90
  });
68
91
 
92
+ sandbox = Object.assign(stub, {
93
+ wsConnect: connect(stub)
94
+ });
95
+
69
96
  // Now spy on the client methods that we need for testing
70
97
  vi.spyOn(sandbox.client.utils, 'createSession').mockResolvedValue({
71
98
  success: true,
72
99
  id: 'sandbox-default',
73
- message: 'Created',
100
+ message: 'Created'
74
101
  } as any);
75
102
 
76
103
  vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({
@@ -79,13 +106,13 @@ describe('Sandbox - Automatic Session Management', () => {
79
106
  stderr: '',
80
107
  exitCode: 0,
81
108
  command: '',
82
- timestamp: new Date().toISOString(),
109
+ timestamp: new Date().toISOString()
83
110
  } as any);
84
111
 
85
112
  vi.spyOn(sandbox.client.files, 'writeFile').mockResolvedValue({
86
113
  success: true,
87
114
  path: '/test.txt',
88
- timestamp: new Date().toISOString(),
115
+ timestamp: new Date().toISOString()
89
116
  } as any);
90
117
  });
91
118
 
@@ -101,7 +128,7 @@ describe('Sandbox - Automatic Session Management', () => {
101
128
  stderr: '',
102
129
  exitCode: 0,
103
130
  command: 'echo test',
104
- timestamp: new Date().toISOString(),
131
+ timestamp: new Date().toISOString()
105
132
  } as any);
106
133
 
107
134
  await sandbox.exec('echo test');
@@ -110,7 +137,7 @@ describe('Sandbox - Automatic Session Management', () => {
110
137
  expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
111
138
  id: expect.stringMatching(/^sandbox-/),
112
139
  env: {},
113
- cwd: '/workspace',
140
+ cwd: '/workspace'
114
141
  });
115
142
 
116
143
  expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
@@ -126,9 +153,12 @@ describe('Sandbox - Automatic Session Management', () => {
126
153
 
127
154
  expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
128
155
 
129
- const firstSessionId = vi.mocked(sandbox.client.commands.execute).mock.calls[0][1];
130
- const fileSessionId = vi.mocked(sandbox.client.files.writeFile).mock.calls[0][2];
131
- const secondSessionId = vi.mocked(sandbox.client.commands.execute).mock.calls[1][1];
156
+ const firstSessionId = vi.mocked(sandbox.client.commands.execute).mock
157
+ .calls[0][1];
158
+ const fileSessionId = vi.mocked(sandbox.client.files.writeFile).mock
159
+ .calls[0][2];
160
+ const secondSessionId = vi.mocked(sandbox.client.commands.execute).mock
161
+ .calls[1][1];
132
162
 
133
163
  expect(firstSessionId).toBe(fileSessionId);
134
164
  expect(firstSessionId).toBe(secondSessionId);
@@ -140,19 +170,21 @@ describe('Sandbox - Automatic Session Management', () => {
140
170
  processId: 'proc-1',
141
171
  pid: 1234,
142
172
  command: 'sleep 10',
143
- timestamp: new Date().toISOString(),
173
+ timestamp: new Date().toISOString()
144
174
  } as any);
145
175
 
146
176
  vi.spyOn(sandbox.client.processes, 'listProcesses').mockResolvedValue({
147
177
  success: true,
148
- processes: [{
149
- id: 'proc-1',
150
- pid: 1234,
151
- command: 'sleep 10',
152
- status: 'running',
153
- startTime: new Date().toISOString(),
154
- }],
155
- timestamp: new Date().toISOString(),
178
+ processes: [
179
+ {
180
+ id: 'proc-1',
181
+ pid: 1234,
182
+ command: 'sleep 10',
183
+ status: 'running',
184
+ startTime: new Date().toISOString()
185
+ }
186
+ ],
187
+ timestamp: new Date().toISOString()
156
188
  } as any);
157
189
 
158
190
  const process = await sandbox.startProcess('sleep 10');
@@ -161,11 +193,14 @@ describe('Sandbox - Automatic Session Management', () => {
161
193
  expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
162
194
 
163
195
  // startProcess uses sessionId (to start process in that session)
164
- const startSessionId = vi.mocked(sandbox.client.processes.startProcess).mock.calls[0][1];
196
+ const startSessionId = vi.mocked(sandbox.client.processes.startProcess)
197
+ .mock.calls[0][1];
165
198
  expect(startSessionId).toMatch(/^sandbox-/);
166
199
 
167
200
  // listProcesses is sandbox-scoped - no sessionId parameter
168
- const listProcessesCall = vi.mocked(sandbox.client.processes.listProcesses).mock.calls[0];
201
+ const listProcessesCall = vi.mocked(
202
+ sandbox.client.processes.listProcesses
203
+ ).mock.calls[0];
169
204
  expect(listProcessesCall).toEqual([]);
170
205
 
171
206
  // Verify the started process appears in the list
@@ -181,10 +216,12 @@ describe('Sandbox - Automatic Session Management', () => {
181
216
  stderr: '',
182
217
  branch: 'main',
183
218
  targetDir: '/workspace/repo',
184
- timestamp: new Date().toISOString(),
219
+ timestamp: new Date().toISOString()
185
220
  } as any);
186
221
 
187
- await sandbox.gitCheckout('https://github.com/test/repo.git', { branch: 'main' });
222
+ await sandbox.gitCheckout('https://github.com/test/repo.git', {
223
+ branch: 'main'
224
+ });
188
225
 
189
226
  expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
190
227
  expect(sandbox.client.git.checkout).toHaveBeenCalledWith(
@@ -202,7 +239,7 @@ describe('Sandbox - Automatic Session Management', () => {
202
239
  expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
203
240
  id: 'sandbox-my-sandbox',
204
241
  env: {},
205
- cwd: '/workspace',
242
+ cwd: '/workspace'
206
243
  });
207
244
  });
208
245
  });
@@ -212,19 +249,19 @@ describe('Sandbox - Automatic Session Management', () => {
212
249
  vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
213
250
  success: true,
214
251
  id: 'custom-session-123',
215
- message: 'Created',
252
+ message: 'Created'
216
253
  } as any);
217
254
 
218
255
  const session = await sandbox.createSession({
219
256
  id: 'custom-session-123',
220
257
  env: { NODE_ENV: 'test' },
221
- cwd: '/test',
258
+ cwd: '/test'
222
259
  });
223
260
 
224
261
  expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
225
262
  id: 'custom-session-123',
226
263
  env: { NODE_ENV: 'test' },
227
- cwd: '/test',
264
+ cwd: '/test'
228
265
  });
229
266
 
230
267
  expect(session.id).toBe('custom-session-123');
@@ -238,7 +275,7 @@ describe('Sandbox - Automatic Session Management', () => {
238
275
  vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
239
276
  success: true,
240
277
  id: 'isolated-session',
241
- message: 'Created',
278
+ message: 'Created'
242
279
  } as any);
243
280
 
244
281
  const session = await sandbox.createSession({ id: 'isolated-session' });
@@ -253,8 +290,16 @@ describe('Sandbox - Automatic Session Management', () => {
253
290
 
254
291
  it('should isolate multiple explicit sessions', async () => {
255
292
  vi.mocked(sandbox.client.utils.createSession)
256
- .mockResolvedValueOnce({ success: true, id: 'session-1', message: 'Created' } as any)
257
- .mockResolvedValueOnce({ success: true, id: 'session-2', message: 'Created' } as any);
293
+ .mockResolvedValueOnce({
294
+ success: true,
295
+ id: 'session-1',
296
+ message: 'Created'
297
+ } as any)
298
+ .mockResolvedValueOnce({
299
+ success: true,
300
+ id: 'session-2',
301
+ message: 'Created'
302
+ } as any);
258
303
 
259
304
  const session1 = await sandbox.createSession({ id: 'session-1' });
260
305
  const session2 = await sandbox.createSession({ id: 'session-2' });
@@ -262,8 +307,10 @@ describe('Sandbox - Automatic Session Management', () => {
262
307
  await session1.exec('echo build');
263
308
  await session2.exec('echo test');
264
309
 
265
- const session1Id = vi.mocked(sandbox.client.commands.execute).mock.calls[0][1];
266
- const session2Id = vi.mocked(sandbox.client.commands.execute).mock.calls[1][1];
310
+ const session1Id = vi.mocked(sandbox.client.commands.execute).mock
311
+ .calls[0][1];
312
+ const session2Id = vi.mocked(sandbox.client.commands.execute).mock
313
+ .calls[1][1];
267
314
 
268
315
  expect(session1Id).toBe('session-1');
269
316
  expect(session2Id).toBe('session-2');
@@ -272,19 +319,32 @@ describe('Sandbox - Automatic Session Management', () => {
272
319
 
273
320
  it('should not interfere with default session', async () => {
274
321
  vi.mocked(sandbox.client.utils.createSession)
275
- .mockResolvedValueOnce({ success: true, id: 'sandbox-default', message: 'Created' } as any)
276
- .mockResolvedValueOnce({ success: true, id: 'explicit-session', message: 'Created' } as any);
322
+ .mockResolvedValueOnce({
323
+ success: true,
324
+ id: 'sandbox-default',
325
+ message: 'Created'
326
+ } as any)
327
+ .mockResolvedValueOnce({
328
+ success: true,
329
+ id: 'explicit-session',
330
+ message: 'Created'
331
+ } as any);
277
332
 
278
333
  await sandbox.exec('echo default');
279
334
 
280
- const explicitSession = await sandbox.createSession({ id: 'explicit-session' });
335
+ const explicitSession = await sandbox.createSession({
336
+ id: 'explicit-session'
337
+ });
281
338
  await explicitSession.exec('echo explicit');
282
339
 
283
340
  await sandbox.exec('echo default-again');
284
341
 
285
- const defaultSessionId1 = vi.mocked(sandbox.client.commands.execute).mock.calls[0][1];
286
- const explicitSessionId = vi.mocked(sandbox.client.commands.execute).mock.calls[1][1];
287
- const defaultSessionId2 = vi.mocked(sandbox.client.commands.execute).mock.calls[2][1];
342
+ const defaultSessionId1 = vi.mocked(sandbox.client.commands.execute).mock
343
+ .calls[0][1];
344
+ const explicitSessionId = vi.mocked(sandbox.client.commands.execute).mock
345
+ .calls[1][1];
346
+ const defaultSessionId2 = vi.mocked(sandbox.client.commands.execute).mock
347
+ .calls[2][1];
288
348
 
289
349
  expect(defaultSessionId1).toBe('sandbox-default');
290
350
  expect(explicitSessionId).toBe('explicit-session');
@@ -297,7 +357,7 @@ describe('Sandbox - Automatic Session Management', () => {
297
357
  vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
298
358
  success: true,
299
359
  id: 'session-generated-123',
300
- message: 'Created',
360
+ message: 'Created'
301
361
  } as any);
302
362
 
303
363
  await sandbox.createSession();
@@ -305,7 +365,7 @@ describe('Sandbox - Automatic Session Management', () => {
305
365
  expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
306
366
  id: expect.stringMatching(/^session-/),
307
367
  env: undefined,
308
- cwd: undefined,
368
+ cwd: undefined
309
369
  });
310
370
  });
311
371
  });
@@ -317,7 +377,7 @@ describe('Sandbox - Automatic Session Management', () => {
317
377
  vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
318
378
  success: true,
319
379
  id: 'test-session',
320
- message: 'Created',
380
+ message: 'Created'
321
381
  } as any);
322
382
 
323
383
  session = await sandbox.createSession({ id: 'test-session' });
@@ -325,7 +385,10 @@ describe('Sandbox - Automatic Session Management', () => {
325
385
 
326
386
  it('should execute command with session context', async () => {
327
387
  await session.exec('pwd');
328
- expect(sandbox.client.commands.execute).toHaveBeenCalledWith('pwd', 'test-session');
388
+ expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
389
+ 'pwd',
390
+ 'test-session'
391
+ );
329
392
  });
330
393
 
331
394
  it('should start process with session context', async () => {
@@ -336,8 +399,8 @@ describe('Sandbox - Automatic Session Management', () => {
336
399
  pid: 1234,
337
400
  command: 'sleep 10',
338
401
  status: 'running',
339
- startTime: new Date().toISOString(),
340
- },
402
+ startTime: new Date().toISOString()
403
+ }
341
404
  } as any);
342
405
 
343
406
  await session.startProcess('sleep 10');
@@ -353,7 +416,7 @@ describe('Sandbox - Automatic Session Management', () => {
353
416
  vi.spyOn(sandbox.client.files, 'writeFile').mockResolvedValue({
354
417
  success: true,
355
418
  path: '/test.txt',
356
- timestamp: new Date().toISOString(),
419
+ timestamp: new Date().toISOString()
357
420
  } as any);
358
421
 
359
422
  await session.writeFile('/test.txt', 'content');
@@ -373,7 +436,7 @@ describe('Sandbox - Automatic Session Management', () => {
373
436
  stderr: '',
374
437
  branch: 'main',
375
438
  targetDir: '/workspace/repo',
376
- timestamp: new Date().toISOString(),
439
+ timestamp: new Date().toISOString()
377
440
  } as any);
378
441
 
379
442
  await session.gitCheckout('https://github.com/test/repo.git');
@@ -392,7 +455,9 @@ describe('Sandbox - Automatic Session Management', () => {
392
455
  new Error('Session creation failed')
393
456
  );
394
457
 
395
- await expect(sandbox.exec('echo test')).rejects.toThrow('Session creation failed');
458
+ await expect(sandbox.exec('echo test')).rejects.toThrow(
459
+ 'Session creation failed'
460
+ );
396
461
  });
397
462
 
398
463
  it('should initialize with empty environment when not set', async () => {
@@ -401,7 +466,7 @@ describe('Sandbox - Automatic Session Management', () => {
401
466
  expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
402
467
  id: expect.any(String),
403
468
  env: {},
404
- cwd: '/workspace',
469
+ cwd: '/workspace'
405
470
  });
406
471
  });
407
472
 
@@ -413,7 +478,7 @@ describe('Sandbox - Automatic Session Management', () => {
413
478
  expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
414
479
  id: expect.any(String),
415
480
  env: { NODE_ENV: 'production', DEBUG: 'true' },
416
- cwd: '/workspace',
481
+ cwd: '/workspace'
417
482
  });
418
483
  });
419
484
  });
@@ -425,7 +490,7 @@ describe('Sandbox - Automatic Session Management', () => {
425
490
  success: true,
426
491
  port: 8080,
427
492
  name: 'test-service',
428
- exposedAt: new Date().toISOString(),
493
+ exposedAt: new Date().toISOString()
429
494
  } as any);
430
495
  });
431
496
 
@@ -459,7 +524,10 @@ describe('Sandbox - Automatic Session Management', () => {
459
524
  ];
460
525
 
461
526
  for (const { hostname } of testCases) {
462
- const result = await sandbox.exposePort(8080, { name: 'test', hostname });
527
+ const result = await sandbox.exposePort(8080, {
528
+ name: 'test',
529
+ hostname
530
+ });
463
531
  expect(result.url).toContain(hostname);
464
532
  expect(result.port).toBe(8080);
465
533
  }
@@ -483,7 +551,8 @@ describe('Sandbox - Automatic Session Management', () => {
483
551
  await sandbox.setSandboxName('test-sandbox');
484
552
 
485
553
  // Spy on Container.prototype.fetch to verify WebSocket routing
486
- superFetchSpy = vi.spyOn(Container.prototype, 'fetch')
554
+ superFetchSpy = vi
555
+ .spyOn(Container.prototype, 'fetch')
487
556
  .mockResolvedValue(new Response('WebSocket response'));
488
557
  });
489
558
 
@@ -494,9 +563,9 @@ describe('Sandbox - Automatic Session Management', () => {
494
563
  it('should detect WebSocket upgrade header and route to super.fetch', async () => {
495
564
  const request = new Request('https://example.com/ws', {
496
565
  headers: {
497
- 'Upgrade': 'websocket',
498
- 'Connection': 'Upgrade',
499
- },
566
+ Upgrade: 'websocket',
567
+ Connection: 'Upgrade'
568
+ }
500
569
  });
501
570
 
502
571
  const response = await sandbox.fetch(request);
@@ -518,7 +587,7 @@ describe('Sandbox - Automatic Session Management', () => {
518
587
  const postRequest = new Request('https://example.com/api/data', {
519
588
  method: 'POST',
520
589
  body: JSON.stringify({ data: 'test' }),
521
- headers: { 'Content-Type': 'application/json' },
590
+ headers: { 'Content-Type': 'application/json' }
522
591
  });
523
592
  await sandbox.fetch(postRequest);
524
593
  expect(superFetchSpy).not.toHaveBeenCalled();
@@ -527,7 +596,7 @@ describe('Sandbox - Automatic Session Management', () => {
527
596
 
528
597
  // SSE request (should not be detected as WebSocket)
529
598
  const sseRequest = new Request('https://example.com/events', {
530
- headers: { 'Accept': 'text/event-stream' },
599
+ headers: { Accept: 'text/event-stream' }
531
600
  });
532
601
  await sandbox.fetch(sseRequest);
533
602
  expect(superFetchSpy).not.toHaveBeenCalled();
@@ -536,10 +605,11 @@ describe('Sandbox - Automatic Session Management', () => {
536
605
  it('should preserve WebSocket request unchanged when calling super.fetch()', async () => {
537
606
  const request = new Request('https://example.com/ws', {
538
607
  headers: {
539
- 'Upgrade': 'websocket',
608
+ Upgrade: 'websocket',
609
+ Connection: 'Upgrade',
540
610
  'Sec-WebSocket-Key': 'test-key-123',
541
- 'Sec-WebSocket-Version': '13',
542
- },
611
+ 'Sec-WebSocket-Version': '13'
612
+ }
543
613
  });
544
614
 
545
615
  await sandbox.fetch(request);
@@ -547,8 +617,90 @@ describe('Sandbox - Automatic Session Management', () => {
547
617
  expect(superFetchSpy).toHaveBeenCalledTimes(1);
548
618
  const passedRequest = superFetchSpy.mock.calls[0][0] as Request;
549
619
  expect(passedRequest.headers.get('Upgrade')).toBe('websocket');
550
- expect(passedRequest.headers.get('Sec-WebSocket-Key')).toBe('test-key-123');
620
+ expect(passedRequest.headers.get('Connection')).toBe('Upgrade');
621
+ expect(passedRequest.headers.get('Sec-WebSocket-Key')).toBe(
622
+ 'test-key-123'
623
+ );
551
624
  expect(passedRequest.headers.get('Sec-WebSocket-Version')).toBe('13');
552
625
  });
553
626
  });
627
+
628
+ describe('wsConnect() method', () => {
629
+ it('should route WebSocket request through switchPort to sandbox.fetch', async () => {
630
+ const { switchPort } = await import('@cloudflare/containers');
631
+ const switchPortMock = vi.mocked(switchPort);
632
+
633
+ const request = new Request('http://localhost/ws/echo', {
634
+ headers: {
635
+ Upgrade: 'websocket',
636
+ Connection: 'Upgrade'
637
+ }
638
+ });
639
+
640
+ const fetchSpy = vi.spyOn(sandbox, 'fetch');
641
+ const response = await sandbox.wsConnect(request, 8080);
642
+
643
+ // Verify switchPort was called with correct port
644
+ expect(switchPortMock).toHaveBeenCalledWith(request, 8080);
645
+
646
+ // Verify fetch was called with the switched request
647
+ expect(fetchSpy).toHaveBeenCalledOnce();
648
+
649
+ // Verify response indicates WebSocket upgrade
650
+ expect(response.status).toBe(200);
651
+ expect(response.headers.get('X-WebSocket-Upgraded')).toBe('true');
652
+ });
653
+
654
+ it('should reject invalid ports with SecurityError', async () => {
655
+ const request = new Request('http://localhost/ws/test', {
656
+ headers: { Upgrade: 'websocket', Connection: 'Upgrade' }
657
+ });
658
+
659
+ // Invalid port values
660
+ await expect(sandbox.wsConnect(request, -1)).rejects.toThrow(
661
+ 'Invalid or restricted port'
662
+ );
663
+ await expect(sandbox.wsConnect(request, 0)).rejects.toThrow(
664
+ 'Invalid or restricted port'
665
+ );
666
+ await expect(sandbox.wsConnect(request, 70000)).rejects.toThrow(
667
+ 'Invalid or restricted port'
668
+ );
669
+
670
+ // Privileged ports
671
+ await expect(sandbox.wsConnect(request, 80)).rejects.toThrow(
672
+ 'Invalid or restricted port'
673
+ );
674
+ await expect(sandbox.wsConnect(request, 443)).rejects.toThrow(
675
+ 'Invalid or restricted port'
676
+ );
677
+ });
678
+
679
+ it('should preserve request properties through routing', async () => {
680
+ const request = new Request(
681
+ 'http://localhost/ws/test?token=abc&room=lobby',
682
+ {
683
+ headers: {
684
+ Upgrade: 'websocket',
685
+ Connection: 'Upgrade',
686
+ 'X-Custom-Header': 'custom-value'
687
+ }
688
+ }
689
+ );
690
+
691
+ const fetchSpy = vi.spyOn(sandbox, 'fetch');
692
+ await sandbox.wsConnect(request, 8080);
693
+
694
+ const calledRequest = fetchSpy.mock.calls[0][0];
695
+
696
+ // Verify headers are preserved
697
+ expect(calledRequest.headers.get('Upgrade')).toBe('websocket');
698
+ expect(calledRequest.headers.get('X-Custom-Header')).toBe('custom-value');
699
+
700
+ // Verify query parameters are preserved
701
+ const url = new URL(calledRequest.url);
702
+ expect(url.searchParams.get('token')).toBe('abc');
703
+ expect(url.searchParams.get('room')).toBe('lobby');
704
+ });
705
+ });
554
706
  });
@@ -1,5 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { asyncIterableToSSEStream, parseSSEStream, responseToAsyncIterable } from '../src/sse-parser';
2
+ import {
3
+ asyncIterableToSSEStream,
4
+ parseSSEStream,
5
+ responseToAsyncIterable
6
+ } from '../src/sse-parser';
3
7
 
4
8
  function createMockSSEStream(events: string[]): ReadableStream<Uint8Array> {
5
9
  return new ReadableStream({
@@ -25,7 +29,6 @@ describe('SSE Parser', () => {
25
29
  });
26
30
 
27
31
  describe('parseSSEStream', () => {
28
-
29
32
  it('should parse valid SSE events', async () => {
30
33
  const stream = createMockSSEStream([
31
34
  'data: {"type":"start","command":"echo test"}\n\n',
@@ -134,9 +137,7 @@ describe('SSE Parser', () => {
134
137
  });
135
138
 
136
139
  it('should handle remaining buffer data after stream ends', async () => {
137
- const stream = createMockSSEStream([
138
- 'data: {"type":"complete"}'
139
- ]);
140
+ const stream = createMockSSEStream(['data: {"type":"complete"}']);
140
141
 
141
142
  const events: any[] = [];
142
143
  for await (const event of parseSSEStream(stream)) {
@@ -153,7 +154,8 @@ describe('SSE Parser', () => {
153
154
  controller.abort();
154
155
 
155
156
  await expect(async () => {
156
- for await (const event of parseSSEStream(stream, controller.signal)) {}
157
+ for await (const event of parseSSEStream(stream, controller.signal)) {
158
+ }
157
159
  }).rejects.toThrow('Operation was aborted');
158
160
  });
159
161
 
@@ -236,10 +238,10 @@ describe('SSE Parser', () => {
236
238
  const stream = asyncIterableToSSEStream(mockEvents());
237
239
  const reader = stream.getReader();
238
240
  const decoder = new TextDecoder();
239
-
241
+
240
242
  const chunks: string[] = [];
241
243
  let done = false;
242
-
244
+
243
245
  while (!done) {
244
246
  const { value, done: readerDone } = await reader.read();
245
247
  done = readerDone;
@@ -251,9 +253,9 @@ describe('SSE Parser', () => {
251
253
  const fullOutput = chunks.join('');
252
254
  expect(fullOutput).toBe(
253
255
  'data: {"type":"start","command":"test"}\n\n' +
254
- 'data: {"type":"stdout","data":"output"}\n\n' +
255
- 'data: {"type":"complete","exitCode":0}\n\n' +
256
- 'data: [DONE]\n\n'
256
+ 'data: {"type":"stdout","data":"output"}\n\n' +
257
+ 'data: {"type":"complete","exitCode":0}\n\n' +
258
+ 'data: [DONE]\n\n'
257
259
  );
258
260
  });
259
261
 
@@ -262,10 +264,9 @@ describe('SSE Parser', () => {
262
264
  yield { name: 'test', value: 123 };
263
265
  }
264
266
 
265
- const stream = asyncIterableToSSEStream(
266
- mockEvents(),
267
- { serialize: (event) => `custom:${event.name}=${event.value}` }
268
- );
267
+ const stream = asyncIterableToSSEStream(mockEvents(), {
268
+ serialize: (event) => `custom:${event.name}=${event.value}`
269
+ });
269
270
 
270
271
  const reader = stream.getReader();
271
272
  const decoder = new TextDecoder();
@@ -287,4 +288,4 @@ describe('SSE Parser', () => {
287
288
  await expect(reader.read()).rejects.toThrow('Async iterable error');
288
289
  });
289
290
  });
290
- });
291
+ });