@ricsam/isolate-test-utils 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.
- package/CHANGELOG.md +13 -0
- package/package.json +52 -7
- package/src/fetch-context.ts +33 -0
- package/src/fs-context.ts +65 -0
- package/src/index.test.ts +473 -0
- package/src/index.ts +209 -0
- package/src/isolate-types.ts +1778 -0
- package/src/mock-fs.ts +246 -0
- package/src/native-input-test.ts +412 -0
- package/src/runtime-context.ts +120 -0
- package/src/server.ts +150 -0
- package/src/typecheck.ts +291 -0
- package/tsconfig.json +8 -0
- package/README.md +0 -45
package/src/mock-fs.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { FileSystemHandler } from "@ricsam/isolate-fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory file system implementation for testing.
|
|
5
|
+
* Implements the FileSystemHandler interface from @ricsam/isolate-fs.
|
|
6
|
+
*/
|
|
7
|
+
export class MockFileSystem implements FileSystemHandler {
|
|
8
|
+
files = new Map<
|
|
9
|
+
string,
|
|
10
|
+
{ data: Uint8Array; lastModified: number; type: string }
|
|
11
|
+
>();
|
|
12
|
+
directories = new Set<string>(["/"]);
|
|
13
|
+
|
|
14
|
+
async getFileHandle(
|
|
15
|
+
path: string,
|
|
16
|
+
options?: { create?: boolean }
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const exists = this.files.has(path);
|
|
19
|
+
if (!exists && !options?.create) {
|
|
20
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
21
|
+
}
|
|
22
|
+
if (this.directories.has(path)) {
|
|
23
|
+
throw new Error("[TypeMismatchError]Path is a directory: " + path);
|
|
24
|
+
}
|
|
25
|
+
if (!exists && options?.create) {
|
|
26
|
+
this.files.set(path, {
|
|
27
|
+
data: new Uint8Array(0),
|
|
28
|
+
lastModified: Date.now(),
|
|
29
|
+
type: "",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async getDirectoryHandle(
|
|
35
|
+
path: string,
|
|
36
|
+
options?: { create?: boolean }
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const exists = this.directories.has(path);
|
|
39
|
+
if (!exists && !options?.create) {
|
|
40
|
+
throw new Error("[NotFoundError]Directory not found: " + path);
|
|
41
|
+
}
|
|
42
|
+
if (this.files.has(path)) {
|
|
43
|
+
throw new Error("[TypeMismatchError]Path is a file: " + path);
|
|
44
|
+
}
|
|
45
|
+
if (!exists && options?.create) {
|
|
46
|
+
this.directories.add(path);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async removeEntry(
|
|
51
|
+
path: string,
|
|
52
|
+
options?: { recursive?: boolean }
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
if (this.files.has(path)) {
|
|
55
|
+
this.files.delete(path);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.directories.has(path)) {
|
|
60
|
+
const prefix = path === "/" ? "/" : path + "/";
|
|
61
|
+
const hasChildren =
|
|
62
|
+
[...this.files.keys()].some((p) => p.startsWith(prefix)) ||
|
|
63
|
+
[...this.directories].some((p) => p !== path && p.startsWith(prefix));
|
|
64
|
+
|
|
65
|
+
if (hasChildren && !options?.recursive) {
|
|
66
|
+
throw new Error("[InvalidModificationError]Directory not empty: " + path);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const p of this.files.keys()) {
|
|
70
|
+
if (p.startsWith(prefix)) {
|
|
71
|
+
this.files.delete(p);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const p of this.directories) {
|
|
75
|
+
if (p.startsWith(prefix) || p === path) {
|
|
76
|
+
this.directories.delete(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error("[NotFoundError]Entry not found: " + path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async readDirectory(
|
|
86
|
+
path: string
|
|
87
|
+
): Promise<Array<{ name: string; kind: "file" | "directory" }>> {
|
|
88
|
+
if (!this.directories.has(path)) {
|
|
89
|
+
throw new Error("[NotFoundError]Directory not found: " + path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const prefix = path === "/" ? "/" : path + "/";
|
|
93
|
+
const entries: Array<{ name: string; kind: "file" | "directory" }> = [];
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
|
|
96
|
+
for (const p of this.files.keys()) {
|
|
97
|
+
if (p.startsWith(prefix)) {
|
|
98
|
+
const rest = p.slice(prefix.length);
|
|
99
|
+
const name = rest.split("/")[0];
|
|
100
|
+
if (name && !rest.includes("/") && !seen.has(name)) {
|
|
101
|
+
seen.add(name);
|
|
102
|
+
entries.push({ name, kind: "file" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const p of this.directories) {
|
|
108
|
+
if (p !== path && p.startsWith(prefix)) {
|
|
109
|
+
const rest = p.slice(prefix.length);
|
|
110
|
+
const name = rest.split("/")[0];
|
|
111
|
+
if (name && !rest.includes("/") && !seen.has(name)) {
|
|
112
|
+
seen.add(name);
|
|
113
|
+
entries.push({ name, kind: "directory" });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return entries;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async readFile(
|
|
122
|
+
path: string
|
|
123
|
+
): Promise<{ data: Uint8Array; size: number; lastModified: number; type: string }> {
|
|
124
|
+
const file = this.files.get(path);
|
|
125
|
+
if (!file) {
|
|
126
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
data: file.data,
|
|
130
|
+
size: file.data.length,
|
|
131
|
+
lastModified: file.lastModified,
|
|
132
|
+
type: file.type,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async writeFile(
|
|
137
|
+
path: string,
|
|
138
|
+
data: Uint8Array,
|
|
139
|
+
position?: number
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const existing = this.files.get(path);
|
|
142
|
+
if (!existing) {
|
|
143
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (position !== undefined && position > 0) {
|
|
147
|
+
const newSize = Math.max(existing.data.length, position + data.length);
|
|
148
|
+
const newData = new Uint8Array(newSize);
|
|
149
|
+
newData.set(existing.data);
|
|
150
|
+
newData.set(data, position);
|
|
151
|
+
existing.data = newData;
|
|
152
|
+
} else if (position === 0) {
|
|
153
|
+
const newSize = Math.max(existing.data.length, data.length);
|
|
154
|
+
const newData = new Uint8Array(newSize);
|
|
155
|
+
newData.set(existing.data);
|
|
156
|
+
newData.set(data, 0);
|
|
157
|
+
existing.data = newData;
|
|
158
|
+
} else {
|
|
159
|
+
existing.data = data;
|
|
160
|
+
}
|
|
161
|
+
existing.lastModified = Date.now();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async truncateFile(path: string, size: number): Promise<void> {
|
|
165
|
+
const file = this.files.get(path);
|
|
166
|
+
if (!file) {
|
|
167
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
168
|
+
}
|
|
169
|
+
if (size < file.data.length) {
|
|
170
|
+
file.data = file.data.slice(0, size);
|
|
171
|
+
} else if (size > file.data.length) {
|
|
172
|
+
const newData = new Uint8Array(size);
|
|
173
|
+
newData.set(file.data);
|
|
174
|
+
file.data = newData;
|
|
175
|
+
}
|
|
176
|
+
file.lastModified = Date.now();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getFileMetadata(
|
|
180
|
+
path: string
|
|
181
|
+
): Promise<{ size: number; lastModified: number; type: string }> {
|
|
182
|
+
const file = this.files.get(path);
|
|
183
|
+
if (!file) {
|
|
184
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
size: file.data.length,
|
|
188
|
+
lastModified: file.lastModified,
|
|
189
|
+
type: file.type,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Test helper methods
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reset the mock file system to its initial state (empty, with only root directory)
|
|
197
|
+
*/
|
|
198
|
+
reset(): void {
|
|
199
|
+
this.files.clear();
|
|
200
|
+
this.directories.clear();
|
|
201
|
+
this.directories.add("/");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Convenience method to set a file with string or binary content
|
|
206
|
+
*/
|
|
207
|
+
setFile(path: string, content: string | Uint8Array, type?: string): void {
|
|
208
|
+
const data =
|
|
209
|
+
typeof content === "string"
|
|
210
|
+
? new TextEncoder().encode(content)
|
|
211
|
+
: content;
|
|
212
|
+
this.files.set(path, {
|
|
213
|
+
data,
|
|
214
|
+
lastModified: Date.now(),
|
|
215
|
+
type: type ?? "",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get file content as Uint8Array, or undefined if not found
|
|
221
|
+
*/
|
|
222
|
+
getFile(path: string): Uint8Array | undefined {
|
|
223
|
+
return this.files.get(path)?.data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get file content as string, or undefined if not found
|
|
228
|
+
*/
|
|
229
|
+
getFileAsString(path: string): string | undefined {
|
|
230
|
+
const data = this.getFile(path);
|
|
231
|
+
if (!data) return undefined;
|
|
232
|
+
return new TextDecoder().decode(data);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Create a directory (and any necessary parent directories)
|
|
237
|
+
*/
|
|
238
|
+
createDirectory(path: string): void {
|
|
239
|
+
const parts = path.split("/").filter(Boolean);
|
|
240
|
+
let current = "";
|
|
241
|
+
for (const part of parts) {
|
|
242
|
+
current += "/" + part;
|
|
243
|
+
this.directories.add(current);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import ivm from "isolated-vm";
|
|
2
|
+
|
|
3
|
+
export interface TestRuntime {
|
|
4
|
+
logs: Record<string, unknown>;
|
|
5
|
+
result: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TestRunner {
|
|
9
|
+
input(inputs: Record<string, unknown>): TestRuntime;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run isolate code with native objects as input and capture logs
|
|
14
|
+
*
|
|
15
|
+
* This utility allows testing whether native objects passed INTO the isolate
|
|
16
|
+
* behave like isolate instances. It converts native web API classes (Headers,
|
|
17
|
+
* Request, Response, URL, Blob, File, FormData) to their isolate equivalents
|
|
18
|
+
* before executing the test code.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const runtime = runTestCode(ctx.context, `
|
|
22
|
+
* const headers = testingInput.headers;
|
|
23
|
+
* log("instanceof", headers instanceof Headers);
|
|
24
|
+
* log("contentType", headers.get("content-type"));
|
|
25
|
+
* `).input({
|
|
26
|
+
* headers: new Headers({ "content-type": "application/json" })
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* expect(runtime.logs.instanceof).toBe(true);
|
|
30
|
+
* expect(runtime.logs.contentType).toBe("application/json");
|
|
31
|
+
*/
|
|
32
|
+
export function runTestCode(context: ivm.Context, code: string): TestRunner {
|
|
33
|
+
return {
|
|
34
|
+
input(inputs: Record<string, unknown>): TestRuntime {
|
|
35
|
+
const logs: Record<string, unknown> = {};
|
|
36
|
+
|
|
37
|
+
// Setup log capture - log(tag, value) stores as logs[tag] = value
|
|
38
|
+
// Values are unmarshalled back to native types for bidirectional testing
|
|
39
|
+
const logCallback = new ivm.Callback(
|
|
40
|
+
(tag: string, valueJson: string) => {
|
|
41
|
+
const value = JSON.parse(valueJson);
|
|
42
|
+
logs[tag] = unmarshalFromJson(value);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
context.global.setSync("__log_callback__", logCallback);
|
|
46
|
+
|
|
47
|
+
// Create a wrapper log function that serializes values
|
|
48
|
+
context.evalSync(`
|
|
49
|
+
globalThis.log = function(tag, value) {
|
|
50
|
+
__log_callback__(tag, JSON.stringify(__serializeForLog__(value)));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
globalThis.__serializeForLog__ = function(value) {
|
|
54
|
+
if (typeof Headers !== 'undefined' && value instanceof Headers) {
|
|
55
|
+
const pairs = [];
|
|
56
|
+
for (const [k, v] of value) pairs.push([k, v]);
|
|
57
|
+
return { __type__: 'Headers', pairs };
|
|
58
|
+
}
|
|
59
|
+
if (typeof Request !== 'undefined' && value instanceof Request) {
|
|
60
|
+
const headers = [];
|
|
61
|
+
for (const [k, v] of value.headers) headers.push([k, v]);
|
|
62
|
+
return {
|
|
63
|
+
__type__: 'Request',
|
|
64
|
+
url: value.url,
|
|
65
|
+
method: value.method,
|
|
66
|
+
headers,
|
|
67
|
+
mode: value.mode,
|
|
68
|
+
credentials: value.credentials,
|
|
69
|
+
cache: value.cache,
|
|
70
|
+
redirect: value.redirect,
|
|
71
|
+
referrer: value.referrer,
|
|
72
|
+
referrerPolicy: value.referrerPolicy,
|
|
73
|
+
integrity: value.integrity,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (typeof Response !== 'undefined' && value instanceof Response) {
|
|
77
|
+
const headers = [];
|
|
78
|
+
for (const [k, v] of value.headers) headers.push([k, v]);
|
|
79
|
+
return {
|
|
80
|
+
__type__: 'Response',
|
|
81
|
+
status: value.status,
|
|
82
|
+
statusText: value.statusText,
|
|
83
|
+
ok: value.ok,
|
|
84
|
+
headers,
|
|
85
|
+
type: value.type,
|
|
86
|
+
redirected: value.redirected,
|
|
87
|
+
url: value.url,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (const [k, v] of value) {
|
|
93
|
+
if (typeof File !== 'undefined' && v instanceof File) {
|
|
94
|
+
entries.push([k, { __type__: 'File', name: v.name, type: v.type, lastModified: v.lastModified }]);
|
|
95
|
+
} else {
|
|
96
|
+
entries.push([k, v]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { __type__: 'FormData', entries };
|
|
100
|
+
}
|
|
101
|
+
if (value instanceof URL) {
|
|
102
|
+
return { __type__: 'URL', href: value.href };
|
|
103
|
+
}
|
|
104
|
+
if (value instanceof File) {
|
|
105
|
+
return { __type__: 'File', name: value.name, type: value.type, lastModified: value.lastModified };
|
|
106
|
+
}
|
|
107
|
+
if (value instanceof Blob) {
|
|
108
|
+
return { __type__: 'Blob', type: value.type, size: value.size };
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
return value.map(v => __serializeForLog__(v));
|
|
112
|
+
}
|
|
113
|
+
if (value && typeof value === 'object' && value.constructor === Object) {
|
|
114
|
+
const result = {};
|
|
115
|
+
for (const [k, v] of Object.entries(value)) {
|
|
116
|
+
result[k] = __serializeForLog__(v);
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
};
|
|
122
|
+
`);
|
|
123
|
+
|
|
124
|
+
// Marshal inputs with special handling for native web API classes
|
|
125
|
+
marshalInputs(context, inputs);
|
|
126
|
+
|
|
127
|
+
// Run the code
|
|
128
|
+
let returnValue: unknown = undefined;
|
|
129
|
+
try {
|
|
130
|
+
returnValue = context.evalSync(code);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Clean up before re-throwing
|
|
133
|
+
context.evalSync(`
|
|
134
|
+
delete globalThis.testingInput;
|
|
135
|
+
delete globalThis.log;
|
|
136
|
+
delete globalThis.__log_callback__;
|
|
137
|
+
delete globalThis.__serializeForLog__;
|
|
138
|
+
`);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Cleanup
|
|
143
|
+
context.evalSync(`
|
|
144
|
+
delete globalThis.testingInput;
|
|
145
|
+
delete globalThis.log;
|
|
146
|
+
delete globalThis.__log_callback__;
|
|
147
|
+
delete globalThis.__serializeForLog__;
|
|
148
|
+
`);
|
|
149
|
+
|
|
150
|
+
return { logs, result: returnValue };
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Marshal inputs into the isolate, converting native web API classes
|
|
157
|
+
*/
|
|
158
|
+
function marshalInputs(
|
|
159
|
+
context: ivm.Context,
|
|
160
|
+
inputs: Record<string, unknown>
|
|
161
|
+
): void {
|
|
162
|
+
// Create the testingInput object in the isolate
|
|
163
|
+
context.evalSync(`globalThis.testingInput = {};`);
|
|
164
|
+
|
|
165
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
166
|
+
marshalValue(context, `testingInput.${key}`, value);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Marshal a single value into the isolate at the given path
|
|
172
|
+
*/
|
|
173
|
+
function marshalValue(
|
|
174
|
+
context: ivm.Context,
|
|
175
|
+
path: string,
|
|
176
|
+
value: unknown
|
|
177
|
+
): void {
|
|
178
|
+
// Check for native Headers
|
|
179
|
+
if (value instanceof Headers) {
|
|
180
|
+
const pairs: [string, string][] = [];
|
|
181
|
+
value.forEach((v, k) => pairs.push([k, v]));
|
|
182
|
+
const pairsJson = JSON.stringify(pairs);
|
|
183
|
+
context.evalSync(`${path} = new Headers(${pairsJson});`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check for native Request
|
|
188
|
+
if (value instanceof Request) {
|
|
189
|
+
// First marshal the headers
|
|
190
|
+
const headerPairs: [string, string][] = [];
|
|
191
|
+
value.headers.forEach((v, k) => headerPairs.push([k, v]));
|
|
192
|
+
const headersJson = JSON.stringify(headerPairs);
|
|
193
|
+
|
|
194
|
+
const urlJson = JSON.stringify(value.url);
|
|
195
|
+
const methodJson = JSON.stringify(value.method);
|
|
196
|
+
const modeJson = JSON.stringify(value.mode);
|
|
197
|
+
const credentialsJson = JSON.stringify(value.credentials);
|
|
198
|
+
const cacheJson = JSON.stringify(value.cache);
|
|
199
|
+
const redirectJson = JSON.stringify(value.redirect);
|
|
200
|
+
const referrerJson = JSON.stringify(value.referrer);
|
|
201
|
+
const referrerPolicyJson = JSON.stringify(value.referrerPolicy);
|
|
202
|
+
const integrityJson = JSON.stringify(value.integrity);
|
|
203
|
+
|
|
204
|
+
context.evalSync(`
|
|
205
|
+
${path} = new Request(${urlJson}, {
|
|
206
|
+
method: ${methodJson},
|
|
207
|
+
headers: new Headers(${headersJson}),
|
|
208
|
+
mode: ${modeJson},
|
|
209
|
+
credentials: ${credentialsJson},
|
|
210
|
+
cache: ${cacheJson},
|
|
211
|
+
redirect: ${redirectJson},
|
|
212
|
+
referrer: ${referrerJson},
|
|
213
|
+
referrerPolicy: ${referrerPolicyJson},
|
|
214
|
+
integrity: ${integrityJson},
|
|
215
|
+
});
|
|
216
|
+
`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check for native Response
|
|
221
|
+
if (value instanceof Response) {
|
|
222
|
+
const headerPairs: [string, string][] = [];
|
|
223
|
+
value.headers.forEach((v, k) => headerPairs.push([k, v]));
|
|
224
|
+
const headersJson = JSON.stringify(headerPairs);
|
|
225
|
+
|
|
226
|
+
const statusJson = JSON.stringify(value.status);
|
|
227
|
+
const statusTextJson = JSON.stringify(value.statusText);
|
|
228
|
+
|
|
229
|
+
context.evalSync(`
|
|
230
|
+
${path} = new Response(null, {
|
|
231
|
+
status: ${statusJson},
|
|
232
|
+
statusText: ${statusTextJson},
|
|
233
|
+
headers: new Headers(${headersJson}),
|
|
234
|
+
});
|
|
235
|
+
`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check for native FormData
|
|
240
|
+
if (value instanceof FormData) {
|
|
241
|
+
context.evalSync(`${path} = new FormData();`);
|
|
242
|
+
|
|
243
|
+
for (const [key, entryValue] of value.entries()) {
|
|
244
|
+
const keyJson = JSON.stringify(key);
|
|
245
|
+
|
|
246
|
+
if (entryValue instanceof File) {
|
|
247
|
+
const nameJson = JSON.stringify(entryValue.name);
|
|
248
|
+
const typeJson = JSON.stringify(entryValue.type);
|
|
249
|
+
const lastModifiedJson = JSON.stringify(entryValue.lastModified);
|
|
250
|
+
context.evalSync(`
|
|
251
|
+
${path}.append(${keyJson}, new File([], ${nameJson}, { type: ${typeJson}, lastModified: ${lastModifiedJson} }));
|
|
252
|
+
`);
|
|
253
|
+
} else {
|
|
254
|
+
const valueJson = JSON.stringify(entryValue);
|
|
255
|
+
context.evalSync(`${path}.append(${keyJson}, ${valueJson});`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Check for native URL
|
|
262
|
+
if (value instanceof URL) {
|
|
263
|
+
const hrefJson = JSON.stringify(value.href);
|
|
264
|
+
context.evalSync(`${path} = new URL(${hrefJson});`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Check for native File (before Blob, since File extends Blob)
|
|
269
|
+
if (value instanceof File) {
|
|
270
|
+
const nameJson = JSON.stringify(value.name);
|
|
271
|
+
const typeJson = JSON.stringify(value.type);
|
|
272
|
+
const lastModifiedJson = JSON.stringify(value.lastModified);
|
|
273
|
+
context.evalSync(
|
|
274
|
+
`${path} = new File([], ${nameJson}, { type: ${typeJson}, lastModified: ${lastModifiedJson} });`
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check for native Blob
|
|
280
|
+
if (value instanceof Blob) {
|
|
281
|
+
const typeJson = JSON.stringify(value.type);
|
|
282
|
+
context.evalSync(`${path} = new Blob([], { type: ${typeJson} });`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Handle arrays recursively
|
|
287
|
+
if (Array.isArray(value)) {
|
|
288
|
+
context.evalSync(`${path} = [];`);
|
|
289
|
+
for (let i = 0; i < value.length; i++) {
|
|
290
|
+
marshalValue(context, `${path}[${i}]`, value[i]);
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Handle plain objects recursively
|
|
296
|
+
if (value && typeof value === "object" && value.constructor === Object) {
|
|
297
|
+
context.evalSync(`${path} = {};`);
|
|
298
|
+
for (const [key, val] of Object.entries(value)) {
|
|
299
|
+
// Use bracket notation for safe property access
|
|
300
|
+
marshalValue(context, `${path}[${JSON.stringify(key)}]`, val);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// For primitives, set directly via JSON
|
|
306
|
+
const valueJson = JSON.stringify(value);
|
|
307
|
+
context.evalSync(`${path} = ${valueJson};`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Unmarshal a value from JSON, converting special __type__ markers back to native instances
|
|
312
|
+
*/
|
|
313
|
+
function unmarshalFromJson(value: unknown): unknown {
|
|
314
|
+
if (value === null || value === undefined) {
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (Array.isArray(value)) {
|
|
319
|
+
return value.map((v) => unmarshalFromJson(v));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (typeof value === "object") {
|
|
323
|
+
const obj = value as Record<string, unknown>;
|
|
324
|
+
|
|
325
|
+
// Check for special type markers
|
|
326
|
+
if (obj.__type__ === "Headers") {
|
|
327
|
+
const pairs = obj.pairs as [string, string][];
|
|
328
|
+
const headers = new Headers();
|
|
329
|
+
for (const [k, v] of pairs) {
|
|
330
|
+
headers.append(k, v);
|
|
331
|
+
}
|
|
332
|
+
return headers;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (obj.__type__ === "Request") {
|
|
336
|
+
const headers = new Headers();
|
|
337
|
+
for (const [k, v] of obj.headers as [string, string][]) {
|
|
338
|
+
headers.append(k, v);
|
|
339
|
+
}
|
|
340
|
+
return new Request(obj.url as string, {
|
|
341
|
+
method: obj.method as string,
|
|
342
|
+
headers,
|
|
343
|
+
mode: obj.mode as Request["mode"],
|
|
344
|
+
credentials: obj.credentials as Request["credentials"],
|
|
345
|
+
cache: obj.cache as Request["cache"],
|
|
346
|
+
redirect: obj.redirect as Request["redirect"],
|
|
347
|
+
referrer: obj.referrer as string,
|
|
348
|
+
referrerPolicy: obj.referrerPolicy as Request["referrerPolicy"],
|
|
349
|
+
integrity: obj.integrity as string,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (obj.__type__ === "Response") {
|
|
354
|
+
const headers = new Headers();
|
|
355
|
+
for (const [k, v] of obj.headers as [string, string][]) {
|
|
356
|
+
headers.append(k, v);
|
|
357
|
+
}
|
|
358
|
+
return new Response(null, {
|
|
359
|
+
status: obj.status as number,
|
|
360
|
+
statusText: obj.statusText as string,
|
|
361
|
+
headers,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (obj.__type__ === "FormData") {
|
|
366
|
+
const formData = new FormData();
|
|
367
|
+
for (const [k, v] of obj.entries as [string, unknown][]) {
|
|
368
|
+
if (
|
|
369
|
+
typeof v === "object" &&
|
|
370
|
+
v !== null &&
|
|
371
|
+
(v as Record<string, unknown>).__type__ === "File"
|
|
372
|
+
) {
|
|
373
|
+
const fileObj = v as Record<string, unknown>;
|
|
374
|
+
formData.append(
|
|
375
|
+
k,
|
|
376
|
+
new File([], fileObj.name as string, {
|
|
377
|
+
type: fileObj.type as string,
|
|
378
|
+
lastModified: fileObj.lastModified as number,
|
|
379
|
+
})
|
|
380
|
+
);
|
|
381
|
+
} else {
|
|
382
|
+
formData.append(k, v as string);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return formData;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (obj.__type__ === "URL") {
|
|
389
|
+
return new URL(obj.href as string);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (obj.__type__ === "File") {
|
|
393
|
+
return new File([], obj.name as string, {
|
|
394
|
+
type: obj.type as string,
|
|
395
|
+
lastModified: obj.lastModified as number,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (obj.__type__ === "Blob") {
|
|
400
|
+
return new Blob([], { type: obj.type as string });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Plain object - recursively unmarshal properties
|
|
404
|
+
const result: Record<string, unknown> = {};
|
|
405
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
406
|
+
result[k] = unmarshalFromJson(v);
|
|
407
|
+
}
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return value;
|
|
412
|
+
}
|