@dotpress/testing 2.0.0-alpha.4 → 2.0.0-alpha.5
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 +1 -1
- package/dist/chunk-MUWNILMA.js +51 -0
- package/dist/chunk-MUWNILMA.js.map +1 -0
- package/dist/conformance.d.ts +28 -0
- package/dist/conformance.js +1266 -0
- package/dist/conformance.js.map +1 -0
- package/dist/index.d.ts +17 -52
- package/dist/index.js +43 -1310
- package/dist/index.js.map +1 -1
- package/dist/target-BZC-J2SE.d.ts +25 -0
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeOf,
|
|
3
|
+
createFetchTarget,
|
|
4
|
+
createMemoryLogger,
|
|
5
|
+
toRequest
|
|
6
|
+
} from "./chunk-MUWNILMA.js";
|
|
7
|
+
|
|
1
8
|
// src/assertions.ts
|
|
2
9
|
import { expect } from "vitest";
|
|
3
10
|
var ExpectationError = class extends Error {
|
|
@@ -16,17 +23,17 @@ var matches = (actual, expected) => {
|
|
|
16
23
|
}
|
|
17
24
|
};
|
|
18
25
|
var show = (value) => {
|
|
19
|
-
let
|
|
26
|
+
let json;
|
|
20
27
|
try {
|
|
21
|
-
|
|
28
|
+
json = JSON.stringify(
|
|
22
29
|
value,
|
|
23
30
|
(_key, item) => typeof item === "object" && item !== null && "asymmetricMatch" in item ? item.toAsymmetricMatcher?.() ?? String(item) : item
|
|
24
31
|
);
|
|
25
32
|
} catch {
|
|
26
|
-
|
|
33
|
+
json = void 0;
|
|
27
34
|
}
|
|
28
|
-
|
|
29
|
-
return
|
|
35
|
+
json ??= String(value);
|
|
36
|
+
return json.length > 300 ? `${json.slice(0, 297)}...` : json;
|
|
30
37
|
};
|
|
31
38
|
var checkTimes = (label, times) => {
|
|
32
39
|
if (times !== void 0 && (!Number.isInteger(times) || times < 0)) {
|
|
@@ -42,1291 +49,6 @@ ${recorded}`
|
|
|
42
49
|
);
|
|
43
50
|
};
|
|
44
51
|
|
|
45
|
-
// src/conformance.ts
|
|
46
|
-
import {
|
|
47
|
-
DEFAULT_BODY_LIMIT,
|
|
48
|
-
createRouter,
|
|
49
|
-
file,
|
|
50
|
-
json,
|
|
51
|
-
notFoundError,
|
|
52
|
-
redirect,
|
|
53
|
-
sse,
|
|
54
|
-
stream,
|
|
55
|
-
text,
|
|
56
|
-
unauthorizedError
|
|
57
|
-
} from "@dotpress/core";
|
|
58
|
-
import { describe, expect as expect2, it, vi } from "vitest";
|
|
59
|
-
|
|
60
|
-
// src/logger.ts
|
|
61
|
-
var createMemoryLogger = () => {
|
|
62
|
-
const entries = [];
|
|
63
|
-
const create = () => ({
|
|
64
|
-
debug: (...args) => void entries.push({ level: "debug", args }),
|
|
65
|
-
info: (...args) => void entries.push({ level: "info", args }),
|
|
66
|
-
warn: (...args) => void entries.push({ level: "warn", args }),
|
|
67
|
-
error: (...args) => void entries.push({ level: "error", args }),
|
|
68
|
-
child: () => create()
|
|
69
|
-
});
|
|
70
|
-
return Object.assign(create(), { entries });
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// src/request.ts
|
|
74
|
-
var toRequest = async (path, init) => {
|
|
75
|
-
const request = new Request(new URL(path, "http://localhost"), init);
|
|
76
|
-
if (!(init?.body instanceof FormData)) return request;
|
|
77
|
-
return new Request(request, { body: await request.arrayBuffer() });
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
// src/conformance.ts
|
|
81
|
-
var createHttpTarget = (baseUrl, close) => ({
|
|
82
|
-
fetch: (path, init) => fetch(new URL(path, baseUrl), init),
|
|
83
|
-
close
|
|
84
|
-
});
|
|
85
|
-
var createFetchTarget = (handle, close) => ({
|
|
86
|
-
fetch: async (path, init) => handle(await toRequest(path, init)),
|
|
87
|
-
close
|
|
88
|
-
});
|
|
89
|
-
var TIMEOUT = 2e3;
|
|
90
|
-
var within = (promise, label) => Promise.race([
|
|
91
|
-
promise,
|
|
92
|
-
new Promise(
|
|
93
|
-
(_, reject) => setTimeout(
|
|
94
|
-
() => reject(new Error(`Timed out waiting for: ${label}`)),
|
|
95
|
-
TIMEOUT
|
|
96
|
-
)
|
|
97
|
-
)
|
|
98
|
-
]);
|
|
99
|
-
var deferred = () => {
|
|
100
|
-
let resolve;
|
|
101
|
-
const promise = new Promise((done) => {
|
|
102
|
-
resolve = done;
|
|
103
|
-
});
|
|
104
|
-
return { promise, resolve };
|
|
105
|
-
};
|
|
106
|
-
var waitForAbort = (signal) => new Promise((resolve) => {
|
|
107
|
-
if (signal.aborted) resolve();
|
|
108
|
-
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
109
|
-
});
|
|
110
|
-
var readChunk = async (reader) => {
|
|
111
|
-
const { done, value } = await reader.read();
|
|
112
|
-
return done ? void 0 : new TextDecoder().decode(value);
|
|
113
|
-
};
|
|
114
|
-
var runConformanceSuite = (options) => {
|
|
115
|
-
const serve = async (define, appOptions = {}) => {
|
|
116
|
-
const router = createRouter();
|
|
117
|
-
define(router);
|
|
118
|
-
return options.setup({
|
|
119
|
-
logger: createMemoryLogger(),
|
|
120
|
-
...appOptions,
|
|
121
|
-
routers: [router]
|
|
122
|
-
});
|
|
123
|
-
};
|
|
124
|
-
const withApp = async (define, run, appOptions) => {
|
|
125
|
-
const target = await serve(define, appOptions);
|
|
126
|
-
try {
|
|
127
|
-
await run(target);
|
|
128
|
-
} finally {
|
|
129
|
-
await target.close();
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
const postJson = (body) => ({
|
|
133
|
-
method: "POST",
|
|
134
|
-
headers: { "content-type": "application/json" },
|
|
135
|
-
body: JSON.stringify(body)
|
|
136
|
-
});
|
|
137
|
-
describe(`${options.name} adapter conformance`, () => {
|
|
138
|
-
describe("routing", () => {
|
|
139
|
-
it("serves JSON results", () => withApp(
|
|
140
|
-
(router) => {
|
|
141
|
-
router.defineRoute({ path: "/hello", handler: () => ({ hi: 1 }) });
|
|
142
|
-
},
|
|
143
|
-
async ({ fetch: fetch2 }) => {
|
|
144
|
-
const res = await fetch2("/hello");
|
|
145
|
-
expect2(res.status).toBe(200);
|
|
146
|
-
expect2(res.headers.get("content-type")).toBe(
|
|
147
|
-
"application/json; charset=utf-8"
|
|
148
|
-
);
|
|
149
|
-
expect2(await res.json()).toEqual({ hi: 1 });
|
|
150
|
-
}
|
|
151
|
-
));
|
|
152
|
-
it("routes every HTTP method", () => withApp(
|
|
153
|
-
(router) => {
|
|
154
|
-
for (const method of [
|
|
155
|
-
"get",
|
|
156
|
-
"post",
|
|
157
|
-
"put",
|
|
158
|
-
"patch",
|
|
159
|
-
"delete",
|
|
160
|
-
"options"
|
|
161
|
-
]) {
|
|
162
|
-
router.defineRoute({
|
|
163
|
-
method,
|
|
164
|
-
path: "/resource",
|
|
165
|
-
handler: ({ req }) => ({ method: req.method })
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
router.defineRoute({
|
|
169
|
-
method: "head",
|
|
170
|
-
path: "/resource",
|
|
171
|
-
handler: ({ res }) => {
|
|
172
|
-
res.setHeader("x-head", "yes");
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
},
|
|
176
|
-
async ({ fetch: fetch2 }) => {
|
|
177
|
-
for (const method of [
|
|
178
|
-
"GET",
|
|
179
|
-
"POST",
|
|
180
|
-
"PUT",
|
|
181
|
-
"PATCH",
|
|
182
|
-
"DELETE",
|
|
183
|
-
"OPTIONS"
|
|
184
|
-
]) {
|
|
185
|
-
const res = await fetch2("/resource", { method });
|
|
186
|
-
expect2(await res.json()).toEqual({ method });
|
|
187
|
-
}
|
|
188
|
-
const head = await fetch2("/resource", { method: "HEAD" });
|
|
189
|
-
expect2(head.headers.get("x-head")).toBe("yes");
|
|
190
|
-
}
|
|
191
|
-
));
|
|
192
|
-
it("extracts decoded path parameters", () => withApp(
|
|
193
|
-
(router) => {
|
|
194
|
-
router.defineRoute({
|
|
195
|
-
path: "/orgs/:orgId/files/:name",
|
|
196
|
-
handler: ({ req }) => req.params
|
|
197
|
-
});
|
|
198
|
-
},
|
|
199
|
-
async ({ fetch: fetch2 }) => {
|
|
200
|
-
const res = await fetch2("/orgs/acme/files/a%20b%2Fc%C3%A9");
|
|
201
|
-
expect2(await res.json()).toEqual({ orgId: "acme", name: "a b/c\xE9" });
|
|
202
|
-
}
|
|
203
|
-
));
|
|
204
|
-
it("gives static segments priority over parameters", () => withApp(
|
|
205
|
-
(router) => {
|
|
206
|
-
router.defineRoute({
|
|
207
|
-
path: "/users/:id",
|
|
208
|
-
handler: ({ req }) => ({ id: req.params.id })
|
|
209
|
-
});
|
|
210
|
-
router.defineRoute({ path: "/users/me", handler: () => "me" });
|
|
211
|
-
},
|
|
212
|
-
async ({ fetch: fetch2 }) => {
|
|
213
|
-
expect2(await (await fetch2("/users/me")).json()).toBe("me");
|
|
214
|
-
expect2(await (await fetch2("/users/42")).json()).toEqual({
|
|
215
|
-
id: "42"
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
));
|
|
219
|
-
it("ignores trailing slashes and matches case-sensitively", () => withApp(
|
|
220
|
-
(router) => {
|
|
221
|
-
router.defineRoute({ path: "/users", handler: () => "users" });
|
|
222
|
-
},
|
|
223
|
-
async ({ fetch: fetch2 }) => {
|
|
224
|
-
expect2((await fetch2("/users/")).status).toBe(200);
|
|
225
|
-
expect2((await fetch2("/Users")).status).toBe(404);
|
|
226
|
-
}
|
|
227
|
-
));
|
|
228
|
-
it("returns the standard 404 error for unknown paths", () => withApp(
|
|
229
|
-
(router) => {
|
|
230
|
-
router.defineRoute({ path: "/items", handler: () => [] });
|
|
231
|
-
},
|
|
232
|
-
async ({ fetch: fetch2 }) => {
|
|
233
|
-
for (const method of ["GET", "POST"]) {
|
|
234
|
-
const res = await fetch2("/missing", { method });
|
|
235
|
-
expect2(res.status).toBe(404);
|
|
236
|
-
expect2(res.headers.get("x-request-id")).toBeTruthy();
|
|
237
|
-
expect2(await res.json()).toEqual({
|
|
238
|
-
status: 404,
|
|
239
|
-
error: "NOT_FOUND",
|
|
240
|
-
message: "Route not found"
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
));
|
|
245
|
-
it("returns 405 with an Allow header for unsupported methods", () => withApp(
|
|
246
|
-
(router) => {
|
|
247
|
-
router.defineRoute({ path: "/items/:id", handler: () => ({}) });
|
|
248
|
-
router.defineRoute({
|
|
249
|
-
method: "delete",
|
|
250
|
-
path: "/items/:id",
|
|
251
|
-
handler: () => {
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
},
|
|
255
|
-
async ({ fetch: fetch2 }) => {
|
|
256
|
-
for (const method of ["POST", "OPTIONS"]) {
|
|
257
|
-
const res = await fetch2("/items/1", { method });
|
|
258
|
-
expect2(res.status).toBe(405);
|
|
259
|
-
expect2(res.headers.get("allow")).toBe("GET, HEAD, DELETE");
|
|
260
|
-
expect2(res.headers.get("x-request-id")).toBeTruthy();
|
|
261
|
-
expect2(await res.json()).toEqual({
|
|
262
|
-
status: 405,
|
|
263
|
-
error: "METHOD_NOT_ALLOWED",
|
|
264
|
-
message: "Method not allowed"
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
));
|
|
269
|
-
it("answers HEAD requests with the GET route, without body", () => withApp(
|
|
270
|
-
(router) => {
|
|
271
|
-
router.defineRoute({ path: "/data", handler: () => ({ a: 1 }) });
|
|
272
|
-
router.defineRoute({
|
|
273
|
-
path: "/stream",
|
|
274
|
-
handler: () => stream(["never sent"])
|
|
275
|
-
});
|
|
276
|
-
},
|
|
277
|
-
async ({ fetch: fetch2 }) => {
|
|
278
|
-
const res = await fetch2("/data", { method: "HEAD" });
|
|
279
|
-
expect2(res.status).toBe(200);
|
|
280
|
-
expect2(res.headers.get("content-type")).toBe(
|
|
281
|
-
"application/json; charset=utf-8"
|
|
282
|
-
);
|
|
283
|
-
expect2(await res.text()).toBe("");
|
|
284
|
-
const streamed = await fetch2("/stream", { method: "HEAD" });
|
|
285
|
-
expect2(streamed.status).toBe(200);
|
|
286
|
-
expect2(await streamed.text()).toBe("");
|
|
287
|
-
}
|
|
288
|
-
));
|
|
289
|
-
it("mounts groups with their prefix and middlewares", () => {
|
|
290
|
-
const calls = [];
|
|
291
|
-
const track = (name) => () => {
|
|
292
|
-
calls.push(name);
|
|
293
|
-
};
|
|
294
|
-
return withApp(
|
|
295
|
-
(router) => {
|
|
296
|
-
const api = router.createGroup("/api", [track("api")]);
|
|
297
|
-
api.createGroup("/admin", [track("admin")]).defineRoute({
|
|
298
|
-
path: "/stats",
|
|
299
|
-
middlewares: [track("route")],
|
|
300
|
-
handler: () => ({ ok: true })
|
|
301
|
-
});
|
|
302
|
-
},
|
|
303
|
-
async ({ fetch: fetch2 }) => {
|
|
304
|
-
expect2(await (await fetch2("/api/admin/stats")).json()).toEqual({
|
|
305
|
-
ok: true
|
|
306
|
-
});
|
|
307
|
-
expect2(calls).toEqual(["global", "api", "admin", "route"]);
|
|
308
|
-
},
|
|
309
|
-
{ middlewares: [track("global")] }
|
|
310
|
-
);
|
|
311
|
-
});
|
|
312
|
-
it("serves the routes under the base path only", () => withApp(
|
|
313
|
-
(router) => {
|
|
314
|
-
router.defineRoute({
|
|
315
|
-
path: "/users/:id",
|
|
316
|
-
handler: ({ req }) => ({ id: req.params.id, path: req.path })
|
|
317
|
-
});
|
|
318
|
-
},
|
|
319
|
-
async ({ fetch: fetch2 }) => {
|
|
320
|
-
const res = await fetch2("/my-app/api/users/42");
|
|
321
|
-
expect2(res.status).toBe(200);
|
|
322
|
-
expect2(await res.json()).toEqual({
|
|
323
|
-
id: "42",
|
|
324
|
-
path: "/my-app/api/users/42"
|
|
325
|
-
});
|
|
326
|
-
for (const path of ["/users/42", "/my-app/users/42"]) {
|
|
327
|
-
const outside = await fetch2(path);
|
|
328
|
-
expect2(outside.status, path).toBe(404);
|
|
329
|
-
expect2(await outside.json()).toMatchObject({
|
|
330
|
-
error: "NOT_FOUND"
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
},
|
|
334
|
-
{ basePath: "/my-app/api" }
|
|
335
|
-
));
|
|
336
|
-
});
|
|
337
|
-
describe("request", () => {
|
|
338
|
-
it("parses the query string", () => withApp(
|
|
339
|
-
(router) => {
|
|
340
|
-
router.defineRoute({
|
|
341
|
-
path: "/search",
|
|
342
|
-
handler: ({ req }) => req.query
|
|
343
|
-
});
|
|
344
|
-
},
|
|
345
|
-
async ({ fetch: fetch2 }) => {
|
|
346
|
-
const res = await fetch2(
|
|
347
|
-
"/search?q=hello+world&tag=a&tag=b&name=%C3%A9&empty="
|
|
348
|
-
);
|
|
349
|
-
expect2(await res.json()).toEqual({
|
|
350
|
-
q: "hello world",
|
|
351
|
-
tag: ["a", "b"],
|
|
352
|
-
name: "\xE9",
|
|
353
|
-
empty: ""
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
));
|
|
357
|
-
it("exposes the method, path, URL, headers and cookies", () => withApp(
|
|
358
|
-
(router) => {
|
|
359
|
-
router.defineRoute({
|
|
360
|
-
method: "post",
|
|
361
|
-
path: "/inspect",
|
|
362
|
-
handler: ({ req }) => ({
|
|
363
|
-
method: req.method,
|
|
364
|
-
path: req.path,
|
|
365
|
-
url: req.url && new URL(req.url).pathname + new URL(req.url).search,
|
|
366
|
-
custom: req.header("X-Custom"),
|
|
367
|
-
lowercase: req.headers["x-custom"],
|
|
368
|
-
cookies: req.cookies
|
|
369
|
-
})
|
|
370
|
-
});
|
|
371
|
-
},
|
|
372
|
-
async ({ fetch: fetch2 }) => {
|
|
373
|
-
const res = await fetch2("/inspect?a=1", {
|
|
374
|
-
method: "POST",
|
|
375
|
-
headers: {
|
|
376
|
-
"X-Custom": "value",
|
|
377
|
-
cookie: "session=abc; theme=dark%20blue"
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
|
-
expect2(await res.json()).toEqual({
|
|
381
|
-
method: "POST",
|
|
382
|
-
path: "/inspect",
|
|
383
|
-
url: "/inspect?a=1",
|
|
384
|
-
custom: "value",
|
|
385
|
-
lowercase: "value",
|
|
386
|
-
cookies: { session: "abc", theme: "dark blue" }
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
));
|
|
390
|
-
it("exposes the client IP address", () => withApp(
|
|
391
|
-
(router) => {
|
|
392
|
-
router.defineRoute({
|
|
393
|
-
path: "/ip",
|
|
394
|
-
handler: ({ req }) => ({ ip: req.ip })
|
|
395
|
-
});
|
|
396
|
-
},
|
|
397
|
-
async ({ fetch: fetch2 }) => {
|
|
398
|
-
const { ip } = await (await fetch2("/ip")).json();
|
|
399
|
-
expect2(ip).toMatch(/^((::ffff:)?127\.0\.0\.1|::1)$/);
|
|
400
|
-
}
|
|
401
|
-
));
|
|
402
|
-
it("parses JSON bodies", () => withApp(
|
|
403
|
-
(router) => {
|
|
404
|
-
router.defineRoute({
|
|
405
|
-
method: "post",
|
|
406
|
-
path: "/echo",
|
|
407
|
-
handler: ({ req }) => ({ body: req.body })
|
|
408
|
-
});
|
|
409
|
-
},
|
|
410
|
-
async ({ fetch: fetch2 }) => {
|
|
411
|
-
const res = await fetch2("/echo", postJson({ a: [1, "\xE9"], b: null }));
|
|
412
|
-
expect2(await res.json()).toEqual({ body: { a: [1, "\xE9"], b: null } });
|
|
413
|
-
}
|
|
414
|
-
));
|
|
415
|
-
it("rejects malformed JSON bodies", () => withApp(
|
|
416
|
-
(router) => {
|
|
417
|
-
router.defineRoute({
|
|
418
|
-
method: "post",
|
|
419
|
-
path: "/echo",
|
|
420
|
-
handler: () => ({ unreachable: true })
|
|
421
|
-
});
|
|
422
|
-
},
|
|
423
|
-
async ({ fetch: fetch2 }) => {
|
|
424
|
-
const res = await fetch2("/echo", {
|
|
425
|
-
method: "POST",
|
|
426
|
-
headers: { "content-type": "application/json" },
|
|
427
|
-
body: '{"broken":'
|
|
428
|
-
});
|
|
429
|
-
expect2(res.status).toBe(400);
|
|
430
|
-
expect2(res.headers.get("x-request-id")).toBeTruthy();
|
|
431
|
-
expect2(await res.json()).toEqual({
|
|
432
|
-
status: 400,
|
|
433
|
-
error: "MALFORMED_BODY",
|
|
434
|
-
message: "Malformed request body"
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
));
|
|
438
|
-
it("parses URL-encoded, text and binary bodies", () => withApp(
|
|
439
|
-
(router) => {
|
|
440
|
-
router.defineRoute({
|
|
441
|
-
method: "post",
|
|
442
|
-
path: "/echo",
|
|
443
|
-
handler: ({ req }) => req.body instanceof Uint8Array ? { bytes: [...req.body] } : { body: req.body }
|
|
444
|
-
});
|
|
445
|
-
},
|
|
446
|
-
async ({ fetch: fetch2 }) => {
|
|
447
|
-
const form = await fetch2("/echo", {
|
|
448
|
-
method: "POST",
|
|
449
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
450
|
-
body: "name=Ada+Lovelace&tag=a&tag=b"
|
|
451
|
-
});
|
|
452
|
-
expect2(await form.json()).toEqual({
|
|
453
|
-
body: { name: "Ada Lovelace", tag: ["a", "b"] }
|
|
454
|
-
});
|
|
455
|
-
const plain = await fetch2("/echo", {
|
|
456
|
-
method: "POST",
|
|
457
|
-
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
458
|
-
body: "h\xE9llo"
|
|
459
|
-
});
|
|
460
|
-
expect2(await plain.json()).toEqual({ body: "h\xE9llo" });
|
|
461
|
-
const binary = await fetch2("/echo", {
|
|
462
|
-
method: "POST",
|
|
463
|
-
headers: { "content-type": "application/octet-stream" },
|
|
464
|
-
body: new Uint8Array([0, 1, 254, 255])
|
|
465
|
-
});
|
|
466
|
-
expect2(await binary.json()).toEqual({ bytes: [0, 1, 254, 255] });
|
|
467
|
-
}
|
|
468
|
-
));
|
|
469
|
-
it("exposes the raw body on rawBody routes", () => withApp(
|
|
470
|
-
(router) => {
|
|
471
|
-
router.defineRoute({
|
|
472
|
-
method: "post",
|
|
473
|
-
path: "/webhooks",
|
|
474
|
-
rawBody: true,
|
|
475
|
-
schema: (z) => ({ body: z.object({ type: z.string() }) }),
|
|
476
|
-
handler: ({ req }) => ({
|
|
477
|
-
raw: new TextDecoder().decode(req.rawBody),
|
|
478
|
-
type: req.body.type
|
|
479
|
-
})
|
|
480
|
-
});
|
|
481
|
-
router.defineRoute({
|
|
482
|
-
method: "post",
|
|
483
|
-
path: "/ping",
|
|
484
|
-
rawBody: true,
|
|
485
|
-
handler: ({ req }) => ({ length: req.rawBody.byteLength })
|
|
486
|
-
});
|
|
487
|
-
},
|
|
488
|
-
async ({ fetch: fetch2 }) => {
|
|
489
|
-
const payload = '{ "type" : "invoice.paid", "id": "\xE9" }';
|
|
490
|
-
const res = await fetch2("/webhooks", {
|
|
491
|
-
method: "POST",
|
|
492
|
-
headers: { "content-type": "application/json" },
|
|
493
|
-
body: payload
|
|
494
|
-
});
|
|
495
|
-
expect2(await res.json()).toEqual({
|
|
496
|
-
raw: payload,
|
|
497
|
-
type: "invoice.paid"
|
|
498
|
-
});
|
|
499
|
-
const empty = await fetch2("/ping", { method: "POST" });
|
|
500
|
-
expect2(await empty.json()).toEqual({ length: 0 });
|
|
501
|
-
}
|
|
502
|
-
));
|
|
503
|
-
it("gives an undefined body when there is none", () => withApp(
|
|
504
|
-
(router) => {
|
|
505
|
-
router.defineRoute({
|
|
506
|
-
method: "post",
|
|
507
|
-
path: "/empty",
|
|
508
|
-
handler: ({ req }) => ({ hasBody: req.body !== void 0 })
|
|
509
|
-
});
|
|
510
|
-
},
|
|
511
|
-
async ({ fetch: fetch2 }) => {
|
|
512
|
-
const res = await fetch2("/empty", { method: "POST" });
|
|
513
|
-
expect2(await res.json()).toEqual({ hasBody: false });
|
|
514
|
-
}
|
|
515
|
-
));
|
|
516
|
-
it("rejects bodies larger than the default limit", () => withApp(
|
|
517
|
-
(router) => {
|
|
518
|
-
router.defineRoute({
|
|
519
|
-
method: "post",
|
|
520
|
-
path: "/upload",
|
|
521
|
-
handler: () => ({ unreachable: true })
|
|
522
|
-
});
|
|
523
|
-
},
|
|
524
|
-
async ({ fetch: fetch2 }) => {
|
|
525
|
-
const payload = new Uint8Array(DEFAULT_BODY_LIMIT + 1);
|
|
526
|
-
const declared = await fetch2("/upload", {
|
|
527
|
-
method: "POST",
|
|
528
|
-
headers: { "content-type": "application/octet-stream" },
|
|
529
|
-
body: payload
|
|
530
|
-
});
|
|
531
|
-
expect2(declared.status).toBe(413);
|
|
532
|
-
expect2(await declared.json()).toEqual({
|
|
533
|
-
status: 413,
|
|
534
|
-
error: "PAYLOAD_TOO_LARGE",
|
|
535
|
-
message: "Request body too large"
|
|
536
|
-
});
|
|
537
|
-
const chunked = await fetch2("/upload", {
|
|
538
|
-
method: "POST",
|
|
539
|
-
headers: { "content-type": "application/octet-stream" },
|
|
540
|
-
body: new ReadableStream({
|
|
541
|
-
start(controller) {
|
|
542
|
-
controller.enqueue(payload.subarray(0, DEFAULT_BODY_LIMIT));
|
|
543
|
-
controller.enqueue(new Uint8Array(1));
|
|
544
|
-
controller.close();
|
|
545
|
-
}
|
|
546
|
-
}),
|
|
547
|
-
duplex: "half"
|
|
548
|
-
});
|
|
549
|
-
expect2(chunked.status).toBe(413);
|
|
550
|
-
}
|
|
551
|
-
));
|
|
552
|
-
it("accepts bodies up to the default limit", () => withApp(
|
|
553
|
-
(router) => {
|
|
554
|
-
router.defineRoute({
|
|
555
|
-
method: "post",
|
|
556
|
-
path: "/upload",
|
|
557
|
-
handler: ({ req }) => ({
|
|
558
|
-
size: req.body.byteLength
|
|
559
|
-
})
|
|
560
|
-
});
|
|
561
|
-
},
|
|
562
|
-
async ({ fetch: fetch2 }) => {
|
|
563
|
-
const res = await fetch2("/upload", {
|
|
564
|
-
method: "POST",
|
|
565
|
-
headers: { "content-type": "application/octet-stream" },
|
|
566
|
-
body: new Uint8Array(DEFAULT_BODY_LIMIT)
|
|
567
|
-
});
|
|
568
|
-
expect2(await res.json()).toEqual({ size: DEFAULT_BODY_LIMIT });
|
|
569
|
-
}
|
|
570
|
-
));
|
|
571
|
-
it("rejects compressed request bodies", () => withApp(
|
|
572
|
-
(router) => {
|
|
573
|
-
router.defineRoute({
|
|
574
|
-
method: "post",
|
|
575
|
-
path: "/echo",
|
|
576
|
-
handler: () => ({ unreachable: true })
|
|
577
|
-
});
|
|
578
|
-
},
|
|
579
|
-
async ({ fetch: fetch2 }) => {
|
|
580
|
-
const res = await fetch2("/echo", {
|
|
581
|
-
method: "POST",
|
|
582
|
-
headers: {
|
|
583
|
-
"content-type": "application/json",
|
|
584
|
-
"content-encoding": "gzip"
|
|
585
|
-
},
|
|
586
|
-
body: new Uint8Array([31, 139, 8, 0])
|
|
587
|
-
});
|
|
588
|
-
expect2(res.status).toBe(415);
|
|
589
|
-
expect2(await res.json()).toMatchObject({
|
|
590
|
-
error: "UNSUPPORTED_MEDIA_TYPE"
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
));
|
|
594
|
-
});
|
|
595
|
-
describe("uploads", () => {
|
|
596
|
-
const formWith = (entries) => {
|
|
597
|
-
const form = new FormData();
|
|
598
|
-
for (const [name, value] of entries) form.append(name, value);
|
|
599
|
-
return form;
|
|
600
|
-
};
|
|
601
|
-
it("parses multipart bodies into fields and files", () => {
|
|
602
|
-
const bytes = Uint8Array.from({ length: 256 }, (_, index) => index);
|
|
603
|
-
return withApp(
|
|
604
|
-
(router) => {
|
|
605
|
-
router.defineRoute({
|
|
606
|
-
method: "post",
|
|
607
|
-
path: "/documents",
|
|
608
|
-
schema: (z) => ({
|
|
609
|
-
body: z.object({ title: z.string() }),
|
|
610
|
-
files: z.object({
|
|
611
|
-
document: z.file().mime(["application/pdf"])
|
|
612
|
-
})
|
|
613
|
-
}),
|
|
614
|
-
handler: async ({ req }) => ({
|
|
615
|
-
title: req.body.title,
|
|
616
|
-
name: req.files.document.name,
|
|
617
|
-
type: req.files.document.type,
|
|
618
|
-
bytes: [
|
|
619
|
-
...new Uint8Array(await req.files.document.arrayBuffer())
|
|
620
|
-
]
|
|
621
|
-
})
|
|
622
|
-
});
|
|
623
|
-
router.defineRoute({
|
|
624
|
-
method: "post",
|
|
625
|
-
path: "/gallery",
|
|
626
|
-
handler: ({ req }) => ({
|
|
627
|
-
fields: req.body,
|
|
628
|
-
files: Object.fromEntries(
|
|
629
|
-
Object.entries(req.files).map(([name, value]) => [
|
|
630
|
-
name,
|
|
631
|
-
[value].flat().map((file2) => file2.name)
|
|
632
|
-
])
|
|
633
|
-
)
|
|
634
|
-
})
|
|
635
|
-
});
|
|
636
|
-
},
|
|
637
|
-
async ({ fetch: fetch2 }) => {
|
|
638
|
-
const res = await fetch2("/documents", {
|
|
639
|
-
method: "POST",
|
|
640
|
-
body: formWith([
|
|
641
|
-
["title", "Contrat sign\xE9"],
|
|
642
|
-
[
|
|
643
|
-
"document",
|
|
644
|
-
new File([bytes], "contrat \xE9t\xE9.pdf", {
|
|
645
|
-
type: "application/pdf"
|
|
646
|
-
})
|
|
647
|
-
]
|
|
648
|
-
])
|
|
649
|
-
});
|
|
650
|
-
expect2(await res.json()).toEqual({
|
|
651
|
-
title: "Contrat sign\xE9",
|
|
652
|
-
name: "contrat \xE9t\xE9.pdf",
|
|
653
|
-
type: "application/pdf",
|
|
654
|
-
bytes: [...bytes]
|
|
655
|
-
});
|
|
656
|
-
const gallery = await fetch2("/gallery", {
|
|
657
|
-
method: "POST",
|
|
658
|
-
body: formWith([
|
|
659
|
-
["tag", "sea"],
|
|
660
|
-
["tag", "sun"],
|
|
661
|
-
["photos", new File(["a"], "a.jpg")],
|
|
662
|
-
["photos", new File(["b"], "b.jpg")],
|
|
663
|
-
["cover", new File(["c"], "c.jpg")]
|
|
664
|
-
])
|
|
665
|
-
});
|
|
666
|
-
expect2(await gallery.json()).toEqual({
|
|
667
|
-
fields: { tag: ["sea", "sun"] },
|
|
668
|
-
files: { photos: ["a.jpg", "b.jpg"], cover: ["c.jpg"] }
|
|
669
|
-
});
|
|
670
|
-
}
|
|
671
|
-
);
|
|
672
|
-
});
|
|
673
|
-
it("validates files", () => withApp(
|
|
674
|
-
(router) => {
|
|
675
|
-
router.defineRoute({
|
|
676
|
-
method: "post",
|
|
677
|
-
path: "/avatar",
|
|
678
|
-
schema: (z) => ({
|
|
679
|
-
files: z.object({
|
|
680
|
-
avatar: z.file().max(10).mime(["image/png"])
|
|
681
|
-
})
|
|
682
|
-
}),
|
|
683
|
-
handler: () => ({ ok: true })
|
|
684
|
-
});
|
|
685
|
-
},
|
|
686
|
-
async ({ fetch: fetch2 }) => {
|
|
687
|
-
const res = await fetch2("/avatar", {
|
|
688
|
-
method: "POST",
|
|
689
|
-
body: formWith([
|
|
690
|
-
[
|
|
691
|
-
"avatar",
|
|
692
|
-
new File([new Uint8Array(11)], "a.gif", {
|
|
693
|
-
type: "image/gif"
|
|
694
|
-
})
|
|
695
|
-
]
|
|
696
|
-
])
|
|
697
|
-
});
|
|
698
|
-
expect2(res.status).toBe(400);
|
|
699
|
-
expect2(await res.json()).toMatchObject({
|
|
700
|
-
error: "VALIDATION_FAILED",
|
|
701
|
-
data: {
|
|
702
|
-
issues: [
|
|
703
|
-
{ source: "files", path: ["avatar"], code: "too_big" },
|
|
704
|
-
{ source: "files", path: ["avatar"], code: "invalid_value" }
|
|
705
|
-
]
|
|
706
|
-
}
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
));
|
|
710
|
-
it("applies the route body limit", () => withApp(
|
|
711
|
-
(router) => {
|
|
712
|
-
router.defineRoute({
|
|
713
|
-
method: "post",
|
|
714
|
-
path: "/large",
|
|
715
|
-
bodyLimit: 3 * DEFAULT_BODY_LIMIT,
|
|
716
|
-
handler: ({ req }) => ({
|
|
717
|
-
size: req.files.file.size
|
|
718
|
-
})
|
|
719
|
-
});
|
|
720
|
-
router.defineRoute({
|
|
721
|
-
method: "post",
|
|
722
|
-
path: "/small",
|
|
723
|
-
bodyLimit: 100,
|
|
724
|
-
handler: () => ({ unreachable: true })
|
|
725
|
-
});
|
|
726
|
-
},
|
|
727
|
-
async ({ fetch: fetch2 }) => {
|
|
728
|
-
const size = 2 * DEFAULT_BODY_LIMIT;
|
|
729
|
-
const large = await fetch2("/large", {
|
|
730
|
-
method: "POST",
|
|
731
|
-
body: formWith([
|
|
732
|
-
["file", new File([new Uint8Array(size)], "big.bin")]
|
|
733
|
-
])
|
|
734
|
-
});
|
|
735
|
-
expect2(await large.json()).toEqual({ size });
|
|
736
|
-
const small = await fetch2("/small", {
|
|
737
|
-
method: "POST",
|
|
738
|
-
body: formWith([
|
|
739
|
-
["file", new File([new Uint8Array(200)], "a.bin")]
|
|
740
|
-
])
|
|
741
|
-
});
|
|
742
|
-
expect2(small.status).toBe(413);
|
|
743
|
-
}
|
|
744
|
-
));
|
|
745
|
-
it("rejects malformed multipart bodies", () => withApp(
|
|
746
|
-
(router) => {
|
|
747
|
-
router.defineRoute({
|
|
748
|
-
method: "post",
|
|
749
|
-
path: "/upload",
|
|
750
|
-
handler: () => ({ unreachable: true })
|
|
751
|
-
});
|
|
752
|
-
},
|
|
753
|
-
async ({ fetch: fetch2 }) => {
|
|
754
|
-
const res = await fetch2("/upload", {
|
|
755
|
-
method: "POST",
|
|
756
|
-
headers: { "content-type": "multipart/form-data; boundary=xyz" },
|
|
757
|
-
body: "not a multipart body"
|
|
758
|
-
});
|
|
759
|
-
expect2(res.status).toBe(400);
|
|
760
|
-
expect2(await res.json()).toMatchObject({ error: "MALFORMED_BODY" });
|
|
761
|
-
}
|
|
762
|
-
));
|
|
763
|
-
});
|
|
764
|
-
describe("validation", () => {
|
|
765
|
-
it("returns VALIDATION_FAILED errors and parsed values", () => withApp(
|
|
766
|
-
(router) => {
|
|
767
|
-
router.defineRoute({
|
|
768
|
-
method: "post",
|
|
769
|
-
path: "/users/:id",
|
|
770
|
-
schema: (z) => ({
|
|
771
|
-
params: z.object({ id: z.coerce.number().int() }),
|
|
772
|
-
query: z.object({ notify: z.enum(["yes", "no"]) }),
|
|
773
|
-
body: z.object({ name: z.string().trim() })
|
|
774
|
-
}),
|
|
775
|
-
handler: ({ req }) => ({
|
|
776
|
-
id: req.params.id,
|
|
777
|
-
notify: req.query.notify,
|
|
778
|
-
name: req.body.name
|
|
779
|
-
})
|
|
780
|
-
});
|
|
781
|
-
},
|
|
782
|
-
async ({ fetch: fetch2 }) => {
|
|
783
|
-
const invalid = await fetch2("/users/abc?notify=maybe", postJson({}));
|
|
784
|
-
expect2(invalid.status).toBe(400);
|
|
785
|
-
expect2(await invalid.json()).toMatchObject({
|
|
786
|
-
status: 400,
|
|
787
|
-
error: "VALIDATION_FAILED",
|
|
788
|
-
data: {
|
|
789
|
-
issues: [
|
|
790
|
-
{ source: "params", path: ["id"] },
|
|
791
|
-
{ source: "query", path: ["notify"] },
|
|
792
|
-
{ source: "body", path: ["name"] }
|
|
793
|
-
]
|
|
794
|
-
}
|
|
795
|
-
});
|
|
796
|
-
const valid = await fetch2(
|
|
797
|
-
"/users/42?notify=yes",
|
|
798
|
-
postJson({ name: " Ada " })
|
|
799
|
-
);
|
|
800
|
-
expect2(await valid.json()).toEqual({
|
|
801
|
-
id: 42,
|
|
802
|
-
notify: "yes",
|
|
803
|
-
name: "Ada"
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
));
|
|
807
|
-
});
|
|
808
|
-
describe("errors", () => {
|
|
809
|
-
it("serializes returned and thrown HTTP errors", () => withApp(
|
|
810
|
-
(router) => {
|
|
811
|
-
router.defineRoute({
|
|
812
|
-
path: "/returned",
|
|
813
|
-
handler: () => notFoundError("User not found", { id: "1" })
|
|
814
|
-
});
|
|
815
|
-
router.defineRoute({
|
|
816
|
-
path: "/thrown",
|
|
817
|
-
handler: ({ res }) => {
|
|
818
|
-
res.setHeader("www-authenticate", "Bearer");
|
|
819
|
-
throw unauthorizedError();
|
|
820
|
-
}
|
|
821
|
-
});
|
|
822
|
-
},
|
|
823
|
-
async ({ fetch: fetch2 }) => {
|
|
824
|
-
const returned = await fetch2("/returned");
|
|
825
|
-
expect2(returned.status).toBe(404);
|
|
826
|
-
expect2(await returned.json()).toEqual({
|
|
827
|
-
status: 404,
|
|
828
|
-
error: "NOT_FOUND",
|
|
829
|
-
message: "User not found",
|
|
830
|
-
data: { id: "1" }
|
|
831
|
-
});
|
|
832
|
-
const thrown = await fetch2("/thrown");
|
|
833
|
-
expect2(thrown.status).toBe(401);
|
|
834
|
-
expect2(thrown.headers.get("www-authenticate")).toBe("Bearer");
|
|
835
|
-
expect2(await thrown.json()).toEqual({
|
|
836
|
-
status: 401,
|
|
837
|
-
error: "UNAUTHORIZED",
|
|
838
|
-
message: "Unauthorized"
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
|
-
));
|
|
842
|
-
it("hides unexpected errors behind a 500 error and reports them", async () => {
|
|
843
|
-
const onError = vi.fn();
|
|
844
|
-
await withApp(
|
|
845
|
-
(router) => {
|
|
846
|
-
router.defineRoute({
|
|
847
|
-
path: "/crash",
|
|
848
|
-
handler: () => {
|
|
849
|
-
throw new Error("Database password is hunter2");
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
},
|
|
853
|
-
async ({ fetch: fetch2 }) => {
|
|
854
|
-
const res = await fetch2("/crash");
|
|
855
|
-
expect2(res.status).toBe(500);
|
|
856
|
-
expect2(res.headers.get("x-request-id")).toBeTruthy();
|
|
857
|
-
expect2(await res.json()).toEqual({
|
|
858
|
-
status: 500,
|
|
859
|
-
error: "INTERNAL_ERROR",
|
|
860
|
-
message: "Internal Server Error"
|
|
861
|
-
});
|
|
862
|
-
expect2(onError).toHaveBeenCalledOnce();
|
|
863
|
-
},
|
|
864
|
-
{ onError }
|
|
865
|
-
);
|
|
866
|
-
});
|
|
867
|
-
it("exposes error details when exposeErrors is enabled", () => withApp(
|
|
868
|
-
(router) => {
|
|
869
|
-
router.defineRoute({
|
|
870
|
-
path: "/crash",
|
|
871
|
-
handler: () => {
|
|
872
|
-
throw new Error("Boom");
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
|
-
},
|
|
876
|
-
async ({ fetch: fetch2 }) => {
|
|
877
|
-
expect2(await (await fetch2("/crash")).json()).toMatchObject({
|
|
878
|
-
status: 500,
|
|
879
|
-
data: { name: "Error", message: "Boom" }
|
|
880
|
-
});
|
|
881
|
-
},
|
|
882
|
-
{ exposeErrors: true }
|
|
883
|
-
));
|
|
884
|
-
it("returns a 500 error when the response does not match its schema", () => withApp(
|
|
885
|
-
(router) => {
|
|
886
|
-
router.defineRoute({
|
|
887
|
-
path: "/invalid",
|
|
888
|
-
schema: (z) => ({ response: z.object({ id: z.string() }) }),
|
|
889
|
-
// @ts-expect-error the result does not match the schema
|
|
890
|
-
handler: () => ({ id: 1 })
|
|
891
|
-
});
|
|
892
|
-
},
|
|
893
|
-
async ({ fetch: fetch2 }) => {
|
|
894
|
-
expect2((await fetch2("/invalid")).status).toBe(500);
|
|
895
|
-
}
|
|
896
|
-
));
|
|
897
|
-
});
|
|
898
|
-
describe("middlewares and filters", () => {
|
|
899
|
-
it("stops the pipeline when a middleware returns a response", () => withApp(
|
|
900
|
-
(router) => {
|
|
901
|
-
router.defineRoute({
|
|
902
|
-
path: "/private",
|
|
903
|
-
middlewares: [
|
|
904
|
-
({ req }) => req.header("authorization") ? void 0 : unauthorizedError()
|
|
905
|
-
],
|
|
906
|
-
handler: () => ({ secret: true })
|
|
907
|
-
});
|
|
908
|
-
router.defineRoute({
|
|
909
|
-
path: "/legacy",
|
|
910
|
-
middlewares: [() => redirect("/modern", 301)],
|
|
911
|
-
handler: () => ({ unreachable: true })
|
|
912
|
-
});
|
|
913
|
-
},
|
|
914
|
-
async ({ fetch: fetch2 }) => {
|
|
915
|
-
expect2((await fetch2("/private")).status).toBe(401);
|
|
916
|
-
expect2(
|
|
917
|
-
await (await fetch2("/private", { headers: { authorization: "x" } })).json()
|
|
918
|
-
).toEqual({ secret: true });
|
|
919
|
-
const legacy = await fetch2("/legacy", { redirect: "manual" });
|
|
920
|
-
expect2(legacy.status).toBe(301);
|
|
921
|
-
expect2(legacy.headers.get("location")).toBe("/modern");
|
|
922
|
-
}
|
|
923
|
-
));
|
|
924
|
-
it("applies app and router response filters", () => withApp(
|
|
925
|
-
(router) => {
|
|
926
|
-
router.addResponseFilter((_ctx, result) => ({
|
|
927
|
-
...result,
|
|
928
|
-
router: true
|
|
929
|
-
}));
|
|
930
|
-
router.defineRoute({ path: "/data", handler: () => ({ a: 1 }) });
|
|
931
|
-
},
|
|
932
|
-
async ({ fetch: fetch2 }) => {
|
|
933
|
-
expect2(await (await fetch2("/data")).json()).toEqual({
|
|
934
|
-
a: 1,
|
|
935
|
-
app: true,
|
|
936
|
-
router: true
|
|
937
|
-
});
|
|
938
|
-
},
|
|
939
|
-
{
|
|
940
|
-
responseFilters: [
|
|
941
|
-
(_ctx, result) => ({ ...result, app: true })
|
|
942
|
-
]
|
|
943
|
-
}
|
|
944
|
-
));
|
|
945
|
-
});
|
|
946
|
-
describe("responses", () => {
|
|
947
|
-
it("applies statuses, headers and cookies", () => withApp(
|
|
948
|
-
(router) => {
|
|
949
|
-
router.defineRoute({
|
|
950
|
-
method: "post",
|
|
951
|
-
path: "/created",
|
|
952
|
-
status: 201,
|
|
953
|
-
handler: ({ res }) => {
|
|
954
|
-
res.setHeader("location", "/items/1");
|
|
955
|
-
res.setCookie("session", "abc", { httpOnly: true });
|
|
956
|
-
res.setCookie("theme", "dark");
|
|
957
|
-
return { id: 1 };
|
|
958
|
-
}
|
|
959
|
-
});
|
|
960
|
-
router.defineRoute({
|
|
961
|
-
path: "/accepted",
|
|
962
|
-
handler: ({ res }) => {
|
|
963
|
-
res.setStatus(202);
|
|
964
|
-
return json({ queued: true }, { headers: { "x-queue": "a" } });
|
|
965
|
-
}
|
|
966
|
-
});
|
|
967
|
-
},
|
|
968
|
-
async ({ fetch: fetch2 }) => {
|
|
969
|
-
const created = await fetch2("/created", { method: "POST" });
|
|
970
|
-
expect2(created.status).toBe(201);
|
|
971
|
-
expect2(created.headers.get("location")).toBe("/items/1");
|
|
972
|
-
expect2(created.headers.getSetCookie()).toEqual([
|
|
973
|
-
"session=abc; Path=/; HttpOnly",
|
|
974
|
-
"theme=dark; Path=/"
|
|
975
|
-
]);
|
|
976
|
-
const accepted = await fetch2("/accepted");
|
|
977
|
-
expect2(accepted.status).toBe(202);
|
|
978
|
-
expect2(accepted.headers.get("x-queue")).toBe("a");
|
|
979
|
-
expect2(await accepted.json()).toEqual({ queued: true });
|
|
980
|
-
}
|
|
981
|
-
));
|
|
982
|
-
it("answers 204 without body when the handler returns nothing", () => withApp(
|
|
983
|
-
(router) => {
|
|
984
|
-
router.defineRoute({
|
|
985
|
-
method: "delete",
|
|
986
|
-
path: "/items/:id",
|
|
987
|
-
handler: () => {
|
|
988
|
-
}
|
|
989
|
-
});
|
|
990
|
-
},
|
|
991
|
-
async ({ fetch: fetch2 }) => {
|
|
992
|
-
const res = await fetch2("/items/1", { method: "DELETE" });
|
|
993
|
-
expect2(res.status).toBe(204);
|
|
994
|
-
expect2(res.headers.get("content-type")).toBeNull();
|
|
995
|
-
expect2(await res.text()).toBe("");
|
|
996
|
-
}
|
|
997
|
-
));
|
|
998
|
-
it("does not add framework headers", () => withApp(
|
|
999
|
-
(router) => {
|
|
1000
|
-
router.defineRoute({ path: "/plain", handler: () => "ok" });
|
|
1001
|
-
},
|
|
1002
|
-
async ({ fetch: fetch2 }) => {
|
|
1003
|
-
const res = await fetch2("/plain");
|
|
1004
|
-
expect2(res.headers.get("x-powered-by")).toBeNull();
|
|
1005
|
-
expect2(res.headers.get("etag")).toBeNull();
|
|
1006
|
-
}
|
|
1007
|
-
));
|
|
1008
|
-
it("sends redirects", () => withApp(
|
|
1009
|
-
(router) => {
|
|
1010
|
-
router.defineRoute({
|
|
1011
|
-
path: "/old",
|
|
1012
|
-
handler: () => redirect("/new")
|
|
1013
|
-
});
|
|
1014
|
-
},
|
|
1015
|
-
async ({ fetch: fetch2 }) => {
|
|
1016
|
-
const res = await fetch2("/old", { redirect: "manual" });
|
|
1017
|
-
expect2(res.status).toBe(302);
|
|
1018
|
-
expect2(res.headers.get("location")).toBe("/new");
|
|
1019
|
-
}
|
|
1020
|
-
));
|
|
1021
|
-
it("sends text responses", () => withApp(
|
|
1022
|
-
(router) => {
|
|
1023
|
-
router.defineRoute({
|
|
1024
|
-
path: "/page",
|
|
1025
|
-
handler: () => text("<h1>H\xE9llo</h1>", {
|
|
1026
|
-
headers: { "content-type": "text/html; charset=utf-8" }
|
|
1027
|
-
})
|
|
1028
|
-
});
|
|
1029
|
-
router.defineRoute({ path: "/health", handler: () => text("OK") });
|
|
1030
|
-
},
|
|
1031
|
-
async ({ fetch: fetch2 }) => {
|
|
1032
|
-
const page = await fetch2("/page");
|
|
1033
|
-
expect2(page.headers.get("content-type")).toBe(
|
|
1034
|
-
"text/html; charset=utf-8"
|
|
1035
|
-
);
|
|
1036
|
-
expect2(await page.text()).toBe("<h1>H\xE9llo</h1>");
|
|
1037
|
-
const health = await fetch2("/health");
|
|
1038
|
-
expect2(health.headers.get("content-type")).toBe(
|
|
1039
|
-
"text/plain; charset=utf-8"
|
|
1040
|
-
);
|
|
1041
|
-
expect2(await health.text()).toBe("OK");
|
|
1042
|
-
}
|
|
1043
|
-
));
|
|
1044
|
-
it("sends files without altering their bytes", () => {
|
|
1045
|
-
const bytes = Uint8Array.from({ length: 256 }, (_, index) => index);
|
|
1046
|
-
return withApp(
|
|
1047
|
-
(router) => {
|
|
1048
|
-
router.defineRoute({
|
|
1049
|
-
path: "/download",
|
|
1050
|
-
handler: () => file(bytes, {
|
|
1051
|
-
filename: "rapport \xE9t\xE9.bin",
|
|
1052
|
-
contentType: "application/octet-stream"
|
|
1053
|
-
})
|
|
1054
|
-
});
|
|
1055
|
-
router.defineRoute({
|
|
1056
|
-
path: "/blob",
|
|
1057
|
-
handler: () => file(new Blob([bytes], { type: "image/png" }), {
|
|
1058
|
-
disposition: "inline"
|
|
1059
|
-
})
|
|
1060
|
-
});
|
|
1061
|
-
},
|
|
1062
|
-
async ({ fetch: fetch2 }) => {
|
|
1063
|
-
const res = await fetch2("/download");
|
|
1064
|
-
expect2(res.headers.get("content-length")).toBe("256");
|
|
1065
|
-
expect2(res.headers.get("content-disposition")).toBe(
|
|
1066
|
-
`attachment; filename="rapport _t_.bin"; filename*=UTF-8''rapport%20%C3%A9t%C3%A9.bin`
|
|
1067
|
-
);
|
|
1068
|
-
expect2(new Uint8Array(await res.arrayBuffer())).toEqual(bytes);
|
|
1069
|
-
const blob = await fetch2("/blob");
|
|
1070
|
-
expect2(blob.headers.get("content-type")).toBe("image/png");
|
|
1071
|
-
expect2(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes);
|
|
1072
|
-
}
|
|
1073
|
-
);
|
|
1074
|
-
});
|
|
1075
|
-
});
|
|
1076
|
-
describe("cors", () => {
|
|
1077
|
-
const APP = "https://app.example.com";
|
|
1078
|
-
const EVIL = "https://evil.example.org";
|
|
1079
|
-
const corsRoutes = (router) => {
|
|
1080
|
-
router.defineRoute({ path: "/users", handler: () => [] });
|
|
1081
|
-
router.defineRoute({
|
|
1082
|
-
method: "post",
|
|
1083
|
-
path: "/users",
|
|
1084
|
-
handler: () => ({ created: true })
|
|
1085
|
-
});
|
|
1086
|
-
router.defineRoute({
|
|
1087
|
-
path: "/private",
|
|
1088
|
-
middlewares: [() => unauthorizedError()],
|
|
1089
|
-
handler: () => ({ secret: true })
|
|
1090
|
-
});
|
|
1091
|
-
router.defineRoute({
|
|
1092
|
-
method: "options",
|
|
1093
|
-
path: "/custom",
|
|
1094
|
-
handler: () => ({ custom: true })
|
|
1095
|
-
});
|
|
1096
|
-
router.defineRoute({
|
|
1097
|
-
path: "/export.csv",
|
|
1098
|
-
handler: () => stream(["a,b\n"], { contentType: "text/csv" })
|
|
1099
|
-
});
|
|
1100
|
-
};
|
|
1101
|
-
const preflight = (origin = APP) => ({
|
|
1102
|
-
method: "OPTIONS",
|
|
1103
|
-
headers: {
|
|
1104
|
-
origin,
|
|
1105
|
-
"access-control-request-method": "POST",
|
|
1106
|
-
"access-control-request-headers": "content-type, authorization"
|
|
1107
|
-
}
|
|
1108
|
-
});
|
|
1109
|
-
it("answers preflight requests before the middlewares", () => withApp(
|
|
1110
|
-
corsRoutes,
|
|
1111
|
-
async ({ fetch: fetch2 }) => {
|
|
1112
|
-
const res = await fetch2("/users", preflight());
|
|
1113
|
-
expect2(res.status).toBe(204);
|
|
1114
|
-
expect2(res.headers.get("access-control-allow-origin")).toBe(APP);
|
|
1115
|
-
expect2(res.headers.get("access-control-allow-methods")).toBe(
|
|
1116
|
-
"GET, HEAD, POST"
|
|
1117
|
-
);
|
|
1118
|
-
expect2(res.headers.get("access-control-allow-headers")).toBe(
|
|
1119
|
-
"content-type, authorization"
|
|
1120
|
-
);
|
|
1121
|
-
expect2(res.headers.get("access-control-max-age")).toBe("600");
|
|
1122
|
-
expect2(res.headers.get("vary")).toContain("Origin");
|
|
1123
|
-
expect2(await res.text()).toBe("");
|
|
1124
|
-
const private_ = await fetch2("/private", preflight());
|
|
1125
|
-
expect2(private_.status).toBe(204);
|
|
1126
|
-
const custom = await fetch2("/custom", preflight());
|
|
1127
|
-
expect2(custom.status).toBe(204);
|
|
1128
|
-
expect2(custom.headers.get("access-control-allow-methods")).toBe(
|
|
1129
|
-
"OPTIONS"
|
|
1130
|
-
);
|
|
1131
|
-
const refused = await fetch2("/users", preflight(EVIL));
|
|
1132
|
-
expect2(refused.status).toBe(405);
|
|
1133
|
-
expect2(
|
|
1134
|
-
refused.headers.get("access-control-allow-origin")
|
|
1135
|
-
).toBeNull();
|
|
1136
|
-
expect2((await fetch2("/missing", preflight())).status).toBe(404);
|
|
1137
|
-
},
|
|
1138
|
-
{ cors: { origin: [APP], maxAge: 600 } }
|
|
1139
|
-
));
|
|
1140
|
-
it("adds the CORS headers to every response, errors included", () => withApp(
|
|
1141
|
-
corsRoutes,
|
|
1142
|
-
async ({ fetch: fetch2 }) => {
|
|
1143
|
-
const requests = [
|
|
1144
|
-
["/users", {}, 200],
|
|
1145
|
-
["/private", {}, 401],
|
|
1146
|
-
["/missing", {}, 404],
|
|
1147
|
-
["/users", { method: "DELETE" }, 405],
|
|
1148
|
-
[
|
|
1149
|
-
"/users",
|
|
1150
|
-
{
|
|
1151
|
-
method: "POST",
|
|
1152
|
-
headers: { "content-type": "application/json" },
|
|
1153
|
-
body: "{oops"
|
|
1154
|
-
},
|
|
1155
|
-
400
|
|
1156
|
-
],
|
|
1157
|
-
["/export.csv", {}, 200]
|
|
1158
|
-
];
|
|
1159
|
-
for (const [path, init, status] of requests) {
|
|
1160
|
-
const res = await fetch2(path, {
|
|
1161
|
-
...init,
|
|
1162
|
-
headers: { ...init.headers, origin: APP }
|
|
1163
|
-
});
|
|
1164
|
-
expect2(res.status).toBe(status);
|
|
1165
|
-
expect2(res.headers.get("access-control-allow-origin")).toBe(APP);
|
|
1166
|
-
expect2(res.headers.get("access-control-allow-credentials")).toBe(
|
|
1167
|
-
"true"
|
|
1168
|
-
);
|
|
1169
|
-
expect2(res.headers.get("access-control-expose-headers")).toBe(
|
|
1170
|
-
"x-request-id"
|
|
1171
|
-
);
|
|
1172
|
-
expect2(res.headers.get("vary")).toContain("Origin");
|
|
1173
|
-
await res.arrayBuffer();
|
|
1174
|
-
}
|
|
1175
|
-
const refused = await fetch2("/users", { headers: { origin: EVIL } });
|
|
1176
|
-
expect2(refused.status).toBe(200);
|
|
1177
|
-
expect2(
|
|
1178
|
-
refused.headers.get("access-control-allow-origin")
|
|
1179
|
-
).toBeNull();
|
|
1180
|
-
expect2(refused.headers.get("vary")).toContain("Origin");
|
|
1181
|
-
},
|
|
1182
|
-
{
|
|
1183
|
-
cors: {
|
|
1184
|
-
origin: APP,
|
|
1185
|
-
credentials: true,
|
|
1186
|
-
exposedHeaders: ["x-request-id"]
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
));
|
|
1190
|
-
});
|
|
1191
|
-
describe("streaming", () => {
|
|
1192
|
-
const incremental = options.bufferedResponses ? it.skip : it;
|
|
1193
|
-
it("streams chunks", () => withApp(
|
|
1194
|
-
(router) => {
|
|
1195
|
-
router.defineRoute({
|
|
1196
|
-
path: "/export.csv",
|
|
1197
|
-
handler: () => stream(
|
|
1198
|
-
async function* () {
|
|
1199
|
-
yield "id,name\n";
|
|
1200
|
-
yield new TextEncoder().encode("1,Ada\n");
|
|
1201
|
-
},
|
|
1202
|
-
{ contentType: "text/csv" }
|
|
1203
|
-
)
|
|
1204
|
-
});
|
|
1205
|
-
},
|
|
1206
|
-
async ({ fetch: fetch2 }) => {
|
|
1207
|
-
const res = await fetch2("/export.csv");
|
|
1208
|
-
expect2(res.status).toBe(200);
|
|
1209
|
-
expect2(res.headers.get("content-type")).toBe("text/csv");
|
|
1210
|
-
expect2(await res.text()).toBe("id,name\n1,Ada\n");
|
|
1211
|
-
}
|
|
1212
|
-
));
|
|
1213
|
-
incremental("flushes Server-Sent Events as they are produced", () => {
|
|
1214
|
-
const released = deferred();
|
|
1215
|
-
return withApp(
|
|
1216
|
-
(router) => {
|
|
1217
|
-
router.defineRoute({
|
|
1218
|
-
path: "/events",
|
|
1219
|
-
handler: () => sse(
|
|
1220
|
-
async function* ({ signal }) {
|
|
1221
|
-
try {
|
|
1222
|
-
yield { event: "ready", data: { ok: true } };
|
|
1223
|
-
await waitForAbort(signal);
|
|
1224
|
-
} finally {
|
|
1225
|
-
released.resolve();
|
|
1226
|
-
}
|
|
1227
|
-
},
|
|
1228
|
-
{ heartbeat: false }
|
|
1229
|
-
)
|
|
1230
|
-
});
|
|
1231
|
-
},
|
|
1232
|
-
async ({ fetch: fetch2 }) => {
|
|
1233
|
-
const res = await fetch2("/events");
|
|
1234
|
-
expect2(res.headers.get("content-type")).toBe(
|
|
1235
|
-
"text/event-stream; charset=utf-8"
|
|
1236
|
-
);
|
|
1237
|
-
expect2(res.headers.get("cache-control")).toBe("no-cache");
|
|
1238
|
-
const reader = res.body.getReader();
|
|
1239
|
-
expect2(await within(readChunk(reader), "first event")).toBe(
|
|
1240
|
-
'event: ready\ndata: {"ok":true}\n\n'
|
|
1241
|
-
);
|
|
1242
|
-
await reader.cancel();
|
|
1243
|
-
await within(released.promise, "source release on disconnect");
|
|
1244
|
-
}
|
|
1245
|
-
);
|
|
1246
|
-
});
|
|
1247
|
-
incremental(
|
|
1248
|
-
"cancels the response stream when the client disconnects",
|
|
1249
|
-
() => {
|
|
1250
|
-
const cancelled = deferred();
|
|
1251
|
-
return withApp(
|
|
1252
|
-
(router) => {
|
|
1253
|
-
router.defineRoute({
|
|
1254
|
-
path: "/download",
|
|
1255
|
-
handler: () => file(
|
|
1256
|
-
new ReadableStream({
|
|
1257
|
-
pull: (controller) => controller.enqueue(new Uint8Array(64 * 1024)),
|
|
1258
|
-
cancel: () => cancelled.resolve()
|
|
1259
|
-
})
|
|
1260
|
-
)
|
|
1261
|
-
});
|
|
1262
|
-
},
|
|
1263
|
-
async ({ fetch: fetch2 }) => {
|
|
1264
|
-
const res = await fetch2("/download");
|
|
1265
|
-
const reader = res.body.getReader();
|
|
1266
|
-
await reader.read();
|
|
1267
|
-
await reader.cancel();
|
|
1268
|
-
await within(cancelled.promise, "response stream cancel");
|
|
1269
|
-
}
|
|
1270
|
-
);
|
|
1271
|
-
}
|
|
1272
|
-
);
|
|
1273
|
-
incremental("aborts req.signal when the client disconnects", () => {
|
|
1274
|
-
const started = deferred();
|
|
1275
|
-
const aborted = deferred();
|
|
1276
|
-
return withApp(
|
|
1277
|
-
(router) => {
|
|
1278
|
-
router.defineRoute({
|
|
1279
|
-
path: "/slow",
|
|
1280
|
-
handler: async ({ req }) => {
|
|
1281
|
-
started.resolve();
|
|
1282
|
-
await waitForAbort(req.signal);
|
|
1283
|
-
aborted.resolve();
|
|
1284
|
-
return { late: true };
|
|
1285
|
-
}
|
|
1286
|
-
});
|
|
1287
|
-
},
|
|
1288
|
-
async ({ fetch: fetch2 }) => {
|
|
1289
|
-
const controller = new AbortController();
|
|
1290
|
-
const pending = fetch2("/slow", { signal: controller.signal }).catch(
|
|
1291
|
-
() => void 0
|
|
1292
|
-
);
|
|
1293
|
-
await within(started.promise, "handler start");
|
|
1294
|
-
controller.abort();
|
|
1295
|
-
await within(aborted.promise, "req.signal abort");
|
|
1296
|
-
await pending;
|
|
1297
|
-
}
|
|
1298
|
-
);
|
|
1299
|
-
});
|
|
1300
|
-
incremental(
|
|
1301
|
-
"aborts the connection when a stream fails",
|
|
1302
|
-
() => withApp(
|
|
1303
|
-
(router) => {
|
|
1304
|
-
router.defineRoute({
|
|
1305
|
-
path: "/broken",
|
|
1306
|
-
handler: () => stream(async function* () {
|
|
1307
|
-
yield "partial";
|
|
1308
|
-
throw new Error("Source failed");
|
|
1309
|
-
})
|
|
1310
|
-
});
|
|
1311
|
-
},
|
|
1312
|
-
async ({ fetch: fetch2 }) => {
|
|
1313
|
-
const res = await fetch2("/broken");
|
|
1314
|
-
expect2(res.status).toBe(200);
|
|
1315
|
-
const outcome = await within(
|
|
1316
|
-
res.text().then(
|
|
1317
|
-
() => "completed",
|
|
1318
|
-
() => "aborted"
|
|
1319
|
-
),
|
|
1320
|
-
"body read"
|
|
1321
|
-
);
|
|
1322
|
-
expect2(outcome).toBe("aborted");
|
|
1323
|
-
}
|
|
1324
|
-
)
|
|
1325
|
-
);
|
|
1326
|
-
});
|
|
1327
|
-
});
|
|
1328
|
-
};
|
|
1329
|
-
|
|
1330
52
|
// src/lambda.ts
|
|
1331
53
|
var CHUNK = 32768;
|
|
1332
54
|
var encodeBase64 = (bytes) => {
|
|
@@ -1336,7 +58,7 @@ var encodeBase64 = (bytes) => {
|
|
|
1336
58
|
}
|
|
1337
59
|
return btoa(binary);
|
|
1338
60
|
};
|
|
1339
|
-
var decodeBase64 = (
|
|
61
|
+
var decodeBase64 = (text) => Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
1340
62
|
var TEXT_TYPES = /^\s*(text\/|application\/(json|x-www-form-urlencoded)\b|[^;]*\+json\b)/i;
|
|
1341
63
|
var eventBody = (bytes, contentType) => {
|
|
1342
64
|
if (bytes.byteLength === 0) return { body: null, isBase64Encoded: false };
|
|
@@ -1493,33 +215,47 @@ var lambdaContext = () => ({
|
|
|
1493
215
|
getRemainingTimeInMillis: () => 3e4
|
|
1494
216
|
});
|
|
1495
217
|
var createLambdaTarget = (handler, options) => {
|
|
218
|
+
const close = closeOf("createLambdaTarget()", options, [
|
|
219
|
+
"format",
|
|
220
|
+
"close",
|
|
221
|
+
"sourceIp"
|
|
222
|
+
]);
|
|
1496
223
|
const invoke = handler;
|
|
1497
|
-
return createFetchTarget(
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
return Response.json(
|
|
1504
|
-
{ message: "Internal server error" },
|
|
1505
|
-
{ status: 502 }
|
|
224
|
+
return createFetchTarget(
|
|
225
|
+
async (request) => {
|
|
226
|
+
const event = await toLambdaEvent(
|
|
227
|
+
request,
|
|
228
|
+
options.format,
|
|
229
|
+
options.sourceIp
|
|
1506
230
|
);
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
231
|
+
let result;
|
|
232
|
+
try {
|
|
233
|
+
result = await invoke(event, lambdaContext());
|
|
234
|
+
} catch {
|
|
235
|
+
return Response.json(
|
|
236
|
+
{ message: "Internal server error" },
|
|
237
|
+
{ status: 502 }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return fromLambdaResult(result);
|
|
241
|
+
},
|
|
242
|
+
{ close }
|
|
243
|
+
);
|
|
1510
244
|
};
|
|
1511
245
|
|
|
1512
246
|
// src/memory.ts
|
|
1513
247
|
import {
|
|
1514
|
-
|
|
1515
|
-
|
|
248
|
+
createApp
|
|
249
|
+
} from "@dotpress/core";
|
|
250
|
+
import {
|
|
251
|
+
DEFAULT_BODY_LIMIT,
|
|
1516
252
|
createRouteMatcher,
|
|
1517
253
|
fromWebRequest,
|
|
1518
254
|
readRequestBody,
|
|
1519
255
|
toWebResponse
|
|
1520
|
-
} from "@dotpress/core";
|
|
256
|
+
} from "@dotpress/core/adapter";
|
|
1521
257
|
var memoryAdapter = (options = {}) => {
|
|
1522
|
-
const bodyLimit = options.bodyLimit ??
|
|
258
|
+
const bodyLimit = options.bodyLimit ?? DEFAULT_BODY_LIMIT;
|
|
1523
259
|
let routes = [];
|
|
1524
260
|
let matcher;
|
|
1525
261
|
let runtime;
|
|
@@ -1796,8 +532,6 @@ var expectNoMailSent = (outbox, criteria = {}) => {
|
|
|
1796
532
|
};
|
|
1797
533
|
export {
|
|
1798
534
|
ExpectationError,
|
|
1799
|
-
createFetchTarget,
|
|
1800
|
-
createHttpTarget,
|
|
1801
535
|
createLambdaTarget,
|
|
1802
536
|
createMemoryLogger,
|
|
1803
537
|
createTestApp,
|
|
@@ -1807,7 +541,6 @@ export {
|
|
|
1807
541
|
expectNoMailSent,
|
|
1808
542
|
fromLambdaResult,
|
|
1809
543
|
memoryAdapter,
|
|
1810
|
-
runConformanceSuite,
|
|
1811
544
|
toLambdaEvent
|
|
1812
545
|
};
|
|
1813
546
|
//# sourceMappingURL=index.js.map
|