@gcoredev/fastedge-test 0.1.6 → 0.2.0
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/dist/frontend/assets/{index-BpdzhbRl.js → index-CiqeJ9rz.js} +24 -24
- package/dist/frontend/index.html +1 -1
- package/dist/lib/index.cjs +164 -66
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +164 -66
- package/dist/lib/runner/HeaderManager.d.ts +6 -4
- package/dist/lib/runner/HostFunctions.d.ts +5 -5
- package/dist/lib/runner/HttpWasmRunner.d.ts +22 -5
- package/dist/lib/runner/IStateManager.d.ts +7 -7
- package/dist/lib/runner/IWasmRunner.d.ts +42 -10
- package/dist/lib/runner/PortManager.d.ts +4 -1
- package/dist/lib/runner/PropertyResolver.d.ts +3 -3
- package/dist/lib/runner/ProxyWasmRunner.d.ts +5 -2
- package/dist/lib/runner/standalone.d.ts +1 -1
- package/dist/lib/runner/types.d.ts +17 -8
- package/dist/lib/schemas/api.d.ts +2 -8
- package/dist/lib/schemas/config.d.ts +2 -13
- package/dist/lib/schemas/index.d.ts +2 -2
- package/dist/lib/test-framework/assertions.d.ts +18 -4
- package/dist/lib/test-framework/index.cjs +18634 -115
- package/dist/lib/test-framework/index.d.ts +2 -0
- package/dist/lib/test-framework/index.js +18651 -104
- package/dist/lib/test-framework/mock-origins.d.ts +56 -0
- package/dist/lib/test-framework/suite-runner.d.ts +16 -0
- package/dist/lib/test-framework/types.d.ts +1 -5
- package/dist/server.js +33 -33
- package/docs/API.md +48 -54
- package/docs/DEBUGGER.md +7 -8
- package/docs/INDEX.md +4 -1
- package/docs/RUNNER.md +126 -74
- package/docs/TEST_CONFIG.md +79 -40
- package/docs/TEST_FRAMEWORK.md +235 -36
- package/docs/WEBSOCKET.md +25 -21
- package/docs/quickstart.md +1 -13
- package/package.json +4 -1
- package/schemas/api-config.schema.json +5 -24
- package/schemas/api-load.schema.json +5 -0
- package/schemas/api-send.schema.json +0 -20
- package/schemas/fastedge-config.test.schema.json +5 -24
- package/schemas/full-flow-result.schema.json +17 -7
- package/schemas/hook-call.schema.json +16 -6
- package/schemas/hook-result.schema.json +16 -6
- package/schemas/http-response.schema.json +227 -5
package/dist/lib/index.js
CHANGED
|
@@ -175,10 +175,36 @@ var MemoryManager = class {
|
|
|
175
175
|
// server/runner/HeaderManager.ts
|
|
176
176
|
var textEncoder2 = new TextEncoder();
|
|
177
177
|
var HeaderManager = class {
|
|
178
|
+
// Read a header value as a single string. For multi-valued headers (string[]) returns the first.
|
|
179
|
+
// Use when callers know the header is conventionally single-valued (content-type, host, location, etc.)
|
|
180
|
+
// and need to satisfy APIs that take a string.
|
|
181
|
+
static firstValue(v) {
|
|
182
|
+
return Array.isArray(v) ? v[0] : v;
|
|
183
|
+
}
|
|
184
|
+
// Flatten a HeaderRecord to a HeaderMap (single string per key) for consumers
|
|
185
|
+
// that can't handle multi-valued headers (e.g. fetch's HeadersInit).
|
|
186
|
+
// Multi-valued entries are joined with ", " — caller must be sure this is acceptable
|
|
187
|
+
// (NOT valid for Set-Cookie; route those through a separate channel).
|
|
188
|
+
static flattenToMap(headers) {
|
|
189
|
+
const flat = {};
|
|
190
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
191
|
+
if (Array.isArray(v)) {
|
|
192
|
+
flat[k] = v.join(", ");
|
|
193
|
+
} else if (v !== void 0) {
|
|
194
|
+
flat[k] = String(v);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return flat;
|
|
198
|
+
}
|
|
178
199
|
static normalize(headers) {
|
|
179
200
|
const normalized = {};
|
|
180
201
|
for (const [key, value] of Object.entries(headers)) {
|
|
181
|
-
|
|
202
|
+
const k = key.toLowerCase();
|
|
203
|
+
if (Array.isArray(value)) {
|
|
204
|
+
normalized[k] = value.map(String);
|
|
205
|
+
} else {
|
|
206
|
+
normalized[k] = String(value);
|
|
207
|
+
}
|
|
182
208
|
}
|
|
183
209
|
return normalized;
|
|
184
210
|
}
|
|
@@ -257,13 +283,31 @@ var HeaderManager = class {
|
|
|
257
283
|
}
|
|
258
284
|
// --- Tuple-based methods for multi-valued header support ---
|
|
259
285
|
static recordToTuples(headers) {
|
|
260
|
-
|
|
286
|
+
const tuples = [];
|
|
287
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
288
|
+
const key = k.toLowerCase();
|
|
289
|
+
if (Array.isArray(v)) {
|
|
290
|
+
for (const val of v) tuples.push([key, String(val)]);
|
|
291
|
+
} else if (v !== void 0) {
|
|
292
|
+
tuples.push([key, String(v)]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return tuples;
|
|
261
296
|
}
|
|
297
|
+
// Lossless projection of tuples to a Record: single-valued keys are string,
|
|
298
|
+
// multi-valued keys are string[] — matching Node's IncomingHttpHeaders shape.
|
|
299
|
+
// Set-Cookie and other legitimately-repeatable headers are preserved across duplicates.
|
|
262
300
|
static tuplesToRecord(tuples) {
|
|
263
301
|
const record = {};
|
|
264
302
|
for (const [key, value] of tuples) {
|
|
265
303
|
const existing = record[key];
|
|
266
|
-
|
|
304
|
+
if (existing === void 0) {
|
|
305
|
+
record[key] = value;
|
|
306
|
+
} else if (Array.isArray(existing)) {
|
|
307
|
+
existing.push(value);
|
|
308
|
+
} else {
|
|
309
|
+
record[key] = [existing, value];
|
|
310
|
+
}
|
|
267
311
|
}
|
|
268
312
|
return record;
|
|
269
313
|
}
|
|
@@ -409,10 +453,11 @@ var PropertyResolver = class {
|
|
|
409
453
|
const url = new URL(targetUrl);
|
|
410
454
|
this.requestUrl = targetUrl;
|
|
411
455
|
this.requestHost = url.hostname + (url.port ? `:${url.port}` : "");
|
|
412
|
-
this.requestPath = url.pathname || "/";
|
|
456
|
+
this.requestPath = (url.pathname || "/") + url.search;
|
|
413
457
|
this.requestQuery = url.search.startsWith("?") ? url.search.substring(1) : url.search;
|
|
414
458
|
this.requestScheme = url.protocol.replace(":", "");
|
|
415
|
-
const
|
|
459
|
+
const pathOnly = url.pathname || "/";
|
|
460
|
+
const pathParts = pathOnly.split("/");
|
|
416
461
|
const lastPart = pathParts[pathParts.length - 1];
|
|
417
462
|
const dotIndex = lastPart.lastIndexOf(".");
|
|
418
463
|
if (dotIndex > 0 && dotIndex < lastPart.length - 1) {
|
|
@@ -482,28 +527,28 @@ var PropertyResolver = class {
|
|
|
482
527
|
if (path2 === "request.url")
|
|
483
528
|
return this.requestUrl || `${this.requestScheme}://${this.requestHost}${this.requestPath}`;
|
|
484
529
|
if (path2 === "request.host")
|
|
485
|
-
return this.requestHost || this.requestHeaders["host"] || "localhost";
|
|
530
|
+
return this.requestHost || HeaderManager.firstValue(this.requestHeaders["host"]) || "localhost";
|
|
486
531
|
if (path2 === "request.scheme") return this.requestScheme;
|
|
487
532
|
if (path2 === "request.protocol") return this.requestScheme;
|
|
488
533
|
if (path2 === "request.query") return this.requestQuery;
|
|
489
534
|
if (path2 === "request.extension") return this.requestExtension;
|
|
490
535
|
if (path2 === "request.content_type") {
|
|
491
|
-
return this.requestHeaders["content-type"] || "";
|
|
536
|
+
return HeaderManager.firstValue(this.requestHeaders["content-type"]) || "";
|
|
492
537
|
}
|
|
493
538
|
if (path2.startsWith("request.headers.")) {
|
|
494
539
|
const headerName = path2.substring("request.headers.".length).toLowerCase();
|
|
495
|
-
return this.requestHeaders[headerName] || "";
|
|
540
|
+
return HeaderManager.firstValue(this.requestHeaders[headerName]) || "";
|
|
496
541
|
}
|
|
497
542
|
if (path2 === "response.code") return this.responseStatus;
|
|
498
543
|
if (path2 === "response.status") return this.responseStatus;
|
|
499
544
|
if (path2 === "response.status_code") return this.responseStatus;
|
|
500
545
|
if (path2 === "response.code_details") return this.responseStatusText;
|
|
501
546
|
if (path2 === "response.content_type") {
|
|
502
|
-
return this.responseHeaders["content-type"] || "";
|
|
547
|
+
return HeaderManager.firstValue(this.responseHeaders["content-type"]) || "";
|
|
503
548
|
}
|
|
504
549
|
if (path2.startsWith("response.headers.")) {
|
|
505
550
|
const headerName = path2.substring("response.headers.".length).toLowerCase();
|
|
506
|
-
return this.responseHeaders[headerName] || "";
|
|
551
|
+
return HeaderManager.firstValue(this.responseHeaders[headerName]) || "";
|
|
507
552
|
}
|
|
508
553
|
return void 0;
|
|
509
554
|
}
|
|
@@ -858,7 +903,8 @@ var HostFunctions = class {
|
|
|
858
903
|
return call;
|
|
859
904
|
}
|
|
860
905
|
setHttpCallResponse(tokenId, headers, body) {
|
|
861
|
-
|
|
906
|
+
const tuples = Array.isArray(headers) ? headers : HeaderManager.recordToTuples(headers);
|
|
907
|
+
this.httpCallResponse = { tokenId, headers: tuples, body };
|
|
862
908
|
}
|
|
863
909
|
clearHttpCallResponse() {
|
|
864
910
|
this.httpCallResponse = null;
|
|
@@ -1288,7 +1334,7 @@ var HostFunctions = class {
|
|
|
1288
1334
|
return this.responseHeaders;
|
|
1289
1335
|
}
|
|
1290
1336
|
if (mapType === 6 /* HttpCallResponseHeaders */ || mapType === 7 /* HttpCallResponseTrailers */) {
|
|
1291
|
-
return
|
|
1337
|
+
return this.httpCallResponse?.headers ?? [];
|
|
1292
1338
|
}
|
|
1293
1339
|
return this.requestHeaders;
|
|
1294
1340
|
}
|
|
@@ -1948,7 +1994,7 @@ var ProxyWasmRunner = class {
|
|
|
1948
1994
|
const local = this.hostFunctions.getLocalResponse();
|
|
1949
1995
|
const responseHeaders2 = results.onRequestHeaders.output.response.headers;
|
|
1950
1996
|
this.hostFunctions.resetLocalResponse();
|
|
1951
|
-
const contentType2 = responseHeaders2["content-type"] || "text/plain";
|
|
1997
|
+
const contentType2 = HeaderManager.firstValue(responseHeaders2["content-type"]) || "text/plain";
|
|
1952
1998
|
const { body, isBase64: isBase642 } = encodeLocalResponseBody(local.body, contentType2);
|
|
1953
1999
|
return {
|
|
1954
2000
|
hookResults: results,
|
|
@@ -1991,7 +2037,7 @@ var ProxyWasmRunner = class {
|
|
|
1991
2037
|
const local = this.hostFunctions.getLocalResponse();
|
|
1992
2038
|
const responseHeaders2 = results.onRequestBody.output.response.headers;
|
|
1993
2039
|
this.hostFunctions.resetLocalResponse();
|
|
1994
|
-
const contentType2 = responseHeaders2["content-type"] || "text/plain";
|
|
2040
|
+
const contentType2 = HeaderManager.firstValue(responseHeaders2["content-type"]) || "text/plain";
|
|
1995
2041
|
const { body, isBase64: isBase642 } = encodeLocalResponseBody(local.body, contentType2);
|
|
1996
2042
|
return {
|
|
1997
2043
|
hookResults: results,
|
|
@@ -2022,7 +2068,7 @@ var ProxyWasmRunner = class {
|
|
|
2022
2068
|
try {
|
|
2023
2069
|
if (isBuiltIn) {
|
|
2024
2070
|
this.logDebug("Using built-in responder");
|
|
2025
|
-
const rawStatus = (modifiedRequestHeaders["x-debugger-status"] || "").trim();
|
|
2071
|
+
const rawStatus = (HeaderManager.firstValue(modifiedRequestHeaders["x-debugger-status"]) || "").trim();
|
|
2026
2072
|
if (rawStatus === "") {
|
|
2027
2073
|
responseStatus = 200;
|
|
2028
2074
|
} else {
|
|
@@ -2033,7 +2079,7 @@ var ProxyWasmRunner = class {
|
|
|
2033
2079
|
}
|
|
2034
2080
|
}
|
|
2035
2081
|
responseStatusText = responseStatus === 200 ? "OK" : String(responseStatus);
|
|
2036
|
-
const responseContentMode = modifiedRequestHeaders["x-debugger-content"] || "";
|
|
2082
|
+
const responseContentMode = HeaderManager.firstValue(modifiedRequestHeaders["x-debugger-content"]) || "";
|
|
2037
2083
|
delete modifiedRequestHeaders["x-debugger-status"];
|
|
2038
2084
|
delete modifiedRequestHeaders["x-debugger-content"];
|
|
2039
2085
|
if (responseContentMode === "status-only") {
|
|
@@ -2041,14 +2087,14 @@ var ProxyWasmRunner = class {
|
|
|
2041
2087
|
contentType = "text/plain";
|
|
2042
2088
|
} else if (responseContentMode === "body-only") {
|
|
2043
2089
|
responseBody = modifiedRequestBody || "";
|
|
2044
|
-
contentType = modifiedRequestHeaders["content-type"] || "text/plain";
|
|
2090
|
+
contentType = HeaderManager.firstValue(modifiedRequestHeaders["content-type"]) || "text/plain";
|
|
2045
2091
|
} else {
|
|
2046
2092
|
contentType = "application/json";
|
|
2047
2093
|
responseBody = JSON.stringify({
|
|
2048
2094
|
method: requestMethod,
|
|
2049
2095
|
reqHeaders: modifiedRequestHeaders,
|
|
2050
2096
|
reqBody: modifiedRequestBody || "",
|
|
2051
|
-
requestUrl: BUILTIN_URL
|
|
2097
|
+
requestUrl: propertiesAfterRequestBody["request.url"] || BUILTIN_URL
|
|
2052
2098
|
});
|
|
2053
2099
|
}
|
|
2054
2100
|
responseHeaders = {
|
|
@@ -2059,27 +2105,30 @@ var ProxyWasmRunner = class {
|
|
|
2059
2105
|
`Built-in responder: ${responseStatus} ${responseStatusText}, mode=${responseContentMode || "full"}`
|
|
2060
2106
|
);
|
|
2061
2107
|
} else {
|
|
2062
|
-
const
|
|
2063
|
-
const
|
|
2064
|
-
const modifiedPath = propertiesAfterRequestBody["request.path"] || "/";
|
|
2065
|
-
const modifiedQuery = propertiesAfterRequestBody["request.query"] || "";
|
|
2066
|
-
const actualTargetUrl = `${modifiedScheme}://${modifiedHost}${modifiedPath}${modifiedQuery ? "?" + modifiedQuery : ""}`;
|
|
2108
|
+
const actualTargetUrl = propertiesAfterRequestBody["request.url"] || targetUrl;
|
|
2109
|
+
const actualScheme = new URL(actualTargetUrl).protocol.replace(":", "");
|
|
2067
2110
|
this.logDebug(`Original URL: ${targetUrl}`);
|
|
2068
|
-
this.logDebug(`
|
|
2111
|
+
this.logDebug(`Effective URL: ${actualTargetUrl}`);
|
|
2069
2112
|
this.logDebug(`Fetching ${requestMethod} ${actualTargetUrl}`);
|
|
2070
|
-
const fetchHeaders =
|
|
2071
|
-
|
|
2072
|
-
|
|
2113
|
+
const fetchHeaders = HeaderManager.flattenToMap(
|
|
2114
|
+
modifiedRequestHeaders
|
|
2115
|
+
);
|
|
2116
|
+
for (const key of Object.keys(fetchHeaders)) {
|
|
2117
|
+
if (key.startsWith(":")) {
|
|
2118
|
+
delete fetchHeaders[key];
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2073
2121
|
const hostHeader = Object.entries(modifiedRequestHeaders).find(
|
|
2074
2122
|
([key]) => key.toLowerCase() === "host"
|
|
2075
2123
|
);
|
|
2076
2124
|
if (hostHeader) {
|
|
2077
|
-
|
|
2078
|
-
|
|
2125
|
+
const hostValue = HeaderManager.firstValue(hostHeader[1]) ?? "";
|
|
2126
|
+
fetchHeaders["x-forwarded-host"] = hostValue;
|
|
2127
|
+
this.logDebug(`Adding x-forwarded-host: ${hostValue}`);
|
|
2079
2128
|
}
|
|
2080
|
-
fetchHeaders["x-forwarded-proto"] =
|
|
2081
|
-
this.logDebug(`Adding x-forwarded-proto: ${
|
|
2082
|
-
fetchHeaders["x-forwarded-port"] =
|
|
2129
|
+
fetchHeaders["x-forwarded-proto"] = actualScheme;
|
|
2130
|
+
this.logDebug(`Adding x-forwarded-proto: ${actualScheme}`);
|
|
2131
|
+
fetchHeaders["x-forwarded-port"] = actualScheme === "https" ? "443" : "80";
|
|
2083
2132
|
this.logDebug(
|
|
2084
2133
|
`Adding x-forwarded-port: ${fetchHeaders["x-forwarded-port"]}`
|
|
2085
2134
|
);
|
|
@@ -2099,8 +2148,14 @@ var ProxyWasmRunner = class {
|
|
|
2099
2148
|
const response = await fetch(actualTargetUrl, fetchOptions);
|
|
2100
2149
|
responseHeaders = {};
|
|
2101
2150
|
response.headers.forEach((value, key) => {
|
|
2102
|
-
|
|
2151
|
+
if (key.toLowerCase() !== "set-cookie") {
|
|
2152
|
+
responseHeaders[key] = value;
|
|
2153
|
+
}
|
|
2103
2154
|
});
|
|
2155
|
+
const setCookies = response.headers.getSetCookie();
|
|
2156
|
+
if (setCookies.length > 0) {
|
|
2157
|
+
responseHeaders["set-cookie"] = setCookies;
|
|
2158
|
+
}
|
|
2104
2159
|
contentType = response.headers.get("content-type") || "text/plain";
|
|
2105
2160
|
responseStatus = response.status;
|
|
2106
2161
|
responseStatusText = response.statusText;
|
|
@@ -2177,7 +2232,7 @@ var ProxyWasmRunner = class {
|
|
|
2177
2232
|
const finalBody = results.onResponseBody.output.response.body;
|
|
2178
2233
|
this.logDebug(`Final response body length: ${finalBody.length}`);
|
|
2179
2234
|
const calculatedProperties = this.propertyResolver.getCalculatedProperties();
|
|
2180
|
-
const finalContentType = finalHeaders["content-type"] || contentType;
|
|
2235
|
+
const finalContentType = HeaderManager.firstValue(finalHeaders["content-type"]) || contentType;
|
|
2181
2236
|
return {
|
|
2182
2237
|
hookResults: results,
|
|
2183
2238
|
finalResponse: {
|
|
@@ -2270,13 +2325,13 @@ var ProxyWasmRunner = class {
|
|
|
2270
2325
|
this.hostFunctions.setLogLevel(0);
|
|
2271
2326
|
const requestHeaders = HeaderManager.normalize(call.request.headers ?? {});
|
|
2272
2327
|
const responseHeaders = HeaderManager.normalize(
|
|
2273
|
-
call.response
|
|
2328
|
+
call.response?.headers ?? {}
|
|
2274
2329
|
);
|
|
2275
2330
|
const requestBody = call.request.body ?? "";
|
|
2276
|
-
const responseBody = call.response
|
|
2331
|
+
const responseBody = call.response?.body ?? "";
|
|
2277
2332
|
const requestMethod = call.request.method ?? "GET";
|
|
2278
|
-
const responseStatus = call.response
|
|
2279
|
-
const responseStatusText = call.response
|
|
2333
|
+
const responseStatus = call.response?.status ?? 200;
|
|
2334
|
+
const responseStatusText = call.response?.statusText ?? "OK";
|
|
2280
2335
|
this.propertyResolver.setProperties({ ...call.properties ?? {} });
|
|
2281
2336
|
this.propertyResolver.setRequestMetadata(
|
|
2282
2337
|
requestHeaders,
|
|
@@ -2363,7 +2418,7 @@ var ProxyWasmRunner = class {
|
|
|
2363
2418
|
for (const [k, v] of Object.entries(pending.headers)) {
|
|
2364
2419
|
if (!k.startsWith(":")) fetchHeaders[k] = v;
|
|
2365
2420
|
}
|
|
2366
|
-
let responseHeaders2 =
|
|
2421
|
+
let responseHeaders2 = [];
|
|
2367
2422
|
let responseBody2 = new Uint8Array(0);
|
|
2368
2423
|
try {
|
|
2369
2424
|
const resp = await fetch(url, {
|
|
@@ -2373,20 +2428,23 @@ var ProxyWasmRunner = class {
|
|
|
2373
2428
|
signal: AbortSignal.timeout(pending.timeoutMs)
|
|
2374
2429
|
});
|
|
2375
2430
|
resp.headers.forEach((v, k) => {
|
|
2376
|
-
responseHeaders2[k
|
|
2431
|
+
if (k.toLowerCase() !== "set-cookie") responseHeaders2.push([k, v]);
|
|
2377
2432
|
});
|
|
2433
|
+
for (const cookie of resp.headers.getSetCookie()) {
|
|
2434
|
+
responseHeaders2.push(["set-cookie", cookie]);
|
|
2435
|
+
}
|
|
2378
2436
|
responseBody2 = new Uint8Array(await resp.arrayBuffer());
|
|
2379
2437
|
this.logDebug(
|
|
2380
|
-
`http_call response: ${resp.status} ${resp.statusText} numHeaders=${
|
|
2438
|
+
`http_call response: ${resp.status} ${resp.statusText} numHeaders=${responseHeaders2.length} bodySize=${responseBody2.byteLength}`
|
|
2381
2439
|
);
|
|
2382
2440
|
} catch (err) {
|
|
2383
2441
|
const errMsg = `http_call failed for ${url}: ${String(err)}`;
|
|
2384
2442
|
this.logDebug(errMsg);
|
|
2385
2443
|
this.logs.push({ level: 3, message: `[host] ${errMsg}` });
|
|
2386
|
-
responseHeaders2 =
|
|
2444
|
+
responseHeaders2 = [];
|
|
2387
2445
|
responseBody2 = new Uint8Array(0);
|
|
2388
2446
|
}
|
|
2389
|
-
const numHeaders =
|
|
2447
|
+
const numHeaders = responseHeaders2.length;
|
|
2390
2448
|
const bodySize = responseBody2.byteLength;
|
|
2391
2449
|
this.hostFunctions.setHttpCallResponse(pending.tokenId, responseHeaders2, responseBody2);
|
|
2392
2450
|
this.hostFunctions.resetStreamClosed();
|
|
@@ -2488,11 +2546,18 @@ var ProxyWasmRunner = class {
|
|
|
2488
2546
|
this.isInitializing = false;
|
|
2489
2547
|
}
|
|
2490
2548
|
buildHookInvocation(hook, requestHeaders, responseHeaders, requestBody, responseBody) {
|
|
2549
|
+
const countEntries = (h) => {
|
|
2550
|
+
let n = 0;
|
|
2551
|
+
for (const v of Object.values(h)) {
|
|
2552
|
+
n += Array.isArray(v) ? v.length : 1;
|
|
2553
|
+
}
|
|
2554
|
+
return n;
|
|
2555
|
+
};
|
|
2491
2556
|
switch (hook) {
|
|
2492
2557
|
case "onRequestHeaders":
|
|
2493
2558
|
return {
|
|
2494
2559
|
exportName: "proxy_on_request_headers",
|
|
2495
|
-
args: [this.currentContextId,
|
|
2560
|
+
args: [this.currentContextId, countEntries(requestHeaders), 0]
|
|
2496
2561
|
};
|
|
2497
2562
|
case "onRequestBody":
|
|
2498
2563
|
return {
|
|
@@ -2502,7 +2567,7 @@ var ProxyWasmRunner = class {
|
|
|
2502
2567
|
case "onResponseHeaders":
|
|
2503
2568
|
return {
|
|
2504
2569
|
exportName: "proxy_on_response_headers",
|
|
2505
|
-
args: [this.currentContextId,
|
|
2570
|
+
args: [this.currentContextId, countEntries(responseHeaders), 0]
|
|
2506
2571
|
};
|
|
2507
2572
|
case "onResponseBody":
|
|
2508
2573
|
return {
|
|
@@ -2617,9 +2682,12 @@ var ProxyWasmRunner = class {
|
|
|
2617
2682
|
console.warn(entry.message);
|
|
2618
2683
|
}
|
|
2619
2684
|
/**
|
|
2620
|
-
* Interface-compliant callFullFlow method
|
|
2685
|
+
* Interface-compliant callFullFlow method.
|
|
2686
|
+
*
|
|
2687
|
+
* The upstream response is generated at runtime by a real HTTP fetch
|
|
2688
|
+
* against `url` or by the built-in responder when `url === "built-in"`.
|
|
2621
2689
|
*/
|
|
2622
|
-
async callFullFlow(url, method, headers, body,
|
|
2690
|
+
async callFullFlow(url, method, headers, body, properties, enforceProductionPropertyRules) {
|
|
2623
2691
|
const call = {
|
|
2624
2692
|
hook: "",
|
|
2625
2693
|
// Not used in fullFlow
|
|
@@ -2628,12 +2696,6 @@ var ProxyWasmRunner = class {
|
|
|
2628
2696
|
body,
|
|
2629
2697
|
method
|
|
2630
2698
|
},
|
|
2631
|
-
response: {
|
|
2632
|
-
headers: responseHeaders,
|
|
2633
|
-
body: responseBody,
|
|
2634
|
-
status: responseStatus,
|
|
2635
|
-
statusText: responseStatusText
|
|
2636
|
-
},
|
|
2637
2699
|
properties,
|
|
2638
2700
|
enforceProductionPropertyRules
|
|
2639
2701
|
};
|
|
@@ -2817,6 +2879,8 @@ var HttpWasmRunner = class {
|
|
|
2817
2879
|
this.stateManager = null;
|
|
2818
2880
|
this.dotenvEnabled = true;
|
|
2819
2881
|
this.dotenvPath = null;
|
|
2882
|
+
/** Pinned ports bypass PortManager allocation and must not be released back to it. */
|
|
2883
|
+
this.isPinnedPort = false;
|
|
2820
2884
|
/** @deprecated Legacy sync support — remove when #[fastedge::http] is retired */
|
|
2821
2885
|
this.isLegacySync = false;
|
|
2822
2886
|
this.portManager = portManager;
|
|
@@ -2843,7 +2907,19 @@ var HttpWasmRunner = class {
|
|
|
2843
2907
|
this.tempWasmPath = wasmPath;
|
|
2844
2908
|
}
|
|
2845
2909
|
this.isLegacySync = await isLegacySyncWasm(bufferOrPath);
|
|
2846
|
-
|
|
2910
|
+
if (config?.httpPort !== void 0) {
|
|
2911
|
+
const pinned = config.httpPort;
|
|
2912
|
+
if (!await this.portManager.isPortFree(pinned)) {
|
|
2913
|
+
throw new Error(
|
|
2914
|
+
`fastedge-run port ${pinned} is not available \u2014 release it or choose a different httpPort in fastedge-config.test.json`
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
this.port = pinned;
|
|
2918
|
+
this.isPinnedPort = true;
|
|
2919
|
+
} else {
|
|
2920
|
+
this.port = await this.portManager.allocate();
|
|
2921
|
+
this.isPinnedPort = false;
|
|
2922
|
+
}
|
|
2847
2923
|
const wasi_http = !this.isLegacySync;
|
|
2848
2924
|
const args = [
|
|
2849
2925
|
"http",
|
|
@@ -2875,7 +2951,13 @@ var HttpWasmRunner = class {
|
|
|
2875
2951
|
await this.waitForServerReady(this.port, timeout);
|
|
2876
2952
|
}
|
|
2877
2953
|
/**
|
|
2878
|
-
* Execute an HTTP request through the WASM module
|
|
2954
|
+
* Execute an HTTP request through the WASM module.
|
|
2955
|
+
*
|
|
2956
|
+
* Redirects are surfaced verbatim — `fetch` is called with
|
|
2957
|
+
* `redirect: "manual"` so 3xx responses (status + `Location`) reach the
|
|
2958
|
+
* caller intact. This matches FastEdge edge behaviour, which returns
|
|
2959
|
+
* redirects to the client rather than following them server-side. See
|
|
2960
|
+
* `IWasmRunner.execute` for the public contract.
|
|
2879
2961
|
*/
|
|
2880
2962
|
async execute(request) {
|
|
2881
2963
|
if (!this.port || !this.process) {
|
|
@@ -2888,8 +2970,12 @@ var HttpWasmRunner = class {
|
|
|
2888
2970
|
method: request.method,
|
|
2889
2971
|
headers: request.headers,
|
|
2890
2972
|
body: request.body || void 0,
|
|
2891
|
-
signal: AbortSignal.timeout(3e4)
|
|
2973
|
+
signal: AbortSignal.timeout(3e4),
|
|
2892
2974
|
// 30 second timeout
|
|
2975
|
+
// Surface 3xx responses verbatim so tests can assert on status/Location.
|
|
2976
|
+
// A FastEdge edge returns redirects to the client rather than following
|
|
2977
|
+
// them server-side; production parity requires the same here.
|
|
2978
|
+
redirect: "manual"
|
|
2893
2979
|
});
|
|
2894
2980
|
const arrayBuffer = await response.arrayBuffer();
|
|
2895
2981
|
const bodyBuffer = Buffer.from(arrayBuffer);
|
|
@@ -2923,7 +3009,7 @@ var HttpWasmRunner = class {
|
|
|
2923
3009
|
/**
|
|
2924
3010
|
* Not supported for HTTP WASM (proxy-wasm only)
|
|
2925
3011
|
*/
|
|
2926
|
-
async callFullFlow(_url, _method, _headers, _body,
|
|
3012
|
+
async callFullFlow(_url, _method, _headers, _body, _properties, _enforceProductionPropertyRules) {
|
|
2927
3013
|
throw new Error(
|
|
2928
3014
|
"callFullFlow() is not supported for HTTP WASM. Use execute() instead."
|
|
2929
3015
|
);
|
|
@@ -2980,8 +3066,11 @@ var HttpWasmRunner = class {
|
|
|
2980
3066
|
this.process = null;
|
|
2981
3067
|
}
|
|
2982
3068
|
if (this.port !== null) {
|
|
2983
|
-
|
|
3069
|
+
if (!this.isPinnedPort) {
|
|
3070
|
+
this.portManager.release(this.port);
|
|
3071
|
+
}
|
|
2984
3072
|
this.port = null;
|
|
3073
|
+
this.isPinnedPort = false;
|
|
2985
3074
|
}
|
|
2986
3075
|
if (this.tempWasmPath) {
|
|
2987
3076
|
await removeTempWasmFile(this.tempWasmPath);
|
|
@@ -3172,17 +3261,23 @@ ${recentLogs || "(no logs)"}`
|
|
|
3172
3261
|
];
|
|
3173
3262
|
return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
|
|
3174
3263
|
}
|
|
3175
|
-
/**
|
|
3176
|
-
* Parse headers from fetch Headers object
|
|
3177
|
-
*/
|
|
3178
3264
|
parseHeaders(headers) {
|
|
3179
|
-
|
|
3180
|
-
headers.forEach((value, key) => {
|
|
3181
|
-
result[key] = value;
|
|
3182
|
-
});
|
|
3183
|
-
return result;
|
|
3265
|
+
return parseFetchHeaders(headers);
|
|
3184
3266
|
}
|
|
3185
3267
|
};
|
|
3268
|
+
function parseFetchHeaders(headers) {
|
|
3269
|
+
const result = {};
|
|
3270
|
+
headers.forEach((value, key) => {
|
|
3271
|
+
if (key.toLowerCase() !== "set-cookie") {
|
|
3272
|
+
result[key] = value;
|
|
3273
|
+
}
|
|
3274
|
+
});
|
|
3275
|
+
const setCookies = headers.getSetCookie();
|
|
3276
|
+
if (setCookies.length > 0) {
|
|
3277
|
+
result["set-cookie"] = setCookies;
|
|
3278
|
+
}
|
|
3279
|
+
return result;
|
|
3280
|
+
}
|
|
3186
3281
|
|
|
3187
3282
|
// server/runner/PortManager.ts
|
|
3188
3283
|
import { createServer } from "net";
|
|
@@ -3198,6 +3293,9 @@ var PortManager = class {
|
|
|
3198
3293
|
* This is necessary when multiple server processes run simultaneously —
|
|
3199
3294
|
* each has its own PortManager with independent in-memory state, so
|
|
3200
3295
|
* in-memory tracking alone is not enough to prevent cross-process conflicts.
|
|
3296
|
+
*
|
|
3297
|
+
* Public so pinned-port callers (HttpWasmRunner with RunnerConfig.httpPort)
|
|
3298
|
+
* can reuse the same OS-level check without going through allocate().
|
|
3201
3299
|
*/
|
|
3202
3300
|
isPortFree(port) {
|
|
3203
3301
|
return new Promise((resolve) => {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { HeaderMap, HeaderTuples } from "./types";
|
|
1
|
+
import type { HeaderMap, HeaderRecord, HeaderTuples } from "./types";
|
|
2
2
|
export declare class HeaderManager {
|
|
3
|
-
static
|
|
3
|
+
static firstValue(v: string | string[] | undefined): string | undefined;
|
|
4
|
+
static flattenToMap(headers: HeaderMap | HeaderRecord): HeaderMap;
|
|
5
|
+
static normalize(headers: HeaderMap | HeaderRecord): HeaderRecord;
|
|
4
6
|
static serialize(headers: HeaderMap): Uint8Array;
|
|
5
7
|
/**
|
|
6
8
|
* Deserializes a proxy-wasm binary header map format:
|
|
@@ -8,8 +10,8 @@ export declare class HeaderManager {
|
|
|
8
10
|
*/
|
|
9
11
|
static deserializeBinary(bytes: Uint8Array): HeaderMap;
|
|
10
12
|
static deserialize(payload: string): HeaderMap;
|
|
11
|
-
static recordToTuples(headers: HeaderMap): HeaderTuples;
|
|
12
|
-
static tuplesToRecord(tuples: HeaderTuples):
|
|
13
|
+
static recordToTuples(headers: HeaderMap | HeaderRecord): HeaderTuples;
|
|
14
|
+
static tuplesToRecord(tuples: HeaderTuples): HeaderRecord;
|
|
13
15
|
static normalizeTuples(tuples: HeaderTuples): HeaderTuples;
|
|
14
16
|
static serializeTuples(tuples: HeaderTuples): Uint8Array;
|
|
15
17
|
static deserializeBinaryToTuples(bytes: Uint8Array): HeaderTuples;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HeaderMap, LogEntry } from "./types";
|
|
1
|
+
import type { HeaderMap, HeaderRecord, HeaderTuples, LogEntry } from "./types";
|
|
2
2
|
import { MemoryManager } from "./MemoryManager";
|
|
3
3
|
import { PropertyResolver } from "./PropertyResolver";
|
|
4
4
|
import { PropertyAccessControl, HookContext } from "./PropertyAccessControl";
|
|
@@ -36,7 +36,7 @@ export declare class HostFunctions {
|
|
|
36
36
|
private dictionary;
|
|
37
37
|
constructor(memory: MemoryManager, propertyResolver: PropertyResolver, propertyAccessControl: PropertyAccessControl, getCurrentHook: () => HookContext | null, debug?: boolean, secretStore?: SecretStore, dictionary?: Dictionary);
|
|
38
38
|
setLogs(logs: LogEntry[]): void;
|
|
39
|
-
setHeadersAndBodies(reqHeaders: HeaderMap, resHeaders: HeaderMap, reqBody: string, resBody: string): void;
|
|
39
|
+
setHeadersAndBodies(reqHeaders: HeaderMap | HeaderRecord, resHeaders: HeaderMap | HeaderRecord, reqBody: string, resBody: string): void;
|
|
40
40
|
setConfigs(vmConfig: string, pluginConfig: string): void;
|
|
41
41
|
setCurrentContext(contextId: number): void;
|
|
42
42
|
getCurrentLogLevel(): number;
|
|
@@ -50,7 +50,7 @@ export declare class HostFunctions {
|
|
|
50
50
|
body: Uint8Array | null;
|
|
51
51
|
timeoutMs: number;
|
|
52
52
|
} | null;
|
|
53
|
-
setHttpCallResponse(tokenId: number, headers: HeaderMap, body: Uint8Array): void;
|
|
53
|
+
setHttpCallResponse(tokenId: number, headers: HeaderMap | HeaderRecord | HeaderTuples, body: Uint8Array): void;
|
|
54
54
|
clearHttpCallResponse(): void;
|
|
55
55
|
isStreamClosed(): boolean;
|
|
56
56
|
resetStreamClosed(): void;
|
|
@@ -61,8 +61,8 @@ export declare class HostFunctions {
|
|
|
61
61
|
body: Uint8Array;
|
|
62
62
|
} | null;
|
|
63
63
|
resetLocalResponse(): void;
|
|
64
|
-
getRequestHeaders():
|
|
65
|
-
getResponseHeaders():
|
|
64
|
+
getRequestHeaders(): HeaderRecord;
|
|
65
|
+
getResponseHeaders(): HeaderRecord;
|
|
66
66
|
getRequestBody(): string;
|
|
67
67
|
getResponseBody(): string;
|
|
68
68
|
getSecretStore(): SecretStore;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Executes HTTP WASM binaries (component model with wasi-http interface)
|
|
5
5
|
* using the FastEdge-run CLI as a process-based runner.
|
|
6
6
|
*/
|
|
7
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
7
8
|
import type { IWasmRunner, WasmType, RunnerConfig, HttpRequest, HttpResponse } from "./IWasmRunner.js";
|
|
8
9
|
import type { HookCall, HookResult, FullFlowResult } from "./types.js";
|
|
9
10
|
import type { IStateManager } from "./IStateManager.js";
|
|
@@ -24,6 +25,8 @@ export declare class HttpWasmRunner implements IWasmRunner {
|
|
|
24
25
|
private portManager;
|
|
25
26
|
private dotenvEnabled;
|
|
26
27
|
private dotenvPath;
|
|
28
|
+
/** Pinned ports bypass PortManager allocation and must not be released back to it. */
|
|
29
|
+
private isPinnedPort;
|
|
27
30
|
/** @deprecated Legacy sync support — remove when #[fastedge::http] is retired */
|
|
28
31
|
private isLegacySync;
|
|
29
32
|
constructor(portManager: PortManager, dotenvEnabled?: boolean);
|
|
@@ -32,7 +35,13 @@ export declare class HttpWasmRunner implements IWasmRunner {
|
|
|
32
35
|
*/
|
|
33
36
|
load(bufferOrPath: Buffer | string, config?: RunnerConfig): Promise<void>;
|
|
34
37
|
/**
|
|
35
|
-
* Execute an HTTP request through the WASM module
|
|
38
|
+
* Execute an HTTP request through the WASM module.
|
|
39
|
+
*
|
|
40
|
+
* Redirects are surfaced verbatim — `fetch` is called with
|
|
41
|
+
* `redirect: "manual"` so 3xx responses (status + `Location`) reach the
|
|
42
|
+
* caller intact. This matches FastEdge edge behaviour, which returns
|
|
43
|
+
* redirects to the client rather than following them server-side. See
|
|
44
|
+
* `IWasmRunner.execute` for the public contract.
|
|
36
45
|
*/
|
|
37
46
|
execute(request: HttpRequest): Promise<HttpResponse>;
|
|
38
47
|
/**
|
|
@@ -42,7 +51,7 @@ export declare class HttpWasmRunner implements IWasmRunner {
|
|
|
42
51
|
/**
|
|
43
52
|
* Not supported for HTTP WASM (proxy-wasm only)
|
|
44
53
|
*/
|
|
45
|
-
callFullFlow(_url: string, _method: string, _headers: Record<string, string>, _body: string,
|
|
54
|
+
callFullFlow(_url: string, _method: string, _headers: Record<string, string>, _body: string, _properties: Record<string, unknown>, _enforceProductionPropertyRules: boolean): Promise<FullFlowResult>;
|
|
46
55
|
/**
|
|
47
56
|
* Apply dotenv settings by restarting the fastedge-run process.
|
|
48
57
|
* The WASM file is not re-read; only the --dotenv flag changes.
|
|
@@ -103,8 +112,16 @@ export declare class HttpWasmRunner implements IWasmRunner {
|
|
|
103
112
|
* Check if content type is binary
|
|
104
113
|
*/
|
|
105
114
|
private isBinaryContentType;
|
|
106
|
-
/**
|
|
107
|
-
* Parse headers from fetch Headers object
|
|
108
|
-
*/
|
|
109
115
|
private parseHeaders;
|
|
110
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Parse a fetch Headers object into an IncomingHttpHeaders-shaped record.
|
|
119
|
+
*
|
|
120
|
+
* Uses `Headers.getSetCookie()` (Node 19.7+, always available on Node ≥22.12)
|
|
121
|
+
* to preserve multiple Set-Cookie entries as a string[] — RFC 6265 §3 exempts
|
|
122
|
+
* Set-Cookie from the "combine duplicates with commas" rule, and real browsers
|
|
123
|
+
* process each Set-Cookie independently.
|
|
124
|
+
*
|
|
125
|
+
* Exported for unit testing; in production use it via HttpWasmRunner.
|
|
126
|
+
*/
|
|
127
|
+
export declare function parseFetchHeaders(headers: Headers): IncomingHttpHeaders;
|
|
@@ -7,30 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export type EventSource = "ui" | "ai_agent" | "api" | "system";
|
|
9
9
|
export interface IStateManager {
|
|
10
|
-
emitRequestStarted(url: string, method: string, headers: Record<string, string>, source?: EventSource): void;
|
|
10
|
+
emitRequestStarted(url: string, method: string, headers: Record<string, string | string[]>, source?: EventSource): void;
|
|
11
11
|
emitHookExecuted(hook: string, returnCode: number | null, logCount: number, input: {
|
|
12
12
|
request: {
|
|
13
|
-
headers: Record<string, string>;
|
|
13
|
+
headers: Record<string, string | string[]>;
|
|
14
14
|
body: string;
|
|
15
15
|
};
|
|
16
16
|
response: {
|
|
17
|
-
headers: Record<string, string>;
|
|
17
|
+
headers: Record<string, string | string[]>;
|
|
18
18
|
body: string;
|
|
19
19
|
};
|
|
20
20
|
}, output: {
|
|
21
21
|
request: {
|
|
22
|
-
headers: Record<string, string>;
|
|
22
|
+
headers: Record<string, string | string[]>;
|
|
23
23
|
body: string;
|
|
24
24
|
};
|
|
25
25
|
response: {
|
|
26
|
-
headers: Record<string, string>;
|
|
26
|
+
headers: Record<string, string | string[]>;
|
|
27
27
|
body: string;
|
|
28
28
|
};
|
|
29
29
|
}, source?: EventSource): void;
|
|
30
30
|
emitRequestCompleted(hookResults: Record<string, unknown>, finalResponse: {
|
|
31
31
|
status: number;
|
|
32
32
|
statusText: string;
|
|
33
|
-
headers: Record<string, string>;
|
|
33
|
+
headers: Record<string, string | string[]>;
|
|
34
34
|
body: string;
|
|
35
35
|
contentType: string;
|
|
36
36
|
isBase64?: boolean;
|
|
@@ -41,7 +41,7 @@ export interface IStateManager {
|
|
|
41
41
|
emitHttpWasmRequestCompleted(response: {
|
|
42
42
|
status: number;
|
|
43
43
|
statusText: string;
|
|
44
|
-
headers: Record<string, string>;
|
|
44
|
+
headers: Record<string, string | string[] | undefined>;
|
|
45
45
|
body: string;
|
|
46
46
|
contentType: string | null;
|
|
47
47
|
isBase64?: boolean;
|