@ricsam/isolate-fetch 0.1.4 → 0.1.7

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.
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # @ricsam/isolate-fetch
2
2
 
3
- Fetch API and HTTP server handler.
3
+ Fetch API and HTTP server handler for isolated-vm V8 sandbox.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm add @ricsam/isolate-fetch
9
+ ```
10
+
11
+ ## Usage
4
12
 
5
13
  ```typescript
6
14
  import { setupFetch } from "@ricsam/isolate-fetch";
@@ -14,12 +22,13 @@ const handle = await setupFetch(context, {
14
22
  });
15
23
  ```
16
24
 
17
- **Injected Globals:**
25
+ ## Injected Globals
26
+
18
27
  - `fetch`, `Request`, `Response`, `Headers`
19
28
  - `FormData`, `AbortController`, `AbortSignal`
20
29
  - `serve` (HTTP server handler)
21
30
 
22
- **Usage in Isolate:**
31
+ ## Usage in Isolate
23
32
 
24
33
  ```javascript
25
34
  // Outbound fetch
@@ -53,30 +62,262 @@ formData.append("name", "John");
53
62
  formData.append("file", new File(["content"], "file.txt"));
54
63
  ```
55
64
 
56
- #### HTTP Server
65
+ ## HTTP Server (`serve`)
66
+
67
+ The `serve()` function registers a request handler in the isolate that can receive HTTP requests dispatched from the host. It uses a Bun-compatible API.
68
+
69
+ ### Basic Usage
70
+
71
+ ```javascript
72
+ // In isolate code
73
+ serve({
74
+ fetch(request, server) {
75
+ const url = new URL(request.url);
76
+ return Response.json({ path: url.pathname, method: request.method });
77
+ }
78
+ });
79
+ ```
80
+
81
+ ### The `fetch` Handler
82
+
83
+ The `fetch` handler receives two arguments:
57
84
 
58
- Register a server handler in the isolate and dispatch requests from the host:
85
+ - `request` - A standard `Request` object
86
+ - `server` - A server object with WebSocket upgrade capability
87
+
88
+ ```javascript
89
+ serve({
90
+ fetch(request, server) {
91
+ // Access request properties
92
+ const url = new URL(request.url);
93
+ const method = request.method;
94
+ const headers = request.headers;
95
+ const body = await request.json(); // for POST/PUT
96
+
97
+ // Return a Response
98
+ return new Response("Hello World");
99
+ }
100
+ });
101
+ ```
102
+
103
+ ### The `server` Object
104
+
105
+ The `server` argument provides WebSocket upgrade functionality:
106
+
107
+ ```javascript
108
+ serve({
109
+ fetch(request, server) {
110
+ // Check for WebSocket upgrade request
111
+ if (request.headers.get("Upgrade") === "websocket") {
112
+ // Upgrade the connection, optionally passing data
113
+ server.upgrade(request, { data: { userId: "123" } });
114
+ return new Response(null, { status: 101 });
115
+ }
116
+
117
+ return new Response("Not a WebSocket request", { status: 400 });
118
+ }
119
+ });
120
+ ```
121
+
122
+ ## WebSocket Support
123
+
124
+ The `serve()` function supports WebSocket connections through a `websocket` handler object.
125
+
126
+ ### WebSocket Handlers
127
+
128
+ ```javascript
129
+ serve({
130
+ fetch(request, server) {
131
+ if (request.headers.get("Upgrade") === "websocket") {
132
+ server.upgrade(request, { data: { userId: "123" } });
133
+ return new Response(null, { status: 101 });
134
+ }
135
+ return new Response("OK");
136
+ },
137
+ websocket: {
138
+ open(ws) {
139
+ // Called when a WebSocket connection is opened
140
+ console.log("Connected:", ws.data.userId);
141
+ ws.send("Welcome!");
142
+ },
143
+ message(ws, message) {
144
+ // Called when a message is received from the client
145
+ console.log("Received:", message);
146
+ ws.send("Echo: " + message);
147
+ },
148
+ close(ws, code, reason) {
149
+ // Called when the connection is closed
150
+ console.log("Closed:", code, reason);
151
+ },
152
+ error(ws, error) {
153
+ // Called when an error occurs
154
+ console.error("Error:", error);
155
+ }
156
+ }
157
+ });
158
+ ```
159
+
160
+ ### The `ws` Object
161
+
162
+ Each WebSocket handler receives a `ws` object with the following properties and methods:
163
+
164
+ | Property/Method | Description |
165
+ |-----------------|-------------|
166
+ | `ws.data` | Custom data passed during `server.upgrade()` |
167
+ | `ws.send(message)` | Send a message to the client (string or ArrayBuffer) |
168
+ | `ws.close(code?, reason?)` | Close the connection with optional code and reason |
169
+ | `ws.readyState` | Current state: 1 (OPEN), 2 (CLOSING), 3 (CLOSED) |
170
+
171
+ ### Optional Handlers
172
+
173
+ All WebSocket handlers are optional. You can define only the handlers you need:
174
+
175
+ ```javascript
176
+ // Only handle messages - no open/close/error handlers needed
177
+ serve({
178
+ fetch(request, server) { /* ... */ },
179
+ websocket: {
180
+ message(ws, message) {
181
+ ws.send("Echo: " + message);
182
+ }
183
+ }
184
+ });
185
+
186
+ // Only handle open and close
187
+ serve({
188
+ fetch(request, server) { /* ... */ },
189
+ websocket: {
190
+ open(ws) {
191
+ console.log("Connected");
192
+ },
193
+ close(ws, code, reason) {
194
+ console.log("Disconnected");
195
+ }
196
+ }
197
+ });
198
+ ```
199
+
200
+ ## Host-Side API
201
+
202
+ The host dispatches requests and WebSocket events to the isolate.
203
+
204
+ ### Dispatching HTTP Requests
205
+
206
+ ```typescript
207
+ // From host code
208
+ const response = await handle.dispatchRequest(
209
+ new Request("http://localhost/api/users", {
210
+ method: "POST",
211
+ body: JSON.stringify({ name: "Alice" }),
212
+ })
213
+ );
214
+
215
+ console.log(await response.json());
216
+ ```
217
+
218
+ ### WebSocket Flow
219
+
220
+ The host manages WebSocket connections and dispatches events to the isolate:
221
+
222
+ ```typescript
223
+ // 1. Dispatch the upgrade request
224
+ await handle.dispatchRequest(
225
+ new Request("http://localhost/ws", {
226
+ headers: { "Upgrade": "websocket" }
227
+ })
228
+ );
229
+
230
+ // 2. Check if isolate requested an upgrade
231
+ const upgradeRequest = handle.getUpgradeRequest();
232
+ if (upgradeRequest?.requested) {
233
+ const connectionId = upgradeRequest.connectionId;
234
+
235
+ // 3. Register callback for commands FROM the isolate
236
+ handle.onWebSocketCommand((cmd) => {
237
+ if (cmd.type === "message") {
238
+ // Isolate called ws.send() - forward to real WebSocket
239
+ realWebSocket.send(cmd.data);
240
+ } else if (cmd.type === "close") {
241
+ // Isolate called ws.close()
242
+ realWebSocket.close(cmd.code, cmd.reason);
243
+ }
244
+ });
245
+
246
+ // 4. Notify isolate the connection is open (triggers websocket.open)
247
+ handle.dispatchWebSocketOpen(connectionId);
248
+
249
+ // 5. Forward messages TO the isolate (triggers websocket.message)
250
+ realWebSocket.onmessage = (event) => {
251
+ handle.dispatchWebSocketMessage(connectionId, event.data);
252
+ };
253
+
254
+ // 6. Forward close events TO the isolate (triggers websocket.close)
255
+ realWebSocket.onclose = (event) => {
256
+ handle.dispatchWebSocketClose(connectionId, event.code, event.reason);
257
+ };
258
+ }
259
+ ```
260
+
261
+ ### Host API Reference
262
+
263
+ | Method | Description |
264
+ |--------|-------------|
265
+ | `dispatchRequest(request)` | Dispatch HTTP request to isolate's `serve()` handler |
266
+ | `hasServeHandler()` | Check if `serve()` has been called in isolate |
267
+ | `hasActiveConnections()` | Check if there are active WebSocket connections |
268
+ | `getUpgradeRequest()` | Get pending WebSocket upgrade request info |
269
+ | `dispatchWebSocketOpen(id)` | Notify isolate that WebSocket connection opened |
270
+ | `dispatchWebSocketMessage(id, data)` | Send message to isolate's `websocket.message` handler |
271
+ | `dispatchWebSocketClose(id, code, reason)` | Notify isolate that connection closed |
272
+ | `dispatchWebSocketError(id, error)` | Notify isolate of WebSocket error |
273
+ | `onWebSocketCommand(callback)` | Register callback for `ws.send()`/`ws.close()` from isolate |
274
+
275
+ ### WebSocket Command Types
276
+
277
+ Commands received via `onWebSocketCommand`:
59
278
 
60
279
  ```typescript
61
- // In isolate
280
+ interface WebSocketCommand {
281
+ type: "message" | "close";
282
+ connectionId: string;
283
+ data?: string | ArrayBuffer; // For "message" type
284
+ code?: number; // For "close" type
285
+ reason?: string; // For "close" type
286
+ }
287
+ ```
288
+
289
+ ## Complete Example
290
+
291
+ ```typescript
292
+ // Host code
293
+ import { setupFetch } from "@ricsam/isolate-fetch";
294
+
295
+ const handle = await setupFetch(context, {
296
+ onFetch: async (request) => fetch(request),
297
+ });
298
+
299
+ // Set up serve handler in isolate
62
300
  await context.eval(`
63
301
  serve({
64
302
  fetch(request, server) {
65
303
  const url = new URL(request.url);
66
304
 
67
- if (url.pathname === "/ws") {
68
- if (server.upgrade(request, { data: { userId: "123" } })) {
69
- return new Response(null, { status: 101 });
70
- }
305
+ // WebSocket upgrade
306
+ if (url.pathname === "/ws" && request.headers.get("Upgrade") === "websocket") {
307
+ server.upgrade(request, { data: { path: url.pathname } });
308
+ return new Response(null, { status: 101 });
71
309
  }
72
310
 
311
+ // Regular HTTP
73
312
  return Response.json({ path: url.pathname });
74
313
  },
75
314
  websocket: {
76
315
  open(ws) {
77
- console.log("Connected:", ws.data.userId);
316
+ console.log("WebSocket connected to:", ws.data.path);
317
+ ws.send("Connected!");
78
318
  },
79
319
  message(ws, message) {
320
+ console.log("Received:", message);
80
321
  ws.send("Echo: " + message);
81
322
  },
82
323
  close(ws, code, reason) {
@@ -86,8 +327,32 @@ await context.eval(`
86
327
  });
87
328
  `, { promise: true });
88
329
 
89
- // From host - dispatch HTTP request
330
+ // Dispatch HTTP request
90
331
  const response = await handle.dispatchRequest(
91
332
  new Request("http://localhost/api/users")
92
333
  );
93
- ```
334
+ console.log(await response.json()); // { path: "/api/users" }
335
+
336
+ // Handle WebSocket connection
337
+ await handle.dispatchRequest(
338
+ new Request("http://localhost/ws", { headers: { "Upgrade": "websocket" } })
339
+ );
340
+
341
+ const upgrade = handle.getUpgradeRequest();
342
+ if (upgrade?.requested) {
343
+ // Listen for commands from isolate
344
+ handle.onWebSocketCommand((cmd) => {
345
+ console.log("Command from isolate:", cmd);
346
+ });
347
+
348
+ // Open connection (triggers websocket.open)
349
+ handle.dispatchWebSocketOpen(upgrade.connectionId);
350
+
351
+ // Send message (triggers websocket.message)
352
+ handle.dispatchWebSocketMessage(upgrade.connectionId, "Hello!");
353
+ }
354
+ ```
355
+
356
+ ## License
357
+
358
+ MIT
@@ -1253,6 +1253,12 @@ function setupRequest(context, stateMap) {
1253
1253
  }
1254
1254
 
1255
1255
  get body() {
1256
+ // Per WHATWG Fetch spec: GET/HEAD requests cannot have a body
1257
+ const method = __Request_get_method(this.#instanceId);
1258
+ if (method === 'GET' || method === 'HEAD') {
1259
+ return null;
1260
+ }
1261
+
1256
1262
  // Return cached body if available
1257
1263
  if (this.#cachedBody !== null) {
1258
1264
  return this.#cachedBody;
@@ -1264,12 +1270,15 @@ function setupRequest(context, stateMap) {
1264
1270
  return this.#cachedBody;
1265
1271
  }
1266
1272
 
1267
- // Create stream from buffered body
1268
- const newStreamId = __Stream_create();
1273
+ // Check if there's any buffered body data
1269
1274
  const buffer = __Request_arrayBuffer(this.#instanceId);
1270
- if (buffer.byteLength > 0) {
1271
- __Stream_push(newStreamId, Array.from(new Uint8Array(buffer)));
1275
+ if (buffer.byteLength === 0) {
1276
+ return null; // Return null per WHATWG Fetch spec for empty body
1272
1277
  }
1278
+
1279
+ // Create stream from non-empty buffered body
1280
+ const newStreamId = __Stream_create();
1281
+ __Stream_push(newStreamId, Array.from(new Uint8Array(buffer)));
1273
1282
  __Stream_close(newStreamId);
1274
1283
 
1275
1284
  this.#cachedBody = HostBackedReadableStream._fromStreamId(newStreamId);
@@ -1568,8 +1577,7 @@ async function setupFetch(context, options) {
1568
1577
  serveState.activeConnections.clear();
1569
1578
  serveState.pendingUpgrade = null;
1570
1579
  },
1571
- async dispatchRequest(request, dispatchOptions) {
1572
- const tick = dispatchOptions?.tick;
1580
+ async dispatchRequest(request, _dispatchOptions) {
1573
1581
  if (serveState.pendingUpgrade) {
1574
1582
  const oldConnectionId = serveState.pendingUpgrade.connectionId;
1575
1583
  context.evalSync(`globalThis.__upgradeRegistry__.delete("${oldConnectionId}")`);
@@ -1581,7 +1589,8 @@ async function setupFetch(context, options) {
1581
1589
  }
1582
1590
  let requestStreamId = null;
1583
1591
  let streamCleanup = null;
1584
- if (request.body) {
1592
+ const canHaveBody = !["GET", "HEAD"].includes(request.method.toUpperCase());
1593
+ if (canHaveBody && request.body) {
1585
1594
  requestStreamId = streamRegistry.create();
1586
1595
  streamCleanup = import_stream_state.startNativeStreamReader(request.body, requestStreamId, streamRegistry);
1587
1596
  }
@@ -1623,9 +1632,6 @@ async function setupFetch(context, options) {
1623
1632
  if (streamDone)
1624
1633
  return;
1625
1634
  while (!streamDone) {
1626
- if (tick) {
1627
- await tick();
1628
- }
1629
1635
  const state = streamRegistry.get(responseStreamId);
1630
1636
  if (!state) {
1631
1637
  controller.close();
@@ -1692,19 +1698,22 @@ async function setupFetch(context, options) {
1692
1698
  return result;
1693
1699
  },
1694
1700
  dispatchWebSocketOpen(connectionId) {
1695
- const hasOpenHandler = context.evalSync(`!!globalThis.__serveOptions__?.websocket?.open`);
1696
- if (!hasOpenHandler) {
1697
- context.evalSync(`globalThis.__upgradeRegistry__.delete("${connectionId}")`);
1698
- return;
1699
- }
1700
1701
  serveState.activeConnections.set(connectionId, { connectionId });
1702
+ const hasOpenHandler = context.evalSync(`!!globalThis.__serveOptions__?.websocket?.open`);
1701
1703
  context.evalSync(`
1702
1704
  (function() {
1703
1705
  const ws = new __ServerWebSocket__("${connectionId}");
1704
1706
  globalThis.__activeWs_${connectionId}__ = ws;
1705
- __serveOptions__.websocket.open(ws);
1706
1707
  })()
1707
1708
  `);
1709
+ if (hasOpenHandler) {
1710
+ context.evalSync(`
1711
+ (function() {
1712
+ const ws = globalThis.__activeWs_${connectionId}__;
1713
+ __serveOptions__.websocket.open(ws);
1714
+ })()
1715
+ `);
1716
+ }
1708
1717
  if (serveState.pendingUpgrade?.connectionId === connectionId) {
1709
1718
  serveState.pendingUpgrade = null;
1710
1719
  }
@@ -1797,4 +1806,4 @@ async function setupFetch(context, options) {
1797
1806
  }
1798
1807
  })
1799
1808
 
1800
- //# debugId=D6B21F115A1DCB5264756E2164756E21
1809
+ //# debugId=1924AA5BC28D2CB964756E2164756E21