@jongleberry/api-server 1.2.1 → 2.0.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 +2 -2
- package/dist/application.d.mts +0 -1
- package/dist/application.mjs +24 -25
- package/dist/context.d.mts +1 -1
- package/dist/context.mjs +2 -2
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1 -0
- package/dist/mutation-media-type.d.mts +8 -0
- package/dist/mutation-media-type.mjs +92 -0
- package/dist/request.d.mts +1 -2
- package/dist/request.mjs +3 -20
- package/dist/router.d.mts +6 -4
- package/dist/router.mjs +16 -10
- package/dist/stream-json-object.d.mts +7 -0
- package/dist/stream-json-object.mjs +186 -0
- package/dist/types.d.mts +0 -9
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -42,13 +42,13 @@ app.route("/").get((ctx) => ctx.json({ ok: true }));
|
|
|
42
42
|
|
|
43
43
|
- **Trie router** — `find-my-way` under the hood; zero regex overhead on the hot path
|
|
44
44
|
- **Buffered responses** — `ctx.json()`, `ctx.response.text()`, `.html()`, `.xml()`, `.buffer()`
|
|
45
|
-
- **Streaming** — `ctx.pipeline(readable, ...transforms)` with back-pressure and error propagation
|
|
45
|
+
- **Streaming** — `ctx.pipeline(readable, ...transforms)` and `streamJsonObject()` with back-pressure and error propagation
|
|
46
46
|
- **Automatic ETag** — SHA-256 ETag on every buffered 2xx; `If-None-Match` → 304
|
|
47
47
|
- **Compression** — `br` / `gzip` / `deflate` negotiation; 1 KB threshold; `SYNC_FLUSH` for streams
|
|
48
48
|
- **Server-Timing** — response latency as a `Server-Timing` header (buffered) or trailer (streaming)
|
|
49
49
|
- **Abort signals** — `ctx.signal` / `ctx.abortController` wired to client disconnect
|
|
50
50
|
- **Request body limits** — `ctx.request.buffer()` and `.json()` use a safe 1 MB default, with per-call overrides
|
|
51
|
-
- **
|
|
51
|
+
- **Mutation media-type safety** — body-bearing POST/PUT/PATCH/DELETE requests default to JSON, with route-level exceptions
|
|
52
52
|
- **AsyncLocalStorage** — per-request store via `app.setAsyncLocalStorage(als)`
|
|
53
53
|
- **Cookies** — `ctx.cookies.get()` / `.set()` with full `Set-Cookie` options
|
|
54
54
|
- **Cache-Control** — `ctx.cacheControl(visibility, maxAge)` helper
|
package/dist/application.d.mts
CHANGED
|
@@ -17,7 +17,6 @@ export declare class Application extends EventEmitter {
|
|
|
17
17
|
private bodyLimit;
|
|
18
18
|
private readonly securityHeaders;
|
|
19
19
|
private trustProxy;
|
|
20
|
-
private strictJsonContentType;
|
|
21
20
|
private readonly oversizedBodyStrategy;
|
|
22
21
|
private readonly fallbackContentSecurityPolicy;
|
|
23
22
|
private readonly strictHttpMethods;
|
package/dist/application.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { Context, createContextClass } from "./context.mjs";
|
|
3
|
-
import { createRouteBuilder, isSupportedHttpMethod } from "./router.mjs";
|
|
3
|
+
import { createRouteBuilder, getAcceptedMediaTypes, isSupportedHttpMethod, } from "./router.mjs";
|
|
4
4
|
import Router from "find-my-way";
|
|
5
5
|
import { ServerTiming } from "./server-timing.mjs";
|
|
6
6
|
import { Logger } from "./logger.mjs";
|
|
7
7
|
import { getRawPath } from "./request-path.mjs";
|
|
8
8
|
import { createRequestAbortController } from "./request-abort.mjs";
|
|
9
|
+
import { assertAcceptedMutationMediaType, drainUnreadHttp2Mutation, } from "./mutation-media-type.mjs";
|
|
9
10
|
import { applySecurityHeaders, ensureFallbackHeaders, getFallbackBody, getFallbackStatus, resolveSecurityHeaders, safeString, sendFallback, } from "./fallback-response.mjs";
|
|
10
11
|
export class Application extends EventEmitter {
|
|
11
12
|
router = Router();
|
|
@@ -18,7 +19,6 @@ export class Application extends EventEmitter {
|
|
|
18
19
|
bodyLimit;
|
|
19
20
|
securityHeaders;
|
|
20
21
|
trustProxy;
|
|
21
|
-
strictJsonContentType;
|
|
22
22
|
oversizedBodyStrategy;
|
|
23
23
|
fallbackContentSecurityPolicy;
|
|
24
24
|
strictHttpMethods;
|
|
@@ -28,7 +28,6 @@ export class Application extends EventEmitter {
|
|
|
28
28
|
this.bodyLimit = options?.bodyLimit ?? "1mb";
|
|
29
29
|
this.securityHeaders = resolveSecurityHeaders(options?.securityHeaders);
|
|
30
30
|
this.trustProxy = options?.trustProxy ?? false;
|
|
31
|
-
this.strictJsonContentType = options?.strictJsonContentType ?? false;
|
|
32
31
|
this.oversizedBodyStrategy = options?.oversizedBodyStrategy ?? "drain";
|
|
33
32
|
this.fallbackContentSecurityPolicy = options?.fallbackContentSecurityPolicy ?? false;
|
|
34
33
|
this.strictHttpMethods = options?.strictHttpMethods ?? false;
|
|
@@ -56,18 +55,13 @@ export class Application extends EventEmitter {
|
|
|
56
55
|
}
|
|
57
56
|
handleRequest(req, res) {
|
|
58
57
|
const run = () => this.runRequest(req, res).catch((err) => {
|
|
59
|
-
// Safety net: if runRequest rejects before its own try-catch (e.g. during
|
|
60
|
-
// context/timing setup), ensure the client always gets a response instead
|
|
61
|
-
// of a socket hang-up from an unhandled promise rejection.
|
|
62
58
|
const error = err instanceof Error ? err : new Error(safeString(err));
|
|
63
59
|
try {
|
|
64
60
|
if (this.listenerCount("error") > 0) {
|
|
65
61
|
this.emit("error", error);
|
|
66
62
|
}
|
|
67
63
|
}
|
|
68
|
-
catch {
|
|
69
|
-
// Swallow listener throws so the 500 response still goes out.
|
|
70
|
-
}
|
|
64
|
+
catch { }
|
|
71
65
|
if (!res.headersSent) {
|
|
72
66
|
ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
|
|
73
67
|
res.writeHead(500);
|
|
@@ -86,7 +80,7 @@ export class Application extends EventEmitter {
|
|
|
86
80
|
const timing = new ServerTiming();
|
|
87
81
|
const ContextClass = this.contextClass;
|
|
88
82
|
const { onWriteHead, onFinish } = this.logger.onRequestStart(req);
|
|
89
|
-
const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.
|
|
83
|
+
const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.oversizedBodyStrategy);
|
|
90
84
|
applySecurityHeaders(res, this.securityHeaders);
|
|
91
85
|
try {
|
|
92
86
|
const method = req.method ?? "GET";
|
|
@@ -97,21 +91,29 @@ export class Application extends EventEmitter {
|
|
|
97
91
|
const rawPath = getRawPath(url);
|
|
98
92
|
const routePath = rawPath.replace(/^\/+/, "/") || "/";
|
|
99
93
|
const found = this.router.find(method, routePath);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
await
|
|
94
|
+
const mediaTypeCheck = assertAcceptedMutationMediaType(req, found ? getAcceptedMediaTypes(found.handler) : undefined);
|
|
95
|
+
if (mediaTypeCheck)
|
|
96
|
+
await mediaTypeCheck;
|
|
97
|
+
try {
|
|
98
|
+
if (found) {
|
|
99
|
+
ctx.params = found.params;
|
|
100
|
+
await found.handler(req, res, found.params, ctx, found.searchParams);
|
|
107
101
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
102
|
+
if (!ctx.response.sent) {
|
|
103
|
+
if (this.notFoundHandlerFn) {
|
|
104
|
+
await this.notFoundHandlerFn(ctx);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
|
|
108
|
+
res.writeHead(404);
|
|
109
|
+
res.end("Not Found");
|
|
110
|
+
}
|
|
112
111
|
}
|
|
112
|
+
onFinish(res.statusCode);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
drainUnreadHttp2Mutation(req);
|
|
113
116
|
}
|
|
114
|
-
onFinish(res.statusCode);
|
|
115
117
|
}
|
|
116
118
|
catch (err) {
|
|
117
119
|
const error = err instanceof Error ? err : new Error(safeString(err));
|
|
@@ -127,9 +129,6 @@ export class Application extends EventEmitter {
|
|
|
127
129
|
this.emit("error", handlerErr);
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
|
-
// Safety net: ensure the client always receives a response, even if the
|
|
131
|
-
// registered error handler threw or returned without sending one. Without
|
|
132
|
-
// this, requests hang until the socket times out (issue #1948).
|
|
133
132
|
if (!res.headersSent) {
|
|
134
133
|
sendFallback(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
|
|
135
134
|
}
|
package/dist/context.d.mts
CHANGED
|
@@ -19,7 +19,7 @@ export declare class Context {
|
|
|
19
19
|
private queryCache;
|
|
20
20
|
private asyncLocalStorage;
|
|
21
21
|
private trustProxy;
|
|
22
|
-
constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, bodyLimit: string | number | false, trustProxy: boolean, onWriteHead?: () => void,
|
|
22
|
+
constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, bodyLimit: string | number | false, trustProxy: boolean, onWriteHead?: () => void, oversizedBodyStrategy?: OversizedBodyStrategy);
|
|
23
23
|
get query(): Record<string, string | string[]>;
|
|
24
24
|
get store(): unknown;
|
|
25
25
|
get ip(): string | undefined;
|
package/dist/context.mjs
CHANGED
|
@@ -26,11 +26,11 @@ export class Context {
|
|
|
26
26
|
queryCache = null;
|
|
27
27
|
asyncLocalStorage;
|
|
28
28
|
trustProxy;
|
|
29
|
-
constructor(req, res, params, timing, als, abortController, bodyLimit, trustProxy, onWriteHead,
|
|
29
|
+
constructor(req, res, params, timing, als, abortController, bodyLimit, trustProxy, onWriteHead, oversizedBodyStrategy) {
|
|
30
30
|
this.req = req;
|
|
31
31
|
this.res = res;
|
|
32
32
|
this.params = params;
|
|
33
|
-
this.request = new Request(req, res, bodyLimit,
|
|
33
|
+
this.request = new Request(req, res, bodyLimit, oversizedBodyStrategy ?? "drain");
|
|
34
34
|
this.response = new Response(req, res, timing, onWriteHead);
|
|
35
35
|
this.cookies = new Cookies(req, res);
|
|
36
36
|
this.abortController = abortController;
|
package/dist/index.d.mts
CHANGED
|
@@ -10,5 +10,8 @@ export type { LoggerOptions } from "./logger.mts";
|
|
|
10
10
|
export * from "./etag.mts";
|
|
11
11
|
export * from "./cache-control.mts";
|
|
12
12
|
export * from "./compression.mts";
|
|
13
|
+
export { streamJsonObject } from "./stream-json-object.mts";
|
|
14
|
+
export type { StreamJsonObjectInput } from "./stream-json-object.mts";
|
|
13
15
|
export type { Handler, RouteBuilder } from "./router.mts";
|
|
16
|
+
export type { MutationRouteOptions } from "./mutation-media-type.mts";
|
|
14
17
|
export type { CookieOptions, ApplicationOptions, OversizedBodyStrategy, SecurityHeaderName, SecurityHeadersOptions, } from "./types.mts";
|
package/dist/index.mjs
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
export declare const DEFAULT_MUTATION_MEDIA_TYPES: readonly ["application/json", "application/*+json"];
|
|
3
|
+
export interface MutationRouteOptions {
|
|
4
|
+
/** Replaces the default JSON media types for this mutation route. */
|
|
5
|
+
acceptedMediaTypes: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function assertAcceptedMutationMediaType(req: IncomingMessage, acceptedMediaTypes?: readonly string[]): Promise<void> | undefined;
|
|
8
|
+
export declare function drainUnreadHttp2Mutation(req: IncomingMessage): void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const DEFAULT_MUTATION_MEDIA_TYPES = ["application/json", "application/*+json"];
|
|
2
|
+
export function assertAcceptedMutationMediaType(req, acceptedMediaTypes = DEFAULT_MUTATION_MEDIA_TYPES) {
|
|
3
|
+
const contentType = req.headers["content-type"];
|
|
4
|
+
const accepted = typeof contentType === "string" &&
|
|
5
|
+
acceptedMediaTypes.some((type) => matchesMediaType(contentType, type));
|
|
6
|
+
if (!isMutationMethod(req.method))
|
|
7
|
+
return;
|
|
8
|
+
if (req.headers["transfer-encoding"] !== undefined || Number(req.headers["content-length"]) > 0) {
|
|
9
|
+
if (!accepted)
|
|
10
|
+
rejectUnsupportedMediaType(req);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (req.httpVersionMajor === 2 && !accepted) {
|
|
14
|
+
return waitForRejectedHttp2Body(req).then((hasBody) => {
|
|
15
|
+
if (hasBody)
|
|
16
|
+
rejectUnsupportedMediaType(req);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export function drainUnreadHttp2Mutation(req) {
|
|
22
|
+
if (req.httpVersionMajor === 2 && isMutationMethod(req.method) && !req.readableEnded)
|
|
23
|
+
drainRequest(req);
|
|
24
|
+
}
|
|
25
|
+
function isMutationMethod(method) {
|
|
26
|
+
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
27
|
+
}
|
|
28
|
+
function matchesMediaType(contentType, acceptedMediaType) {
|
|
29
|
+
const actual = parseMediaType(contentType);
|
|
30
|
+
const expected = parseMediaType(acceptedMediaType);
|
|
31
|
+
return (actual !== null &&
|
|
32
|
+
expected !== null &&
|
|
33
|
+
matchesToken(actual[0], expected[0]) &&
|
|
34
|
+
matchesToken(actual[1], expected[1]));
|
|
35
|
+
}
|
|
36
|
+
function parseMediaType(value) {
|
|
37
|
+
const [type, subtype, ...extra] = value.split(";", 1)[0]?.trim().toLowerCase().split("/") ?? [];
|
|
38
|
+
return type && subtype && extra.length === 0 ? [type, subtype] : null;
|
|
39
|
+
}
|
|
40
|
+
function matchesToken(actual, expected) {
|
|
41
|
+
if (!expected.includes("*"))
|
|
42
|
+
return actual === expected;
|
|
43
|
+
const parts = expected.split("*");
|
|
44
|
+
if (!actual.startsWith(parts[0] ?? ""))
|
|
45
|
+
return false;
|
|
46
|
+
let offset = (parts[0] ?? "").length;
|
|
47
|
+
for (const part of parts.slice(1, -1)) {
|
|
48
|
+
const index = actual.indexOf(part, offset);
|
|
49
|
+
if (index === -1)
|
|
50
|
+
return false;
|
|
51
|
+
offset = index + part.length;
|
|
52
|
+
}
|
|
53
|
+
const suffix = parts.at(-1) ?? "";
|
|
54
|
+
return expected.endsWith("*") || actual.slice(offset).endsWith(suffix);
|
|
55
|
+
}
|
|
56
|
+
function rejectUnsupportedMediaType(req) {
|
|
57
|
+
drainRequest(req);
|
|
58
|
+
throw Object.assign(new Error("Unsupported Media Type"), { status: 415 });
|
|
59
|
+
}
|
|
60
|
+
function waitForRejectedHttp2Body(req) {
|
|
61
|
+
if (req.readableEnded)
|
|
62
|
+
return Promise.resolve(false);
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const cleanup = () => {
|
|
65
|
+
req.removeListener("data", onData);
|
|
66
|
+
req.removeListener("end", onEnd);
|
|
67
|
+
req.removeListener("error", onError);
|
|
68
|
+
};
|
|
69
|
+
const onData = () => {
|
|
70
|
+
drainRequest(req);
|
|
71
|
+
cleanup();
|
|
72
|
+
resolve(true);
|
|
73
|
+
};
|
|
74
|
+
const onEnd = () => {
|
|
75
|
+
cleanup();
|
|
76
|
+
resolve(false);
|
|
77
|
+
};
|
|
78
|
+
const onError = (error) => {
|
|
79
|
+
cleanup();
|
|
80
|
+
reject(error);
|
|
81
|
+
};
|
|
82
|
+
req.once("data", onData);
|
|
83
|
+
req.once("end", onEnd);
|
|
84
|
+
req.once("error", onError);
|
|
85
|
+
req.resume();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function drainRequest(req) {
|
|
89
|
+
req.on("error", noop);
|
|
90
|
+
req.resume();
|
|
91
|
+
}
|
|
92
|
+
function noop() { }
|
package/dist/request.d.mts
CHANGED
|
@@ -5,9 +5,8 @@ export declare class Request {
|
|
|
5
5
|
private res;
|
|
6
6
|
private bodyPromise;
|
|
7
7
|
private defaultLimit;
|
|
8
|
-
private strictJsonContentType;
|
|
9
8
|
private readonly oversizedBodyStrategy;
|
|
10
|
-
constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false,
|
|
9
|
+
constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false, oversizedBodyStrategy?: OversizedBodyStrategy);
|
|
11
10
|
is(type: string | string[]): string | false | null;
|
|
12
11
|
buffer(limit?: string | number | false): Promise<Buffer>;
|
|
13
12
|
json<T = unknown>(limit?: string | number | false): Promise<T>;
|
package/dist/request.mjs
CHANGED
|
@@ -8,13 +8,11 @@ export class Request {
|
|
|
8
8
|
res;
|
|
9
9
|
bodyPromise = null;
|
|
10
10
|
defaultLimit;
|
|
11
|
-
strictJsonContentType;
|
|
12
11
|
oversizedBodyStrategy;
|
|
13
|
-
constructor(req, res, defaultLimit = "1mb",
|
|
12
|
+
constructor(req, res, defaultLimit = "1mb", oversizedBodyStrategy = "drain") {
|
|
14
13
|
this.req = req;
|
|
15
14
|
this.res = res;
|
|
16
15
|
this.defaultLimit = defaultLimit;
|
|
17
|
-
this.strictJsonContentType = strictJsonContentType;
|
|
18
16
|
this.oversizedBodyStrategy = oversizedBodyStrategy;
|
|
19
17
|
}
|
|
20
18
|
is(type) {
|
|
@@ -31,23 +29,6 @@ export class Request {
|
|
|
31
29
|
return this.bodyPromise;
|
|
32
30
|
}
|
|
33
31
|
async json(limit) {
|
|
34
|
-
if (this.strictJsonContentType) {
|
|
35
|
-
// type-is semantics:
|
|
36
|
-
// null → no body (no Content-Length / Transfer-Encoding header);
|
|
37
|
-
// skip the content-type check — buffer() will return an empty
|
|
38
|
-
// buffer that JSON.parse rejects with 400 as usual.
|
|
39
|
-
// false → a body is indicated but Content-Type is not a JSON type.
|
|
40
|
-
// We 415 only when the body actually has content (CL > 0 or
|
|
41
|
-
// Transfer-Encoding is set without CL). A Content-Length: 0
|
|
42
|
-
// body has nothing to read, so let it fall through to 400.
|
|
43
|
-
// truthy → recognised JSON type; proceed normally.
|
|
44
|
-
const mediaType = this.is(["json", "application/*+json"]);
|
|
45
|
-
const cl = this.req.headers["content-length"];
|
|
46
|
-
const emptyBody = cl !== undefined && Number(cl) === 0;
|
|
47
|
-
if (mediaType === false && !emptyBody) {
|
|
48
|
-
throw Object.assign(new Error("Unsupported Media Type"), { status: 415 });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
32
|
const buf = await this.buffer(limit);
|
|
52
33
|
try {
|
|
53
34
|
return JSON.parse(buf.toString("utf8"));
|
|
@@ -67,6 +48,8 @@ function parseLimit(limit) {
|
|
|
67
48
|
return parsed;
|
|
68
49
|
}
|
|
69
50
|
function readBody(req, res, limit, oversizedBodyStrategy) {
|
|
51
|
+
if (req.readableEnded)
|
|
52
|
+
return Promise.resolve(Buffer.alloc(0));
|
|
70
53
|
return new Promise((resolve, reject) => {
|
|
71
54
|
const maxBytes = parseLimit(limit);
|
|
72
55
|
if (oversizedBodyStrategy === "close") {
|
package/dist/router.d.mts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import Router from "find-my-way";
|
|
2
2
|
import type { Context } from "./context.mts";
|
|
3
|
+
import type { MutationRouteOptions } from "./mutation-media-type.mts";
|
|
3
4
|
type RouterInstance = Router.Instance<Router.HTTPVersion.V1>;
|
|
4
5
|
export type Handler = (ctx: Context) => Promise<void> | void;
|
|
5
6
|
export interface RouteBuilder {
|
|
6
7
|
get(handler: Handler): RouteBuilder;
|
|
7
|
-
post(handler: Handler): RouteBuilder;
|
|
8
|
-
put(handler: Handler): RouteBuilder;
|
|
9
|
-
delete(handler: Handler): RouteBuilder;
|
|
10
|
-
patch(handler: Handler): RouteBuilder;
|
|
8
|
+
post(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
|
|
9
|
+
put(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
|
|
10
|
+
delete(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
|
|
11
|
+
patch(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
|
|
11
12
|
}
|
|
12
13
|
export declare function isSupportedHttpMethod(method: string): boolean;
|
|
13
14
|
export declare function createRouteBuilder(router: RouterInstance, path: string): RouteBuilder;
|
|
15
|
+
export declare function getAcceptedMediaTypes(handler: Router.Handler<Router.HTTPVersion.V1>): readonly string[] | undefined;
|
|
14
16
|
export {};
|
package/dist/router.mjs
CHANGED
|
@@ -10,28 +10,34 @@ export function createRouteBuilder(router, path) {
|
|
|
10
10
|
router.on("HEAD", path, wrapHandler(handler));
|
|
11
11
|
return builder;
|
|
12
12
|
},
|
|
13
|
-
post(handler) {
|
|
14
|
-
router.on("POST", path, wrapHandler(handler));
|
|
13
|
+
post(handler, options) {
|
|
14
|
+
router.on("POST", path, wrapHandler(handler, options));
|
|
15
15
|
return builder;
|
|
16
16
|
},
|
|
17
|
-
put(handler) {
|
|
18
|
-
router.on("PUT", path, wrapHandler(handler));
|
|
17
|
+
put(handler, options) {
|
|
18
|
+
router.on("PUT", path, wrapHandler(handler, options));
|
|
19
19
|
return builder;
|
|
20
20
|
},
|
|
21
|
-
delete(handler) {
|
|
22
|
-
router.on("DELETE", path, wrapHandler(handler));
|
|
21
|
+
delete(handler, options) {
|
|
22
|
+
router.on("DELETE", path, wrapHandler(handler, options));
|
|
23
23
|
return builder;
|
|
24
24
|
},
|
|
25
|
-
patch(handler) {
|
|
26
|
-
router.on("PATCH", path, wrapHandler(handler));
|
|
25
|
+
patch(handler, options) {
|
|
26
|
+
router.on("PATCH", path, wrapHandler(handler, options));
|
|
27
27
|
return builder;
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
30
|
return builder;
|
|
31
31
|
}
|
|
32
|
-
function
|
|
33
|
-
return
|
|
32
|
+
export function getAcceptedMediaTypes(handler) {
|
|
33
|
+
return handler.acceptedMediaTypes;
|
|
34
|
+
}
|
|
35
|
+
function wrapHandler(handler, options) {
|
|
36
|
+
const wrapped = (_req, _res, _params, store) => {
|
|
34
37
|
const ctx = store;
|
|
35
38
|
return handler(ctx);
|
|
36
39
|
};
|
|
40
|
+
if (options)
|
|
41
|
+
wrapped.acceptedMediaTypes = options.acceptedMediaTypes;
|
|
42
|
+
return wrapped;
|
|
37
43
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
export type StreamJsonObjectInput<T extends object> = {
|
|
3
|
+
[Key in keyof T]: T[Key] | PromiseLike<T[Key]>;
|
|
4
|
+
};
|
|
5
|
+
export declare function streamJsonObject<T extends object>(input: StreamJsonObjectInput<T>): Readable;
|
|
6
|
+
/** @internal Test-only visibility for bounded waiter regression coverage. */
|
|
7
|
+
export declare function getStreamJsonObjectWaiterCountForTesting(stream: Readable): number;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { isPromise } from "node:util/types";
|
|
3
|
+
import { JsonStreamStringify } from "json-stream-stringify";
|
|
4
|
+
const cancelled = Symbol("cancelled");
|
|
5
|
+
const states = new WeakMap();
|
|
6
|
+
export function streamJsonObject(input) {
|
|
7
|
+
const state = createState();
|
|
8
|
+
const stream = Readable.from(iterateObject(snapshotEntries(input, state), state));
|
|
9
|
+
const destroy = stream.destroy.bind(stream);
|
|
10
|
+
stream.destroy = ((error) => {
|
|
11
|
+
stop(state);
|
|
12
|
+
return destroy(error);
|
|
13
|
+
});
|
|
14
|
+
stream.once("close", () => stop(state));
|
|
15
|
+
states.set(stream, state);
|
|
16
|
+
return stream;
|
|
17
|
+
}
|
|
18
|
+
/** @internal Test-only visibility for bounded waiter regression coverage. */
|
|
19
|
+
export function getStreamJsonObjectWaiterCountForTesting(stream) {
|
|
20
|
+
return states.get(stream)?.listeners.size ?? 0;
|
|
21
|
+
}
|
|
22
|
+
async function* iterateObject(entries, state) {
|
|
23
|
+
if (state.failed)
|
|
24
|
+
throw state.failure;
|
|
25
|
+
yield "{";
|
|
26
|
+
let hasEntries = false;
|
|
27
|
+
while (!state.closed) {
|
|
28
|
+
const entry = await waitFor(nextEntry(entries), state);
|
|
29
|
+
if (entry === cancelled)
|
|
30
|
+
return;
|
|
31
|
+
if (!entry)
|
|
32
|
+
break;
|
|
33
|
+
if ("error" in entry)
|
|
34
|
+
throw entry.error;
|
|
35
|
+
hasEntries = yield* iterateEntry(entry, hasEntries, state);
|
|
36
|
+
}
|
|
37
|
+
yield "}";
|
|
38
|
+
}
|
|
39
|
+
async function* iterateEntry(entry, hasEntries, state) {
|
|
40
|
+
const source = entry.value instanceof Readable ? entry.value : null;
|
|
41
|
+
const producer = new JsonStreamStringify(entry.value);
|
|
42
|
+
state.producer = producer;
|
|
43
|
+
try {
|
|
44
|
+
const iterator = producer[Symbol.asyncIterator]();
|
|
45
|
+
const first = await waitFor(iterator.next(), state);
|
|
46
|
+
if (first === cancelled || first.done)
|
|
47
|
+
return hasEntries;
|
|
48
|
+
yield `${hasEntries ? "," : ""}${JSON.stringify(entry.key)}:${String(first.value)}`;
|
|
49
|
+
for (;;) {
|
|
50
|
+
const next = await waitFor(iterator.next(), state);
|
|
51
|
+
if (next === cancelled || next.done)
|
|
52
|
+
return true;
|
|
53
|
+
yield String(next.value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
if (state.producer === producer)
|
|
58
|
+
state.producer = null;
|
|
59
|
+
source?.destroy();
|
|
60
|
+
producer.destroy();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function createState() {
|
|
64
|
+
const state = {
|
|
65
|
+
closed: false,
|
|
66
|
+
failure: undefined,
|
|
67
|
+
failed: false,
|
|
68
|
+
fail: null,
|
|
69
|
+
listeners: new Set(),
|
|
70
|
+
producer: null,
|
|
71
|
+
sources: new Set(),
|
|
72
|
+
};
|
|
73
|
+
state.fail = (error) => {
|
|
74
|
+
if (!state.failed) {
|
|
75
|
+
state.failed = true;
|
|
76
|
+
state.failure = error;
|
|
77
|
+
for (const listener of state.listeners)
|
|
78
|
+
listener.fail(error);
|
|
79
|
+
state.listeners.clear();
|
|
80
|
+
state.producer?.destroy();
|
|
81
|
+
for (const source of state.sources)
|
|
82
|
+
source.destroy();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
function stop(state) {
|
|
88
|
+
if (state.closed)
|
|
89
|
+
return;
|
|
90
|
+
state.closed = true;
|
|
91
|
+
for (const listener of state.listeners)
|
|
92
|
+
listener.stop();
|
|
93
|
+
state.listeners.clear();
|
|
94
|
+
state.producer?.destroy();
|
|
95
|
+
for (const source of state.sources)
|
|
96
|
+
source.destroy();
|
|
97
|
+
}
|
|
98
|
+
function snapshotEntries(input, state) {
|
|
99
|
+
const pending = { count: 0, entries: [], waiters: [] };
|
|
100
|
+
for (const [key, value] of Object.entries(input)) {
|
|
101
|
+
const promise = assimilate(value);
|
|
102
|
+
if (promise)
|
|
103
|
+
addPendingEntry(pending, key, promise, state);
|
|
104
|
+
else {
|
|
105
|
+
if (value instanceof Readable)
|
|
106
|
+
state.sources.add(value);
|
|
107
|
+
pending.entries.push({ key, value });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return pending;
|
|
111
|
+
}
|
|
112
|
+
function addPendingEntry(pending, key, value, state) {
|
|
113
|
+
pending.count += 1;
|
|
114
|
+
const resolve = (resolved) => settle(pending, { key, value: resolved });
|
|
115
|
+
const reject = (error) => {
|
|
116
|
+
state.fail(error);
|
|
117
|
+
settle(pending, { error });
|
|
118
|
+
};
|
|
119
|
+
Reflect.apply(Promise.prototype.then, value, [resolve, reject]);
|
|
120
|
+
}
|
|
121
|
+
function settle(pending, entry) {
|
|
122
|
+
pending.count -= 1;
|
|
123
|
+
const waiter = pending.waiters.shift();
|
|
124
|
+
if (waiter)
|
|
125
|
+
waiter(entry);
|
|
126
|
+
else
|
|
127
|
+
pending.entries.push(entry);
|
|
128
|
+
}
|
|
129
|
+
function nextEntry(pending) {
|
|
130
|
+
const entry = pending.entries.shift();
|
|
131
|
+
if (entry)
|
|
132
|
+
return Promise.resolve(entry);
|
|
133
|
+
return pending.count === 0
|
|
134
|
+
? Promise.resolve(undefined)
|
|
135
|
+
: new Promise((resolve) => pending.waiters.push(resolve));
|
|
136
|
+
}
|
|
137
|
+
function waitFor(value, state) {
|
|
138
|
+
if (state.closed)
|
|
139
|
+
return Promise.resolve(cancelled);
|
|
140
|
+
if (state.failed)
|
|
141
|
+
return Promise.reject(state.failure);
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
let settled = false;
|
|
144
|
+
const finish = (settle) => {
|
|
145
|
+
if (settled)
|
|
146
|
+
return;
|
|
147
|
+
settled = true;
|
|
148
|
+
state.listeners.delete(listener);
|
|
149
|
+
settle();
|
|
150
|
+
};
|
|
151
|
+
const listener = {
|
|
152
|
+
fail: (error) => finish(() => reject(error)),
|
|
153
|
+
stop: () => finish(() => resolve(cancelled)),
|
|
154
|
+
};
|
|
155
|
+
state.listeners.add(listener);
|
|
156
|
+
Reflect.apply(Promise.prototype.then, value, [
|
|
157
|
+
(result) => finish(() => resolve(result)),
|
|
158
|
+
(error) => finish(() => reject(error)),
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function assimilate(value) {
|
|
163
|
+
if (!value || (typeof value !== "object" && typeof value !== "function"))
|
|
164
|
+
return;
|
|
165
|
+
if (isPromise(value))
|
|
166
|
+
return value;
|
|
167
|
+
let then;
|
|
168
|
+
try {
|
|
169
|
+
then = value.then;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
return Promise.reject(error);
|
|
173
|
+
}
|
|
174
|
+
if (typeof then !== "function")
|
|
175
|
+
return;
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
queueMicrotask(() => {
|
|
178
|
+
try {
|
|
179
|
+
Reflect.apply(then, value, [resolve, reject]);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
reject(error);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
package/dist/types.d.mts
CHANGED
|
@@ -22,13 +22,4 @@ export interface ApplicationOptions {
|
|
|
22
22
|
trustProxy?: boolean;
|
|
23
23
|
/** Reject methods outside node:http.METHODS with 400. Defaults to false. */
|
|
24
24
|
strictHttpMethods?: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* When true, ctx.request.json() rejects requests whose Content-Type is not
|
|
27
|
-
* application/json (or a compatible JSON subtype such as application/merge-patch+json)
|
|
28
|
-
* with a 415 Unsupported Media Type error. Requests with no body are unaffected.
|
|
29
|
-
*
|
|
30
|
-
* Defaults to false (lenient: any Content-Type is accepted, preserving
|
|
31
|
-
* backward-compatible behavior).
|
|
32
|
-
*/
|
|
33
|
-
strictJsonContentType?: boolean;
|
|
34
25
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jongleberry/api-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A Node.js HTTP server library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"find-my-way": "^9.6.0",
|
|
31
31
|
"http-assert": "^1.5.0",
|
|
32
32
|
"http-errors": "^2.0.1",
|
|
33
|
+
"json-stream-stringify": "^3.1.7",
|
|
33
34
|
"negotiator": "^1.0.0",
|
|
34
35
|
"type-is": "^2.1.0"
|
|
35
36
|
},
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"@types/type-is": "^1.6.7",
|
|
45
46
|
"@vitest/coverage-v8": "^4.1.6",
|
|
46
47
|
"husky": "^9.1.7",
|
|
47
|
-
"oxfmt": "^0.
|
|
48
|
+
"oxfmt": "^0.63.0",
|
|
48
49
|
"oxlint": "^1.65.0",
|
|
49
50
|
"supertest": "^7.2.2",
|
|
50
51
|
"typescript": "^7.0.2",
|