@fonderie/core 0.8.0 → 0.10.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.
@@ -136,6 +136,8 @@ new FonderieApp(config: FonderieConfig): FonderieApp
136
136
  .addRoute(method: string, path: string, ...handlers: Middleware[]): void
137
137
  .handle(request: Request): Promise<Response>
138
138
 
139
+ const DEFAULT_MAX_BODY_BYTES: number
140
+
139
141
  function defineConfig(config: FonderieConfig): FonderieConfig
140
142
 
141
143
  function compose(middlewares: Middleware[]): (ctx: IFonderieContext, fallback: () => Promise<Response>) => Promise<Response>
@@ -159,7 +161,9 @@ interface FonderieConfig {
159
161
  skipProductionReadinessGate?: boolean;
160
162
  healthChecks?: boolean;
161
163
  readyProbe?: () => boolean | Promise<boolean>;
164
+ exposeReadyzDetails?: boolean;
162
165
  metrics?: boolean;
166
+ maxBodyBytes?: number;
163
167
  onError?: (err: unknown) => Response;
164
168
  onResponse?: (body: unknown, info: {
165
169
  status: number;
@@ -197,7 +201,7 @@ interface IApiError {
197
201
 
198
202
  type HttpStatus = (typeof HTTP)[keyof typeof HTTP];
199
203
 
200
- const HTTP: { readonly OK: 200; readonly CREATED: 201; readonly ACCEPTED: 202; readonly NO_CONTENT: 204; readonly BAD_REQUEST: 400; readonly UNAUTHORIZED: 401; readonly PAYMENT_REQUIRED: 402; readonly FORBIDDEN: 403; readonly NOT_FOUND: 404; readonly CONFLICT: 409; readonly GONE: 410; readonly UNPROCESSABLE: 422; readonly TOO_MANY_REQUESTS: 429; readonly SERVER_ERROR: 500; readonly NOT_IMPLEMENTED: 501; readonly BAD_GATEWAY: 502; readonly SERVICE_UNAVAILABLE: 503; }
204
+ const HTTP: { readonly OK: 200; readonly CREATED: 201; readonly ACCEPTED: 202; readonly NO_CONTENT: 204; readonly BAD_REQUEST: 400; readonly UNAUTHORIZED: 401; readonly PAYMENT_REQUIRED: 402; readonly FORBIDDEN: 403; readonly NOT_FOUND: 404; readonly CONFLICT: 409; readonly GONE: 410; readonly PAYLOAD_TOO_LARGE: 413; readonly UNPROCESSABLE: 422; readonly TOO_MANY_REQUESTS: 429; readonly SERVER_ERROR: 500; readonly NOT_IMPLEMENTED: 501; readonly BAD_GATEWAY: 502; readonly SERVICE_UNAVAILABLE: 503; }
201
205
 
202
206
  function setApiResponse<T>(status: number, reason: string, explanation: string, payload?: T | undefined): Response
203
207
 
@@ -0,0 +1,19 @@
1
+ import { Middleware } from './types.js';
2
+
3
+ /**
4
+ * Default cap on the request body the pipeline will buffer, in bytes (5 MiB).
5
+ * Enforced HERE, in the body parser, so EVERY entry point inherits it — the
6
+ * built-in listen() server, and all adapters' buildContext()/handle() paths.
7
+ * (An uncapped parser was a memory-exhaustion DoS on any adapter whose
8
+ * transport didn't add its own cap, e.g. adapter-hono on node-server.)
9
+ */
10
+ declare const DEFAULT_MAX_BODY_BYTES: number;
11
+ /**
12
+ * Build the body-parsing middleware with an explicit byte cap. The core app
13
+ * wires this with `config.maxBodyBytes`; the bare `withBody` export below
14
+ * keeps the default cap for direct users.
15
+ */
16
+ declare function bodyParser(maxBytes?: number): Middleware;
17
+ declare const withBody: Middleware;
18
+
19
+ export { DEFAULT_MAX_BODY_BYTES as D, bodyParser as b, withBody as w };
@@ -0,0 +1,19 @@
1
+ import { Middleware } from './types.cjs';
2
+
3
+ /**
4
+ * Default cap on the request body the pipeline will buffer, in bytes (5 MiB).
5
+ * Enforced HERE, in the body parser, so EVERY entry point inherits it — the
6
+ * built-in listen() server, and all adapters' buildContext()/handle() paths.
7
+ * (An uncapped parser was a memory-exhaustion DoS on any adapter whose
8
+ * transport didn't add its own cap, e.g. adapter-hono on node-server.)
9
+ */
10
+ declare const DEFAULT_MAX_BODY_BYTES: number;
11
+ /**
12
+ * Build the body-parsing middleware with an explicit byte cap. The core app
13
+ * wires this with `config.maxBodyBytes`; the bare `withBody` export below
14
+ * keeps the default cap for direct users.
15
+ */
16
+ declare function bodyParser(maxBytes?: number): Middleware;
17
+ declare const withBody: Middleware;
18
+
19
+ export { DEFAULT_MAX_BODY_BYTES as D, bodyParser as b, withBody as w };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts"],"sourcesContent":["export interface IBillingPlan {\n\tname: string;\n\tprice: number | null; // null = custom/enterprise pricing\n\tseats: number | 'unlimited';\n\tinterval?: 'month' | 'year';\n\ttrialDays?: number;\n}\n\nexport interface ISMTPConfig {\n\thost: string;\n\tport: number;\n\tsecure: boolean; // true = TLS, false = STARTTLS\n\tuser: string;\n\tpass: string;\n}\n\nexport interface FonderieConfig {\n\tbasePath?: string; // e.g. '/v1' — prefixes all routes; defaults to ''\n\n\tdb: {\n\t\turl: string; // Standard postgres:// connection string\n\t\t// Future: adapter pattern for other vendors\n\t\t// adapter?: 'pg' | 'mysql2' | 'oracledb' ← v2 concern\n\t};\n\n\tbilling?: {\n\t\tprovider: 'stripe';\n\t\tplans: IBillingPlan[];\n\t\tstripeSecretKey: string;\n\t};\n\n\temail?: {\n\t\tfrom: string;\n\t\t// Credentials from their .env — Fonderie never stores these\n\t\tapiKey?: string;\n\t\tsmtp?: ISMTPConfig;\n\t\tprovider: 'resend' | 'ses' | 'smtp';\n\t};\n\n\t// Fail-closed production boot gate. In production, `boot()` refuses to start\n\t// when any registered module reports an `error`-severity readiness problem\n\t// (e.g. a weak signing secret, a missing at-rest encryption key). Set this to\n\t// true only to deliberately override the gate — not recommended. Outside\n\t// production the gate never runs.\n\tskipProductionReadinessGate?: boolean;\n\n\t// Built-in health endpoints, registered on boot (unprefixed by basePath):\n\t// GET /healthz — liveness, always 200 while the process is up\n\t// GET /readyz — readiness, 200 when checkProductionReadiness() is ok AND\n\t// readyProbe() (if provided) resolves truthy, else 503\n\t// Set false to disable. Point your platform's probes at these.\n\thealthChecks?: boolean;\n\t// Optional dependency probe for /readyz — e.g. `() => store.testConnection()`.\n\t// Throwing or returning false makes /readyz report 503.\n\treadyProbe?: () => boolean | Promise<boolean>;\n\n\t// Enable Prometheus-format metrics: counts requests by status class and\n\t// serves them at GET /metrics (unprefixed). Off by default. Access to\n\t// /metrics should be restricted at your ingress/scraper.\n\tmetrics?: boolean;\n\n\tonError?: (err: unknown) => Response;\n\n\t// Transform every JSON response body just before it is sent. Return the new\n\t// body, or `undefined` to leave that response untouched. This is the single,\n\t// adapter-agnostic seam for adapting Fonderie's `{ reason, explanation, result }`\n\t// envelope to an app's own contract (e.g. flat shapes an existing frontend\n\t// expects) WITHOUT editing handlers. Applied at the one egress point, so it\n\t// covers every route and every adapter. Status code, headers, and cookies are\n\t// preserved; only the body shape changes. Non-JSON responses pass through.\n\t// Opt-in: unset = current behaviour, unchanged.\n\tonResponse?: (body: unknown, info: { status: number; request: Request }) => unknown;\n}\n\nexport function defineConfig(config: FonderieConfig): FonderieConfig {\n\treturn config; // typed identity — same pattern as defineConfig in Vite/Nuxt\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA0EO,SAAS,aAAa,QAAwC;AACpE,SAAO;AACR;","names":[]}
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["export interface IBillingPlan {\n\tname: string;\n\tprice: number | null; // null = custom/enterprise pricing\n\tseats: number | 'unlimited';\n\tinterval?: 'month' | 'year';\n\ttrialDays?: number;\n}\n\nexport interface ISMTPConfig {\n\thost: string;\n\tport: number;\n\tsecure: boolean; // true = TLS, false = STARTTLS\n\tuser: string;\n\tpass: string;\n}\n\nexport interface FonderieConfig {\n\tbasePath?: string; // e.g. '/v1' — prefixes all routes; defaults to ''\n\n\tdb: {\n\t\turl: string; // Standard postgres:// connection string\n\t\t// Future: adapter pattern for other vendors\n\t\t// adapter?: 'pg' | 'mysql2' | 'oracledb' ← v2 concern\n\t};\n\n\tbilling?: {\n\t\tprovider: 'stripe';\n\t\tplans: IBillingPlan[];\n\t\tstripeSecretKey: string;\n\t};\n\n\temail?: {\n\t\tfrom: string;\n\t\t// Credentials from their .env — Fonderie never stores these\n\t\tapiKey?: string;\n\t\tsmtp?: ISMTPConfig;\n\t\tprovider: 'resend' | 'ses' | 'smtp';\n\t};\n\n\t// Fail-closed production boot gate. In production, `boot()` refuses to start\n\t// when any registered module reports an `error`-severity readiness problem\n\t// (e.g. a weak signing secret, a missing at-rest encryption key). Set this to\n\t// true only to deliberately override the gate — not recommended. Outside\n\t// production the gate never runs.\n\tskipProductionReadinessGate?: boolean;\n\n\t// Built-in health endpoints, registered on boot (unprefixed by basePath):\n\t// GET /healthz — liveness, always 200 while the process is up\n\t// GET /readyz — readiness, 200 when checkProductionReadiness() is ok AND\n\t// readyProbe() (if provided) resolves truthy, else 503\n\t// Set false to disable. Point your platform's probes at these.\n\thealthChecks?: boolean;\n\t// Optional dependency probe for /readyz — e.g. `() => store.testConnection()`.\n\t// Throwing or returning false makes /readyz report 503.\n\treadyProbe?: () => boolean | Promise<boolean>;\n\t// /readyz's `problems` list names weak secrets and placeholder tokens — a\n\t// security-posture map — so in production it is omitted unless this is\n\t// explicitly true. Probes only need the status code. Non-production always\n\t// includes details.\n\texposeReadyzDetails?: boolean;\n\n\t// Enable Prometheus-format metrics: counts requests by status class and\n\t// serves them at GET /metrics (unprefixed). Off by default. Access to\n\t// /metrics should be restricted at your ingress/scraper.\n\tmetrics?: boolean;\n\n\t// Cap on the request body the built-in `listen()` server will buffer, in\n\t// bytes. Defaults to 5 MiB (matching the adapters). Without a cap, an\n\t// unauthenticated request could stream an arbitrarily large body fully into\n\t// memory before any handler runs — a memory-exhaustion DoS. Oversize\n\t// requests get 413. Raise for large uploads (e.g. @fonderie/media images);\n\t// adapter deployments configure this on the adapter instead.\n\tmaxBodyBytes?: number;\n\n\tonError?: (err: unknown) => Response;\n\n\t// Transform every JSON response body just before it is sent. Return the new\n\t// body, or `undefined` to leave that response untouched. This is the single,\n\t// adapter-agnostic seam for adapting Fonderie's `{ reason, explanation, result }`\n\t// envelope to an app's own contract (e.g. flat shapes an existing frontend\n\t// expects) WITHOUT editing handlers. Applied at the one egress point, so it\n\t// covers every route and every adapter. Status code, headers, and cookies are\n\t// preserved; only the body shape changes. Non-JSON responses pass through.\n\t// Opt-in: unset = current behaviour, unchanged.\n\tonResponse?: (body: unknown, info: { status: number; request: Request }) => unknown;\n}\n\nexport function defineConfig(config: FonderieConfig): FonderieConfig {\n\treturn config; // typed identity — same pattern as defineConfig in Vite/Nuxt\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAuFO,SAAS,aAAa,QAAwC;AACpE,SAAO;AACR;","names":[]}
package/dist/config.d.cts CHANGED
@@ -31,7 +31,9 @@ interface FonderieConfig {
31
31
  skipProductionReadinessGate?: boolean;
32
32
  healthChecks?: boolean;
33
33
  readyProbe?: () => boolean | Promise<boolean>;
34
+ exposeReadyzDetails?: boolean;
34
35
  metrics?: boolean;
36
+ maxBodyBytes?: number;
35
37
  onError?: (err: unknown) => Response;
36
38
  onResponse?: (body: unknown, info: {
37
39
  status: number;
package/dist/config.d.ts CHANGED
@@ -31,7 +31,9 @@ interface FonderieConfig {
31
31
  skipProductionReadinessGate?: boolean;
32
32
  healthChecks?: boolean;
33
33
  readyProbe?: () => boolean | Promise<boolean>;
34
+ exposeReadyzDetails?: boolean;
34
35
  metrics?: boolean;
36
+ maxBodyBytes?: number;
35
37
  onError?: (err: unknown) => Response;
36
38
  onResponse?: (body: unknown, info: {
37
39
  status: number;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts"],"sourcesContent":["export interface IBillingPlan {\n\tname: string;\n\tprice: number | null; // null = custom/enterprise pricing\n\tseats: number | 'unlimited';\n\tinterval?: 'month' | 'year';\n\ttrialDays?: number;\n}\n\nexport interface ISMTPConfig {\n\thost: string;\n\tport: number;\n\tsecure: boolean; // true = TLS, false = STARTTLS\n\tuser: string;\n\tpass: string;\n}\n\nexport interface FonderieConfig {\n\tbasePath?: string; // e.g. '/v1' — prefixes all routes; defaults to ''\n\n\tdb: {\n\t\turl: string; // Standard postgres:// connection string\n\t\t// Future: adapter pattern for other vendors\n\t\t// adapter?: 'pg' | 'mysql2' | 'oracledb' ← v2 concern\n\t};\n\n\tbilling?: {\n\t\tprovider: 'stripe';\n\t\tplans: IBillingPlan[];\n\t\tstripeSecretKey: string;\n\t};\n\n\temail?: {\n\t\tfrom: string;\n\t\t// Credentials from their .env — Fonderie never stores these\n\t\tapiKey?: string;\n\t\tsmtp?: ISMTPConfig;\n\t\tprovider: 'resend' | 'ses' | 'smtp';\n\t};\n\n\t// Fail-closed production boot gate. In production, `boot()` refuses to start\n\t// when any registered module reports an `error`-severity readiness problem\n\t// (e.g. a weak signing secret, a missing at-rest encryption key). Set this to\n\t// true only to deliberately override the gate — not recommended. Outside\n\t// production the gate never runs.\n\tskipProductionReadinessGate?: boolean;\n\n\t// Built-in health endpoints, registered on boot (unprefixed by basePath):\n\t// GET /healthz — liveness, always 200 while the process is up\n\t// GET /readyz — readiness, 200 when checkProductionReadiness() is ok AND\n\t// readyProbe() (if provided) resolves truthy, else 503\n\t// Set false to disable. Point your platform's probes at these.\n\thealthChecks?: boolean;\n\t// Optional dependency probe for /readyz — e.g. `() => store.testConnection()`.\n\t// Throwing or returning false makes /readyz report 503.\n\treadyProbe?: () => boolean | Promise<boolean>;\n\n\t// Enable Prometheus-format metrics: counts requests by status class and\n\t// serves them at GET /metrics (unprefixed). Off by default. Access to\n\t// /metrics should be restricted at your ingress/scraper.\n\tmetrics?: boolean;\n\n\tonError?: (err: unknown) => Response;\n\n\t// Transform every JSON response body just before it is sent. Return the new\n\t// body, or `undefined` to leave that response untouched. This is the single,\n\t// adapter-agnostic seam for adapting Fonderie's `{ reason, explanation, result }`\n\t// envelope to an app's own contract (e.g. flat shapes an existing frontend\n\t// expects) WITHOUT editing handlers. Applied at the one egress point, so it\n\t// covers every route and every adapter. Status code, headers, and cookies are\n\t// preserved; only the body shape changes. Non-JSON responses pass through.\n\t// Opt-in: unset = current behaviour, unchanged.\n\tonResponse?: (body: unknown, info: { status: number; request: Request }) => unknown;\n}\n\nexport function defineConfig(config: FonderieConfig): FonderieConfig {\n\treturn config; // typed identity — same pattern as defineConfig in Vite/Nuxt\n}\n"],"mappings":";AA0EO,SAAS,aAAa,QAAwC;AACpE,SAAO;AACR;","names":[]}
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["export interface IBillingPlan {\n\tname: string;\n\tprice: number | null; // null = custom/enterprise pricing\n\tseats: number | 'unlimited';\n\tinterval?: 'month' | 'year';\n\ttrialDays?: number;\n}\n\nexport interface ISMTPConfig {\n\thost: string;\n\tport: number;\n\tsecure: boolean; // true = TLS, false = STARTTLS\n\tuser: string;\n\tpass: string;\n}\n\nexport interface FonderieConfig {\n\tbasePath?: string; // e.g. '/v1' — prefixes all routes; defaults to ''\n\n\tdb: {\n\t\turl: string; // Standard postgres:// connection string\n\t\t// Future: adapter pattern for other vendors\n\t\t// adapter?: 'pg' | 'mysql2' | 'oracledb' ← v2 concern\n\t};\n\n\tbilling?: {\n\t\tprovider: 'stripe';\n\t\tplans: IBillingPlan[];\n\t\tstripeSecretKey: string;\n\t};\n\n\temail?: {\n\t\tfrom: string;\n\t\t// Credentials from their .env — Fonderie never stores these\n\t\tapiKey?: string;\n\t\tsmtp?: ISMTPConfig;\n\t\tprovider: 'resend' | 'ses' | 'smtp';\n\t};\n\n\t// Fail-closed production boot gate. In production, `boot()` refuses to start\n\t// when any registered module reports an `error`-severity readiness problem\n\t// (e.g. a weak signing secret, a missing at-rest encryption key). Set this to\n\t// true only to deliberately override the gate — not recommended. Outside\n\t// production the gate never runs.\n\tskipProductionReadinessGate?: boolean;\n\n\t// Built-in health endpoints, registered on boot (unprefixed by basePath):\n\t// GET /healthz — liveness, always 200 while the process is up\n\t// GET /readyz — readiness, 200 when checkProductionReadiness() is ok AND\n\t// readyProbe() (if provided) resolves truthy, else 503\n\t// Set false to disable. Point your platform's probes at these.\n\thealthChecks?: boolean;\n\t// Optional dependency probe for /readyz — e.g. `() => store.testConnection()`.\n\t// Throwing or returning false makes /readyz report 503.\n\treadyProbe?: () => boolean | Promise<boolean>;\n\t// /readyz's `problems` list names weak secrets and placeholder tokens — a\n\t// security-posture map — so in production it is omitted unless this is\n\t// explicitly true. Probes only need the status code. Non-production always\n\t// includes details.\n\texposeReadyzDetails?: boolean;\n\n\t// Enable Prometheus-format metrics: counts requests by status class and\n\t// serves them at GET /metrics (unprefixed). Off by default. Access to\n\t// /metrics should be restricted at your ingress/scraper.\n\tmetrics?: boolean;\n\n\t// Cap on the request body the built-in `listen()` server will buffer, in\n\t// bytes. Defaults to 5 MiB (matching the adapters). Without a cap, an\n\t// unauthenticated request could stream an arbitrarily large body fully into\n\t// memory before any handler runs — a memory-exhaustion DoS. Oversize\n\t// requests get 413. Raise for large uploads (e.g. @fonderie/media images);\n\t// adapter deployments configure this on the adapter instead.\n\tmaxBodyBytes?: number;\n\n\tonError?: (err: unknown) => Response;\n\n\t// Transform every JSON response body just before it is sent. Return the new\n\t// body, or `undefined` to leave that response untouched. This is the single,\n\t// adapter-agnostic seam for adapting Fonderie's `{ reason, explanation, result }`\n\t// envelope to an app's own contract (e.g. flat shapes an existing frontend\n\t// expects) WITHOUT editing handlers. Applied at the one egress point, so it\n\t// covers every route and every adapter. Status code, headers, and cookies are\n\t// preserved; only the body shape changes. Non-JSON responses pass through.\n\t// Opt-in: unset = current behaviour, unchanged.\n\tonResponse?: (body: unknown, info: { status: number; request: Request }) => unknown;\n}\n\nexport function defineConfig(config: FonderieConfig): FonderieConfig {\n\treturn config; // typed identity — same pattern as defineConfig in Vite/Nuxt\n}\n"],"mappings":";AAuFO,SAAS,aAAa,QAAwC;AACpE,SAAO;AACR;","names":[]}
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_MAX_BODY_BYTES: () => DEFAULT_MAX_BODY_BYTES,
23
24
  FonderieApp: () => FonderieApp,
24
25
  HTTP: () => HTTP,
25
26
  MIN_SECRET_LENGTH: () => MIN_SECRET_LENGTH,
@@ -133,6 +134,7 @@ var HTTP = {
133
134
  NOT_FOUND: 404,
134
135
  CONFLICT: 409,
135
136
  GONE: 410,
137
+ PAYLOAD_TOO_LARGE: 413,
136
138
  UNPROCESSABLE: 422,
137
139
  TOO_MANY_REQUESTS: 429,
138
140
  SERVER_ERROR: 500,
@@ -154,25 +156,75 @@ function notFoundMiddleware() {
154
156
  }
155
157
 
156
158
  // src/middlewares/body-parser.ts
157
- var withBody = async (ctx, next) => {
158
- const method = ctx.request.method.toUpperCase();
159
- if (method === "GET" || method === "HEAD") {
160
- return next();
159
+ var DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024;
160
+ var PayloadTooLargeError = class extends Error {
161
+ fonderiePayloadTooLarge = true;
162
+ };
163
+ async function readTextCapped(req, maxBytes) {
164
+ const declared = Number(req.headers.get("content-length"));
165
+ if (Number.isFinite(declared) && declared > maxBytes) {
166
+ throw new PayloadTooLargeError();
161
167
  }
162
- const ct = ctx.request.headers.get("content-type") ?? "";
163
- try {
164
- if (ct.includes("application/json")) {
165
- const text = (await ctx.request.clone().text()).trim();
166
- ctx.meta.body = text ? JSON.parse(text) : {};
167
- } else if (ct.includes("application/x-www-form-urlencoded")) {
168
- const text = await ctx.request.clone().text();
169
- ctx.meta.body = Object.fromEntries(new URLSearchParams(text));
168
+ if (!req.body) return null;
169
+ const reader = req.body.getReader();
170
+ const chunks = [];
171
+ let total = 0;
172
+ for (; ; ) {
173
+ const { done, value } = await reader.read();
174
+ if (done) break;
175
+ total += value.byteLength;
176
+ if (total > maxBytes) {
177
+ await reader.cancel().catch(() => void 0);
178
+ throw new PayloadTooLargeError();
170
179
  }
171
- } catch {
172
- return setApiResponse(HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid request body");
180
+ chunks.push(value);
173
181
  }
174
- return next();
175
- };
182
+ const merged = new Uint8Array(total);
183
+ let offset = 0;
184
+ for (const c of chunks) {
185
+ merged.set(c, offset);
186
+ offset += c.byteLength;
187
+ }
188
+ return new TextDecoder().decode(merged);
189
+ }
190
+ function bodyParser(maxBytes = DEFAULT_MAX_BODY_BYTES) {
191
+ return async (ctx, next) => {
192
+ const method = ctx.request.method.toUpperCase();
193
+ if (method === "GET" || method === "HEAD") {
194
+ return next();
195
+ }
196
+ const ct = ctx.request.headers.get("content-type") ?? "";
197
+ try {
198
+ if (ct.includes("application/json") || ct.includes("application/x-www-form-urlencoded")) {
199
+ const raw = await readTextCapped(ctx.request, maxBytes);
200
+ if (raw !== null) {
201
+ ctx.request = new Request(ctx.request.url, {
202
+ method: ctx.request.method,
203
+ headers: ctx.request.headers,
204
+ body: raw.length > 0 ? raw : null
205
+ });
206
+ }
207
+ if (ct.includes("application/json")) {
208
+ const text = raw?.trim() ?? "";
209
+ ctx.meta.body = text ? JSON.parse(text) : {};
210
+ } else {
211
+ ctx.meta.body = Object.fromEntries(new URLSearchParams(raw ?? ""));
212
+ }
213
+ }
214
+ } catch (err) {
215
+ if (err?.fonderiePayloadTooLarge) {
216
+ return setApiResponse(
217
+ HTTP.PAYLOAD_TOO_LARGE,
218
+ "PAYLOAD_TOO_LARGE",
219
+ "Request body too large"
220
+ );
221
+ }
222
+ return setApiResponse(HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid request body");
223
+ }
224
+ return next();
225
+ };
226
+ }
227
+ var withBody = bodyParser();
176
228
 
177
229
  // src/middlewares/security-headers.ts
178
230
  function withSecurityHeaders(options = {}) {
@@ -268,6 +320,14 @@ function withMetrics(registry) {
268
320
  }
269
321
 
270
322
  // src/app.ts
323
+ var PayloadTooLargeError2 = class extends Error {
324
+ fonderiePayloadTooLarge = true;
325
+ };
326
+ function payloadTooLarge(res) {
327
+ res.statusCode = 413;
328
+ res.setHeader("content-type", "application/json");
329
+ res.end(JSON.stringify({ reason: "PAYLOAD_TOO_LARGE", explanation: "Request body too large" }));
330
+ }
271
331
  var FonderieApp = class {
272
332
  config;
273
333
  prefix;
@@ -278,7 +338,7 @@ var FonderieApp = class {
278
338
  constructor(config) {
279
339
  this.config = config;
280
340
  this.prefix = (config.basePath ?? "").replace(/\/$/, "");
281
- this.middlewares = [withBody, withSecurityHeaders()];
341
+ this.middlewares = [bodyParser(config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES), withSecurityHeaders()];
282
342
  if (config.metrics) this.middlewares.push(withMetrics(this.metrics));
283
343
  }
284
344
  listen(port, options = {}) {
@@ -288,41 +348,81 @@ var FonderieApp = class {
288
348
  env = process.env["NODE_ENV"] ?? "development",
289
349
  quiet = false
290
350
  } = options;
351
+ const maxBodyBytes = this.config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
291
352
  const server = (0, import_node_http.createServer)(async (req, res) => {
292
- const host = req.headers.host ?? "localhost";
293
- const url = `http://${host}${req.url ?? "/"}`;
294
- const headers = new Headers();
295
- for (const [key, value] of Object.entries(req.headers)) {
296
- if (!value) {
297
- continue;
353
+ try {
354
+ const host = req.headers.host ?? "localhost";
355
+ const url = `http://${host}${req.url ?? "/"}`;
356
+ const headers = new Headers();
357
+ for (const [key, value] of Object.entries(req.headers)) {
358
+ if (!value) {
359
+ continue;
360
+ }
361
+ if (Array.isArray(value)) {
362
+ for (const v of value) headers.append(key, v);
363
+ } else {
364
+ headers.set(key, value);
365
+ }
298
366
  }
299
- if (Array.isArray(value)) {
300
- for (const v of value) headers.append(key, v);
301
- } else {
302
- headers.set(key, value);
367
+ const method = req.method ?? "GET";
368
+ const hasBody = !["GET", "HEAD"].includes(method.toUpperCase());
369
+ const declared = Number(req.headers["content-length"]);
370
+ if (hasBody && Number.isFinite(declared) && declared > maxBodyBytes) {
371
+ payloadTooLarge(res);
372
+ res.once("close", () => req.destroy());
373
+ return;
374
+ }
375
+ let body;
376
+ try {
377
+ body = await new Promise((resolve, reject) => {
378
+ const chunks = [];
379
+ let total = 0;
380
+ req.on("data", (chunk) => {
381
+ total += chunk.length;
382
+ if (total > maxBodyBytes) {
383
+ reject(new PayloadTooLargeError2());
384
+ req.destroy();
385
+ return;
386
+ }
387
+ chunks.push(chunk);
388
+ });
389
+ req.on("end", () => resolve(Buffer.concat(chunks)));
390
+ req.on("error", reject);
391
+ });
392
+ } catch (err) {
393
+ if (err instanceof PayloadTooLargeError2) {
394
+ payloadTooLarge(res);
395
+ return;
396
+ }
397
+ res.statusCode = 400;
398
+ res.end();
399
+ return;
400
+ }
401
+ const request = new Request(url, {
402
+ method,
403
+ headers,
404
+ body: hasBody && body.length > 0 ? new Uint8Array(body) : null
405
+ });
406
+ const response = await this.handle(request);
407
+ res.statusCode = response.status;
408
+ const setCookies = response.headers.getSetCookie?.() ?? [];
409
+ if (setCookies.length) res.setHeader("Set-Cookie", setCookies);
410
+ response.headers.forEach((v, k) => {
411
+ if (k.toLowerCase() !== "set-cookie") res.setHeader(k, v);
412
+ });
413
+ res.end(Buffer.from(await response.arrayBuffer()));
414
+ } catch (err) {
415
+ console.error("[fonderie] request handling failed:", err?.message);
416
+ try {
417
+ if (!res.headersSent) {
418
+ res.statusCode = 400;
419
+ res.setHeader("content-type", "application/json");
420
+ }
421
+ res.end(JSON.stringify({ reason: "BAD_REQUEST", explanation: "Malformed request" }));
422
+ } catch {
423
+ req.destroy();
303
424
  }
304
425
  }
305
- const body = await new Promise((resolve, reject) => {
306
- const chunks = [];
307
- req.on("data", (chunk) => chunks.push(chunk));
308
- req.on("end", () => resolve(Buffer.concat(chunks)));
309
- req.on("error", reject);
310
- });
311
- const method = req.method ?? "GET";
312
- const hasBody = !["GET", "HEAD"].includes(method.toUpperCase());
313
- const request = new Request(url, {
314
- method,
315
- headers,
316
- body: hasBody && body.length > 0 ? new Uint8Array(body) : null
317
- });
318
- const response = await this.handle(request);
319
- res.statusCode = response.status;
320
- const setCookies = response.headers.getSetCookie?.() ?? [];
321
- if (setCookies.length) res.setHeader("Set-Cookie", setCookies);
322
- response.headers.forEach((v, k) => {
323
- if (k.toLowerCase() !== "set-cookie") res.setHeader(k, v);
324
- });
325
- res.end(Buffer.from(await response.arrayBuffer()));
326
426
  }).listen(port, () => {
327
427
  if (quiet) return;
328
428
  const ip = getLocalIPv4();
@@ -405,8 +505,13 @@ var FonderieApp = class {
405
505
  }
406
506
  }
407
507
  const ready = report.ok && dependencies;
508
+ const exposeDetails = process.env["NODE_ENV"] !== "production" || this.config.exposeReadyzDetails === true;
408
509
  return Response.json(
409
- { status: ready ? "ready" : "not_ready", dependencies, problems: report.problems },
510
+ {
511
+ status: ready ? "ready" : "not_ready",
512
+ dependencies,
513
+ ...exposeDetails ? { problems: report.problems } : {}
514
+ },
410
515
  { status: ready ? 200 : 503 }
411
516
  );
412
517
  }
@@ -579,6 +684,7 @@ function decodeKeysetCursor(cursor) {
579
684
  }
580
685
  // Annotate the CommonJS export names for ESM import in node:
581
686
  0 && (module.exports = {
687
+ DEFAULT_MAX_BODY_BYTES,
582
688
  FonderieApp,
583
689
  HTTP,
584
690
  MIN_SECRET_LENGTH,