@fonderie/core 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -161,6 +161,7 @@ interface FonderieConfig {
161
161
  skipProductionReadinessGate?: boolean;
162
162
  healthChecks?: boolean;
163
163
  readyProbe?: () => boolean | Promise<boolean>;
164
+ exposeReadyzDetails?: boolean;
164
165
  metrics?: boolean;
165
166
  maxBodyBytes?: number;
166
167
  onError?: (err: unknown) => Response;
@@ -200,7 +201,7 @@ interface IApiError {
200
201
 
201
202
  type HttpStatus = (typeof HTTP)[keyof typeof HTTP];
202
203
 
203
- 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; }
204
205
 
205
206
  function setApiResponse<T>(status: number, reason: string, explanation: string, payload?: T | undefined): Response
206
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\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;AAkFO,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,6 +31,7 @@ interface FonderieConfig {
31
31
  skipProductionReadinessGate?: boolean;
32
32
  healthChecks?: boolean;
33
33
  readyProbe?: () => boolean | Promise<boolean>;
34
+ exposeReadyzDetails?: boolean;
34
35
  metrics?: boolean;
35
36
  maxBodyBytes?: number;
36
37
  onError?: (err: unknown) => Response;
package/dist/config.d.ts CHANGED
@@ -31,6 +31,7 @@ interface FonderieConfig {
31
31
  skipProductionReadinessGate?: boolean;
32
32
  healthChecks?: boolean;
33
33
  readyProbe?: () => boolean | Promise<boolean>;
34
+ exposeReadyzDetails?: boolean;
34
35
  metrics?: boolean;
35
36
  maxBodyBytes?: number;
36
37
  onError?: (err: unknown) => Response;
@@ -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\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":";AAkFO,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
@@ -134,6 +134,7 @@ var HTTP = {
134
134
  NOT_FOUND: 404,
135
135
  CONFLICT: 409,
136
136
  GONE: 410,
137
+ PAYLOAD_TOO_LARGE: 413,
137
138
  UNPROCESSABLE: 422,
138
139
  TOO_MANY_REQUESTS: 429,
139
140
  SERVER_ERROR: 500,
@@ -155,25 +156,75 @@ function notFoundMiddleware() {
155
156
  }
156
157
 
157
158
  // src/middlewares/body-parser.ts
158
- var withBody = async (ctx, next) => {
159
- const method = ctx.request.method.toUpperCase();
160
- if (method === "GET" || method === "HEAD") {
161
- 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();
162
167
  }
163
- const ct = ctx.request.headers.get("content-type") ?? "";
164
- try {
165
- if (ct.includes("application/json")) {
166
- const text = (await ctx.request.clone().text()).trim();
167
- ctx.meta.body = text ? JSON.parse(text) : {};
168
- } else if (ct.includes("application/x-www-form-urlencoded")) {
169
- const text = await ctx.request.clone().text();
170
- 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();
171
179
  }
172
- } catch {
173
- return setApiResponse(HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid request body");
180
+ chunks.push(value);
174
181
  }
175
- return next();
176
- };
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();
177
228
 
178
229
  // src/middlewares/security-headers.ts
179
230
  function withSecurityHeaders(options = {}) {
@@ -269,8 +320,7 @@ function withMetrics(registry) {
269
320
  }
270
321
 
271
322
  // src/app.ts
272
- var DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024;
273
- var PayloadTooLargeError = class extends Error {
323
+ var PayloadTooLargeError2 = class extends Error {
274
324
  fonderiePayloadTooLarge = true;
275
325
  };
276
326
  function payloadTooLarge(res) {
@@ -288,7 +338,7 @@ var FonderieApp = class {
288
338
  constructor(config) {
289
339
  this.config = config;
290
340
  this.prefix = (config.basePath ?? "").replace(/\/$/, "");
291
- this.middlewares = [withBody, withSecurityHeaders()];
341
+ this.middlewares = [bodyParser(config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES), withSecurityHeaders()];
292
342
  if (config.metrics) this.middlewares.push(withMetrics(this.metrics));
293
343
  }
294
344
  listen(port, options = {}) {
@@ -300,66 +350,79 @@ var FonderieApp = class {
300
350
  } = options;
301
351
  const maxBodyBytes = this.config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
302
352
  const server = (0, import_node_http.createServer)(async (req, res) => {
303
- const host = req.headers.host ?? "localhost";
304
- const url = `http://${host}${req.url ?? "/"}`;
305
- const headers = new Headers();
306
- for (const [key, value] of Object.entries(req.headers)) {
307
- if (!value) {
308
- 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
+ }
309
366
  }
310
- if (Array.isArray(value)) {
311
- for (const v of value) headers.append(key, v);
312
- } else {
313
- 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;
314
374
  }
315
- }
316
- const method = req.method ?? "GET";
317
- const hasBody = !["GET", "HEAD"].includes(method.toUpperCase());
318
- const declared = Number(req.headers["content-length"]);
319
- if (hasBody && Number.isFinite(declared) && declared > maxBodyBytes) {
320
- payloadTooLarge(res);
321
- req.destroy();
322
- return;
323
- }
324
- let body;
325
- try {
326
- body = await new Promise((resolve, reject) => {
327
- const chunks = [];
328
- let total = 0;
329
- req.on("data", (chunk) => {
330
- total += chunk.length;
331
- if (total > maxBodyBytes) {
332
- reject(new PayloadTooLargeError());
333
- req.destroy();
334
- return;
335
- }
336
- chunks.push(chunk);
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);
337
391
  });
338
- req.on("end", () => resolve(Buffer.concat(chunks)));
339
- req.on("error", reject);
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
340
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()));
341
414
  } catch (err) {
342
- if (err instanceof PayloadTooLargeError) {
343
- payloadTooLarge(res);
344
- return;
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();
345
424
  }
346
- res.statusCode = 400;
347
- res.end();
348
- return;
349
425
  }
350
- const request = new Request(url, {
351
- method,
352
- headers,
353
- body: hasBody && body.length > 0 ? new Uint8Array(body) : null
354
- });
355
- const response = await this.handle(request);
356
- res.statusCode = response.status;
357
- const setCookies = response.headers.getSetCookie?.() ?? [];
358
- if (setCookies.length) res.setHeader("Set-Cookie", setCookies);
359
- response.headers.forEach((v, k) => {
360
- if (k.toLowerCase() !== "set-cookie") res.setHeader(k, v);
361
- });
362
- res.end(Buffer.from(await response.arrayBuffer()));
363
426
  }).listen(port, () => {
364
427
  if (quiet) return;
365
428
  const ip = getLocalIPv4();
@@ -442,8 +505,13 @@ var FonderieApp = class {
442
505
  }
443
506
  }
444
507
  const ready = report.ok && dependencies;
508
+ const exposeDetails = process.env["NODE_ENV"] !== "production" || this.config.exposeReadyzDetails === true;
445
509
  return Response.json(
446
- { status: ready ? "ready" : "not_ready", dependencies, problems: report.problems },
510
+ {
511
+ status: ready ? "ready" : "not_ready",
512
+ dependencies,
513
+ ...exposeDetails ? { problems: report.problems } : {}
514
+ },
447
515
  { status: ready ? 200 : 503 }
448
516
  );
449
517
  }
@@ -473,7 +541,12 @@ var FonderieApp = class {
473
541
  workspace: null,
474
542
  meta: { _buildContext: true }
475
543
  };
476
- await compose(this.middlewares)(ctx, async () => new Response());
544
+ let completed = false;
545
+ const out = await compose(this.middlewares)(ctx, async () => {
546
+ completed = true;
547
+ return new Response();
548
+ });
549
+ if (!completed) ctx.meta["pipelineResponse"] = out;
477
550
  delete ctx.meta["_buildContext"];
478
551
  return ctx;
479
552
  }