@alxia/core 0.1.0 → 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/README.md +103 -3
- package/dist/app/alxia.d.ts +53 -5
- package/dist/app/alxia.d.ts.map +1 -1
- package/dist/app/chain.d.ts +5 -8
- package/dist/app/chain.d.ts.map +1 -1
- package/dist/app/context.d.ts +12 -0
- package/dist/app/context.d.ts.map +1 -0
- package/dist/app/definition.d.ts +22 -1
- package/dist/app/definition.d.ts.map +1 -1
- package/dist/app/refusal.d.ts +15 -0
- package/dist/app/refusal.d.ts.map +1 -0
- package/dist/app/send.d.ts +8 -1
- package/dist/app/send.d.ts.map +1 -1
- package/dist/app/socket.d.ts +1 -1
- package/dist/app/socket.d.ts.map +1 -1
- package/dist/app/types/index.d.ts +1 -0
- package/dist/app/types/index.d.ts.map +1 -1
- package/dist/app/types/refusal.d.ts +94 -0
- package/dist/app/types/refusal.d.ts.map +1 -0
- package/dist/app/types/route-table.d.ts +3 -2
- package/dist/app/types/route-table.d.ts.map +1 -1
- package/dist/app/types/schema.d.ts +7 -0
- package/dist/app/types/schema.d.ts.map +1 -1
- package/dist/app/types/valid-schema.d.ts +1 -1
- package/dist/app/types/valid-schema.d.ts.map +1 -1
- package/dist/errors/errors.d.ts +47 -1
- package/dist/errors/errors.d.ts.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +395 -97
- package/dist/index.js.map +20 -13
- package/dist/reply/problem.d.ts +33 -0
- package/dist/reply/problem.d.ts.map +1 -0
- package/dist/reply/reply.d.ts.map +1 -1
- package/dist/request/limit.d.ts +13 -0
- package/dist/request/limit.d.ts.map +1 -0
- package/dist/request/read.d.ts +3 -2
- package/dist/request/read.d.ts.map +1 -1
- package/dist/sse/async-iterable.d.ts +6 -0
- package/dist/sse/async-iterable.d.ts.map +1 -0
- package/dist/sse/event-stream.d.ts +29 -6
- package/dist/sse/event-stream.d.ts.map +1 -1
- package/dist/sse/frame.d.ts +35 -0
- package/dist/sse/frame.d.ts.map +1 -0
- package/dist/sse/named-events.d.ts +70 -0
- package/dist/sse/named-events.d.ts.map +1 -0
- package/docs/README.md +3 -3
- package/docs/guide/groups-and-plugins.md +8 -1
- package/docs/guide/hooks.md +128 -3
- package/docs/guide/replies.md +40 -0
- package/docs/guide/routes.md +115 -1
- package/docs/guide/server-sent-events.md +177 -5
- package/docs/guide/serving.md +1 -1
- package/docs/guide/types.md +3 -1
- package/docs/guide/websockets.md +1 -1
- package/docs/guide/writing-a-plugin.md +1 -1
- package/docs/roadmap.md +35 -2
- package/docs/troubleshooting.md +303 -6
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
+
// src/errors/errors.ts
|
|
2
|
+
class HttpError extends Error {
|
|
3
|
+
name = "HttpError";
|
|
4
|
+
status;
|
|
5
|
+
body;
|
|
6
|
+
constructor(status, body, message) {
|
|
7
|
+
super(message ?? `HTTP ${status}`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class ContentTooLargeError extends HttpError {
|
|
14
|
+
name = "ContentTooLargeError";
|
|
15
|
+
limit;
|
|
16
|
+
constructor(limit) {
|
|
17
|
+
super(413, { error: "content_too_large", limit }, `The request body is larger than the route's limit of ${limit} bytes`);
|
|
18
|
+
this.limit = limit;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class ResponseValidationError extends Error {
|
|
23
|
+
name = "ResponseValidationError";
|
|
24
|
+
status;
|
|
25
|
+
issues;
|
|
26
|
+
constructor(method, path, status, issues, message) {
|
|
27
|
+
super(message ?? `${method} ${path}: the ${status} reply does not match its schema: ${issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.issues = issues;
|
|
30
|
+
}
|
|
31
|
+
static undeclared(method, path, status) {
|
|
32
|
+
return new ResponseValidationError(method, path, status, [
|
|
33
|
+
{
|
|
34
|
+
target: "body",
|
|
35
|
+
path: [],
|
|
36
|
+
code: "undeclared_status",
|
|
37
|
+
message: `the route declares no ${status} reply`
|
|
38
|
+
}
|
|
39
|
+
], `${method} ${path} declares no ${status} reply`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/request/limit.ts
|
|
44
|
+
function limitBody(request, limit) {
|
|
45
|
+
const source = request.body;
|
|
46
|
+
if (source === null || request.bodyUsed || source.locked)
|
|
47
|
+
return request;
|
|
48
|
+
const declared = Number(request.headers.get("content-length") ?? Number.NaN);
|
|
49
|
+
let body;
|
|
50
|
+
if (declared > limit) {
|
|
51
|
+
source.cancel().catch(() => {});
|
|
52
|
+
body = new ReadableStream({
|
|
53
|
+
start: (controller) => controller.error(new ContentTooLargeError(limit))
|
|
54
|
+
});
|
|
55
|
+
} else {
|
|
56
|
+
let count = 0;
|
|
57
|
+
body = source.pipeThrough(new TransformStream({
|
|
58
|
+
transform(chunk, controller) {
|
|
59
|
+
count += chunk.byteLength;
|
|
60
|
+
if (count > limit) {
|
|
61
|
+
controller.error(new ContentTooLargeError(limit));
|
|
62
|
+
} else
|
|
63
|
+
controller.enqueue(chunk);
|
|
64
|
+
}
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
return new Request(request, { body, duplex: "half" });
|
|
68
|
+
}
|
|
69
|
+
function checkLimit(limit, where) {
|
|
70
|
+
if (!Number.isSafeInteger(limit) || limit < 0) {
|
|
71
|
+
throw new TypeError(`${where}: bodyLimit must be a whole number of bytes, 0 or more; got ${limit}`);
|
|
72
|
+
}
|
|
73
|
+
return limit;
|
|
74
|
+
}
|
|
75
|
+
|
|
1
76
|
// src/router/compile.ts
|
|
2
77
|
var LITERAL = 0;
|
|
3
78
|
var PARAMETER = 1;
|
|
@@ -233,6 +308,11 @@ class Router {
|
|
|
233
308
|
// src/static/serve.ts
|
|
234
309
|
import { extname as extname2, resolve, sep } from "node:path";
|
|
235
310
|
|
|
311
|
+
// src/sse/async-iterable.ts
|
|
312
|
+
function isAsyncIterable(value) {
|
|
313
|
+
return value !== null && typeof value === "object" && Symbol.asyncIterator in value && !(value instanceof ReadableStream);
|
|
314
|
+
}
|
|
315
|
+
|
|
236
316
|
// src/schema/standard-schema.ts
|
|
237
317
|
async function check(schema, value, target) {
|
|
238
318
|
let result = schema["~standard"].validate(value);
|
|
@@ -254,8 +334,143 @@ async function check(schema, value, target) {
|
|
|
254
334
|
};
|
|
255
335
|
}
|
|
256
336
|
|
|
337
|
+
// src/sse/frame.ts
|
|
338
|
+
class Frame {
|
|
339
|
+
event;
|
|
340
|
+
data;
|
|
341
|
+
id;
|
|
342
|
+
retry;
|
|
343
|
+
constructor(event, data, id, retry) {
|
|
344
|
+
this.event = event;
|
|
345
|
+
this.data = data;
|
|
346
|
+
this.id = id;
|
|
347
|
+
this.retry = retry;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function frameText(value) {
|
|
351
|
+
const lines = [];
|
|
352
|
+
let data = value;
|
|
353
|
+
if (value instanceof Frame) {
|
|
354
|
+
lines.push(`event: ${value.event}`);
|
|
355
|
+
if (value.id !== undefined)
|
|
356
|
+
lines.push(`id: ${value.id}`);
|
|
357
|
+
if (value.retry !== undefined)
|
|
358
|
+
lines.push(`retry: ${value.retry}`);
|
|
359
|
+
data = value.data;
|
|
360
|
+
}
|
|
361
|
+
const json = JSON.stringify(data) ?? "null";
|
|
362
|
+
for (const line of json.split(/\r\n|\r|\n/))
|
|
363
|
+
lines.push(`data: ${line}`);
|
|
364
|
+
return `${lines.join(`
|
|
365
|
+
`)}
|
|
366
|
+
|
|
367
|
+
`;
|
|
368
|
+
}
|
|
369
|
+
function mismatch(issues, name) {
|
|
370
|
+
return new TypeError(`An event does not match its schema: ${issues.map((issue) => {
|
|
371
|
+
const path = name === undefined ? issue.path : [name, ...issue.path];
|
|
372
|
+
return `${path.join(".") || "(root)"}: ${issue.message}`;
|
|
373
|
+
}).join("; ")}`);
|
|
374
|
+
}
|
|
375
|
+
var BREAK = /[\r\n\0]/;
|
|
376
|
+
function refuseName(name) {
|
|
377
|
+
if (name === "")
|
|
378
|
+
return "An event name must not be empty";
|
|
379
|
+
if (BREAK.test(name)) {
|
|
380
|
+
return `An event name must not hold a line break or a NUL: ${JSON.stringify(name)}`;
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
function refuseId(id) {
|
|
385
|
+
if (id === undefined)
|
|
386
|
+
return;
|
|
387
|
+
if (typeof id !== "string")
|
|
388
|
+
return "An event id must be a string";
|
|
389
|
+
if (BREAK.test(id)) {
|
|
390
|
+
return `An event id must not hold a line break or a NUL: ${JSON.stringify(id)}`;
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
function refuseRetry(retry) {
|
|
395
|
+
if (retry === undefined)
|
|
396
|
+
return;
|
|
397
|
+
if (typeof retry !== "number" || !Number.isSafeInteger(retry) || retry < 0) {
|
|
398
|
+
return `An event retry must be a whole number of milliseconds, 0 or more: ${String(retry)}`;
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/sse/named-events.ts
|
|
404
|
+
function namedEventStream(events) {
|
|
405
|
+
const names = Object.keys(events);
|
|
406
|
+
if (names.length === 0) {
|
|
407
|
+
throw new TypeError("A named event stream declares at least one event");
|
|
408
|
+
}
|
|
409
|
+
for (const name of names) {
|
|
410
|
+
const refused = refuseName(name);
|
|
411
|
+
if (refused !== undefined)
|
|
412
|
+
throw new TypeError(refused);
|
|
413
|
+
const schema = events[name];
|
|
414
|
+
if (schema === null || typeof schema !== "object" || !("~standard" in schema)) {
|
|
415
|
+
throw new TypeError(`The event ${JSON.stringify(name)} is not a Standard Schema`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
"~events": events,
|
|
420
|
+
event: (event, data, fields) => ({
|
|
421
|
+
...fields,
|
|
422
|
+
event,
|
|
423
|
+
data
|
|
424
|
+
}),
|
|
425
|
+
"~standard": {
|
|
426
|
+
version: 1,
|
|
427
|
+
vendor: "alxia",
|
|
428
|
+
validate: (value) => {
|
|
429
|
+
if (!isAsyncIterable(value)) {
|
|
430
|
+
return {
|
|
431
|
+
issues: [
|
|
432
|
+
{ message: "An event stream replies with an async iterable" }
|
|
433
|
+
]
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return { value: toFrames(events, value, true) };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function isNamedEventStreamSchema(schema) {
|
|
442
|
+
return "~events" in schema;
|
|
443
|
+
}
|
|
444
|
+
async function* toFrames(events, values, validate) {
|
|
445
|
+
for await (const value of values) {
|
|
446
|
+
if (value === null || typeof value !== "object" || !("event" in value)) {
|
|
447
|
+
throw new TypeError("An event of a named stream is an object { event, data }");
|
|
448
|
+
}
|
|
449
|
+
const { event, data, id, retry } = value;
|
|
450
|
+
const schema = typeof event === "string" && Object.hasOwn(events, event) ? events[event] : undefined;
|
|
451
|
+
if (schema === undefined) {
|
|
452
|
+
throw new TypeError(`The event ${JSON.stringify(event)} is not declared: ${Object.keys(events).join(", ")}`);
|
|
453
|
+
}
|
|
454
|
+
const refused = refuseId(id) ?? refuseRetry(retry);
|
|
455
|
+
if (refused !== undefined)
|
|
456
|
+
throw new TypeError(refused);
|
|
457
|
+
let output = data;
|
|
458
|
+
if (validate) {
|
|
459
|
+
const checked = await check(schema, data, "body");
|
|
460
|
+
if (!checked.ok) {
|
|
461
|
+
throw mismatch(checked.issues, event);
|
|
462
|
+
}
|
|
463
|
+
output = checked.value;
|
|
464
|
+
}
|
|
465
|
+
yield new Frame(event, output, id, retry);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
257
469
|
// src/sse/event-stream.ts
|
|
258
|
-
function eventStream(
|
|
470
|
+
function eventStream(schema) {
|
|
471
|
+
if (!isStandardSchema(schema))
|
|
472
|
+
return namedEventStream(schema);
|
|
473
|
+
const item = schema;
|
|
259
474
|
return {
|
|
260
475
|
"~eventStream": item,
|
|
261
476
|
"~standard": {
|
|
@@ -277,14 +492,14 @@ function eventStream(item) {
|
|
|
277
492
|
async function* checkEach(item, values) {
|
|
278
493
|
for await (const value of values) {
|
|
279
494
|
const checked = await check(item, value, "body");
|
|
280
|
-
if (!checked.ok)
|
|
281
|
-
throw
|
|
282
|
-
}
|
|
495
|
+
if (!checked.ok)
|
|
496
|
+
throw mismatch(checked.issues);
|
|
283
497
|
yield checked.value;
|
|
284
498
|
}
|
|
285
499
|
}
|
|
286
|
-
function
|
|
287
|
-
|
|
500
|
+
function isStandardSchema(value) {
|
|
501
|
+
const standard = value["~standard"];
|
|
502
|
+
return standard?.version === 1 && typeof standard.vendor === "string";
|
|
288
503
|
}
|
|
289
504
|
function isEventStreamSchema(schema) {
|
|
290
505
|
return "~eventStream" in schema;
|
|
@@ -327,12 +542,7 @@ function toEventStream(values, signal) {
|
|
|
327
542
|
controller.close();
|
|
328
543
|
return;
|
|
329
544
|
}
|
|
330
|
-
|
|
331
|
-
controller.enqueue(encoder.encode(`${data.split(`
|
|
332
|
-
`).map((line) => `data: ${line}`).join(`
|
|
333
|
-
`)}
|
|
334
|
-
|
|
335
|
-
`));
|
|
545
|
+
controller.enqueue(encoder.encode(frameText(next.value)));
|
|
336
546
|
} catch (error) {
|
|
337
547
|
stop();
|
|
338
548
|
console.error(error);
|
|
@@ -661,39 +871,6 @@ function isBunFile(blob) {
|
|
|
661
871
|
return typeof blob.exists === "function";
|
|
662
872
|
}
|
|
663
873
|
|
|
664
|
-
// src/errors/errors.ts
|
|
665
|
-
class HttpError extends Error {
|
|
666
|
-
name = "HttpError";
|
|
667
|
-
status;
|
|
668
|
-
body;
|
|
669
|
-
constructor(status, body, message) {
|
|
670
|
-
super(message ?? `HTTP ${status}`);
|
|
671
|
-
this.status = status;
|
|
672
|
-
this.body = body;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
class ResponseValidationError extends Error {
|
|
677
|
-
name = "ResponseValidationError";
|
|
678
|
-
status;
|
|
679
|
-
issues;
|
|
680
|
-
constructor(method, path, status, issues, message) {
|
|
681
|
-
super(message ?? `${method} ${path}: the ${status} reply does not match its schema: ${issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
|
|
682
|
-
this.status = status;
|
|
683
|
-
this.issues = issues;
|
|
684
|
-
}
|
|
685
|
-
static undeclared(method, path, status) {
|
|
686
|
-
return new ResponseValidationError(method, path, status, [
|
|
687
|
-
{
|
|
688
|
-
target: "body",
|
|
689
|
-
path: [],
|
|
690
|
-
code: "undeclared_status",
|
|
691
|
-
message: `the route declares no ${status} reply`
|
|
692
|
-
}
|
|
693
|
-
], `${method} ${path} declares no ${status} reply`);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
874
|
// src/request/read.ts
|
|
698
875
|
function readQuery(url) {
|
|
699
876
|
const query = {};
|
|
@@ -730,6 +907,8 @@ async function readBody(request, parsers = []) {
|
|
|
730
907
|
try {
|
|
731
908
|
return { ok: true, value: await custom.parse(request) };
|
|
732
909
|
} catch (error) {
|
|
910
|
+
if (error instanceof ContentTooLargeError)
|
|
911
|
+
throw error;
|
|
733
912
|
return {
|
|
734
913
|
ok: false,
|
|
735
914
|
issue: {
|
|
@@ -780,6 +959,33 @@ async function readBody(request, parsers = []) {
|
|
|
780
959
|
return { ok: true, value: await request.arrayBuffer() };
|
|
781
960
|
}
|
|
782
961
|
|
|
962
|
+
// src/app/context.ts
|
|
963
|
+
var redirect = (location, status) => new Reply(status ?? 302, undefined, {
|
|
964
|
+
headers: { location: String(location) }
|
|
965
|
+
});
|
|
966
|
+
function routeContext(definition, request, pathParams) {
|
|
967
|
+
let cookies;
|
|
968
|
+
const set = {
|
|
969
|
+
headers: new Headers,
|
|
970
|
+
get cookies() {
|
|
971
|
+
cookies ??= new Bun.CookieMap;
|
|
972
|
+
return cookies;
|
|
973
|
+
},
|
|
974
|
+
touched: () => cookies !== undefined
|
|
975
|
+
};
|
|
976
|
+
const limit = "bodyLimit" in definition ? definition.bodyLimit : undefined;
|
|
977
|
+
const ctx = {
|
|
978
|
+
...request,
|
|
979
|
+
...limit === undefined ? {} : { request: limitBody(request.request, limit) },
|
|
980
|
+
route: definition.path,
|
|
981
|
+
pathParams,
|
|
982
|
+
set,
|
|
983
|
+
reply: createReply,
|
|
984
|
+
redirect
|
|
985
|
+
};
|
|
986
|
+
return { ctx, set };
|
|
987
|
+
}
|
|
988
|
+
|
|
783
989
|
// src/app/send.ts
|
|
784
990
|
var internal = { error: "internal" };
|
|
785
991
|
function internalError() {
|
|
@@ -810,17 +1016,23 @@ async function sendDeclared(route, reply, set, signal, validateResponses) {
|
|
|
810
1016
|
if (responses === undefined || isRedirect(reply)) {
|
|
811
1017
|
return send2(reply, set, signal);
|
|
812
1018
|
}
|
|
1019
|
+
const checked = await checkReply(route.method, route.path, responses, reply, validateResponses);
|
|
1020
|
+
return send2(checked, set, signal);
|
|
1021
|
+
}
|
|
1022
|
+
async function checkReply(method, path, responses, reply, validateResponses) {
|
|
813
1023
|
const schema = responses[reply.status];
|
|
814
1024
|
if (schema === undefined) {
|
|
815
|
-
throw ResponseValidationError.undeclared(
|
|
1025
|
+
throw ResponseValidationError.undeclared(method, path, reply.status);
|
|
816
1026
|
}
|
|
817
1027
|
if (!validateResponses)
|
|
818
|
-
return
|
|
1028
|
+
return framed(schema, reply);
|
|
819
1029
|
const checked = await check(schema, reply.body, "body");
|
|
820
1030
|
if (!checked.ok) {
|
|
821
|
-
throw new ResponseValidationError(
|
|
1031
|
+
throw new ResponseValidationError(method, path, reply.status, checked.issues);
|
|
822
1032
|
}
|
|
823
|
-
return
|
|
1033
|
+
return new Reply(reply.status, checked.value, {
|
|
1034
|
+
headers: reply.headers ?? {}
|
|
1035
|
+
});
|
|
824
1036
|
}
|
|
825
1037
|
function routingError(status, error, allowed) {
|
|
826
1038
|
const headers = new Headers;
|
|
@@ -829,42 +1041,73 @@ function routingError(status, error, allowed) {
|
|
|
829
1041
|
const body = { error };
|
|
830
1042
|
return toResponse(status, body, headers);
|
|
831
1043
|
}
|
|
1044
|
+
function framed(schema, reply) {
|
|
1045
|
+
if (!isNamedEventStreamSchema(schema) || !isAsyncIterable(reply.body)) {
|
|
1046
|
+
return reply;
|
|
1047
|
+
}
|
|
1048
|
+
return new Reply(reply.status, toFrames(schema["~events"], reply.body, false), { headers: reply.headers ?? {} });
|
|
1049
|
+
}
|
|
832
1050
|
function isRedirect(reply) {
|
|
833
1051
|
return reply.status >= 300 && reply.status < 400 && reply.body === undefined;
|
|
834
1052
|
}
|
|
835
1053
|
|
|
836
|
-
// src/app/
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1054
|
+
// src/app/refusal.ts
|
|
1055
|
+
async function refuse(definition, refusal, set, ctx, validateResponses) {
|
|
1056
|
+
const handler = definition.refusal;
|
|
1057
|
+
if (handler !== undefined) {
|
|
1058
|
+
let reply = handler.hook(refusal, ctx);
|
|
1059
|
+
if (reply instanceof Promise)
|
|
1060
|
+
reply = await reply;
|
|
1061
|
+
const method = "method" in definition ? definition.method : "WS";
|
|
1062
|
+
if (reply instanceof Reply) {
|
|
1063
|
+
if (handler.response !== undefined) {
|
|
1064
|
+
reply = await checkReply(method, definition.path, handler.response, reply, validateResponses);
|
|
1065
|
+
}
|
|
1066
|
+
return send2(withContentType2(reply, handler.contentType), set);
|
|
1067
|
+
}
|
|
1068
|
+
if (reply !== undefined) {
|
|
1069
|
+
throw new TypeError(`${method} ${definition.path}: the onRefusal hook returned neither a reply nor nothing.`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return send2(byDefault(refusal), set);
|
|
1073
|
+
}
|
|
1074
|
+
function byDefault(refusal) {
|
|
1075
|
+
switch (refusal.kind) {
|
|
1076
|
+
case "validation": {
|
|
1077
|
+
const body = {
|
|
1078
|
+
error: "validation",
|
|
1079
|
+
issues: refusal.issues
|
|
1080
|
+
};
|
|
1081
|
+
return new Reply(400, body);
|
|
1082
|
+
}
|
|
1083
|
+
case "body_limit": {
|
|
1084
|
+
const body = {
|
|
1085
|
+
error: "content_too_large",
|
|
1086
|
+
limit: refusal.limit
|
|
1087
|
+
};
|
|
1088
|
+
return new Reply(413, body);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function withContentType2(reply, type) {
|
|
1093
|
+
if (type === undefined)
|
|
1094
|
+
return reply;
|
|
1095
|
+
const headers = new Headers(reply.headers);
|
|
1096
|
+
if (headers.has("content-type"))
|
|
1097
|
+
return reply;
|
|
1098
|
+
headers.set("content-type", type);
|
|
1099
|
+
return new Reply(reply.status, reply.body, { headers });
|
|
859
1100
|
}
|
|
860
|
-
|
|
1101
|
+
|
|
1102
|
+
// src/app/chain.ts
|
|
1103
|
+
async function chain(definition, request, rawParams, set, ctx, parsers, validateResponses, last) {
|
|
861
1104
|
const hooks = definition.derive;
|
|
862
1105
|
const socket = !("method" in definition);
|
|
863
1106
|
const signal = request.request.signal;
|
|
864
1107
|
const step = async (index) => {
|
|
865
1108
|
const hook = hooks[index];
|
|
866
1109
|
if (hook === undefined) {
|
|
867
|
-
const refused = await validate(definition, request, rawParams, set, ctx, parsers);
|
|
1110
|
+
const refused = await validate(definition, request, rawParams, set, ctx, parsers, validateResponses);
|
|
868
1111
|
return refused ?? last();
|
|
869
1112
|
}
|
|
870
1113
|
if (hook.kind === "wrap") {
|
|
@@ -887,9 +1130,10 @@ async function chain(definition, request, rawParams, set, ctx, parsers, last) {
|
|
|
887
1130
|
};
|
|
888
1131
|
return step(0);
|
|
889
1132
|
}
|
|
890
|
-
async function validate(definition, request, rawParams, set, ctx, parsers) {
|
|
1133
|
+
async function validate(definition, request, rawParams, set, ctx, parsers, validateResponses) {
|
|
891
1134
|
const { schema } = definition;
|
|
892
1135
|
const issues = [];
|
|
1136
|
+
let part;
|
|
893
1137
|
const parts = [
|
|
894
1138
|
["params", schema.params, () => rawParams],
|
|
895
1139
|
["query", schema.query, () => readQuery(request.url)],
|
|
@@ -905,33 +1149,36 @@ async function validate(definition, request, rawParams, set, ctx, parsers) {
|
|
|
905
1149
|
const checked = await check(partSchema, raw, target);
|
|
906
1150
|
if (checked.ok)
|
|
907
1151
|
ctx[target] = checked.value;
|
|
908
|
-
else
|
|
1152
|
+
else {
|
|
1153
|
+
part ??= target;
|
|
909
1154
|
issues.push(...checked.issues);
|
|
1155
|
+
}
|
|
910
1156
|
}
|
|
911
1157
|
ctx["body"] = undefined;
|
|
912
1158
|
const bodySchema = "body" in schema ? schema.body : undefined;
|
|
913
1159
|
if (bodySchema !== undefined) {
|
|
914
|
-
const body = await readBody(
|
|
915
|
-
if (!body.ok)
|
|
1160
|
+
const body = await readBody(ctx.request, parsers);
|
|
1161
|
+
if (!body.ok) {
|
|
1162
|
+
part ??= "body";
|
|
916
1163
|
issues.push(body.issue);
|
|
917
|
-
else {
|
|
1164
|
+
} else {
|
|
918
1165
|
const checked = await check(bodySchema, body.value, "body");
|
|
919
1166
|
if (checked.ok)
|
|
920
1167
|
ctx["body"] = checked.value;
|
|
921
|
-
else
|
|
1168
|
+
else {
|
|
1169
|
+
part ??= "body";
|
|
922
1170
|
issues.push(...checked.issues);
|
|
1171
|
+
}
|
|
923
1172
|
}
|
|
924
1173
|
}
|
|
925
|
-
if (
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
}
|
|
929
|
-
return;
|
|
1174
|
+
if (part === undefined)
|
|
1175
|
+
return;
|
|
1176
|
+
return refuse(definition, { kind: "validation", part, issues }, set, ctx, validateResponses);
|
|
930
1177
|
}
|
|
931
1178
|
async function handle(route, request, rawParams, parsers, validateResponses) {
|
|
932
1179
|
const { ctx, set } = routeContext(route, request, rawParams);
|
|
933
1180
|
try {
|
|
934
|
-
return await chain(route, request, rawParams, set, ctx, parsers, async () => {
|
|
1181
|
+
return await chain(route, request, rawParams, set, ctx, parsers, validateResponses, async () => {
|
|
935
1182
|
let reply = route.handler(ctx);
|
|
936
1183
|
if (reply instanceof Promise)
|
|
937
1184
|
reply = await reply;
|
|
@@ -942,10 +1189,18 @@ async function handle(route, request, rawParams, parsers, validateResponses) {
|
|
|
942
1189
|
});
|
|
943
1190
|
} catch (error) {
|
|
944
1191
|
request.error = error;
|
|
945
|
-
return fail(route, error, ctx);
|
|
1192
|
+
return fail(route, error, ctx, validateResponses);
|
|
946
1193
|
}
|
|
947
1194
|
}
|
|
948
|
-
async function fail(definition, error, ctx) {
|
|
1195
|
+
async function fail(definition, error, ctx, validateResponses = true) {
|
|
1196
|
+
if (error instanceof ContentTooLargeError) {
|
|
1197
|
+
try {
|
|
1198
|
+
return await refuse(definition, { kind: "body_limit", limit: error.limit }, ctx.set, ctx, validateResponses);
|
|
1199
|
+
} catch (thrown) {
|
|
1200
|
+
console.error(thrown);
|
|
1201
|
+
return internalError();
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
949
1204
|
for (const hook of definition.onError) {
|
|
950
1205
|
let handled = hook(error, ctx);
|
|
951
1206
|
if (handled instanceof Promise)
|
|
@@ -962,11 +1217,11 @@ async function fail(definition, error, ctx) {
|
|
|
962
1217
|
|
|
963
1218
|
// src/app/socket.ts
|
|
964
1219
|
var UPGRADED = Symbol("upgraded");
|
|
965
|
-
async function upgradeSocket(definition, request, rawParams, parsers) {
|
|
1220
|
+
async function upgradeSocket(definition, request, rawParams, parsers, validateResponses) {
|
|
966
1221
|
const { ctx, set } = routeContext(definition, request, rawParams);
|
|
967
1222
|
const server = request.server;
|
|
968
1223
|
try {
|
|
969
|
-
return await chain(definition, request, rawParams, set, ctx, parsers, async () => {
|
|
1224
|
+
return await chain(definition, request, rawParams, set, ctx, parsers, validateResponses, async () => {
|
|
970
1225
|
if (server === undefined) {
|
|
971
1226
|
return routingError(426, "upgrade_required");
|
|
972
1227
|
}
|
|
@@ -1158,7 +1413,7 @@ async function route(runtime, ctx, path) {
|
|
|
1158
1413
|
const socket = find("WS");
|
|
1159
1414
|
if (socket !== undefined && "value" in socket && socket.value.kind === "ws") {
|
|
1160
1415
|
ctx.route = socket.value.path;
|
|
1161
|
-
return upgradeSocket(socket.value, ctx, socket.params, globals.parsers);
|
|
1416
|
+
return upgradeSocket(socket.value, ctx, socket.params, globals.parsers, runtime.validateResponses);
|
|
1162
1417
|
}
|
|
1163
1418
|
}
|
|
1164
1419
|
let match = find(request.method);
|
|
@@ -1197,6 +1452,8 @@ class Alxia {
|
|
|
1197
1452
|
#sockets = [];
|
|
1198
1453
|
#derive = [];
|
|
1199
1454
|
#onError = [];
|
|
1455
|
+
#refusal;
|
|
1456
|
+
#bodyLimit;
|
|
1200
1457
|
#server;
|
|
1201
1458
|
#websocket;
|
|
1202
1459
|
constructor(options = {}) {
|
|
@@ -1253,7 +1510,8 @@ class Alxia {
|
|
|
1253
1510
|
schema,
|
|
1254
1511
|
handlers,
|
|
1255
1512
|
derive: [...this.#derive],
|
|
1256
|
-
onError: [...this.#onError]
|
|
1513
|
+
onError: [...this.#onError],
|
|
1514
|
+
refusal: this.#refusal
|
|
1257
1515
|
};
|
|
1258
1516
|
this.#mount(definition);
|
|
1259
1517
|
return this;
|
|
@@ -1270,10 +1528,29 @@ class Alxia {
|
|
|
1270
1528
|
this.#derive.push({ kind: "wrap", run: hook });
|
|
1271
1529
|
return this;
|
|
1272
1530
|
}
|
|
1531
|
+
bodyLimit(bytes) {
|
|
1532
|
+
this.#bodyLimit = checkLimit(bytes, "bodyLimit()");
|
|
1533
|
+
return this;
|
|
1534
|
+
}
|
|
1273
1535
|
onError(hook) {
|
|
1274
1536
|
this.#onError.push(hook);
|
|
1275
1537
|
return this;
|
|
1276
1538
|
}
|
|
1539
|
+
onRefusal(schemaOrHook, maybeHook) {
|
|
1540
|
+
if (typeof schemaOrHook === "function") {
|
|
1541
|
+
this.#refusal = { hook: schemaOrHook };
|
|
1542
|
+
return this;
|
|
1543
|
+
}
|
|
1544
|
+
if (typeof maybeHook !== "function") {
|
|
1545
|
+
throw new TypeError("onRefusal(): the hook is missing");
|
|
1546
|
+
}
|
|
1547
|
+
this.#refusal = {
|
|
1548
|
+
hook: maybeHook,
|
|
1549
|
+
response: schemaOrHook.response,
|
|
1550
|
+
...schemaOrHook.contentType === undefined ? {} : { contentType: schemaOrHook.contentType }
|
|
1551
|
+
};
|
|
1552
|
+
return this;
|
|
1553
|
+
}
|
|
1277
1554
|
onRequest(hook) {
|
|
1278
1555
|
this.#runtime.globals.onRequest.push(hook);
|
|
1279
1556
|
return this;
|
|
@@ -1308,6 +1585,8 @@ class Alxia {
|
|
|
1308
1585
|
});
|
|
1309
1586
|
child.#derive = [...this.#derive];
|
|
1310
1587
|
child.#onError = [...this.#onError];
|
|
1588
|
+
child.#refusal = this.#refusal;
|
|
1589
|
+
child.#bodyLimit = this.#bodyLimit;
|
|
1311
1590
|
child.#runtime = { ...child.#runtime, globals: this.#runtime.globals };
|
|
1312
1591
|
const before = new Set(this.#runtime.globals.pages.keys());
|
|
1313
1592
|
const built = build(child);
|
|
@@ -1330,7 +1609,8 @@ class Alxia {
|
|
|
1330
1609
|
...route,
|
|
1331
1610
|
path: joinPath(this.#prefix, route.path),
|
|
1332
1611
|
derive: [...this.#derive, ...route.derive],
|
|
1333
|
-
onError: [...route.onError, ...this.#onError]
|
|
1612
|
+
onError: [...route.onError, ...this.#onError],
|
|
1613
|
+
refusal: route.refusal ?? this.#refusal
|
|
1334
1614
|
});
|
|
1335
1615
|
}
|
|
1336
1616
|
for (const socket of plugin.sockets) {
|
|
@@ -1338,11 +1618,14 @@ class Alxia {
|
|
|
1338
1618
|
...socket,
|
|
1339
1619
|
path: joinPath(this.#prefix, socket.path),
|
|
1340
1620
|
derive: [...this.#derive, ...socket.derive],
|
|
1341
|
-
onError: [...socket.onError, ...this.#onError]
|
|
1621
|
+
onError: [...socket.onError, ...this.#onError],
|
|
1622
|
+
refusal: socket.refusal ?? this.#refusal
|
|
1342
1623
|
});
|
|
1343
1624
|
}
|
|
1344
1625
|
this.#derive = [...this.#derive, ...plugin.#derive];
|
|
1345
1626
|
this.#onError = [...plugin.#onError, ...this.#onError];
|
|
1627
|
+
this.#refusal = plugin.#refusal ?? this.#refusal;
|
|
1628
|
+
this.#bodyLimit = plugin.#bodyLimit ?? this.#bodyLimit;
|
|
1346
1629
|
if (plugin.#runtime.globals !== this.#runtime.globals) {
|
|
1347
1630
|
const globals = plugin.#runtime.globals;
|
|
1348
1631
|
this.#runtime.globals.around.push(...globals.around);
|
|
@@ -1410,13 +1693,17 @@ class Alxia {
|
|
|
1410
1693
|
if (typeof handler !== "function") {
|
|
1411
1694
|
throw new TypeError(`${method} ${path}: the handler is missing`);
|
|
1412
1695
|
}
|
|
1696
|
+
const full = joinPath(this.#prefix, path);
|
|
1697
|
+
const bodyLimit = schema.bodyLimit === undefined ? this.#bodyLimit : checkLimit(schema.bodyLimit, `${method} ${full}`);
|
|
1413
1698
|
this.#register({
|
|
1414
1699
|
method,
|
|
1415
|
-
path:
|
|
1700
|
+
path: full,
|
|
1416
1701
|
schema,
|
|
1702
|
+
...bodyLimit === undefined ? {} : { bodyLimit },
|
|
1417
1703
|
handler,
|
|
1418
1704
|
derive: [...this.#derive],
|
|
1419
|
-
onError: [...this.#onError]
|
|
1705
|
+
onError: [...this.#onError],
|
|
1706
|
+
refusal: this.#refusal
|
|
1420
1707
|
});
|
|
1421
1708
|
return this;
|
|
1422
1709
|
};
|
|
@@ -1455,8 +1742,17 @@ function alxia(options = {}) {
|
|
|
1455
1742
|
function definePlugin() {
|
|
1456
1743
|
return (build) => build(alxia());
|
|
1457
1744
|
}
|
|
1745
|
+
// src/reply/problem.ts
|
|
1746
|
+
function problem(body, init) {
|
|
1747
|
+
const headers = new Headers(init?.headers);
|
|
1748
|
+
if (!headers.has("content-type")) {
|
|
1749
|
+
headers.set("content-type", "application/problem+json");
|
|
1750
|
+
}
|
|
1751
|
+
return new Reply(body.status, body, { ...init, headers });
|
|
1752
|
+
}
|
|
1458
1753
|
export {
|
|
1459
1754
|
Alxia,
|
|
1755
|
+
ContentTooLargeError,
|
|
1460
1756
|
HttpError,
|
|
1461
1757
|
Reply,
|
|
1462
1758
|
ResponseValidationError,
|
|
@@ -1466,12 +1762,14 @@ export {
|
|
|
1466
1762
|
definePlugin,
|
|
1467
1763
|
eventStream,
|
|
1468
1764
|
isEventStreamSchema,
|
|
1765
|
+
isNamedEventStreamSchema,
|
|
1469
1766
|
joinPath,
|
|
1470
1767
|
parseRange,
|
|
1768
|
+
problem,
|
|
1471
1769
|
shapeOf,
|
|
1472
1770
|
vary,
|
|
1473
1771
|
withHeaders
|
|
1474
1772
|
};
|
|
1475
1773
|
|
|
1476
|
-
//# debugId=
|
|
1774
|
+
//# debugId=62C2FCF982C60F6964756E2164756E21
|
|
1477
1775
|
//# sourceMappingURL=index.js.map
|