@openlup/platform 0.0.0 → 0.15.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/CHANGELOG.md +19 -0
- package/LICENSE +204 -0
- package/README.md +124 -1
- package/dist/bff/dispatch.d.ts +27 -0
- package/dist/bff/dispatch.js +47 -0
- package/dist/bff/envelope.d.ts +46 -0
- package/dist/bff/envelope.js +80 -0
- package/dist/bff/health.d.ts +24 -0
- package/dist/bff/health.js +19 -0
- package/dist/bff/index.d.ts +6 -0
- package/dist/bff/index.js +6 -0
- package/dist/bff/lazyRoute.d.ts +4 -0
- package/dist/bff/lazyRoute.js +13 -0
- package/dist/bff/requestContext.d.ts +17 -0
- package/dist/bff/requestContext.js +26 -0
- package/dist/bff/routerRewrite.d.ts +2 -0
- package/dist/bff/routerRewrite.js +31 -0
- package/dist/blob/binding.d.ts +16 -0
- package/dist/blob/binding.js +37 -0
- package/dist/blob/filesystem.d.ts +8 -0
- package/dist/blob/filesystem.js +71 -0
- package/dist/blob/index.d.ts +6 -0
- package/dist/blob/index.js +6 -0
- package/dist/blob/s3.d.ts +32 -0
- package/dist/blob/s3.js +56 -0
- package/dist/blob/s3Client.d.ts +25 -0
- package/dist/blob/s3Client.js +76 -0
- package/dist/blob/supabase.d.ts +40 -0
- package/dist/blob/supabase.js +59 -0
- package/dist/blob/vercel.d.ts +63 -0
- package/dist/blob/vercel.js +69 -0
- package/dist/composition/composeBundle.d.ts +47 -0
- package/dist/composition/composeBundle.js +45 -0
- package/dist/composition/httpBinding.d.ts +8 -0
- package/dist/composition/httpBinding.js +18 -0
- package/dist/composition/index.d.ts +3 -0
- package/dist/composition/index.js +3 -0
- package/dist/composition/nodeHost.d.ts +103 -0
- package/dist/composition/nodeHost.js +145 -0
- package/dist/data/binding.d.ts +34 -0
- package/dist/data/binding.js +92 -0
- package/dist/data/index.d.ts +5 -0
- package/dist/data/index.js +5 -0
- package/dist/data/postgresGateway.d.ts +42 -0
- package/dist/data/postgresGateway.js +135 -0
- package/dist/data/queryBuilder.d.ts +54 -0
- package/dist/data/queryBuilder.js +240 -0
- package/dist/data/queryBuilderSql.d.ts +76 -0
- package/dist/data/queryBuilderSql.js +145 -0
- package/dist/data/supabaseClientFactory.d.ts +34 -0
- package/dist/data/supabaseClientFactory.js +56 -0
- package/dist/data/supabaseGateway.d.ts +22 -0
- package/dist/data/supabaseGateway.js +49 -0
- package/dist/http/deadline.d.ts +20 -0
- package/dist/http/deadline.js +93 -0
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.js +5 -0
- package/dist/http/requestAdapter.d.ts +38 -0
- package/dist/http/requestAdapter.js +203 -0
- package/dist/http/runtime.d.ts +83 -0
- package/dist/http/runtime.js +199 -0
- package/dist/http/security.d.ts +44 -0
- package/dist/http/security.js +114 -0
- package/dist/http/staticAssets.d.ts +3 -0
- package/dist/http/staticAssets.js +42 -0
- package/dist/http/types.d.ts +23 -0
- package/dist/http/types.js +6 -0
- package/dist/job-ledger/index.d.ts +5 -0
- package/dist/job-ledger/index.js +3 -0
- package/dist/job-ledger/ports.d.ts +10 -0
- package/dist/job-ledger/ports.js +7 -0
- package/dist/job-ledger/postgres.d.ts +10 -0
- package/dist/job-ledger/postgres.js +101 -0
- package/dist/job-ledger/sql.d.ts +7 -0
- package/dist/job-ledger/sql.js +10 -0
- package/dist/job-ledger/supabase.d.ts +77 -0
- package/dist/job-ledger/supabase.js +202 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +1 -0
- package/dist/kernel/platformKernel.d.ts +34 -0
- package/dist/kernel/platformKernel.js +115 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.js +1 -0
- package/dist/migrations/postgresRunner.d.ts +50 -0
- package/dist/migrations/postgresRunner.js +176 -0
- package/dist/scheduler/advisoryLock.d.ts +44 -0
- package/dist/scheduler/advisoryLock.js +114 -0
- package/dist/scheduler/cronGuard.d.ts +10 -0
- package/dist/scheduler/cronGuard.js +19 -0
- package/dist/scheduler/cronSecret.d.ts +5 -0
- package/dist/scheduler/cronSecret.js +23 -0
- package/dist/scheduler/handlers.d.ts +9 -0
- package/dist/scheduler/handlers.js +10 -0
- package/dist/scheduler/httpEntry.d.ts +16 -0
- package/dist/scheduler/httpEntry.js +31 -0
- package/dist/scheduler/index.d.ts +9 -0
- package/dist/scheduler/index.js +9 -0
- package/dist/scheduler/jobRegistry.d.ts +21 -0
- package/dist/scheduler/jobRegistry.js +39 -0
- package/dist/scheduler/jobs.d.ts +48 -0
- package/dist/scheduler/jobs.js +92 -0
- package/dist/scheduler/nodeScheduler.d.ts +42 -0
- package/dist/scheduler/nodeScheduler.js +73 -0
- package/dist/scheduler/vercelScheduler.d.ts +29 -0
- package/dist/scheduler/vercelScheduler.js +51 -0
- package/package.json +106 -3
- package/release-gates.json +121 -0
- package/sql/20260809150000_job_run_ledger.sql +301 -0
- package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
- package/src/bff/dispatch.ts +81 -0
- package/src/bff/envelope.ts +142 -0
- package/src/bff/health.ts +37 -0
- package/src/bff/index.ts +30 -0
- package/src/bff/lazyRoute.ts +28 -0
- package/src/bff/requestContext.ts +48 -0
- package/src/bff/routerRewrite.ts +38 -0
- package/src/blob/binding.ts +55 -0
- package/src/blob/filesystem.ts +86 -0
- package/src/blob/index.ts +17 -0
- package/src/blob/s3.ts +87 -0
- package/src/blob/s3Client.ts +91 -0
- package/src/blob/supabase.ts +88 -0
- package/src/blob/vercel.ts +127 -0
- package/src/composition/composeBundle.ts +109 -0
- package/src/composition/httpBinding.ts +30 -0
- package/src/composition/index.ts +17 -0
- package/src/composition/nodeHost.ts +289 -0
- package/src/data/binding.ts +157 -0
- package/src/data/index.ts +33 -0
- package/src/data/postgresGateway.ts +186 -0
- package/src/data/queryBuilder.ts +300 -0
- package/src/data/queryBuilderSql.ts +183 -0
- package/src/data/supabaseClientFactory.ts +92 -0
- package/src/data/supabaseGateway.ts +76 -0
- package/src/http/deadline.ts +117 -0
- package/src/http/index.ts +36 -0
- package/src/http/requestAdapter.ts +215 -0
- package/src/http/runtime.ts +319 -0
- package/src/http/security.ts +155 -0
- package/src/http/staticAssets.ts +42 -0
- package/src/http/types.ts +31 -0
- package/src/job-ledger/index.ts +25 -0
- package/src/job-ledger/ports.ts +27 -0
- package/src/job-ledger/postgres.ts +121 -0
- package/src/job-ledger/sql.ts +12 -0
- package/src/job-ledger/supabase.ts +294 -0
- package/src/kernel/index.ts +17 -0
- package/src/kernel/platformKernel.ts +153 -0
- package/src/migrations/index.ts +10 -0
- package/src/migrations/postgresRunner.ts +255 -0
- package/src/scheduler/advisoryLock.ts +145 -0
- package/src/scheduler/cronGuard.ts +30 -0
- package/src/scheduler/cronSecret.ts +26 -0
- package/src/scheduler/handlers.ts +24 -0
- package/src/scheduler/httpEntry.ts +49 -0
- package/src/scheduler/index.ts +47 -0
- package/src/scheduler/jobRegistry.ts +57 -0
- package/src/scheduler/jobs.ts +119 -0
- package/src/scheduler/nodeScheduler.ts +123 -0
- package/src/scheduler/vercelScheduler.ts +71 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Bounded request/response lifecycle for the Node HTTP adapter.
|
|
2
|
+
//
|
|
3
|
+
// This owns transport cancellation only. It gives handlers a cooperative
|
|
4
|
+
// AbortSignal, but cannot roll back downstream work that ignores it.
|
|
5
|
+
|
|
6
|
+
import type { HttpRequest, HttpResponse } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export const NODE_HTTP_TIMEOUT_CODE = "NODE_HTTP_TIMEOUT";
|
|
9
|
+
|
|
10
|
+
type Destroyable = { destroy?: (error?: Error) => void };
|
|
11
|
+
type Evented = { once?: (event: string, listener: () => void) => unknown; removeListener?: (event: string, listener: () => void) => unknown };
|
|
12
|
+
type WritableResponse = HttpResponse & {
|
|
13
|
+
write?: (...args: unknown[]) => boolean;
|
|
14
|
+
writeHead?: (...args: unknown[]) => unknown;
|
|
15
|
+
end?: (...args: unknown[]) => unknown;
|
|
16
|
+
setHeader?: (...args: unknown[]) => unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface HttpDeadlineOptions {
|
|
20
|
+
req: HttpRequest;
|
|
21
|
+
res: HttpResponse;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
run: () => Promise<unknown> | unknown;
|
|
24
|
+
onLateError?: (error: unknown) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run one Node HTTP admission from the instant before lazy loading/adaptation.
|
|
29
|
+
*
|
|
30
|
+
* A timeout before headers commit gets one minimal JSON 504. After headers are
|
|
31
|
+
* committed we cannot change the status safely, so the response is destroyed.
|
|
32
|
+
* The invocation promise remains observed, preventing a late rejection from
|
|
33
|
+
* becoming an unhandled rejection or a second response.
|
|
34
|
+
*/
|
|
35
|
+
export async function runWithHttpDeadline(options: HttpDeadlineOptions): Promise<void> {
|
|
36
|
+
const { req, res, durationMs, run, onLateError } = options;
|
|
37
|
+
if (!Number.isSafeInteger(durationMs) || durationMs <= 0 || durationMs > 2_147_483_647) {
|
|
38
|
+
throw new Error("invalid_node_http_deadline");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
// Node 24 exposes IncomingMessage.signal as a getter without a setter. Keep
|
|
43
|
+
// that host-native property untouched and use the portable adapter seam.
|
|
44
|
+
req.abortSignal = controller.signal;
|
|
45
|
+
const eventedReq = req as HttpRequest & Evented;
|
|
46
|
+
const eventedRes = res as HttpResponse & Evented;
|
|
47
|
+
const abortForClose = () => controller.abort();
|
|
48
|
+
eventedReq.once?.("aborted", abortForClose);
|
|
49
|
+
eventedRes.once?.("close", abortForClose);
|
|
50
|
+
|
|
51
|
+
let expired = false;
|
|
52
|
+
let finishTimeout: (() => void) | undefined;
|
|
53
|
+
const timeout = new Promise<void>((resolve) => { finishTimeout = resolve; });
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
expired = true;
|
|
56
|
+
controller.abort();
|
|
57
|
+
if (res.destroyed || res.writableEnded) {
|
|
58
|
+
destroyInput(req);
|
|
59
|
+
return finishTimeout?.();
|
|
60
|
+
}
|
|
61
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
62
|
+
closeInputAfterResponse(req, res);
|
|
63
|
+
res.statusCode = 504;
|
|
64
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
65
|
+
res.end(JSON.stringify({ ok: false, error: { code: NODE_HTTP_TIMEOUT_CODE, message: "Request timed out" } }));
|
|
66
|
+
sealLateWrites(res);
|
|
67
|
+
} else if (!res.writableEnded) {
|
|
68
|
+
sealLateWrites(res);
|
|
69
|
+
(res as HttpResponse & Destroyable).destroy?.();
|
|
70
|
+
destroyInput(req);
|
|
71
|
+
}
|
|
72
|
+
finishTimeout?.();
|
|
73
|
+
}, durationMs);
|
|
74
|
+
|
|
75
|
+
const invocation = Promise.resolve().then(run);
|
|
76
|
+
// Keep a late completion/rejection observed after timeout. It must never get a
|
|
77
|
+
// chance to write a second envelope, and its raw value is not client output.
|
|
78
|
+
void invocation.catch((error) => {
|
|
79
|
+
if (expired) onLateError?.(error);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await Promise.race([invocation.then(() => undefined), timeout]);
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
eventedReq.removeListener?.("aborted", abortForClose);
|
|
87
|
+
eventedRes.removeListener?.("close", abortForClose);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Send a response before closing its request input, so the status is deliverable. */
|
|
92
|
+
export function closeInputAfterResponse(req: HttpRequest, res: HttpResponse): void {
|
|
93
|
+
const evented = res as HttpResponse & Evented;
|
|
94
|
+
let closed = false;
|
|
95
|
+
const destroy = () => {
|
|
96
|
+
if (closed) return;
|
|
97
|
+
closed = true;
|
|
98
|
+
evented.removeListener?.("finish", destroy);
|
|
99
|
+
evented.removeListener?.("close", destroy);
|
|
100
|
+
destroyInput(req);
|
|
101
|
+
};
|
|
102
|
+
evented.once?.("finish", destroy);
|
|
103
|
+
evented.once?.("close", destroy);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function destroyInput(req: HttpRequest): void {
|
|
107
|
+
(req as HttpRequest & Destroyable).destroy?.();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Refuse late handler writes after a deadline response has committed. */
|
|
111
|
+
function sealLateWrites(res: HttpResponse): void {
|
|
112
|
+
const mutable = res as WritableResponse;
|
|
113
|
+
mutable.write = () => false;
|
|
114
|
+
mutable.writeHead = () => mutable;
|
|
115
|
+
mutable.end = () => mutable;
|
|
116
|
+
mutable.setHeader = () => mutable;
|
|
117
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type { EntrypointLoader, HttpHandler, HttpRequest, HttpResponse } from "./types.js";
|
|
2
|
+
export {
|
|
3
|
+
DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES,
|
|
4
|
+
NodeRequestAbortedError,
|
|
5
|
+
NodeRequestBodyTooLargeError,
|
|
6
|
+
adaptRequest,
|
|
7
|
+
decorateRequest,
|
|
8
|
+
decorateResponse,
|
|
9
|
+
isRawStreamEntrypoint,
|
|
10
|
+
isWebhookPath,
|
|
11
|
+
pathnameOf,
|
|
12
|
+
} from "./requestAdapter.js";
|
|
13
|
+
export {
|
|
14
|
+
NODE_HTTP_TIMEOUT_CODE,
|
|
15
|
+
closeInputAfterResponse,
|
|
16
|
+
runWithHttpDeadline,
|
|
17
|
+
type HttpDeadlineOptions,
|
|
18
|
+
} from "./deadline.js";
|
|
19
|
+
export {
|
|
20
|
+
applySecurityHeaders,
|
|
21
|
+
contentTypeFor,
|
|
22
|
+
looksLikeAsset,
|
|
23
|
+
serveSpaStatic,
|
|
24
|
+
type NodeSecurityHeaderPolicy,
|
|
25
|
+
type StaticAssetReader,
|
|
26
|
+
} from "./security.js";
|
|
27
|
+
export { createDistStaticReader } from "./staticAssets.js";
|
|
28
|
+
export {
|
|
29
|
+
createNodeHttpRuntime,
|
|
30
|
+
createRequestHandler,
|
|
31
|
+
type HttpEntrypointRegistration,
|
|
32
|
+
type HttpPathEntrypoint,
|
|
33
|
+
type NodeCsrFallback,
|
|
34
|
+
type NodeHttpRuntimeOptions,
|
|
35
|
+
type NodeLegacyRedirect,
|
|
36
|
+
} from "./runtime.js";
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Node request/response adaptation to the hosted-function request shape for the HttpRuntimePort.
|
|
2
|
+
//
|
|
3
|
+
// Owns the RAW-BODY INVARIANT: every buffered request gets the exact bytes re-exposed as an
|
|
4
|
+
// async-iterable (so raw-body signature verification works for ANY route, including ones outside a
|
|
5
|
+
// `/webhooks/` segment) while still setting a parsed JSON req.body; stream-first standalone
|
|
6
|
+
// entrypoints keep their live stream and NO req.body.
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
|
|
10
|
+
import type { HttpRequest, HttpResponse } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES = 1_048_576;
|
|
13
|
+
|
|
14
|
+
export class NodeRequestBodyTooLargeError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super("node_request_body_too_large");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class NodeRequestAbortedError extends Error {
|
|
21
|
+
constructor() {
|
|
22
|
+
super("node_request_aborted");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A path is a webhook iff it contains a `/webhooks/` segment. */
|
|
27
|
+
export function isWebhookPath(pathname: string): boolean {
|
|
28
|
+
return pathname.includes("/webhooks/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The PLATFORM's stream-first standalone entrypoints: they read the raw stream
|
|
33
|
+
* themselves, so the runtime must never pre-parse their body.
|
|
34
|
+
*
|
|
35
|
+
* A deployment's own stream-first endpoint is NOT listed here. It declares
|
|
36
|
+
* `rawStream: true` on its `HttpEntrypointRegistration` and the runtime passes that
|
|
37
|
+
* decision to `adaptRequest` directly — which is what stops this publishable list
|
|
38
|
+
* from having to name a path only that deployment owns.
|
|
39
|
+
*/
|
|
40
|
+
export function isRawStreamEntrypoint(pathname: string): boolean {
|
|
41
|
+
return pathname === "/api/csp-report";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pathnameOf(req: IncomingMessage): string {
|
|
45
|
+
return new URL(req.url ?? "/", "http://node.local").pathname;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseQuery(req: IncomingMessage): Record<string, string | string[] | undefined> {
|
|
49
|
+
const url = new URL(req.url ?? "/", "http://node.local");
|
|
50
|
+
const query: Record<string, string | string[] | undefined> = {};
|
|
51
|
+
for (const key of url.searchParams.keys()) {
|
|
52
|
+
if (key in query) continue;
|
|
53
|
+
const all = url.searchParams.getAll(key);
|
|
54
|
+
query[key] = all.length > 1 ? all : all[0];
|
|
55
|
+
}
|
|
56
|
+
return query;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseCookies(req: IncomingMessage): Record<string, string> {
|
|
60
|
+
const header = req.headers.cookie;
|
|
61
|
+
if (!header) return {};
|
|
62
|
+
const out: Record<string, string> = {};
|
|
63
|
+
for (const part of header.split(";")) {
|
|
64
|
+
const idx = part.indexOf("=");
|
|
65
|
+
if (idx < 0) continue;
|
|
66
|
+
const key = part.slice(0, idx).trim();
|
|
67
|
+
const value = part.slice(idx + 1).trim();
|
|
68
|
+
if (key) out[key] = decodeURIComponent(value);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Buffer the request stream once into a single Buffer (GET/HEAD => empty). */
|
|
74
|
+
async function bufferBody(req: HttpRequest, maxBytes: number): Promise<Buffer> {
|
|
75
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
76
|
+
if (method === "GET" || method === "HEAD") return Buffer.alloc(0);
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const chunks: Buffer[] = [];
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
let settled = false;
|
|
81
|
+
const finish = (result: Buffer | Error) => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
settled = true;
|
|
84
|
+
req.removeListener("data", onData);
|
|
85
|
+
req.removeListener("end", onEnd);
|
|
86
|
+
req.removeListener("error", onError);
|
|
87
|
+
req.abortSignal?.removeEventListener("abort", onAbort);
|
|
88
|
+
if (result instanceof Error) reject(result);
|
|
89
|
+
else resolve(result);
|
|
90
|
+
};
|
|
91
|
+
const onData = (chunk: Buffer | string) => {
|
|
92
|
+
const bytesChunk = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
93
|
+
bytes += bytesChunk.length;
|
|
94
|
+
if (bytes > maxBytes) {
|
|
95
|
+
finish(new NodeRequestBodyTooLargeError());
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
chunks.push(bytesChunk);
|
|
99
|
+
};
|
|
100
|
+
const onEnd = () => finish(Buffer.concat(chunks));
|
|
101
|
+
const onError = (error: Error) => finish(error);
|
|
102
|
+
const onAbort = () => finish(new NodeRequestAbortedError());
|
|
103
|
+
if (req.abortSignal?.aborted) return onAbort();
|
|
104
|
+
req.on("data", onData);
|
|
105
|
+
req.once("end", onEnd);
|
|
106
|
+
req.once("error", onError);
|
|
107
|
+
req.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Re-expose a fixed Buffer as the request's async-iterable so readRawBody sees the EXACT bytes. */
|
|
112
|
+
function reexposeRawStream(req: IncomingMessage, raw: Buffer): void {
|
|
113
|
+
(req as unknown as { [Symbol.asyncIterator]: () => AsyncIterator<Buffer> })[Symbol.asyncIterator] =
|
|
114
|
+
async function* () {
|
|
115
|
+
yield raw;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Best-effort JSON parse of a buffered body for handlers that read `req.body`. */
|
|
120
|
+
function parseJsonBody(raw: Buffer, contentType: string): unknown {
|
|
121
|
+
if (raw.length === 0) return undefined;
|
|
122
|
+
const text = raw.toString("utf8").trim();
|
|
123
|
+
if (!text) return undefined;
|
|
124
|
+
if (contentType.includes("application/json") || text.startsWith("{") || text.startsWith("[")) {
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
return text;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Decorate a Node response with Vercel's status/json/send/redirect sugar (productionized from the
|
|
136
|
+
* dev shim's decorateResponse — identical semantics).
|
|
137
|
+
*/
|
|
138
|
+
export function decorateResponse(res: ServerResponse): HttpResponse {
|
|
139
|
+
const vres = res as HttpResponse;
|
|
140
|
+
vres.status = function status(code: number): HttpResponse {
|
|
141
|
+
res.statusCode = code;
|
|
142
|
+
return vres;
|
|
143
|
+
};
|
|
144
|
+
vres.json = function json(body: unknown): HttpResponse {
|
|
145
|
+
if (!res.hasHeader("content-type")) {
|
|
146
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
147
|
+
}
|
|
148
|
+
res.end(JSON.stringify(body));
|
|
149
|
+
return vres;
|
|
150
|
+
};
|
|
151
|
+
vres.send = function send(body: string | Buffer | object): HttpResponse {
|
|
152
|
+
if (Buffer.isBuffer(body) || typeof body === "string") {
|
|
153
|
+
res.end(body);
|
|
154
|
+
return vres;
|
|
155
|
+
}
|
|
156
|
+
if (!res.hasHeader("content-type")) {
|
|
157
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
158
|
+
}
|
|
159
|
+
res.end(JSON.stringify(body));
|
|
160
|
+
return vres;
|
|
161
|
+
};
|
|
162
|
+
vres.redirect = function redirect(statusOrUrl: number | string, maybeUrl?: string): HttpResponse {
|
|
163
|
+
const code = typeof statusOrUrl === "number" ? statusOrUrl : 302;
|
|
164
|
+
const location = typeof statusOrUrl === "number" ? maybeUrl ?? "/" : statusOrUrl;
|
|
165
|
+
res.statusCode = code;
|
|
166
|
+
res.setHeader("location", location);
|
|
167
|
+
res.end();
|
|
168
|
+
return vres;
|
|
169
|
+
};
|
|
170
|
+
return vres;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Adapt a Node IncomingMessage into the Vercel request shape, honoring the raw-body invariant:
|
|
175
|
+
* - raw-stream entrypoints (process/csp/feedback): stream left intact, NO req.body;
|
|
176
|
+
* - everything else: raw bytes buffered + re-exposed as async-iterable (so readRawBody sees the
|
|
177
|
+
* EXACT bytes for any signature-verifying route, webhook-segment or not) AND req.body = parsed JSON.
|
|
178
|
+
*/
|
|
179
|
+
export async function adaptRequest(
|
|
180
|
+
req: IncomingMessage,
|
|
181
|
+
pathname: string,
|
|
182
|
+
/** Explicit override for a caller that knows the entrypoint's body contract (see above). */
|
|
183
|
+
rawStream: boolean = isRawStreamEntrypoint(pathname),
|
|
184
|
+
bufferedBodyLimitBytes: number = DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES,
|
|
185
|
+
): Promise<HttpRequest> {
|
|
186
|
+
const vreq = req as HttpRequest;
|
|
187
|
+
vreq.query = parseQuery(req);
|
|
188
|
+
vreq.cookies = parseCookies(req);
|
|
189
|
+
|
|
190
|
+
if (rawStream) {
|
|
191
|
+
// Leave the live stream untouched; the handler reads it itself (formidable / custom readBody).
|
|
192
|
+
return vreq;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const raw = await bufferBody(vreq, bufferedBodyLimitBytes);
|
|
196
|
+
const contentType = String(req.headers["content-type"] ?? "");
|
|
197
|
+
|
|
198
|
+
// Re-expose the EXACT bytes as an async-iterable for EVERY buffered request, not only
|
|
199
|
+
// `/webhooks/` paths. Some signature-verifying routes live outside that segment — e.g.
|
|
200
|
+
// `/api/bff/communications/integrations/events` calls readRawBody for HMAC verification.
|
|
201
|
+
// readRawBody is stream-first (isAsyncIterable), so any handler that needs the raw bytes
|
|
202
|
+
// works; handlers that read req.body still get parsed JSON. The re-exposed iterator is
|
|
203
|
+
// simply never consumed for routes that don't call readRawBody, so this is a safe superset
|
|
204
|
+
// of the old `/webhooks/`-only behavior (and not dependent on a fragile path convention).
|
|
205
|
+
reexposeRawStream(req, raw);
|
|
206
|
+
vreq.body = parseJsonBody(raw, contentType);
|
|
207
|
+
return vreq;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Narrow a raw IncomingMessage to HttpRequest at the server boundary (query/cookies set in adapt). */
|
|
211
|
+
export function decorateRequest(req: IncomingMessage): HttpRequest {
|
|
212
|
+
const vreq = req as HttpRequest;
|
|
213
|
+
if (!vreq.query) vreq.query = {};
|
|
214
|
+
return vreq;
|
|
215
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Native Node adapter for manifest-driven public routing and API topology.
|
|
2
|
+
//
|
|
3
|
+
// The request order is fixed: trailing-slash redirect, the CSP report entrypoint, deployment
|
|
4
|
+
// exact-path entrypoints, the BFF router entrypoint, deployment path entrypoints, the BFF
|
|
5
|
+
// dispatcher, robots.txt, declared scheduled routes, the API 404, legacy redirects, deployment
|
|
6
|
+
// fallback path entrypoints, then static documents. Every deployment value (routes, entrypoints,
|
|
7
|
+
// header policy, robots policy, deadlines) is supplied by the composition root.
|
|
8
|
+
|
|
9
|
+
import { createServer, type Server } from "node:http";
|
|
10
|
+
|
|
11
|
+
import type { HttpRuntimePort } from "@openlup/core/platform-runtime";
|
|
12
|
+
import type { EntrypointLoader, HttpRequest, HttpResponse } from "./types.js";
|
|
13
|
+
import type { BffDispatch, RouteEntry } from "../bff/index.js";
|
|
14
|
+
import {
|
|
15
|
+
adaptRequest,
|
|
16
|
+
decorateRequest,
|
|
17
|
+
decorateResponse,
|
|
18
|
+
NodeRequestAbortedError,
|
|
19
|
+
NodeRequestBodyTooLargeError,
|
|
20
|
+
} from "./requestAdapter.js";
|
|
21
|
+
import { closeInputAfterResponse, runWithHttpDeadline } from "./deadline.js";
|
|
22
|
+
import {
|
|
23
|
+
applySecurityHeaders,
|
|
24
|
+
serveSpaStatic,
|
|
25
|
+
type NodeSecurityHeaderPolicy,
|
|
26
|
+
type StaticAssetReader,
|
|
27
|
+
} from "./security.js";
|
|
28
|
+
|
|
29
|
+
const SCHEDULED_PREFIX = "/api/cron/";
|
|
30
|
+
const ROBOTS_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
31
|
+
const EMPTY_ROUTING = { legacyRedirects: [], ssgPaths: [], csrFallback: { exactPaths: [], routeFamilies: [] } };
|
|
32
|
+
|
|
33
|
+
function requestUrl(req: HttpRequest): URL {
|
|
34
|
+
return new URL(req.url ?? "/", "http://node.local");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function capturesFor(source: string, pathname: string): Record<string, string> | null {
|
|
38
|
+
const sourceParts = source.split("/").slice(1);
|
|
39
|
+
const pathParts = pathname.split("/").slice(1);
|
|
40
|
+
const captures: Record<string, string> = {};
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
for (const sourcePart of sourceParts) {
|
|
43
|
+
if (!sourcePart.startsWith(":")) {
|
|
44
|
+
if (pathParts[cursor++] !== sourcePart) return null;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const name = sourcePart.slice(1).replace(/\*$/, "");
|
|
48
|
+
if (sourcePart.endsWith("*")) {
|
|
49
|
+
captures[name] = pathParts.slice(cursor).join("/");
|
|
50
|
+
cursor = pathParts.length;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const value = pathParts[cursor++];
|
|
54
|
+
if (!value) return null;
|
|
55
|
+
captures[name] = value;
|
|
56
|
+
}
|
|
57
|
+
return cursor === pathParts.length ? captures : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function legacyRedirect(pathname: string, redirects: readonly NodeLegacyRedirect[]): string | null {
|
|
61
|
+
for (const redirect of redirects) {
|
|
62
|
+
const captures = capturesFor(redirect.source, pathname);
|
|
63
|
+
if (!captures) continue;
|
|
64
|
+
return redirect.destination.replace(/:([A-Za-z][A-Za-z0-9_]*)\*?/g, (_match, name: string) => captures[name] ?? "");
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isCsrFallback(pathname: string, csrFallback: NodeCsrFallback): boolean {
|
|
70
|
+
return csrFallback.exactPaths.includes(pathname)
|
|
71
|
+
|| csrFallback.routeFamilies.some((path) => pathname === path || pathname.startsWith(`${path}/`));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One deployment-specific standalone entrypoint, matched by its exact path. */
|
|
75
|
+
export interface HttpEntrypointRegistration {
|
|
76
|
+
path: string;
|
|
77
|
+
rawStream?: boolean;
|
|
78
|
+
maxDurationMs?: number;
|
|
79
|
+
load: EntrypointLoader;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One deployment-specific entrypoint matched by a path rule. `match` returns the query values the
|
|
84
|
+
* entrypoint receives for this path, or null when the path is not its own. The body is buffered.
|
|
85
|
+
*/
|
|
86
|
+
export interface HttpPathEntrypoint {
|
|
87
|
+
match(pathname: string): Record<string, string> | null;
|
|
88
|
+
load: EntrypointLoader;
|
|
89
|
+
/** Request deadline; defaults to 10 seconds. */
|
|
90
|
+
maxDurationMs?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface NodeLegacyRedirect {
|
|
94
|
+
source: string;
|
|
95
|
+
destination: string;
|
|
96
|
+
permanent: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface NodeCsrFallback {
|
|
100
|
+
exactPaths: readonly string[];
|
|
101
|
+
routeFamilies: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface NodeHttpRuntimeOptions {
|
|
105
|
+
/** The deployment's BFF route table. */
|
|
106
|
+
routes: readonly RouteEntry[];
|
|
107
|
+
/** The dispatcher that matches `routes` (see `createBffDispatcher`). */
|
|
108
|
+
dispatch: BffDispatch;
|
|
109
|
+
/** Validated public-route contract supplied by the host composition root. */
|
|
110
|
+
routing?: {
|
|
111
|
+
legacyRedirects: readonly NodeLegacyRedirect[];
|
|
112
|
+
ssgPaths: readonly string[];
|
|
113
|
+
csrFallback: NodeCsrFallback;
|
|
114
|
+
};
|
|
115
|
+
/** Deployment header policy, including the content security policy. */
|
|
116
|
+
security: NodeSecurityHeaderPolicy;
|
|
117
|
+
/** robots.txt body for a request host; without it `/robots.txt` is served like any other path. */
|
|
118
|
+
robotsTxt?: (host: string | undefined) => string;
|
|
119
|
+
/**
|
|
120
|
+
* Platform-path standalone handlers: the CSP report sink at `/api/csp-report` and the hosted
|
|
121
|
+
* BFF router at `/api/bff-router`. A path without a handler is treated like any other path.
|
|
122
|
+
*/
|
|
123
|
+
entrypoints?: {
|
|
124
|
+
cspReport?: EntrypointLoader;
|
|
125
|
+
bffRouter?: EntrypointLoader;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Deployment exact-path handlers, checked after the CSP report entrypoint and before the BFF
|
|
129
|
+
* router; a later registration replaces an earlier one with the same path.
|
|
130
|
+
*/
|
|
131
|
+
extraEntrypoints?: readonly HttpEntrypointRegistration[];
|
|
132
|
+
/** Deployment path-rule handlers checked before the BFF dispatcher, first match wins. */
|
|
133
|
+
pathEntrypoints?: readonly HttpPathEntrypoint[];
|
|
134
|
+
/** Deployment path-rule handlers checked after legacy redirects and before static documents. */
|
|
135
|
+
fallbackPathEntrypoints?: readonly HttpPathEntrypoint[];
|
|
136
|
+
/** Registry-derived allowlist; undeclared job ids must resolve null. */
|
|
137
|
+
scheduledEntrypoint?: (jobId: string) => EntrypointLoader | null;
|
|
138
|
+
/** Explicit host deadlines; missing cells use the bounded defaults below. */
|
|
139
|
+
durations?: {
|
|
140
|
+
bff?: number;
|
|
141
|
+
cspReport?: number;
|
|
142
|
+
bffRouter?: number;
|
|
143
|
+
scheduled?: (jobId: string) => number;
|
|
144
|
+
};
|
|
145
|
+
/** Static SPA reader; without one, unmatched non-API paths answer 404. */
|
|
146
|
+
staticReader?: StaticAssetReader;
|
|
147
|
+
/** Structured error sink (defaults to console.error). */
|
|
148
|
+
onError?: (context: string, error: unknown) => void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finalize(res: HttpResponse): void {
|
|
152
|
+
if (!res.writableEnded && !res.destroyed) res.end();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function matchPathEntrypoint(
|
|
156
|
+
entrypoints: readonly HttpPathEntrypoint[] | undefined,
|
|
157
|
+
pathname: string,
|
|
158
|
+
): { entrypoint: HttpPathEntrypoint; query: Record<string, string> } | null {
|
|
159
|
+
for (const entrypoint of entrypoints ?? []) {
|
|
160
|
+
const query = entrypoint.match(pathname);
|
|
161
|
+
if (query) return { entrypoint, query };
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Build the runtime's request handler (exported for unit tests; wraps every dispatch path). */
|
|
167
|
+
export function createRequestHandler(options: NodeHttpRuntimeOptions): HttpRuntimePort["handle"] {
|
|
168
|
+
const onError = options.onError ?? ((ctx, err) => console.error(`[node-http] ${ctx}`, err));
|
|
169
|
+
const staticReader = options.staticReader;
|
|
170
|
+
const routing = options.routing ?? EMPTY_ROUTING;
|
|
171
|
+
const ssgPaths = new Set(routing.ssgPaths);
|
|
172
|
+
// Built once; a deployment can deliberately replace an inherited path.
|
|
173
|
+
const extra = new Map((options.extraEntrypoints ?? []).map((entry) => [entry.path, entry]));
|
|
174
|
+
|
|
175
|
+
return async function handle(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
176
|
+
const url = requestUrl(req);
|
|
177
|
+
const pathname = url.pathname;
|
|
178
|
+
const host = req.headers.host;
|
|
179
|
+
const bounded = (durationMs: number, run: () => Promise<unknown> | unknown) =>
|
|
180
|
+
runWithHttpDeadline({ req, res, durationMs, run, onLateError: (error) => onError(`${pathname}:late`, error) });
|
|
181
|
+
const invoke = (
|
|
182
|
+
loader: EntrypointLoader,
|
|
183
|
+
durationMs: number,
|
|
184
|
+
rawStream?: boolean,
|
|
185
|
+
query?: Record<string, string>,
|
|
186
|
+
) => bounded(durationMs, async () => {
|
|
187
|
+
const [mod, vreq] = await Promise.all([loader(), adaptRequest(req, pathname, rawStream)]);
|
|
188
|
+
if (query) vreq.query = { ...vreq.query, ...query };
|
|
189
|
+
await mod.default(vreq, res);
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
applySecurityHeaders(res, pathname, host, options.security);
|
|
193
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
194
|
+
res.statusCode = 308;
|
|
195
|
+
res.setHeader("location", `${pathname.replace(/\/+$/, "")}${url.search}`);
|
|
196
|
+
return finalize(res);
|
|
197
|
+
}
|
|
198
|
+
// 1. Standalone entrypoints (filesystem-parity with api/*.ts).
|
|
199
|
+
const cspReport = options.entrypoints?.cspReport;
|
|
200
|
+
if (pathname === "/api/csp-report" && cspReport) {
|
|
201
|
+
await invoke(cspReport, options.durations?.cspReport ?? 30_000);
|
|
202
|
+
return finalize(res);
|
|
203
|
+
}
|
|
204
|
+
// 1b. Deployment entrypoints: exact path, lazy load, owner-declared body policy.
|
|
205
|
+
const registered = extra.get(pathname);
|
|
206
|
+
if (registered) {
|
|
207
|
+
await invoke(registered.load, registered.maxDurationMs ?? 30_000, registered.rawStream);
|
|
208
|
+
return finalize(res);
|
|
209
|
+
}
|
|
210
|
+
const bffRouter = options.entrypoints?.bffRouter;
|
|
211
|
+
if (pathname === "/api/bff-router" && bffRouter) {
|
|
212
|
+
await invoke(bffRouter, options.durations?.bffRouter ?? 30_000);
|
|
213
|
+
return finalize(res);
|
|
214
|
+
}
|
|
215
|
+
// 1c. Deployment path-rule entrypoints, before the BFF dispatcher.
|
|
216
|
+
const routed = matchPathEntrypoint(options.pathEntrypoints, pathname);
|
|
217
|
+
if (routed) {
|
|
218
|
+
await invoke(routed.entrypoint.load, routed.entrypoint.maxDurationMs ?? 10_000, false, routed.query);
|
|
219
|
+
return finalize(res);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 2. The BFF dispatcher (same routes + dispatch as the hosted entrypoint).
|
|
223
|
+
if (pathname === "/api/bff" || pathname.startsWith("/api/bff/")) {
|
|
224
|
+
await bounded(options.durations?.bff ?? 30_000, async () =>
|
|
225
|
+
options.dispatch(options.routes, await adaptRequest(req, pathname), res));
|
|
226
|
+
return finalize(res);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (pathname === "/robots.txt" && options.robotsTxt) {
|
|
230
|
+
res.statusCode = 200;
|
|
231
|
+
res.setHeader("content-type", ROBOTS_CONTENT_TYPE);
|
|
232
|
+
res.setHeader("cache-control", "public, max-age=0, s-maxage=300, stale-while-revalidate=3600");
|
|
233
|
+
res.end(options.robotsTxt(host));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 3. Scheduled routes share the scheduler's single-source allowlist.
|
|
238
|
+
if (pathname.startsWith(SCHEDULED_PREFIX)) {
|
|
239
|
+
const jobId = pathname.slice(SCHEDULED_PREFIX.length);
|
|
240
|
+
const loader = options.scheduledEntrypoint?.(jobId);
|
|
241
|
+
if (loader) {
|
|
242
|
+
await invoke(loader, options.durations?.scheduled?.(jobId) ?? 60_000);
|
|
243
|
+
return finalize(res);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 4. Unknown /api/* => 404 JSON (never fall through to the SPA).
|
|
248
|
+
if (pathname.startsWith("/api/")) {
|
|
249
|
+
res.statusCode = 404;
|
|
250
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
251
|
+
res.end(JSON.stringify({ ok: false, error: { code: "NOT_FOUND", message: "Not found" } }));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const legacyLocation = legacyRedirect(pathname, routing.legacyRedirects);
|
|
256
|
+
if (legacyLocation) {
|
|
257
|
+
res.statusCode = 308;
|
|
258
|
+
res.setHeader("location", `${legacyLocation}${url.search}`);
|
|
259
|
+
return finalize(res);
|
|
260
|
+
}
|
|
261
|
+
const fallback = matchPathEntrypoint(options.fallbackPathEntrypoints, pathname);
|
|
262
|
+
if (fallback) {
|
|
263
|
+
await invoke(fallback.entrypoint.load, fallback.entrypoint.maxDurationMs ?? 10_000, false, fallback.query);
|
|
264
|
+
return finalize(res);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 5. Static documents and declared CSR fallback. Unknown extensionless routes are 404.
|
|
268
|
+
if (staticReader) {
|
|
269
|
+
await serveSpaStatic(
|
|
270
|
+
req,
|
|
271
|
+
res,
|
|
272
|
+
pathname,
|
|
273
|
+
staticReader,
|
|
274
|
+
ssgPaths.has(pathname),
|
|
275
|
+
isCsrFallback(pathname, routing.csrFallback),
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// No static reader configured (pure-API smoke): respond 404 rather than hang.
|
|
280
|
+
res.statusCode = 404;
|
|
281
|
+
res.end("Not Found");
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error instanceof NodeRequestAbortedError) return;
|
|
284
|
+
onError(pathname, error);
|
|
285
|
+
const tooLarge = error instanceof NodeRequestBodyTooLargeError;
|
|
286
|
+
if (tooLarge) closeInputAfterResponse(req, res);
|
|
287
|
+
if (!res.headersSent && !res.destroyed) {
|
|
288
|
+
res.statusCode = tooLarge ? 413 : 500;
|
|
289
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
290
|
+
}
|
|
291
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
292
|
+
res.end(JSON.stringify({ ok: false, error: {
|
|
293
|
+
code: tooLarge ? "REQUEST_BODY_TOO_LARGE" : "NODE_HTTP_ERROR",
|
|
294
|
+
message: tooLarge ? "Request body too large" : "Internal server error",
|
|
295
|
+
} }));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Create the Node HttpRuntimePort: `handle` for one request, `listen` to bind the server. */
|
|
302
|
+
export function createNodeHttpRuntime(options: NodeHttpRuntimeOptions): HttpRuntimePort & {
|
|
303
|
+
server: Server | null;
|
|
304
|
+
} {
|
|
305
|
+
const handle = createRequestHandler(options);
|
|
306
|
+
let server: Server | null = null;
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
server,
|
|
310
|
+
handle,
|
|
311
|
+
async listen({ port, host }): Promise<void> {
|
|
312
|
+
server = createServer((req, res) => {
|
|
313
|
+
void handle(decorateRequest(req), decorateResponse(res));
|
|
314
|
+
});
|
|
315
|
+
this.server = server;
|
|
316
|
+
await new Promise<void>((resolve) => server!.listen(port, host ?? "0.0.0.0", resolve));
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|