@ricsam/isolate-fs 0.0.1 → 0.1.1

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.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Streaming Integration Tests
3
+ *
4
+ * Tests to verify that file uploads and downloads are truly streamed
5
+ * (not buffered) through the entire pipeline:
6
+ *
7
+ * Upload: Host ReadableStream -> Isolate request.body -> FileHandle.createWritable() -> Host writes to disk
8
+ * Download: Host request -> Isolate FileHandle.getFile() -> Host reads from disk -> Isolate Response -> Host
9
+ *
10
+ * These tests use instrumented FileSystemHandlers to track chunk-by-chunk streaming.
11
+ */
12
+ import { describe, test, afterEach } from "node:test";
13
+ import assert from "node:assert";
14
+ import { createRuntime, type RuntimeHandle } from "@ricsam/isolate-runtime";
15
+ import type { FileSystemHandler } from "./index.ts";
16
+
17
+ describe("Streaming Integration Tests", () => {
18
+ let runtime: RuntimeHandle | undefined;
19
+
20
+ afterEach(async () => {
21
+ if (runtime) {
22
+ runtime.dispose();
23
+ runtime = undefined;
24
+ }
25
+ });
26
+
27
+ describe("Upload Streaming (Host -> Isolate -> Filesystem)", () => {
28
+ test("streaming upload writes chunks progressively (not buffered)", async () => {
29
+ // Track all writeFile calls with timestamps
30
+ const writeCalls: Array<{ data: Uint8Array; timestamp: number }> = [];
31
+
32
+ const mockHandler: FileSystemHandler = {
33
+ async getFileHandle(path, options) {
34
+ // Allow file creation
35
+ },
36
+ async getDirectoryHandle() {},
37
+ async removeEntry() {},
38
+ async readDirectory() {
39
+ return [];
40
+ },
41
+ async readFile(path) {
42
+ // Combine all writes
43
+ const totalSize = writeCalls.reduce((sum, w) => sum + w.data.length, 0);
44
+ const combined = new Uint8Array(totalSize);
45
+ let offset = 0;
46
+ for (const write of writeCalls) {
47
+ combined.set(write.data, offset);
48
+ offset += write.data.length;
49
+ }
50
+ return {
51
+ data: combined,
52
+ size: totalSize,
53
+ lastModified: Date.now(),
54
+ type: "application/octet-stream",
55
+ };
56
+ },
57
+ async writeFile(path, data) {
58
+ writeCalls.push({ data: new Uint8Array(data), timestamp: Date.now() });
59
+ },
60
+ async truncate() {},
61
+ async isSameEntry() {
62
+ return false;
63
+ },
64
+ };
65
+
66
+ runtime = await createRuntime({
67
+ console: { onLog: () => {} },
68
+ fs: { getDirectory: async () => mockHandler },
69
+ });
70
+
71
+ // Set up a server that reads request body chunk-by-chunk and writes to filesystem
72
+ await runtime.context.eval(
73
+ `
74
+ serve({
75
+ async fetch(request) {
76
+ const root = await getDirectory("/");
77
+ const fileHandle = await root.getFileHandle("upload.bin", { create: true });
78
+ const writable = await fileHandle.createWritable();
79
+
80
+ // Read request body chunk-by-chunk (streaming)
81
+ const reader = request.body.getReader();
82
+ let totalBytes = 0;
83
+ let chunkCount = 0;
84
+
85
+ while (true) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+ await writable.write(value);
89
+ totalBytes += value.length;
90
+ chunkCount++;
91
+ }
92
+
93
+ await writable.close();
94
+ return Response.json({ totalBytes, chunkCount });
95
+ }
96
+ });
97
+ `,
98
+ { promise: true }
99
+ );
100
+
101
+ // Create a streaming request with multiple chunks
102
+ const numChunks = 5;
103
+ const chunkSize = 1024;
104
+ let chunksSent = 0;
105
+
106
+ const stream = new ReadableStream({
107
+ pull(controller) {
108
+ if (chunksSent < numChunks) {
109
+ // Each chunk has distinct content for verification
110
+ const chunk = new Uint8Array(chunkSize).fill(chunksSent + 1);
111
+ controller.enqueue(chunk);
112
+ chunksSent++;
113
+ } else {
114
+ controller.close();
115
+ }
116
+ },
117
+ });
118
+
119
+ const request = new Request("http://test/upload", {
120
+ method: "POST",
121
+ body: stream,
122
+ // @ts-expect-error Node.js requires duplex for streaming bodies
123
+ duplex: "half",
124
+ });
125
+
126
+ const response = await runtime.fetch.dispatchRequest(request, {
127
+ tick: () => runtime!.tick(),
128
+ });
129
+
130
+ const result = (await response.json()) as {
131
+ totalBytes: number;
132
+ chunkCount: number;
133
+ };
134
+
135
+ // Verify the isolate received multiple chunks (not one buffered blob)
136
+ assert.strictEqual(result.totalBytes, numChunks * chunkSize);
137
+ assert.strictEqual(
138
+ result.chunkCount,
139
+ numChunks,
140
+ `Expected ${numChunks} chunks but got ${result.chunkCount} - data was buffered instead of streamed`
141
+ );
142
+
143
+ // Verify the filesystem received multiple write calls (streaming)
144
+ assert.strictEqual(
145
+ writeCalls.length,
146
+ numChunks,
147
+ `Expected ${numChunks} writeFile calls but got ${writeCalls.length} - filesystem writes were buffered`
148
+ );
149
+
150
+ // Verify each chunk has correct content
151
+ for (let i = 0; i < writeCalls.length; i++) {
152
+ assert.strictEqual(writeCalls[i]!.data.length, chunkSize);
153
+ assert.strictEqual(writeCalls[i]!.data[0], i + 1);
154
+ }
155
+ });
156
+
157
+ test("large file upload streams without buffering entire file in memory", async () => {
158
+ const writeCalls: Array<{ size: number; timestamp: number }> = [];
159
+ let maxMemoryAtOnce = 0;
160
+ let currentMemory = 0;
161
+
162
+ const mockHandler: FileSystemHandler = {
163
+ async getFileHandle() {},
164
+ async getDirectoryHandle() {},
165
+ async removeEntry() {},
166
+ async readDirectory() {
167
+ return [];
168
+ },
169
+ async readFile() {
170
+ return {
171
+ data: new Uint8Array(0),
172
+ size: 0,
173
+ lastModified: Date.now(),
174
+ type: "application/octet-stream",
175
+ };
176
+ },
177
+ async writeFile(path, data) {
178
+ currentMemory += data.length;
179
+ maxMemoryAtOnce = Math.max(maxMemoryAtOnce, currentMemory);
180
+ writeCalls.push({ size: data.length, timestamp: Date.now() });
181
+ // Simulate write completing (memory released)
182
+ currentMemory -= data.length;
183
+ },
184
+ async truncate() {},
185
+ async isSameEntry() {
186
+ return false;
187
+ },
188
+ };
189
+
190
+ runtime = await createRuntime({
191
+ console: { onLog: () => {} },
192
+ fs: { getDirectory: async () => mockHandler },
193
+ });
194
+
195
+ await runtime.context.eval(
196
+ `
197
+ serve({
198
+ async fetch(request) {
199
+ const root = await getDirectory("/");
200
+ const fileHandle = await root.getFileHandle("large.bin", { create: true });
201
+ const writable = await fileHandle.createWritable();
202
+
203
+ const reader = request.body.getReader();
204
+ while (true) {
205
+ const { done, value } = await reader.read();
206
+ if (done) break;
207
+ await writable.write(value);
208
+ }
209
+ await writable.close();
210
+ return new Response("OK");
211
+ }
212
+ });
213
+ `,
214
+ { promise: true }
215
+ );
216
+
217
+ // Stream 1MB in 64KB chunks
218
+ const totalSize = 1024 * 1024;
219
+ const chunkSize = 64 * 1024;
220
+ let generated = 0;
221
+
222
+ const stream = new ReadableStream({
223
+ pull(controller) {
224
+ if (generated < totalSize) {
225
+ const size = Math.min(chunkSize, totalSize - generated);
226
+ controller.enqueue(new Uint8Array(size).fill(0x42));
227
+ generated += size;
228
+ } else {
229
+ controller.close();
230
+ }
231
+ },
232
+ });
233
+
234
+ const request = new Request("http://test/upload", {
235
+ method: "POST",
236
+ body: stream,
237
+ // @ts-expect-error Node.js requires duplex for streaming bodies
238
+ duplex: "half",
239
+ });
240
+
241
+ await runtime.fetch.dispatchRequest(request, {
242
+ tick: () => runtime!.tick(),
243
+ });
244
+
245
+ // Should have multiple write calls (streaming behavior)
246
+ const expectedChunks = Math.ceil(totalSize / chunkSize);
247
+ assert.ok(
248
+ writeCalls.length >= expectedChunks,
249
+ `Expected at least ${expectedChunks} writes but got ${writeCalls.length}`
250
+ );
251
+
252
+ // Max memory should be much less than total file size (streaming)
253
+ // Allow for some buffering but not the entire file
254
+ assert.ok(
255
+ maxMemoryAtOnce < totalSize / 2,
256
+ `Max memory ${maxMemoryAtOnce} exceeded half of total size ${totalSize} - file was buffered`
257
+ );
258
+ });
259
+ });
260
+
261
+ describe("Download Streaming (Filesystem -> Isolate -> Host)", () => {
262
+ test("streaming download sends chunks progressively (not buffered)", async () => {
263
+ const numChunks = 5;
264
+ const chunkSize = 1024;
265
+ let readCallCount = 0;
266
+
267
+ // Create data as multiple chunks
268
+ const chunks: Uint8Array[] = [];
269
+ for (let i = 0; i < numChunks; i++) {
270
+ chunks.push(new Uint8Array(chunkSize).fill(i + 1));
271
+ }
272
+ const totalData = new Uint8Array(numChunks * chunkSize);
273
+ let offset = 0;
274
+ for (const chunk of chunks) {
275
+ totalData.set(chunk, offset);
276
+ offset += chunk.length;
277
+ }
278
+
279
+ const mockHandler: FileSystemHandler = {
280
+ async getFileHandle() {},
281
+ async getDirectoryHandle() {},
282
+ async removeEntry() {},
283
+ async readDirectory() {
284
+ return [];
285
+ },
286
+ async readFile() {
287
+ readCallCount++;
288
+ return {
289
+ data: totalData,
290
+ size: totalData.length,
291
+ lastModified: Date.now(),
292
+ type: "application/octet-stream",
293
+ };
294
+ },
295
+ async writeFile() {},
296
+ async truncate() {},
297
+ async isSameEntry() {
298
+ return false;
299
+ },
300
+ };
301
+
302
+ runtime = await createRuntime({
303
+ console: { onLog: () => {} },
304
+ fs: { getDirectory: async () => mockHandler },
305
+ });
306
+
307
+ await runtime.context.eval(
308
+ `
309
+ serve({
310
+ async fetch(request) {
311
+ const root = await getDirectory("/");
312
+ const fileHandle = await root.getFileHandle("download.bin");
313
+ const file = await fileHandle.getFile();
314
+
315
+ // Return file directly - should stream
316
+ return new Response(file);
317
+ }
318
+ });
319
+ `,
320
+ { promise: true }
321
+ );
322
+
323
+ const request = new Request("http://test/download");
324
+ const response = await runtime.fetch.dispatchRequest(request, {
325
+ tick: () => runtime!.tick(),
326
+ });
327
+
328
+ // Read response body chunk-by-chunk to verify streaming
329
+ const reader = response.body!.getReader();
330
+ const receivedChunks: Uint8Array[] = [];
331
+
332
+ while (true) {
333
+ const { done, value } = await reader.read();
334
+ if (done) break;
335
+ receivedChunks.push(value);
336
+ }
337
+
338
+ // Verify we received the correct data
339
+ const totalReceived = receivedChunks.reduce((sum, c) => sum + c.length, 0);
340
+ assert.strictEqual(totalReceived, numChunks * chunkSize);
341
+
342
+ // Verify content is correct
343
+ const combined = new Uint8Array(totalReceived);
344
+ let combineOffset = 0;
345
+ for (const chunk of receivedChunks) {
346
+ combined.set(chunk, combineOffset);
347
+ combineOffset += chunk.length;
348
+ }
349
+ assert.deepStrictEqual(combined, totalData);
350
+ });
351
+ });
352
+
353
+ describe("WHATWG Compliance - Response(file) streaming", () => {
354
+ test("new Response(file) uses streaming (not buffered)", async () => {
355
+ // This test verifies that when Response(file) is used,
356
+ // the file content is streamed, not loaded entirely into memory first
357
+
358
+ const fileSize = 1024 * 1024; // 1MB
359
+ const fileData = new Uint8Array(fileSize).fill(0x42);
360
+ let readFileCallCount = 0;
361
+
362
+ const mockHandler: FileSystemHandler = {
363
+ async getFileHandle() {},
364
+ async getDirectoryHandle() {},
365
+ async removeEntry() {},
366
+ async readDirectory() {
367
+ return [];
368
+ },
369
+ async readFile() {
370
+ readFileCallCount++;
371
+ return {
372
+ data: fileData,
373
+ size: fileSize,
374
+ lastModified: Date.now(),
375
+ type: "application/octet-stream",
376
+ };
377
+ },
378
+ async writeFile() {},
379
+ async truncate() {},
380
+ async isSameEntry() {
381
+ return false;
382
+ },
383
+ };
384
+
385
+ runtime = await createRuntime({
386
+ console: { onLog: () => {} },
387
+ fs: { getDirectory: async () => mockHandler },
388
+ });
389
+
390
+ await runtime.context.eval(
391
+ `
392
+ serve({
393
+ async fetch(request) {
394
+ const root = await getDirectory("/");
395
+ const fileHandle = await root.getFileHandle("large.bin");
396
+ const file = await fileHandle.getFile();
397
+
398
+ // WHATWG spec: Response(file) should stream the file body
399
+ return new Response(file, {
400
+ headers: { "Content-Type": file.type }
401
+ });
402
+ }
403
+ });
404
+ `,
405
+ { promise: true }
406
+ );
407
+
408
+ const request = new Request("http://test/file");
409
+ const response = await runtime.fetch.dispatchRequest(request, {
410
+ tick: () => runtime!.tick(),
411
+ });
412
+
413
+ // Read first chunk only
414
+ const reader = response.body!.getReader();
415
+ const { value: firstChunk } = await reader.read();
416
+ reader.releaseLock();
417
+
418
+ // File should have been read from disk
419
+ assert.ok(readFileCallCount >= 1, "readFile should have been called");
420
+
421
+ // First chunk should exist and have correct content
422
+ assert.ok(firstChunk, "Should receive at least one chunk");
423
+ assert.strictEqual(firstChunk[0], 0x42);
424
+ });
425
+
426
+ test("file.stream() returns a ReadableStream for chunk-by-chunk reading", async () => {
427
+ const fileData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
428
+
429
+ const mockHandler: FileSystemHandler = {
430
+ async getFileHandle() {},
431
+ async getDirectoryHandle() {},
432
+ async removeEntry() {},
433
+ async readDirectory() {
434
+ return [];
435
+ },
436
+ async readFile() {
437
+ return {
438
+ data: fileData,
439
+ size: fileData.length,
440
+ lastModified: Date.now(),
441
+ type: "application/octet-stream",
442
+ };
443
+ },
444
+ async writeFile() {},
445
+ async truncate() {},
446
+ async isSameEntry() {
447
+ return false;
448
+ },
449
+ };
450
+
451
+ runtime = await createRuntime({
452
+ console: { onLog: () => {} },
453
+ fs: { getDirectory: async () => mockHandler },
454
+ });
455
+
456
+ const result = await runtime.context.eval(
457
+ `
458
+ (async () => {
459
+ const root = await getDirectory("/");
460
+ const fileHandle = await root.getFileHandle("test.bin");
461
+ const file = await fileHandle.getFile();
462
+
463
+ // WHATWG File.stream() should return a ReadableStream
464
+ const stream = file.stream();
465
+ const reader = stream.getReader();
466
+
467
+ const chunks = [];
468
+ while (true) {
469
+ const { done, value } = await reader.read();
470
+ if (done) break;
471
+ chunks.push(Array.from(value));
472
+ }
473
+
474
+ return JSON.stringify({
475
+ isReadableStream: stream instanceof ReadableStream,
476
+ chunkCount: chunks.length,
477
+ totalBytes: chunks.reduce((sum, c) => sum + c.length, 0)
478
+ });
479
+ })()
480
+ `,
481
+ { promise: true }
482
+ );
483
+
484
+ const parsed = JSON.parse(result as string);
485
+ assert.strictEqual(parsed.isReadableStream, true, "file.stream() should return ReadableStream");
486
+ assert.strictEqual(parsed.totalBytes, fileData.length);
487
+ });
488
+ });
489
+
490
+ describe("WHATWG Compliance - WritableStream streaming", () => {
491
+ test("writable.write(chunk) streams each chunk separately", async () => {
492
+ const writeCalls: Array<{ data: Uint8Array }> = [];
493
+
494
+ const mockHandler: FileSystemHandler = {
495
+ async getFileHandle() {},
496
+ async getDirectoryHandle() {},
497
+ async removeEntry() {},
498
+ async readDirectory() {
499
+ return [];
500
+ },
501
+ async readFile() {
502
+ return {
503
+ data: new Uint8Array(0),
504
+ size: 0,
505
+ lastModified: Date.now(),
506
+ type: "application/octet-stream",
507
+ };
508
+ },
509
+ async writeFile(path, data) {
510
+ writeCalls.push({ data: new Uint8Array(data) });
511
+ },
512
+ async truncate() {},
513
+ async isSameEntry() {
514
+ return false;
515
+ },
516
+ };
517
+
518
+ runtime = await createRuntime({
519
+ console: { onLog: () => {} },
520
+ fs: { getDirectory: async () => mockHandler },
521
+ });
522
+
523
+ await runtime.context.eval(
524
+ `
525
+ (async () => {
526
+ const root = await getDirectory("/");
527
+ const fileHandle = await root.getFileHandle("chunked.bin", { create: true });
528
+ const writable = await fileHandle.createWritable();
529
+
530
+ // Write multiple chunks - each should trigger a separate writeFile call
531
+ await writable.write(new Uint8Array([1, 2, 3]));
532
+ await writable.write(new Uint8Array([4, 5, 6]));
533
+ await writable.write(new Uint8Array([7, 8, 9]));
534
+
535
+ await writable.close();
536
+ })()
537
+ `,
538
+ { promise: true }
539
+ );
540
+
541
+ // Each write() should result in a separate writeFile call (streaming)
542
+ assert.strictEqual(
543
+ writeCalls.length,
544
+ 3,
545
+ `Expected 3 writeFile calls but got ${writeCalls.length} - writes were buffered`
546
+ );
547
+
548
+ // Verify content of each call
549
+ assert.deepStrictEqual(Array.from(writeCalls[0]!.data), [1, 2, 3]);
550
+ assert.deepStrictEqual(Array.from(writeCalls[1]!.data), [4, 5, 6]);
551
+ assert.deepStrictEqual(Array.from(writeCalls[2]!.data), [7, 8, 9]);
552
+ });
553
+
554
+ test("pipeTo(writable) streams chunks from ReadableStream", async () => {
555
+ const writeCalls: Array<{ data: Uint8Array }> = [];
556
+
557
+ const mockHandler: FileSystemHandler = {
558
+ async getFileHandle() {},
559
+ async getDirectoryHandle() {},
560
+ async removeEntry() {},
561
+ async readDirectory() {
562
+ return [];
563
+ },
564
+ async readFile() {
565
+ return {
566
+ data: new Uint8Array(0),
567
+ size: 0,
568
+ lastModified: Date.now(),
569
+ type: "application/octet-stream",
570
+ };
571
+ },
572
+ async writeFile(path, data) {
573
+ writeCalls.push({ data: new Uint8Array(data) });
574
+ },
575
+ async truncate() {},
576
+ async isSameEntry() {
577
+ return false;
578
+ },
579
+ };
580
+
581
+ runtime = await createRuntime({
582
+ console: { onLog: () => {} },
583
+ fs: { getDirectory: async () => mockHandler },
584
+ });
585
+
586
+ await runtime.context.eval(
587
+ `
588
+ (async () => {
589
+ const root = await getDirectory("/");
590
+ const fileHandle = await root.getFileHandle("piped.bin", { create: true });
591
+ const writable = await fileHandle.createWritable();
592
+
593
+ // Create a ReadableStream with multiple chunks
594
+ let chunkIndex = 0;
595
+ const chunks = [
596
+ new Uint8Array([1, 2]),
597
+ new Uint8Array([3, 4]),
598
+ new Uint8Array([5, 6]),
599
+ ];
600
+
601
+ const readable = new ReadableStream({
602
+ pull(controller) {
603
+ if (chunkIndex < chunks.length) {
604
+ controller.enqueue(chunks[chunkIndex]);
605
+ chunkIndex++;
606
+ } else {
607
+ controller.close();
608
+ }
609
+ }
610
+ });
611
+
612
+ // Pipe should stream chunks one by one
613
+ const reader = readable.getReader();
614
+ while (true) {
615
+ const { done, value } = await reader.read();
616
+ if (done) break;
617
+ await writable.write(value);
618
+ }
619
+
620
+ await writable.close();
621
+ })()
622
+ `,
623
+ { promise: true }
624
+ );
625
+
626
+ // Each chunk from the stream should result in a separate writeFile call
627
+ assert.strictEqual(
628
+ writeCalls.length,
629
+ 3,
630
+ `Expected 3 writeFile calls but got ${writeCalls.length} - pipe was buffered`
631
+ );
632
+ });
633
+ });
634
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src"
5
+ },
6
+ "include": ["src/**/*"],
7
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
8
+ }
package/README.md DELETED
@@ -1,45 +0,0 @@
1
- # @ricsam/isolate-fs
2
-
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
4
-
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
6
-
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
8
-
9
- ## Purpose
10
-
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@ricsam/isolate-fs`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
15
-
16
- ## What is OIDC Trusted Publishing?
17
-
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
19
-
20
- ## Setup Instructions
21
-
22
- To properly configure OIDC trusted publishing for this package:
23
-
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
28
-
29
- ## DO NOT USE THIS PACKAGE
30
-
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
36
-
37
- ## More Information
38
-
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
42
-
43
- ---
44
-
45
- **Maintained for OIDC setup purposes only**