@alxia/core 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -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/pipeline.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 +24 -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 +413 -103
- 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 +131 -4
- package/docs/guide/replies.md +47 -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 +331 -7
- 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,11 +959,51 @@ 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() {
|
|
786
992
|
return toResponse(500, internal, new Headers);
|
|
787
993
|
}
|
|
994
|
+
function clientGone(error, request) {
|
|
995
|
+
if (!request.signal.aborted)
|
|
996
|
+
return false;
|
|
997
|
+
return error === request.signal.reason || error instanceof DOMException && error.name === "AbortError";
|
|
998
|
+
}
|
|
999
|
+
function failed(error, request) {
|
|
1000
|
+
if (clientGone(error, request)) {
|
|
1001
|
+
return new Response(null, { status: CLIENT_GONE });
|
|
1002
|
+
}
|
|
1003
|
+
console.error(error);
|
|
1004
|
+
return internalError();
|
|
1005
|
+
}
|
|
1006
|
+
var CLIENT_GONE = 499;
|
|
788
1007
|
function send2(reply, set, signal) {
|
|
789
1008
|
const headers = new Headers(set.headers);
|
|
790
1009
|
if (reply.headers !== undefined) {
|
|
@@ -810,17 +1029,23 @@ async function sendDeclared(route, reply, set, signal, validateResponses) {
|
|
|
810
1029
|
if (responses === undefined || isRedirect(reply)) {
|
|
811
1030
|
return send2(reply, set, signal);
|
|
812
1031
|
}
|
|
1032
|
+
const checked = await checkReply(route.method, route.path, responses, reply, validateResponses);
|
|
1033
|
+
return send2(checked, set, signal);
|
|
1034
|
+
}
|
|
1035
|
+
async function checkReply(method, path, responses, reply, validateResponses) {
|
|
813
1036
|
const schema = responses[reply.status];
|
|
814
1037
|
if (schema === undefined) {
|
|
815
|
-
throw ResponseValidationError.undeclared(
|
|
1038
|
+
throw ResponseValidationError.undeclared(method, path, reply.status);
|
|
816
1039
|
}
|
|
817
1040
|
if (!validateResponses)
|
|
818
|
-
return
|
|
1041
|
+
return framed(schema, reply);
|
|
819
1042
|
const checked = await check(schema, reply.body, "body");
|
|
820
1043
|
if (!checked.ok) {
|
|
821
|
-
throw new ResponseValidationError(
|
|
1044
|
+
throw new ResponseValidationError(method, path, reply.status, checked.issues);
|
|
822
1045
|
}
|
|
823
|
-
return
|
|
1046
|
+
return new Reply(reply.status, checked.value, {
|
|
1047
|
+
headers: reply.headers ?? {}
|
|
1048
|
+
});
|
|
824
1049
|
}
|
|
825
1050
|
function routingError(status, error, allowed) {
|
|
826
1051
|
const headers = new Headers;
|
|
@@ -829,42 +1054,73 @@ function routingError(status, error, allowed) {
|
|
|
829
1054
|
const body = { error };
|
|
830
1055
|
return toResponse(status, body, headers);
|
|
831
1056
|
}
|
|
1057
|
+
function framed(schema, reply) {
|
|
1058
|
+
if (!isNamedEventStreamSchema(schema) || !isAsyncIterable(reply.body)) {
|
|
1059
|
+
return reply;
|
|
1060
|
+
}
|
|
1061
|
+
return new Reply(reply.status, toFrames(schema["~events"], reply.body, false), { headers: reply.headers ?? {} });
|
|
1062
|
+
}
|
|
832
1063
|
function isRedirect(reply) {
|
|
833
1064
|
return reply.status >= 300 && reply.status < 400 && reply.body === undefined;
|
|
834
1065
|
}
|
|
835
1066
|
|
|
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
|
-
|
|
1067
|
+
// src/app/refusal.ts
|
|
1068
|
+
async function refuse(definition, refusal, set, ctx, validateResponses) {
|
|
1069
|
+
const handler = definition.refusal;
|
|
1070
|
+
if (handler !== undefined) {
|
|
1071
|
+
let reply = handler.hook(refusal, ctx);
|
|
1072
|
+
if (reply instanceof Promise)
|
|
1073
|
+
reply = await reply;
|
|
1074
|
+
const method = "method" in definition ? definition.method : "WS";
|
|
1075
|
+
if (reply instanceof Reply) {
|
|
1076
|
+
if (handler.response !== undefined) {
|
|
1077
|
+
reply = await checkReply(method, definition.path, handler.response, reply, validateResponses);
|
|
1078
|
+
}
|
|
1079
|
+
return send2(withContentType2(reply, handler.contentType), set);
|
|
1080
|
+
}
|
|
1081
|
+
if (reply !== undefined) {
|
|
1082
|
+
throw new TypeError(`${method} ${definition.path}: the onRefusal hook returned neither a reply nor nothing.`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return send2(byDefault(refusal), set);
|
|
1086
|
+
}
|
|
1087
|
+
function byDefault(refusal) {
|
|
1088
|
+
switch (refusal.kind) {
|
|
1089
|
+
case "validation": {
|
|
1090
|
+
const body = {
|
|
1091
|
+
error: "validation",
|
|
1092
|
+
issues: refusal.issues
|
|
1093
|
+
};
|
|
1094
|
+
return new Reply(400, body);
|
|
1095
|
+
}
|
|
1096
|
+
case "body_limit": {
|
|
1097
|
+
const body = {
|
|
1098
|
+
error: "content_too_large",
|
|
1099
|
+
limit: refusal.limit
|
|
1100
|
+
};
|
|
1101
|
+
return new Reply(413, body);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
859
1104
|
}
|
|
860
|
-
|
|
1105
|
+
function withContentType2(reply, type) {
|
|
1106
|
+
if (type === undefined)
|
|
1107
|
+
return reply;
|
|
1108
|
+
const headers = new Headers(reply.headers);
|
|
1109
|
+
if (headers.has("content-type"))
|
|
1110
|
+
return reply;
|
|
1111
|
+
headers.set("content-type", type);
|
|
1112
|
+
return new Reply(reply.status, reply.body, { headers });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// src/app/chain.ts
|
|
1116
|
+
async function chain(definition, request, rawParams, set, ctx, parsers, validateResponses, last) {
|
|
861
1117
|
const hooks = definition.derive;
|
|
862
1118
|
const socket = !("method" in definition);
|
|
863
1119
|
const signal = request.request.signal;
|
|
864
1120
|
const step = async (index) => {
|
|
865
1121
|
const hook = hooks[index];
|
|
866
1122
|
if (hook === undefined) {
|
|
867
|
-
const refused = await validate(definition, request, rawParams, set, ctx, parsers);
|
|
1123
|
+
const refused = await validate(definition, request, rawParams, set, ctx, parsers, validateResponses);
|
|
868
1124
|
return refused ?? last();
|
|
869
1125
|
}
|
|
870
1126
|
if (hook.kind === "wrap") {
|
|
@@ -887,9 +1143,10 @@ async function chain(definition, request, rawParams, set, ctx, parsers, last) {
|
|
|
887
1143
|
};
|
|
888
1144
|
return step(0);
|
|
889
1145
|
}
|
|
890
|
-
async function validate(definition, request, rawParams, set, ctx, parsers) {
|
|
1146
|
+
async function validate(definition, request, rawParams, set, ctx, parsers, validateResponses) {
|
|
891
1147
|
const { schema } = definition;
|
|
892
1148
|
const issues = [];
|
|
1149
|
+
let part;
|
|
893
1150
|
const parts = [
|
|
894
1151
|
["params", schema.params, () => rawParams],
|
|
895
1152
|
["query", schema.query, () => readQuery(request.url)],
|
|
@@ -905,33 +1162,36 @@ async function validate(definition, request, rawParams, set, ctx, parsers) {
|
|
|
905
1162
|
const checked = await check(partSchema, raw, target);
|
|
906
1163
|
if (checked.ok)
|
|
907
1164
|
ctx[target] = checked.value;
|
|
908
|
-
else
|
|
1165
|
+
else {
|
|
1166
|
+
part ??= target;
|
|
909
1167
|
issues.push(...checked.issues);
|
|
1168
|
+
}
|
|
910
1169
|
}
|
|
911
1170
|
ctx["body"] = undefined;
|
|
912
1171
|
const bodySchema = "body" in schema ? schema.body : undefined;
|
|
913
1172
|
if (bodySchema !== undefined) {
|
|
914
|
-
const body = await readBody(
|
|
915
|
-
if (!body.ok)
|
|
1173
|
+
const body = await readBody(ctx.request, parsers);
|
|
1174
|
+
if (!body.ok) {
|
|
1175
|
+
part ??= "body";
|
|
916
1176
|
issues.push(body.issue);
|
|
917
|
-
else {
|
|
1177
|
+
} else {
|
|
918
1178
|
const checked = await check(bodySchema, body.value, "body");
|
|
919
1179
|
if (checked.ok)
|
|
920
1180
|
ctx["body"] = checked.value;
|
|
921
|
-
else
|
|
1181
|
+
else {
|
|
1182
|
+
part ??= "body";
|
|
922
1183
|
issues.push(...checked.issues);
|
|
1184
|
+
}
|
|
923
1185
|
}
|
|
924
1186
|
}
|
|
925
|
-
if (
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
}
|
|
929
|
-
return;
|
|
1187
|
+
if (part === undefined)
|
|
1188
|
+
return;
|
|
1189
|
+
return refuse(definition, { kind: "validation", part, issues }, set, ctx, validateResponses);
|
|
930
1190
|
}
|
|
931
1191
|
async function handle(route, request, rawParams, parsers, validateResponses) {
|
|
932
1192
|
const { ctx, set } = routeContext(route, request, rawParams);
|
|
933
1193
|
try {
|
|
934
|
-
return await chain(route, request, rawParams, set, ctx, parsers, async () => {
|
|
1194
|
+
return await chain(route, request, rawParams, set, ctx, parsers, validateResponses, async () => {
|
|
935
1195
|
let reply = route.handler(ctx);
|
|
936
1196
|
if (reply instanceof Promise)
|
|
937
1197
|
reply = await reply;
|
|
@@ -942,10 +1202,20 @@ async function handle(route, request, rawParams, parsers, validateResponses) {
|
|
|
942
1202
|
});
|
|
943
1203
|
} catch (error) {
|
|
944
1204
|
request.error = error;
|
|
945
|
-
return fail(route, error, ctx);
|
|
1205
|
+
return fail(route, error, ctx, validateResponses);
|
|
946
1206
|
}
|
|
947
1207
|
}
|
|
948
|
-
async function fail(definition, error, ctx) {
|
|
1208
|
+
async function fail(definition, error, ctx, validateResponses = true) {
|
|
1209
|
+
if (clientGone(error, ctx.request))
|
|
1210
|
+
return failed(error, ctx.request);
|
|
1211
|
+
if (error instanceof ContentTooLargeError) {
|
|
1212
|
+
try {
|
|
1213
|
+
return await refuse(definition, { kind: "body_limit", limit: error.limit }, ctx.set, ctx, validateResponses);
|
|
1214
|
+
} catch (thrown) {
|
|
1215
|
+
console.error(thrown);
|
|
1216
|
+
return internalError();
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
949
1219
|
for (const hook of definition.onError) {
|
|
950
1220
|
let handled = hook(error, ctx);
|
|
951
1221
|
if (handled instanceof Promise)
|
|
@@ -956,17 +1226,16 @@ async function fail(definition, error, ctx) {
|
|
|
956
1226
|
if (error instanceof HttpError) {
|
|
957
1227
|
return send2(new Reply(error.status, error.body), ctx.set);
|
|
958
1228
|
}
|
|
959
|
-
|
|
960
|
-
return internalError();
|
|
1229
|
+
return failed(error, ctx.request);
|
|
961
1230
|
}
|
|
962
1231
|
|
|
963
1232
|
// src/app/socket.ts
|
|
964
1233
|
var UPGRADED = Symbol("upgraded");
|
|
965
|
-
async function upgradeSocket(definition, request, rawParams, parsers) {
|
|
1234
|
+
async function upgradeSocket(definition, request, rawParams, parsers, validateResponses) {
|
|
966
1235
|
const { ctx, set } = routeContext(definition, request, rawParams);
|
|
967
1236
|
const server = request.server;
|
|
968
1237
|
try {
|
|
969
|
-
return await chain(definition, request, rawParams, set, ctx, parsers, async () => {
|
|
1238
|
+
return await chain(definition, request, rawParams, set, ctx, parsers, validateResponses, async () => {
|
|
970
1239
|
if (server === undefined) {
|
|
971
1240
|
return routingError(426, "upgrade_required");
|
|
972
1241
|
}
|
|
@@ -1100,8 +1369,7 @@ async function serve(runtime, request, server, path) {
|
|
|
1100
1369
|
try {
|
|
1101
1370
|
return await run(0);
|
|
1102
1371
|
} catch (error) {
|
|
1103
|
-
|
|
1104
|
-
return internalError();
|
|
1372
|
+
return failed(error, ctx.request);
|
|
1105
1373
|
}
|
|
1106
1374
|
}
|
|
1107
1375
|
async function pipeline(runtime, ctx, path) {
|
|
@@ -1118,8 +1386,7 @@ async function pipeline(runtime, ctx, path) {
|
|
|
1118
1386
|
}
|
|
1119
1387
|
response ??= await route(runtime, ctx, path);
|
|
1120
1388
|
} catch (error) {
|
|
1121
|
-
|
|
1122
|
-
response = internalError();
|
|
1389
|
+
response = failed(error, ctx.request);
|
|
1123
1390
|
}
|
|
1124
1391
|
if (response === UPGRADED)
|
|
1125
1392
|
return;
|
|
@@ -1158,7 +1425,7 @@ async function route(runtime, ctx, path) {
|
|
|
1158
1425
|
const socket = find("WS");
|
|
1159
1426
|
if (socket !== undefined && "value" in socket && socket.value.kind === "ws") {
|
|
1160
1427
|
ctx.route = socket.value.path;
|
|
1161
|
-
return upgradeSocket(socket.value, ctx, socket.params, globals.parsers);
|
|
1428
|
+
return upgradeSocket(socket.value, ctx, socket.params, globals.parsers, runtime.validateResponses);
|
|
1162
1429
|
}
|
|
1163
1430
|
}
|
|
1164
1431
|
let match = find(request.method);
|
|
@@ -1197,6 +1464,8 @@ class Alxia {
|
|
|
1197
1464
|
#sockets = [];
|
|
1198
1465
|
#derive = [];
|
|
1199
1466
|
#onError = [];
|
|
1467
|
+
#refusal;
|
|
1468
|
+
#bodyLimit;
|
|
1200
1469
|
#server;
|
|
1201
1470
|
#websocket;
|
|
1202
1471
|
constructor(options = {}) {
|
|
@@ -1253,7 +1522,8 @@ class Alxia {
|
|
|
1253
1522
|
schema,
|
|
1254
1523
|
handlers,
|
|
1255
1524
|
derive: [...this.#derive],
|
|
1256
|
-
onError: [...this.#onError]
|
|
1525
|
+
onError: [...this.#onError],
|
|
1526
|
+
refusal: this.#refusal
|
|
1257
1527
|
};
|
|
1258
1528
|
this.#mount(definition);
|
|
1259
1529
|
return this;
|
|
@@ -1270,10 +1540,29 @@ class Alxia {
|
|
|
1270
1540
|
this.#derive.push({ kind: "wrap", run: hook });
|
|
1271
1541
|
return this;
|
|
1272
1542
|
}
|
|
1543
|
+
bodyLimit(bytes) {
|
|
1544
|
+
this.#bodyLimit = checkLimit(bytes, "bodyLimit()");
|
|
1545
|
+
return this;
|
|
1546
|
+
}
|
|
1273
1547
|
onError(hook) {
|
|
1274
1548
|
this.#onError.push(hook);
|
|
1275
1549
|
return this;
|
|
1276
1550
|
}
|
|
1551
|
+
onRefusal(schemaOrHook, maybeHook) {
|
|
1552
|
+
if (typeof schemaOrHook === "function") {
|
|
1553
|
+
this.#refusal = { hook: schemaOrHook };
|
|
1554
|
+
return this;
|
|
1555
|
+
}
|
|
1556
|
+
if (typeof maybeHook !== "function") {
|
|
1557
|
+
throw new TypeError("onRefusal(): the hook is missing");
|
|
1558
|
+
}
|
|
1559
|
+
this.#refusal = {
|
|
1560
|
+
hook: maybeHook,
|
|
1561
|
+
response: schemaOrHook.response,
|
|
1562
|
+
...schemaOrHook.contentType === undefined ? {} : { contentType: schemaOrHook.contentType }
|
|
1563
|
+
};
|
|
1564
|
+
return this;
|
|
1565
|
+
}
|
|
1277
1566
|
onRequest(hook) {
|
|
1278
1567
|
this.#runtime.globals.onRequest.push(hook);
|
|
1279
1568
|
return this;
|
|
@@ -1308,6 +1597,8 @@ class Alxia {
|
|
|
1308
1597
|
});
|
|
1309
1598
|
child.#derive = [...this.#derive];
|
|
1310
1599
|
child.#onError = [...this.#onError];
|
|
1600
|
+
child.#refusal = this.#refusal;
|
|
1601
|
+
child.#bodyLimit = this.#bodyLimit;
|
|
1311
1602
|
child.#runtime = { ...child.#runtime, globals: this.#runtime.globals };
|
|
1312
1603
|
const before = new Set(this.#runtime.globals.pages.keys());
|
|
1313
1604
|
const built = build(child);
|
|
@@ -1330,7 +1621,8 @@ class Alxia {
|
|
|
1330
1621
|
...route,
|
|
1331
1622
|
path: joinPath(this.#prefix, route.path),
|
|
1332
1623
|
derive: [...this.#derive, ...route.derive],
|
|
1333
|
-
onError: [...route.onError, ...this.#onError]
|
|
1624
|
+
onError: [...route.onError, ...this.#onError],
|
|
1625
|
+
refusal: route.refusal ?? this.#refusal
|
|
1334
1626
|
});
|
|
1335
1627
|
}
|
|
1336
1628
|
for (const socket of plugin.sockets) {
|
|
@@ -1338,11 +1630,14 @@ class Alxia {
|
|
|
1338
1630
|
...socket,
|
|
1339
1631
|
path: joinPath(this.#prefix, socket.path),
|
|
1340
1632
|
derive: [...this.#derive, ...socket.derive],
|
|
1341
|
-
onError: [...socket.onError, ...this.#onError]
|
|
1633
|
+
onError: [...socket.onError, ...this.#onError],
|
|
1634
|
+
refusal: socket.refusal ?? this.#refusal
|
|
1342
1635
|
});
|
|
1343
1636
|
}
|
|
1344
1637
|
this.#derive = [...this.#derive, ...plugin.#derive];
|
|
1345
1638
|
this.#onError = [...plugin.#onError, ...this.#onError];
|
|
1639
|
+
this.#refusal = plugin.#refusal ?? this.#refusal;
|
|
1640
|
+
this.#bodyLimit = plugin.#bodyLimit ?? this.#bodyLimit;
|
|
1346
1641
|
if (plugin.#runtime.globals !== this.#runtime.globals) {
|
|
1347
1642
|
const globals = plugin.#runtime.globals;
|
|
1348
1643
|
this.#runtime.globals.around.push(...globals.around);
|
|
@@ -1410,13 +1705,17 @@ class Alxia {
|
|
|
1410
1705
|
if (typeof handler !== "function") {
|
|
1411
1706
|
throw new TypeError(`${method} ${path}: the handler is missing`);
|
|
1412
1707
|
}
|
|
1708
|
+
const full = joinPath(this.#prefix, path);
|
|
1709
|
+
const bodyLimit = schema.bodyLimit === undefined ? this.#bodyLimit : checkLimit(schema.bodyLimit, `${method} ${full}`);
|
|
1413
1710
|
this.#register({
|
|
1414
1711
|
method,
|
|
1415
|
-
path:
|
|
1712
|
+
path: full,
|
|
1416
1713
|
schema,
|
|
1714
|
+
...bodyLimit === undefined ? {} : { bodyLimit },
|
|
1417
1715
|
handler,
|
|
1418
1716
|
derive: [...this.#derive],
|
|
1419
|
-
onError: [...this.#onError]
|
|
1717
|
+
onError: [...this.#onError],
|
|
1718
|
+
refusal: this.#refusal
|
|
1420
1719
|
});
|
|
1421
1720
|
return this;
|
|
1422
1721
|
};
|
|
@@ -1455,8 +1754,17 @@ function alxia(options = {}) {
|
|
|
1455
1754
|
function definePlugin() {
|
|
1456
1755
|
return (build) => build(alxia());
|
|
1457
1756
|
}
|
|
1757
|
+
// src/reply/problem.ts
|
|
1758
|
+
function problem(body, init) {
|
|
1759
|
+
const headers = new Headers(init?.headers);
|
|
1760
|
+
if (!headers.has("content-type")) {
|
|
1761
|
+
headers.set("content-type", "application/problem+json");
|
|
1762
|
+
}
|
|
1763
|
+
return new Reply(body.status, body, { ...init, headers });
|
|
1764
|
+
}
|
|
1458
1765
|
export {
|
|
1459
1766
|
Alxia,
|
|
1767
|
+
ContentTooLargeError,
|
|
1460
1768
|
HttpError,
|
|
1461
1769
|
Reply,
|
|
1462
1770
|
ResponseValidationError,
|
|
@@ -1466,12 +1774,14 @@ export {
|
|
|
1466
1774
|
definePlugin,
|
|
1467
1775
|
eventStream,
|
|
1468
1776
|
isEventStreamSchema,
|
|
1777
|
+
isNamedEventStreamSchema,
|
|
1469
1778
|
joinPath,
|
|
1470
1779
|
parseRange,
|
|
1780
|
+
problem,
|
|
1471
1781
|
shapeOf,
|
|
1472
1782
|
vary,
|
|
1473
1783
|
withHeaders
|
|
1474
1784
|
};
|
|
1475
1785
|
|
|
1476
|
-
//# debugId=
|
|
1786
|
+
//# debugId=B38C455B7CE36AB464756E2164756E21
|
|
1477
1787
|
//# sourceMappingURL=index.js.map
|