Skip to content

Commit 77cb937

Browse files
Move .connect to .wsConnect within DO stub (#175)
* Move .connect to .wsConnect within DO stub * Use `Sandbox` type instead of DurableObjectStub<> This gets around IDEs not being able to do go-to-definition due to mapped types not being resolved properly. This is less truthful perhaps (hides RPC semantics), but more useful for developers. * Add changeset * Revert "Use `Sandbox` type instead of DurableObjectStub<>" This reverts commit 4ed544e. * Undo unnecessary type changes
1 parent 7edbfa9 commit 77cb937

File tree

15 files changed

+16822
-79
lines changed

15 files changed

+16822
-79
lines changed

.changeset/hot-pans-warn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/sandbox": patch
3+
---
4+
5+
Move .connect to .wsConnect within DO stub

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ tests/e2e/test-worker/wrangler.jsonc
149149
# Allow config files and scripts
150150
!scripts/**/*.js
151151
!examples/**/*.js
152+
# Allow generated types in examples
153+
!examples/**/*.d.ts
152154
# Allow source in dist directories
153155
!dist/**
154156
!node_modules/**

examples/claude-code/src/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getSandbox, type Sandbox } from '@cloudflare/sandbox';
1+
import { getSandbox } from '@cloudflare/sandbox';
22

33
interface CmdOutput {
44
success: boolean;
@@ -8,11 +8,6 @@ interface CmdOutput {
88
// helper to read the outputs from `.exec` results
99
const getOutput = (res: CmdOutput) => (res.success ? res.stdout : res.stderr);
1010

11-
type Env = {
12-
Sandbox: DurableObjectNamespace<Sandbox>;
13-
ANTHROPIC_API_KEY: string;
14-
};
15-
1611
const EXTRA_SYSTEM =
1712
'You are an automatic feature-implementer/bug-fixer.' +
1813
'You apply all necessary changes to achieve the user request. You must ensure you DO NOT commit the changes, ' +

examples/claude-code/worker-configuration.d.ts

Lines changed: 8383 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CLOUDFLARE_ACCOUNT_ID=xxxx
2+
CLOUDFLARE_API_KEY=xxxx

examples/minimal/src/index.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
import { getSandbox, type Sandbox } from '@cloudflare/sandbox';
1+
import { getSandbox } from '@cloudflare/sandbox';
22

33
export { Sandbox } from '@cloudflare/sandbox';
44

5-
type Env = {
6-
Sandbox: DurableObjectNamespace<Sandbox>;
7-
};
8-
95
export default {
106
async fetch(request: Request, env: Env): Promise<Response> {
117
const url = new URL(request.url);

examples/minimal/worker-configuration.d.ts

Lines changed: 8376 additions & 0 deletions
Large diffs are not rendered by default.

packages/sandbox/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export {
1010
SandboxClient,
1111
UtilityClient
1212
} from './clients';
13-
export { connect, getSandbox, Sandbox } from './sandbox';
13+
export { getSandbox, Sandbox } from './sandbox';
1414

1515
// Legacy types are now imported from the new client architecture
1616

packages/sandbox/src/sandbox.ts

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function getSandbox(
3232
id: string,
3333
options?: SandboxOptions
3434
): Sandbox {
35-
const stub = getContainer(ns, id) as any as Sandbox;
35+
const stub = getContainer(ns, id) as unknown as Sandbox;
3636

3737
// Store the name on first access
3838
stub.setSandboxName?.(id);
@@ -49,41 +49,24 @@ export function getSandbox(
4949
stub.setKeepAlive(options.keepAlive);
5050
}
5151

52-
return stub;
52+
return Object.assign(stub, {
53+
wsConnect: connect(stub)
54+
});
5355
}
5456

55-
/**
56-
* Connect an incoming WebSocket request to a specific port inside the container.
57-
*
58-
* Note: This is a standalone function (not a Sandbox method) because WebSocket
59-
* connections cannot be serialized over Durable Object RPC.
60-
*
61-
* @param sandbox - The Sandbox instance to route the request through
62-
* @param request - The incoming WebSocket upgrade request
63-
* @param port - The port number to connect to (1024-65535)
64-
* @returns The WebSocket upgrade response
65-
* @throws {SecurityError} - If port is invalid or in restricted range
66-
*
67-
* @example
68-
* const sandbox = getSandbox(env.Sandbox, 'sandbox-id');
69-
* if (request.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
70-
* return await connect(sandbox, request, 8080);
71-
* }
72-
*/
73-
export async function connect(
74-
sandbox: Sandbox,
75-
request: Request,
76-
port: number
77-
): Promise<Response> {
78-
// Validate port before routing
79-
if (!validatePort(port)) {
80-
throw new SecurityError(
81-
`Invalid or restricted port: ${port}. Ports must be in range 1024-65535 and not reserved.`
82-
);
83-
}
84-
85-
const portSwitchedRequest = switchPort(request, port);
86-
return await sandbox.fetch(portSwitchedRequest);
57+
export function connect(
58+
stub: { fetch: (request: Request) => Promise<Response> }
59+
) {
60+
return async (request: Request, port: number) => {
61+
// Validate port before routing
62+
if (!validatePort(port)) {
63+
throw new SecurityError(
64+
`Invalid or restricted port: ${port}. Ports must be in range 1024-65535 and not reserved.`
65+
);
66+
}
67+
const portSwitchedRequest = switchPort(request, port);
68+
return await stub.fetch(portSwitchedRequest);
69+
};
8770
}
8871

8972
export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
@@ -100,7 +83,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
10083
private logger: ReturnType<typeof createLogger>;
10184
private keepAliveEnabled: boolean = false;
10285

103-
constructor(ctx: DurableObject['ctx'], env: Env) {
86+
constructor(ctx: DurableObjectState<{}>, env: Env) {
10487
super(ctx, env);
10588

10689
const envObj = env as any;
@@ -360,6 +343,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
360343
});
361344
}
362345

346+
wsConnect(request: Request, port: number): Promise<Response> {
347+
// Dummy implementation that will be overridden by the stub
348+
throw new Error('Not implemented here to avoid RPC serialization issues');
349+
}
350+
363351
private determinePort(url: URL): number {
364352
// Extract port from proxy requests (e.g., /proxy/8080/*)
365353
const proxyMatch = url.pathname.match(/^\/proxy\/(\d+)/);

packages/sandbox/tests/sandbox.test.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ vi.mock('@cloudflare/containers', () => {
5353

5454
describe('Sandbox - Automatic Session Management', () => {
5555
let sandbox: Sandbox;
56-
let mockCtx: Partial<DurableObjectState>;
56+
let mockCtx: Partial<DurableObjectState<{}>>;
5757
let mockEnv: any;
5858

5959
beforeEach(async () => {
@@ -82,13 +82,17 @@ describe('Sandbox - Automatic Session Management', () => {
8282
mockEnv = {};
8383

8484
// Create Sandbox instance - SandboxClient is created internally
85-
sandbox = new Sandbox(mockCtx as DurableObjectState, mockEnv);
85+
const stub = new Sandbox(mockCtx, mockEnv);
8686

8787
// Wait for blockConcurrencyWhile to complete
8888
await vi.waitFor(() => {
8989
expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled();
9090
});
9191

92+
sandbox = Object.assign(stub, {
93+
wsConnect: connect(stub)
94+
});
95+
9296
// Now spy on the client methods that we need for testing
9397
vi.spyOn(sandbox.client.utils, 'createSession').mockResolvedValue({
9498
success: true,
@@ -621,7 +625,7 @@ describe('Sandbox - Automatic Session Management', () => {
621625
});
622626
});
623627

624-
describe('connect() function', () => {
628+
describe('wsConnect() method', () => {
625629
it('should route WebSocket request through switchPort to sandbox.fetch', async () => {
626630
const { switchPort } = await import('@cloudflare/containers');
627631
const switchPortMock = vi.mocked(switchPort);
@@ -634,7 +638,7 @@ describe('Sandbox - Automatic Session Management', () => {
634638
});
635639

636640
const fetchSpy = vi.spyOn(sandbox, 'fetch');
637-
const response = await connect(sandbox, request, 8080);
641+
const response = await sandbox.wsConnect(request, 8080);
638642

639643
// Verify switchPort was called with correct port
640644
expect(switchPortMock).toHaveBeenCalledWith(request, 8080);
@@ -653,21 +657,21 @@ describe('Sandbox - Automatic Session Management', () => {
653657
});
654658

655659
// Invalid port values
656-
await expect(connect(sandbox, request, -1)).rejects.toThrow(
660+
await expect(sandbox.wsConnect(request, -1)).rejects.toThrow(
657661
'Invalid or restricted port'
658662
);
659-
await expect(connect(sandbox, request, 0)).rejects.toThrow(
663+
await expect(sandbox.wsConnect(request, 0)).rejects.toThrow(
660664
'Invalid or restricted port'
661665
);
662-
await expect(connect(sandbox, request, 70000)).rejects.toThrow(
666+
await expect(sandbox.wsConnect(request, 70000)).rejects.toThrow(
663667
'Invalid or restricted port'
664668
);
665669

666670
// Privileged ports
667-
await expect(connect(sandbox, request, 80)).rejects.toThrow(
671+
await expect(sandbox.wsConnect(request, 80)).rejects.toThrow(
668672
'Invalid or restricted port'
669673
);
670-
await expect(connect(sandbox, request, 443)).rejects.toThrow(
674+
await expect(sandbox.wsConnect(request, 443)).rejects.toThrow(
671675
'Invalid or restricted port'
672676
);
673677
});
@@ -685,7 +689,7 @@ describe('Sandbox - Automatic Session Management', () => {
685689
);
686690

687691
const fetchSpy = vi.spyOn(sandbox, 'fetch');
688-
await connect(sandbox, request, 8080);
692+
await sandbox.wsConnect(request, 8080);
689693

690694
const calledRequest = fetchSpy.mock.calls[0][0];
691695

0 commit comments

Comments
 (0)