@fonderie/adapter-hono 6.1.3 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,8 @@ function mount(hono: Hono<BlankEnv, BlankSchema, "/">, fonderie: FonderieApp): H
19
19
 
20
20
  function cors(options?: CorsOptions | undefined): MiddlewareHandler
21
21
 
22
+ function drainQueue(bus: IDrainable, options?: IDrainQueueOptions): MiddlewareHandler
23
+
22
24
  const OPERATIONS: { readonly CREATE: "create"; readonly READ: "read"; readonly UPDATE: "update"; readonly DELETE: "delete"; }
23
25
 
24
26
  type FonderieVariables = {
@@ -30,4 +32,15 @@ interface IBridgeOptions {
30
32
  }
31
33
 
32
34
  function requireAuth(c: Context<any, string, {}>, next: Next): Promise<void | Response>
35
+
36
+ interface IDrainable {
37
+ drain(options?: {
38
+ maxMs?: number;
39
+ }): Promise<void>;
40
+ }
41
+
42
+ interface IDrainQueueOptions {
43
+ maxMs?: number;
44
+ onError?: (error: unknown) => void;
45
+ }
33
46
  ```
package/dist/index.cjs CHANGED
@@ -30,10 +30,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- OPERATIONS: () => import_core.OPERATIONS,
33
+ OPERATIONS: () => import_core2.OPERATIONS,
34
34
  adapt: () => adapt,
35
35
  bridge: () => bridge,
36
36
  cors: () => cors,
37
+ drainQueue: () => drainQueue,
37
38
  mount: () => mount,
38
39
  requireAuth: () => requireAuth,
39
40
  requireFeature: () => requireFeature,
@@ -41,8 +42,9 @@ __export(index_exports, {
41
42
  withWorkspace: () => withWorkspace
42
43
  });
43
44
  module.exports = __toCommonJS(index_exports);
44
- var import_middlewares = require("@fonderie/core/middlewares");
45
45
  var import_core = require("@fonderie/core");
46
+ var import_middlewares = require("@fonderie/core/middlewares");
47
+ var import_core2 = require("@fonderie/core");
46
48
  async function loadOptionalPeer(load, pkg, api) {
47
49
  try {
48
50
  return await load();
@@ -154,12 +156,39 @@ function cors(options) {
154
156
  }
155
157
  };
156
158
  }
159
+ function createDrainRunner(bus, options = {}) {
160
+ const maxMs = options.maxMs ?? 1e4;
161
+ let inFlight = null;
162
+ return () => {
163
+ if (!inFlight) {
164
+ inFlight = bus.drain({ maxMs }).catch((err) => {
165
+ if (options.onError) options.onError(err);
166
+ else console.error("[fonderie] queue drain failed:", describeDrainError(err));
167
+ }).finally(() => {
168
+ inFlight = null;
169
+ });
170
+ }
171
+ return inFlight;
172
+ };
173
+ }
174
+ function describeDrainError(err) {
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ return /(column|relation) .* does not exist/i.test(message) ? `${message} \u2014 this deployment is ahead of its migrations. Run them against this database; queued work is durable and delivers once they land.` : message;
177
+ }
178
+ function drainQueue(bus, options = {}) {
179
+ const run = createDrainRunner(bus, options);
180
+ return async (_c, next) => {
181
+ await next();
182
+ void (0, import_core.background)(run());
183
+ };
184
+ }
157
185
  // Annotate the CommonJS export names for ESM import in node:
158
186
  0 && (module.exports = {
159
187
  OPERATIONS,
160
188
  adapt,
161
189
  bridge,
162
190
  cors,
191
+ drainQueue,
163
192
  mount,
164
193
  requireAuth,
165
194
  requireFeature,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Context, MiddlewareHandler } from 'hono';\nimport type { Hono } from 'hono';\n\nimport type { FonderieApp, IFonderieContext, Middleware } from '@fonderie/core';\nimport {\n\trequireAuth as _requireAuth,\n\tresolveClientIp,\n\tresolveCorsOptions,\n\tcorsHeadersFor,\n\ttype CorsOptions,\n} from '@fonderie/core/middlewares';\n// Optional peers: type-only imports (erased at runtime). The guard factories\n// below load them lazily so installing this adapter never requires\n// @fonderie/workspaces, @fonderie/permissions, or @fonderie/billing unless\n// the corresponding guard is actually used.\nimport type { withWorkspace as _withWorkspace } from '@fonderie/workspaces';\nimport type { requirePermission as _requirePermission } from '@fonderie/permissions';\n\nexport { OPERATIONS } from '@fonderie/core';\n\nasync function loadOptionalPeer<T>(load: () => Promise<T>, pkg: string, api: string): Promise<T> {\n\ttry {\n\t\treturn await load();\n\t} catch (err) {\n\t\tconst e = err as { code?: string; message?: string } | undefined;\n\t\tconst notFound = e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND';\n\t\t// Only claim the peer is missing when the unresolved specifier IS the\n\t\t// peer — a transitive failure inside an installed peer must surface\n\t\t// as-is, not as a misleading install hint.\n\t\tconst missing = notFound ? /Cannot find (?:package|module) '([^']+)'/.exec(e?.message ?? '')?.[1] : undefined;\n\t\tif (missing === pkg || missing?.startsWith(pkg + '/')) {\n\t\t\tthrow new Error(\n\t\t\t\t`[fonderie] ${api} requires the optional peer dependency \"${pkg}\". Install it: npm install ${pkg}`,\n\t\t\t);\n\t\t}\n\t\tthrow err;\n\t}\n}\n\n// Augment Hono's ContextVariableMap so c.get('_fonderie') is typed.\ndeclare module 'hono' {\n\tinterface ContextVariableMap {\n\t\t_fonderie: IFonderieContext;\n\t}\n}\n\n// Re-export for consumers who want to type their Hono app:\n// const hono = new Hono<{ Variables: FonderieVariables }>()\nexport type FonderieVariables = {\n\t_fonderie: IFonderieContext;\n};\n\n// ── bridge ────────────────────────────────────────────────────────\n//\n// Global middleware. Runs fonderie's session + billing global stack so that\n// ctx.user and ctx.meta['billing'] are available in every route handler.\n// Must be registered before any fonderie-aware route middleware.\n//\n// hono.use('*', bridge(fonderie))\n\nexport interface IBridgeOptions {\n\t/**\n\t * Name of the header that carries the PLATFORM-VERIFIED client IP — e.g.\n\t * 'cf-connecting-ip' on Cloudflare, 'x-real-ip' behind an nginx that sets\n\t * it. This is an explicit opt-in: request headers are attacker-settable,\n\t * so trusting one by default would let any client spoof its IP and dodge\n\t * per-IP rate limits (auth brute-force limiters key on this). Only set it\n\t * when your platform/proxy STRIPS the header from client requests and\n\t * injects its own value.\n\t */\n\tipHeader?: string;\n}\n\nexport function bridge(fonderie: FonderieApp, options: IBridgeOptions = {}): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\t// No clone(): teeing a request and fully reading ONE branch while the\n\t\t// other sits unread stalls once the body crosses the stream's\n\t\t// high-water mark (undici's tee applies backpressure from the slower\n\t\t// consumer). buildContext consumes the body and core's parser\n\t\t// re-materializes ctx.request from the buffered bytes, so mount() and\n\t\t// app routes read from THAT instead of the spent raw request.\n\t\tconst ctx = await fonderie.buildContext(c.req.raw);\n\t\t// A global middleware short-circuited while building context (e.g. the\n\t\t// body parser's 413) — that response must reach the client, not be\n\t\t// swallowed by context-building.\n\t\tconst early = ctx.meta['pipelineResponse'];\n\t\tif (early instanceof Response) return early;\n\t\t// buildContext CONSUMED c.req.raw's body (no clone — a tee stalls on\n\t\t// large bodies). Core's parser re-materialized ctx.request from the\n\t\t// buffered bytes; point Hono's request at it so the app's OWN native\n\t\t// handlers (c.req.json()/text()/parseBody()) still read the body —\n\t\t// otherwise they'd hit a drained stream. For content-types the parser\n\t\t// leaves untouched (multipart), ctx.request === the original, so this\n\t\t// is a no-op. bodyCache is empty here (nothing read yet).\n\t\tif (ctx.request !== c.req.raw) {\n\t\t\t(c.req as { raw: Request }).raw = ctx.request;\n\t\t}\n\t\t// Client IP, spoof-safe by default (mirrors core's trustProxy model):\n\t\t// 1. The real socket address when the runtime exposes one\n\t\t// (@hono/node-server puts the node request on c.env.incoming).\n\t\t// 2. A platform header ONLY when explicitly configured via ipHeader.\n\t\t// 3. X-Forwarded-For only per core's TRUST_PROXY hop count.\n\t\t// Previously cf-connecting-ip/x-real-ip were trusted UNCONDITIONALLY,\n\t\t// which let any direct client forge its IP (fresh rate-limit bucket per\n\t\t// request) or omit it (limiter skipped) on self-hosted deployments.\n\t\tconst socketIp = (\n\t\t\tc.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined\n\t\t)?.incoming?.socket?.remoteAddress;\n\t\tconst headerIp = options.ipHeader\n\t\t\t? (c.req.raw.headers.get(options.ipHeader) ?? undefined)\n\t\t\t: undefined;\n\t\tconst clientIp = resolveClientIp(headerIp ?? socketIp ?? undefined, c.req.raw.headers);\n\t\tif (clientIp) ctx.meta.clientIp = clientIp;\n\t\tc.set('_fonderie', ctx);\n\t\tawait next();\n\t};\n}\n\n// ── adapt ─────────────────────────────────────────────────────────\n//\n// Low-level escape hatch — wraps any fonderie Middleware into a Hono\n// MiddlewareHandler. Use this for custom fonderie middleware; prefer the\n// named exports below for the built-in fonderie guards.\n\nexport function adapt(middleware: Middleware): MiddlewareHandler {\n\treturn async (c: Context, next) => {\n\t\tconst ctx = c.get('_fonderie');\n\t\tif (!ctx) throw new Error('[fonderie] bridge() must be registered before adapt()');\n\n\t\tlet continued = false;\n\t\tconst result = await middleware(ctx, async () => {\n\t\t\tcontinued = true;\n\t\t\treturn new Response();\n\t\t});\n\n\t\tif (continued) {\n\t\t\tawait next();\n\t\t} else {\n\t\t\treturn result;\n\t\t}\n\t};\n}\n\n// ── Pre-adapted middleware ────────────────────────────────────────\n//\n// Drop-in replacements for the fonderie middleware functions — no adapt()\n// needed. Import directly from this package instead of from the source\n// packages, and use them as native Hono middleware.\n//\n// hono.get('/jobs', requireAuth, withWorkspace(store), ...)\n\nexport const requireAuth: MiddlewareHandler = adapt(_requireAuth);\n\n// The three guards below wrap OPTIONAL peers, so the peer is imported lazily\n// on first request — not at module load. MiddlewareHandler is async either\n// way, so the extra await changes nothing for callers.\n\nexport function withWorkspace(store: Parameters<typeof _withWorkspace>[0]): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/workspaces'),\n\t\t\t\t'@fonderie/workspaces',\n\t\t\t\t'withWorkspace()',\n\t\t\t);\n\t\t\tinner = adapt(mod.withWorkspace(store));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requirePermission(\n\toperation: Parameters<typeof _requirePermission>[0],\n\tpermissionKey: Parameters<typeof _requirePermission>[1],\n): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/permissions'),\n\t\t\t\t'@fonderie/permissions',\n\t\t\t\t'requirePermission()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requirePermission(operation, permissionKey));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requireFeature(key: string): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/billing'),\n\t\t\t\t'@fonderie/billing',\n\t\t\t\t'requireFeature()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requireFeature(key));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\n// ── mount ─────────────────────────────────────────────────────────\n//\n// Wires up fonderie to a Hono app. Returns the same app so you can add\n// routes after mount() — fonderie infra is the notFound handler, so\n// user routes always take priority:\n//\n// const api = mount(hono, fonderie)\n// api.get('/v1/todos', requireAuth, handler)\n// export default hono\n\n// mount() wires fonderie's infrastructure routes as the notFound fallback so\n// user routes always take priority. Call bridge() yourself before your routes\n// to ensure _fonderie is populated for them.\nexport function mount(hono: Hono, fonderie: FonderieApp): Hono {\n\thono.notFound((c) => {\n\t\t// bridge() consumed the raw body (see its no-clone note) and core's\n\t\t// parser re-materialized ctx.request with the buffered bytes — route\n\t\t// fonderie's handling through THAT. Without bridge (no ctx), the raw\n\t\t// request is untouched and safe to hand over directly.\n\t\tconst ctx = c.get('_fonderie') as IFonderieContext | undefined;\n\t\t// Hand over what only the adapter can observe — the socket-derived\n\t\t// client IP. handle() builds a fresh context, so without this seed every\n\t\t// fonderie-owned route sees no IP (login events, per-IP limits, geo/risk).\n\t\tconst clientIp = ctx?.meta.clientIp;\n\t\treturn fonderie.handle(ctx?.request ?? c.req.raw, clientIp ? { meta: { clientIp } } : undefined);\n\t});\n\treturn hono;\n}\n\n// ── App-level CORS ────────────────────────────────────────────────\n//\n// Native Hono middleware speaking core's CORS contract — same options and\n// defaults as withCors, so the headers @fonderie/client sends are allowed out\n// of the box (unlike hono/cors, whose defaults know nothing about them).\n// Register it on the Hono app itself so it covers EVERY route, including\n// ones outside the fonderie pipeline: fonderie.use(withCors()) only guards\n// the mounted basePath.\n//\n// app.use('*', cors({ credentials: true, origin: process.env.FRONTEND_URL! }))\n\nexport function cors(options?: CorsOptions): MiddlewareHandler {\n\tconst resolved = resolveCorsOptions(options);\n\treturn async (c, next) => {\n\t\tconst corsHeaders = corsHeadersFor(resolved, c.req.header('origin') ?? '');\n\t\t// Preflight — respond immediately, skip the pipeline\n\t\tif (c.req.method === 'OPTIONS') {\n\t\t\treturn c.body(null, 204, corsHeaders);\n\t\t}\n\t\tawait next();\n\t\tfor (const [k, v] of Object.entries(corsHeaders)) {\n\t\t\tc.res.headers.set(k, v);\n\t\t}\n\t};\n}\n"],"mappings":"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","names":["_requireAuth"]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Context, MiddlewareHandler } from 'hono';\nimport type { Hono } from 'hono';\n\nimport type { FonderieApp, IFonderieContext, Middleware } from '@fonderie/core';\nimport { background } from '@fonderie/core';\nimport {\n\trequireAuth as _requireAuth,\n\tresolveClientIp,\n\tresolveCorsOptions,\n\tcorsHeadersFor,\n\ttype CorsOptions,\n} from '@fonderie/core/middlewares';\n// Optional peers: type-only imports (erased at runtime). The guard factories\n// below load them lazily so installing this adapter never requires\n// @fonderie/workspaces, @fonderie/permissions, or @fonderie/billing unless\n// the corresponding guard is actually used.\nimport type { withWorkspace as _withWorkspace } from '@fonderie/workspaces';\nimport type { requirePermission as _requirePermission } from '@fonderie/permissions';\n\nexport { OPERATIONS } from '@fonderie/core';\n\nasync function loadOptionalPeer<T>(load: () => Promise<T>, pkg: string, api: string): Promise<T> {\n\ttry {\n\t\treturn await load();\n\t} catch (err) {\n\t\tconst e = err as { code?: string; message?: string } | undefined;\n\t\tconst notFound = e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND';\n\t\t// Only claim the peer is missing when the unresolved specifier IS the\n\t\t// peer — a transitive failure inside an installed peer must surface\n\t\t// as-is, not as a misleading install hint.\n\t\tconst missing = notFound ? /Cannot find (?:package|module) '([^']+)'/.exec(e?.message ?? '')?.[1] : undefined;\n\t\tif (missing === pkg || missing?.startsWith(pkg + '/')) {\n\t\t\tthrow new Error(\n\t\t\t\t`[fonderie] ${api} requires the optional peer dependency \"${pkg}\". Install it: npm install ${pkg}`,\n\t\t\t);\n\t\t}\n\t\tthrow err;\n\t}\n}\n\n// Augment Hono's ContextVariableMap so c.get('_fonderie') is typed.\ndeclare module 'hono' {\n\tinterface ContextVariableMap {\n\t\t_fonderie: IFonderieContext;\n\t}\n}\n\n// Re-export for consumers who want to type their Hono app:\n// const hono = new Hono<{ Variables: FonderieVariables }>()\nexport type FonderieVariables = {\n\t_fonderie: IFonderieContext;\n};\n\n// ── bridge ────────────────────────────────────────────────────────\n//\n// Global middleware. Runs fonderie's session + billing global stack so that\n// ctx.user and ctx.meta['billing'] are available in every route handler.\n// Must be registered before any fonderie-aware route middleware.\n//\n// hono.use('*', bridge(fonderie))\n\nexport interface IBridgeOptions {\n\t/**\n\t * Name of the header that carries the PLATFORM-VERIFIED client IP — e.g.\n\t * 'cf-connecting-ip' on Cloudflare, 'x-real-ip' behind an nginx that sets\n\t * it. This is an explicit opt-in: request headers are attacker-settable,\n\t * so trusting one by default would let any client spoof its IP and dodge\n\t * per-IP rate limits (auth brute-force limiters key on this). Only set it\n\t * when your platform/proxy STRIPS the header from client requests and\n\t * injects its own value.\n\t */\n\tipHeader?: string;\n}\n\nexport function bridge(fonderie: FonderieApp, options: IBridgeOptions = {}): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\t// No clone(): teeing a request and fully reading ONE branch while the\n\t\t// other sits unread stalls once the body crosses the stream's\n\t\t// high-water mark (undici's tee applies backpressure from the slower\n\t\t// consumer). buildContext consumes the body and core's parser\n\t\t// re-materializes ctx.request from the buffered bytes, so mount() and\n\t\t// app routes read from THAT instead of the spent raw request.\n\t\tconst ctx = await fonderie.buildContext(c.req.raw);\n\t\t// A global middleware short-circuited while building context (e.g. the\n\t\t// body parser's 413) — that response must reach the client, not be\n\t\t// swallowed by context-building.\n\t\tconst early = ctx.meta['pipelineResponse'];\n\t\tif (early instanceof Response) return early;\n\t\t// buildContext CONSUMED c.req.raw's body (no clone — a tee stalls on\n\t\t// large bodies). Core's parser re-materialized ctx.request from the\n\t\t// buffered bytes; point Hono's request at it so the app's OWN native\n\t\t// handlers (c.req.json()/text()/parseBody()) still read the body —\n\t\t// otherwise they'd hit a drained stream. For content-types the parser\n\t\t// leaves untouched (multipart), ctx.request === the original, so this\n\t\t// is a no-op. bodyCache is empty here (nothing read yet).\n\t\tif (ctx.request !== c.req.raw) {\n\t\t\t(c.req as { raw: Request }).raw = ctx.request;\n\t\t}\n\t\t// Client IP, spoof-safe by default (mirrors core's trustProxy model):\n\t\t// 1. The real socket address when the runtime exposes one\n\t\t// (@hono/node-server puts the node request on c.env.incoming).\n\t\t// 2. A platform header ONLY when explicitly configured via ipHeader.\n\t\t// 3. X-Forwarded-For only per core's TRUST_PROXY hop count.\n\t\t// Previously cf-connecting-ip/x-real-ip were trusted UNCONDITIONALLY,\n\t\t// which let any direct client forge its IP (fresh rate-limit bucket per\n\t\t// request) or omit it (limiter skipped) on self-hosted deployments.\n\t\tconst socketIp = (\n\t\t\tc.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined\n\t\t)?.incoming?.socket?.remoteAddress;\n\t\tconst headerIp = options.ipHeader\n\t\t\t? (c.req.raw.headers.get(options.ipHeader) ?? undefined)\n\t\t\t: undefined;\n\t\tconst clientIp = resolveClientIp(headerIp ?? socketIp ?? undefined, c.req.raw.headers);\n\t\tif (clientIp) ctx.meta.clientIp = clientIp;\n\t\tc.set('_fonderie', ctx);\n\t\tawait next();\n\t};\n}\n\n// ── adapt ─────────────────────────────────────────────────────────\n//\n// Low-level escape hatch — wraps any fonderie Middleware into a Hono\n// MiddlewareHandler. Use this for custom fonderie middleware; prefer the\n// named exports below for the built-in fonderie guards.\n\nexport function adapt(middleware: Middleware): MiddlewareHandler {\n\treturn async (c: Context, next) => {\n\t\tconst ctx = c.get('_fonderie');\n\t\tif (!ctx) throw new Error('[fonderie] bridge() must be registered before adapt()');\n\n\t\tlet continued = false;\n\t\tconst result = await middleware(ctx, async () => {\n\t\t\tcontinued = true;\n\t\t\treturn new Response();\n\t\t});\n\n\t\tif (continued) {\n\t\t\tawait next();\n\t\t} else {\n\t\t\treturn result;\n\t\t}\n\t};\n}\n\n// ── Pre-adapted middleware ────────────────────────────────────────\n//\n// Drop-in replacements for the fonderie middleware functions — no adapt()\n// needed. Import directly from this package instead of from the source\n// packages, and use them as native Hono middleware.\n//\n// hono.get('/jobs', requireAuth, withWorkspace(store), ...)\n\nexport const requireAuth: MiddlewareHandler = adapt(_requireAuth);\n\n// The three guards below wrap OPTIONAL peers, so the peer is imported lazily\n// on first request — not at module load. MiddlewareHandler is async either\n// way, so the extra await changes nothing for callers.\n\nexport function withWorkspace(store: Parameters<typeof _withWorkspace>[0]): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/workspaces'),\n\t\t\t\t'@fonderie/workspaces',\n\t\t\t\t'withWorkspace()',\n\t\t\t);\n\t\t\tinner = adapt(mod.withWorkspace(store));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requirePermission(\n\toperation: Parameters<typeof _requirePermission>[0],\n\tpermissionKey: Parameters<typeof _requirePermission>[1],\n): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/permissions'),\n\t\t\t\t'@fonderie/permissions',\n\t\t\t\t'requirePermission()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requirePermission(operation, permissionKey));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requireFeature(key: string): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/billing'),\n\t\t\t\t'@fonderie/billing',\n\t\t\t\t'requireFeature()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requireFeature(key));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\n// ── mount ─────────────────────────────────────────────────────────\n//\n// Wires up fonderie to a Hono app. Returns the same app so you can add\n// routes after mount() — fonderie infra is the notFound handler, so\n// user routes always take priority:\n//\n// const api = mount(hono, fonderie)\n// api.get('/v1/todos', requireAuth, handler)\n// export default hono\n\n// mount() wires fonderie's infrastructure routes as the notFound fallback so\n// user routes always take priority. Call bridge() yourself before your routes\n// to ensure _fonderie is populated for them.\nexport function mount(hono: Hono, fonderie: FonderieApp): Hono {\n\thono.notFound((c) => {\n\t\t// bridge() consumed the raw body (see its no-clone note) and core's\n\t\t// parser re-materialized ctx.request with the buffered bytes — route\n\t\t// fonderie's handling through THAT. Without bridge (no ctx), the raw\n\t\t// request is untouched and safe to hand over directly.\n\t\tconst ctx = c.get('_fonderie') as IFonderieContext | undefined;\n\t\t// Hand over what only the adapter can observe — the socket-derived\n\t\t// client IP. handle() builds a fresh context, so without this seed every\n\t\t// fonderie-owned route sees no IP (login events, per-IP limits, geo/risk).\n\t\tconst clientIp = ctx?.meta.clientIp;\n\t\treturn fonderie.handle(ctx?.request ?? c.req.raw, clientIp ? { meta: { clientIp } } : undefined);\n\t});\n\treturn hono;\n}\n\n// ── App-level CORS ────────────────────────────────────────────────\n//\n// Native Hono middleware speaking core's CORS contract — same options and\n// defaults as withCors, so the headers @fonderie/client sends are allowed out\n// of the box (unlike hono/cors, whose defaults know nothing about them).\n// Register it on the Hono app itself so it covers EVERY route, including\n// ones outside the fonderie pipeline: fonderie.use(withCors()) only guards\n// the mounted basePath.\n//\n// app.use('*', cors({ credentials: true, origin: process.env.FRONTEND_URL! }))\n\nexport function cors(options?: CorsOptions): MiddlewareHandler {\n\tconst resolved = resolveCorsOptions(options);\n\treturn async (c, next) => {\n\t\tconst corsHeaders = corsHeadersFor(resolved, c.req.header('origin') ?? '');\n\t\t// Preflight — respond immediately, skip the pipeline\n\t\tif (c.req.method === 'OPTIONS') {\n\t\t\treturn c.body(null, 204, corsHeaders);\n\t\t}\n\t\tawait next();\n\t\tfor (const [k, v] of Object.entries(corsHeaders)) {\n\t\t\tc.res.headers.set(k, v);\n\t\t}\n\t};\n}\n\n// ── Draining the outbox where nothing else can ─────────────────────\n//\n// On a long-running host the events transport LISTENs and delivers in\n// milliseconds, and this is unnecessary. On serverless there is no such\n// process: a poll loop never returns, and LISTEN is rejected outright by a\n// transaction-mode pooler — so work is published durably and then nobody\n// consumes it. Nothing looks broken; the mail simply never arrives.\n//\n// So the API consumes what it produced, after its own response. Never before:\n// draining first would make every caller wait on somebody else's work.\n//\n// Safe by construction. The row is already durable, so a drain that is\n// skipped, cut short, or loses its instance costs latency and nothing else —\n// the next one resumes where it stopped, and concurrent drains claim\n// exclusively. One drain is in flight per instance; the rest join it.\n//\n// app.use(drainQueue(events.bus))\n\n/** Structural — the adapter takes no dependency on @fonderie/events. */\nexport interface IDrainable {\n\tdrain(options?: { maxMs?: number }): Promise<void>;\n}\n\nexport interface IDrainQueueOptions {\n\t/**\n\t * Bound on one drain pass. Keep it comfortably under the platform's\n\t * function timeout: this runs inside the same invocation as the response,\n\t * so it spends the same budget.\n\t */\n\tmaxMs?: number;\n\t/** Defaults to console.error with the diagnosis the drain returned. */\n\tonError?: (error: unknown) => void;\n}\n\nfunction createDrainRunner(bus: IDrainable, options: IDrainQueueOptions = {}) {\n\tconst maxMs = options.maxMs ?? 10_000;\n\t// Coalesce: several concurrent responses should join ONE drain, not start\n\t// one each. Cross-instance concurrency is safe regardless — claims are\n\t// exclusive — this just avoids pointless work inside a single instance.\n\tlet inFlight: Promise<void> | null = null;\n\treturn () => {\n\t\tif (!inFlight) {\n\t\t\tinFlight = bus\n\t\t\t\t.drain({ maxMs })\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tif (options.onError) options.onError(err);\n\t\t\t\t\telse console.error('[fonderie] queue drain failed:', describeDrainError(err));\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tinFlight = null;\n\t\t\t\t});\n\t\t}\n\t\treturn inFlight;\n\t};\n}\n\n// Kept local so the adapter does not depend on @fonderie/events just to\n// phrase an error. Mirrors explainDrainFailure() there.\nfunction describeDrainError(err: unknown): string {\n\tconst message = err instanceof Error ? err.message : String(err);\n\treturn /(column|relation) .* does not exist/i.test(message)\n\t\t? `${message} — this deployment is ahead of its migrations. Run them against this database; queued work is durable and delivers once they land.`\n\t\t: message;\n}\n\nexport function drainQueue(bus: IDrainable, options: IDrainQueueOptions = {}): MiddlewareHandler {\n\tconst run = createDrainRunner(bus, options);\n\treturn async (_c, next) => {\n\t\tawait next();\n\t\tvoid background(run());\n\t};\n}\n"],"mappings":"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","names":["import_core","_requireAuth"]}
package/dist/index.d.cts CHANGED
@@ -33,5 +33,22 @@ declare function requirePermission(operation: Parameters<typeof requirePermissio
33
33
  declare function requireFeature(key: string): MiddlewareHandler;
34
34
  declare function mount(hono: Hono, fonderie: FonderieApp): Hono;
35
35
  declare function cors(options?: CorsOptions): MiddlewareHandler;
36
+ /** Structural — the adapter takes no dependency on @fonderie/events. */
37
+ interface IDrainable {
38
+ drain(options?: {
39
+ maxMs?: number;
40
+ }): Promise<void>;
41
+ }
42
+ interface IDrainQueueOptions {
43
+ /**
44
+ * Bound on one drain pass. Keep it comfortably under the platform's
45
+ * function timeout: this runs inside the same invocation as the response,
46
+ * so it spends the same budget.
47
+ */
48
+ maxMs?: number;
49
+ /** Defaults to console.error with the diagnosis the drain returned. */
50
+ onError?: (error: unknown) => void;
51
+ }
52
+ declare function drainQueue(bus: IDrainable, options?: IDrainQueueOptions): MiddlewareHandler;
36
53
 
37
- export { type FonderieVariables, type IBridgeOptions, adapt, bridge, cors, mount, requireAuth, requireFeature, requirePermission, withWorkspace };
54
+ export { type FonderieVariables, type IBridgeOptions, type IDrainQueueOptions, type IDrainable, adapt, bridge, cors, drainQueue, mount, requireAuth, requireFeature, requirePermission, withWorkspace };
package/dist/index.d.ts CHANGED
@@ -33,5 +33,22 @@ declare function requirePermission(operation: Parameters<typeof requirePermissio
33
33
  declare function requireFeature(key: string): MiddlewareHandler;
34
34
  declare function mount(hono: Hono, fonderie: FonderieApp): Hono;
35
35
  declare function cors(options?: CorsOptions): MiddlewareHandler;
36
+ /** Structural — the adapter takes no dependency on @fonderie/events. */
37
+ interface IDrainable {
38
+ drain(options?: {
39
+ maxMs?: number;
40
+ }): Promise<void>;
41
+ }
42
+ interface IDrainQueueOptions {
43
+ /**
44
+ * Bound on one drain pass. Keep it comfortably under the platform's
45
+ * function timeout: this runs inside the same invocation as the response,
46
+ * so it spends the same budget.
47
+ */
48
+ maxMs?: number;
49
+ /** Defaults to console.error with the diagnosis the drain returned. */
50
+ onError?: (error: unknown) => void;
51
+ }
52
+ declare function drainQueue(bus: IDrainable, options?: IDrainQueueOptions): MiddlewareHandler;
36
53
 
37
- export { type FonderieVariables, type IBridgeOptions, adapt, bridge, cors, mount, requireAuth, requireFeature, requirePermission, withWorkspace };
54
+ export { type FonderieVariables, type IBridgeOptions, type IDrainQueueOptions, type IDrainable, adapt, bridge, cors, drainQueue, mount, requireAuth, requireFeature, requirePermission, withWorkspace };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/index.ts
2
+ import { background } from "@fonderie/core";
2
3
  import {
3
4
  requireAuth as _requireAuth,
4
5
  resolveClientIp,
@@ -117,11 +118,38 @@ function cors(options) {
117
118
  }
118
119
  };
119
120
  }
121
+ function createDrainRunner(bus, options = {}) {
122
+ const maxMs = options.maxMs ?? 1e4;
123
+ let inFlight = null;
124
+ return () => {
125
+ if (!inFlight) {
126
+ inFlight = bus.drain({ maxMs }).catch((err) => {
127
+ if (options.onError) options.onError(err);
128
+ else console.error("[fonderie] queue drain failed:", describeDrainError(err));
129
+ }).finally(() => {
130
+ inFlight = null;
131
+ });
132
+ }
133
+ return inFlight;
134
+ };
135
+ }
136
+ function describeDrainError(err) {
137
+ const message = err instanceof Error ? err.message : String(err);
138
+ return /(column|relation) .* does not exist/i.test(message) ? `${message} \u2014 this deployment is ahead of its migrations. Run them against this database; queued work is durable and delivers once they land.` : message;
139
+ }
140
+ function drainQueue(bus, options = {}) {
141
+ const run = createDrainRunner(bus, options);
142
+ return async (_c, next) => {
143
+ await next();
144
+ void background(run());
145
+ };
146
+ }
120
147
  export {
121
148
  OPERATIONS,
122
149
  adapt,
123
150
  bridge,
124
151
  cors,
152
+ drainQueue,
125
153
  mount,
126
154
  requireAuth,
127
155
  requireFeature,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Context, MiddlewareHandler } from 'hono';\nimport type { Hono } from 'hono';\n\nimport type { FonderieApp, IFonderieContext, Middleware } from '@fonderie/core';\nimport {\n\trequireAuth as _requireAuth,\n\tresolveClientIp,\n\tresolveCorsOptions,\n\tcorsHeadersFor,\n\ttype CorsOptions,\n} from '@fonderie/core/middlewares';\n// Optional peers: type-only imports (erased at runtime). The guard factories\n// below load them lazily so installing this adapter never requires\n// @fonderie/workspaces, @fonderie/permissions, or @fonderie/billing unless\n// the corresponding guard is actually used.\nimport type { withWorkspace as _withWorkspace } from '@fonderie/workspaces';\nimport type { requirePermission as _requirePermission } from '@fonderie/permissions';\n\nexport { OPERATIONS } from '@fonderie/core';\n\nasync function loadOptionalPeer<T>(load: () => Promise<T>, pkg: string, api: string): Promise<T> {\n\ttry {\n\t\treturn await load();\n\t} catch (err) {\n\t\tconst e = err as { code?: string; message?: string } | undefined;\n\t\tconst notFound = e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND';\n\t\t// Only claim the peer is missing when the unresolved specifier IS the\n\t\t// peer — a transitive failure inside an installed peer must surface\n\t\t// as-is, not as a misleading install hint.\n\t\tconst missing = notFound ? /Cannot find (?:package|module) '([^']+)'/.exec(e?.message ?? '')?.[1] : undefined;\n\t\tif (missing === pkg || missing?.startsWith(pkg + '/')) {\n\t\t\tthrow new Error(\n\t\t\t\t`[fonderie] ${api} requires the optional peer dependency \"${pkg}\". Install it: npm install ${pkg}`,\n\t\t\t);\n\t\t}\n\t\tthrow err;\n\t}\n}\n\n// Augment Hono's ContextVariableMap so c.get('_fonderie') is typed.\ndeclare module 'hono' {\n\tinterface ContextVariableMap {\n\t\t_fonderie: IFonderieContext;\n\t}\n}\n\n// Re-export for consumers who want to type their Hono app:\n// const hono = new Hono<{ Variables: FonderieVariables }>()\nexport type FonderieVariables = {\n\t_fonderie: IFonderieContext;\n};\n\n// ── bridge ────────────────────────────────────────────────────────\n//\n// Global middleware. Runs fonderie's session + billing global stack so that\n// ctx.user and ctx.meta['billing'] are available in every route handler.\n// Must be registered before any fonderie-aware route middleware.\n//\n// hono.use('*', bridge(fonderie))\n\nexport interface IBridgeOptions {\n\t/**\n\t * Name of the header that carries the PLATFORM-VERIFIED client IP — e.g.\n\t * 'cf-connecting-ip' on Cloudflare, 'x-real-ip' behind an nginx that sets\n\t * it. This is an explicit opt-in: request headers are attacker-settable,\n\t * so trusting one by default would let any client spoof its IP and dodge\n\t * per-IP rate limits (auth brute-force limiters key on this). Only set it\n\t * when your platform/proxy STRIPS the header from client requests and\n\t * injects its own value.\n\t */\n\tipHeader?: string;\n}\n\nexport function bridge(fonderie: FonderieApp, options: IBridgeOptions = {}): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\t// No clone(): teeing a request and fully reading ONE branch while the\n\t\t// other sits unread stalls once the body crosses the stream's\n\t\t// high-water mark (undici's tee applies backpressure from the slower\n\t\t// consumer). buildContext consumes the body and core's parser\n\t\t// re-materializes ctx.request from the buffered bytes, so mount() and\n\t\t// app routes read from THAT instead of the spent raw request.\n\t\tconst ctx = await fonderie.buildContext(c.req.raw);\n\t\t// A global middleware short-circuited while building context (e.g. the\n\t\t// body parser's 413) — that response must reach the client, not be\n\t\t// swallowed by context-building.\n\t\tconst early = ctx.meta['pipelineResponse'];\n\t\tif (early instanceof Response) return early;\n\t\t// buildContext CONSUMED c.req.raw's body (no clone — a tee stalls on\n\t\t// large bodies). Core's parser re-materialized ctx.request from the\n\t\t// buffered bytes; point Hono's request at it so the app's OWN native\n\t\t// handlers (c.req.json()/text()/parseBody()) still read the body —\n\t\t// otherwise they'd hit a drained stream. For content-types the parser\n\t\t// leaves untouched (multipart), ctx.request === the original, so this\n\t\t// is a no-op. bodyCache is empty here (nothing read yet).\n\t\tif (ctx.request !== c.req.raw) {\n\t\t\t(c.req as { raw: Request }).raw = ctx.request;\n\t\t}\n\t\t// Client IP, spoof-safe by default (mirrors core's trustProxy model):\n\t\t// 1. The real socket address when the runtime exposes one\n\t\t// (@hono/node-server puts the node request on c.env.incoming).\n\t\t// 2. A platform header ONLY when explicitly configured via ipHeader.\n\t\t// 3. X-Forwarded-For only per core's TRUST_PROXY hop count.\n\t\t// Previously cf-connecting-ip/x-real-ip were trusted UNCONDITIONALLY,\n\t\t// which let any direct client forge its IP (fresh rate-limit bucket per\n\t\t// request) or omit it (limiter skipped) on self-hosted deployments.\n\t\tconst socketIp = (\n\t\t\tc.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined\n\t\t)?.incoming?.socket?.remoteAddress;\n\t\tconst headerIp = options.ipHeader\n\t\t\t? (c.req.raw.headers.get(options.ipHeader) ?? undefined)\n\t\t\t: undefined;\n\t\tconst clientIp = resolveClientIp(headerIp ?? socketIp ?? undefined, c.req.raw.headers);\n\t\tif (clientIp) ctx.meta.clientIp = clientIp;\n\t\tc.set('_fonderie', ctx);\n\t\tawait next();\n\t};\n}\n\n// ── adapt ─────────────────────────────────────────────────────────\n//\n// Low-level escape hatch — wraps any fonderie Middleware into a Hono\n// MiddlewareHandler. Use this for custom fonderie middleware; prefer the\n// named exports below for the built-in fonderie guards.\n\nexport function adapt(middleware: Middleware): MiddlewareHandler {\n\treturn async (c: Context, next) => {\n\t\tconst ctx = c.get('_fonderie');\n\t\tif (!ctx) throw new Error('[fonderie] bridge() must be registered before adapt()');\n\n\t\tlet continued = false;\n\t\tconst result = await middleware(ctx, async () => {\n\t\t\tcontinued = true;\n\t\t\treturn new Response();\n\t\t});\n\n\t\tif (continued) {\n\t\t\tawait next();\n\t\t} else {\n\t\t\treturn result;\n\t\t}\n\t};\n}\n\n// ── Pre-adapted middleware ────────────────────────────────────────\n//\n// Drop-in replacements for the fonderie middleware functions — no adapt()\n// needed. Import directly from this package instead of from the source\n// packages, and use them as native Hono middleware.\n//\n// hono.get('/jobs', requireAuth, withWorkspace(store), ...)\n\nexport const requireAuth: MiddlewareHandler = adapt(_requireAuth);\n\n// The three guards below wrap OPTIONAL peers, so the peer is imported lazily\n// on first request — not at module load. MiddlewareHandler is async either\n// way, so the extra await changes nothing for callers.\n\nexport function withWorkspace(store: Parameters<typeof _withWorkspace>[0]): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/workspaces'),\n\t\t\t\t'@fonderie/workspaces',\n\t\t\t\t'withWorkspace()',\n\t\t\t);\n\t\t\tinner = adapt(mod.withWorkspace(store));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requirePermission(\n\toperation: Parameters<typeof _requirePermission>[0],\n\tpermissionKey: Parameters<typeof _requirePermission>[1],\n): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/permissions'),\n\t\t\t\t'@fonderie/permissions',\n\t\t\t\t'requirePermission()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requirePermission(operation, permissionKey));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requireFeature(key: string): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/billing'),\n\t\t\t\t'@fonderie/billing',\n\t\t\t\t'requireFeature()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requireFeature(key));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\n// ── mount ─────────────────────────────────────────────────────────\n//\n// Wires up fonderie to a Hono app. Returns the same app so you can add\n// routes after mount() — fonderie infra is the notFound handler, so\n// user routes always take priority:\n//\n// const api = mount(hono, fonderie)\n// api.get('/v1/todos', requireAuth, handler)\n// export default hono\n\n// mount() wires fonderie's infrastructure routes as the notFound fallback so\n// user routes always take priority. Call bridge() yourself before your routes\n// to ensure _fonderie is populated for them.\nexport function mount(hono: Hono, fonderie: FonderieApp): Hono {\n\thono.notFound((c) => {\n\t\t// bridge() consumed the raw body (see its no-clone note) and core's\n\t\t// parser re-materialized ctx.request with the buffered bytes — route\n\t\t// fonderie's handling through THAT. Without bridge (no ctx), the raw\n\t\t// request is untouched and safe to hand over directly.\n\t\tconst ctx = c.get('_fonderie') as IFonderieContext | undefined;\n\t\t// Hand over what only the adapter can observe — the socket-derived\n\t\t// client IP. handle() builds a fresh context, so without this seed every\n\t\t// fonderie-owned route sees no IP (login events, per-IP limits, geo/risk).\n\t\tconst clientIp = ctx?.meta.clientIp;\n\t\treturn fonderie.handle(ctx?.request ?? c.req.raw, clientIp ? { meta: { clientIp } } : undefined);\n\t});\n\treturn hono;\n}\n\n// ── App-level CORS ────────────────────────────────────────────────\n//\n// Native Hono middleware speaking core's CORS contract — same options and\n// defaults as withCors, so the headers @fonderie/client sends are allowed out\n// of the box (unlike hono/cors, whose defaults know nothing about them).\n// Register it on the Hono app itself so it covers EVERY route, including\n// ones outside the fonderie pipeline: fonderie.use(withCors()) only guards\n// the mounted basePath.\n//\n// app.use('*', cors({ credentials: true, origin: process.env.FRONTEND_URL! }))\n\nexport function cors(options?: CorsOptions): MiddlewareHandler {\n\tconst resolved = resolveCorsOptions(options);\n\treturn async (c, next) => {\n\t\tconst corsHeaders = corsHeadersFor(resolved, c.req.header('origin') ?? '');\n\t\t// Preflight — respond immediately, skip the pipeline\n\t\tif (c.req.method === 'OPTIONS') {\n\t\t\treturn c.body(null, 204, corsHeaders);\n\t\t}\n\t\tawait next();\n\t\tfor (const [k, v] of Object.entries(corsHeaders)) {\n\t\t\tc.res.headers.set(k, v);\n\t\t}\n\t};\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Context, MiddlewareHandler } from 'hono';\nimport type { Hono } from 'hono';\n\nimport type { FonderieApp, IFonderieContext, Middleware } from '@fonderie/core';\nimport { background } from '@fonderie/core';\nimport {\n\trequireAuth as _requireAuth,\n\tresolveClientIp,\n\tresolveCorsOptions,\n\tcorsHeadersFor,\n\ttype CorsOptions,\n} from '@fonderie/core/middlewares';\n// Optional peers: type-only imports (erased at runtime). The guard factories\n// below load them lazily so installing this adapter never requires\n// @fonderie/workspaces, @fonderie/permissions, or @fonderie/billing unless\n// the corresponding guard is actually used.\nimport type { withWorkspace as _withWorkspace } from '@fonderie/workspaces';\nimport type { requirePermission as _requirePermission } from '@fonderie/permissions';\n\nexport { OPERATIONS } from '@fonderie/core';\n\nasync function loadOptionalPeer<T>(load: () => Promise<T>, pkg: string, api: string): Promise<T> {\n\ttry {\n\t\treturn await load();\n\t} catch (err) {\n\t\tconst e = err as { code?: string; message?: string } | undefined;\n\t\tconst notFound = e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND';\n\t\t// Only claim the peer is missing when the unresolved specifier IS the\n\t\t// peer — a transitive failure inside an installed peer must surface\n\t\t// as-is, not as a misleading install hint.\n\t\tconst missing = notFound ? /Cannot find (?:package|module) '([^']+)'/.exec(e?.message ?? '')?.[1] : undefined;\n\t\tif (missing === pkg || missing?.startsWith(pkg + '/')) {\n\t\t\tthrow new Error(\n\t\t\t\t`[fonderie] ${api} requires the optional peer dependency \"${pkg}\". Install it: npm install ${pkg}`,\n\t\t\t);\n\t\t}\n\t\tthrow err;\n\t}\n}\n\n// Augment Hono's ContextVariableMap so c.get('_fonderie') is typed.\ndeclare module 'hono' {\n\tinterface ContextVariableMap {\n\t\t_fonderie: IFonderieContext;\n\t}\n}\n\n// Re-export for consumers who want to type their Hono app:\n// const hono = new Hono<{ Variables: FonderieVariables }>()\nexport type FonderieVariables = {\n\t_fonderie: IFonderieContext;\n};\n\n// ── bridge ────────────────────────────────────────────────────────\n//\n// Global middleware. Runs fonderie's session + billing global stack so that\n// ctx.user and ctx.meta['billing'] are available in every route handler.\n// Must be registered before any fonderie-aware route middleware.\n//\n// hono.use('*', bridge(fonderie))\n\nexport interface IBridgeOptions {\n\t/**\n\t * Name of the header that carries the PLATFORM-VERIFIED client IP — e.g.\n\t * 'cf-connecting-ip' on Cloudflare, 'x-real-ip' behind an nginx that sets\n\t * it. This is an explicit opt-in: request headers are attacker-settable,\n\t * so trusting one by default would let any client spoof its IP and dodge\n\t * per-IP rate limits (auth brute-force limiters key on this). Only set it\n\t * when your platform/proxy STRIPS the header from client requests and\n\t * injects its own value.\n\t */\n\tipHeader?: string;\n}\n\nexport function bridge(fonderie: FonderieApp, options: IBridgeOptions = {}): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\t// No clone(): teeing a request and fully reading ONE branch while the\n\t\t// other sits unread stalls once the body crosses the stream's\n\t\t// high-water mark (undici's tee applies backpressure from the slower\n\t\t// consumer). buildContext consumes the body and core's parser\n\t\t// re-materializes ctx.request from the buffered bytes, so mount() and\n\t\t// app routes read from THAT instead of the spent raw request.\n\t\tconst ctx = await fonderie.buildContext(c.req.raw);\n\t\t// A global middleware short-circuited while building context (e.g. the\n\t\t// body parser's 413) — that response must reach the client, not be\n\t\t// swallowed by context-building.\n\t\tconst early = ctx.meta['pipelineResponse'];\n\t\tif (early instanceof Response) return early;\n\t\t// buildContext CONSUMED c.req.raw's body (no clone — a tee stalls on\n\t\t// large bodies). Core's parser re-materialized ctx.request from the\n\t\t// buffered bytes; point Hono's request at it so the app's OWN native\n\t\t// handlers (c.req.json()/text()/parseBody()) still read the body —\n\t\t// otherwise they'd hit a drained stream. For content-types the parser\n\t\t// leaves untouched (multipart), ctx.request === the original, so this\n\t\t// is a no-op. bodyCache is empty here (nothing read yet).\n\t\tif (ctx.request !== c.req.raw) {\n\t\t\t(c.req as { raw: Request }).raw = ctx.request;\n\t\t}\n\t\t// Client IP, spoof-safe by default (mirrors core's trustProxy model):\n\t\t// 1. The real socket address when the runtime exposes one\n\t\t// (@hono/node-server puts the node request on c.env.incoming).\n\t\t// 2. A platform header ONLY when explicitly configured via ipHeader.\n\t\t// 3. X-Forwarded-For only per core's TRUST_PROXY hop count.\n\t\t// Previously cf-connecting-ip/x-real-ip were trusted UNCONDITIONALLY,\n\t\t// which let any direct client forge its IP (fresh rate-limit bucket per\n\t\t// request) or omit it (limiter skipped) on self-hosted deployments.\n\t\tconst socketIp = (\n\t\t\tc.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined\n\t\t)?.incoming?.socket?.remoteAddress;\n\t\tconst headerIp = options.ipHeader\n\t\t\t? (c.req.raw.headers.get(options.ipHeader) ?? undefined)\n\t\t\t: undefined;\n\t\tconst clientIp = resolveClientIp(headerIp ?? socketIp ?? undefined, c.req.raw.headers);\n\t\tif (clientIp) ctx.meta.clientIp = clientIp;\n\t\tc.set('_fonderie', ctx);\n\t\tawait next();\n\t};\n}\n\n// ── adapt ─────────────────────────────────────────────────────────\n//\n// Low-level escape hatch — wraps any fonderie Middleware into a Hono\n// MiddlewareHandler. Use this for custom fonderie middleware; prefer the\n// named exports below for the built-in fonderie guards.\n\nexport function adapt(middleware: Middleware): MiddlewareHandler {\n\treturn async (c: Context, next) => {\n\t\tconst ctx = c.get('_fonderie');\n\t\tif (!ctx) throw new Error('[fonderie] bridge() must be registered before adapt()');\n\n\t\tlet continued = false;\n\t\tconst result = await middleware(ctx, async () => {\n\t\t\tcontinued = true;\n\t\t\treturn new Response();\n\t\t});\n\n\t\tif (continued) {\n\t\t\tawait next();\n\t\t} else {\n\t\t\treturn result;\n\t\t}\n\t};\n}\n\n// ── Pre-adapted middleware ────────────────────────────────────────\n//\n// Drop-in replacements for the fonderie middleware functions — no adapt()\n// needed. Import directly from this package instead of from the source\n// packages, and use them as native Hono middleware.\n//\n// hono.get('/jobs', requireAuth, withWorkspace(store), ...)\n\nexport const requireAuth: MiddlewareHandler = adapt(_requireAuth);\n\n// The three guards below wrap OPTIONAL peers, so the peer is imported lazily\n// on first request — not at module load. MiddlewareHandler is async either\n// way, so the extra await changes nothing for callers.\n\nexport function withWorkspace(store: Parameters<typeof _withWorkspace>[0]): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/workspaces'),\n\t\t\t\t'@fonderie/workspaces',\n\t\t\t\t'withWorkspace()',\n\t\t\t);\n\t\t\tinner = adapt(mod.withWorkspace(store));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requirePermission(\n\toperation: Parameters<typeof _requirePermission>[0],\n\tpermissionKey: Parameters<typeof _requirePermission>[1],\n): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/permissions'),\n\t\t\t\t'@fonderie/permissions',\n\t\t\t\t'requirePermission()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requirePermission(operation, permissionKey));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\nexport function requireFeature(key: string): MiddlewareHandler {\n\tlet inner: MiddlewareHandler | undefined;\n\treturn async (c, next) => {\n\t\tif (!inner) {\n\t\t\tconst mod = await loadOptionalPeer(\n\t\t\t\t() => import('@fonderie/billing'),\n\t\t\t\t'@fonderie/billing',\n\t\t\t\t'requireFeature()',\n\t\t\t);\n\t\t\tinner = adapt(mod.requireFeature(key));\n\t\t}\n\t\treturn inner(c, next);\n\t};\n}\n\n// ── mount ─────────────────────────────────────────────────────────\n//\n// Wires up fonderie to a Hono app. Returns the same app so you can add\n// routes after mount() — fonderie infra is the notFound handler, so\n// user routes always take priority:\n//\n// const api = mount(hono, fonderie)\n// api.get('/v1/todos', requireAuth, handler)\n// export default hono\n\n// mount() wires fonderie's infrastructure routes as the notFound fallback so\n// user routes always take priority. Call bridge() yourself before your routes\n// to ensure _fonderie is populated for them.\nexport function mount(hono: Hono, fonderie: FonderieApp): Hono {\n\thono.notFound((c) => {\n\t\t// bridge() consumed the raw body (see its no-clone note) and core's\n\t\t// parser re-materialized ctx.request with the buffered bytes — route\n\t\t// fonderie's handling through THAT. Without bridge (no ctx), the raw\n\t\t// request is untouched and safe to hand over directly.\n\t\tconst ctx = c.get('_fonderie') as IFonderieContext | undefined;\n\t\t// Hand over what only the adapter can observe — the socket-derived\n\t\t// client IP. handle() builds a fresh context, so without this seed every\n\t\t// fonderie-owned route sees no IP (login events, per-IP limits, geo/risk).\n\t\tconst clientIp = ctx?.meta.clientIp;\n\t\treturn fonderie.handle(ctx?.request ?? c.req.raw, clientIp ? { meta: { clientIp } } : undefined);\n\t});\n\treturn hono;\n}\n\n// ── App-level CORS ────────────────────────────────────────────────\n//\n// Native Hono middleware speaking core's CORS contract — same options and\n// defaults as withCors, so the headers @fonderie/client sends are allowed out\n// of the box (unlike hono/cors, whose defaults know nothing about them).\n// Register it on the Hono app itself so it covers EVERY route, including\n// ones outside the fonderie pipeline: fonderie.use(withCors()) only guards\n// the mounted basePath.\n//\n// app.use('*', cors({ credentials: true, origin: process.env.FRONTEND_URL! }))\n\nexport function cors(options?: CorsOptions): MiddlewareHandler {\n\tconst resolved = resolveCorsOptions(options);\n\treturn async (c, next) => {\n\t\tconst corsHeaders = corsHeadersFor(resolved, c.req.header('origin') ?? '');\n\t\t// Preflight — respond immediately, skip the pipeline\n\t\tif (c.req.method === 'OPTIONS') {\n\t\t\treturn c.body(null, 204, corsHeaders);\n\t\t}\n\t\tawait next();\n\t\tfor (const [k, v] of Object.entries(corsHeaders)) {\n\t\t\tc.res.headers.set(k, v);\n\t\t}\n\t};\n}\n\n// ── Draining the outbox where nothing else can ─────────────────────\n//\n// On a long-running host the events transport LISTENs and delivers in\n// milliseconds, and this is unnecessary. On serverless there is no such\n// process: a poll loop never returns, and LISTEN is rejected outright by a\n// transaction-mode pooler — so work is published durably and then nobody\n// consumes it. Nothing looks broken; the mail simply never arrives.\n//\n// So the API consumes what it produced, after its own response. Never before:\n// draining first would make every caller wait on somebody else's work.\n//\n// Safe by construction. The row is already durable, so a drain that is\n// skipped, cut short, or loses its instance costs latency and nothing else —\n// the next one resumes where it stopped, and concurrent drains claim\n// exclusively. One drain is in flight per instance; the rest join it.\n//\n// app.use(drainQueue(events.bus))\n\n/** Structural — the adapter takes no dependency on @fonderie/events. */\nexport interface IDrainable {\n\tdrain(options?: { maxMs?: number }): Promise<void>;\n}\n\nexport interface IDrainQueueOptions {\n\t/**\n\t * Bound on one drain pass. Keep it comfortably under the platform's\n\t * function timeout: this runs inside the same invocation as the response,\n\t * so it spends the same budget.\n\t */\n\tmaxMs?: number;\n\t/** Defaults to console.error with the diagnosis the drain returned. */\n\tonError?: (error: unknown) => void;\n}\n\nfunction createDrainRunner(bus: IDrainable, options: IDrainQueueOptions = {}) {\n\tconst maxMs = options.maxMs ?? 10_000;\n\t// Coalesce: several concurrent responses should join ONE drain, not start\n\t// one each. Cross-instance concurrency is safe regardless — claims are\n\t// exclusive — this just avoids pointless work inside a single instance.\n\tlet inFlight: Promise<void> | null = null;\n\treturn () => {\n\t\tif (!inFlight) {\n\t\t\tinFlight = bus\n\t\t\t\t.drain({ maxMs })\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tif (options.onError) options.onError(err);\n\t\t\t\t\telse console.error('[fonderie] queue drain failed:', describeDrainError(err));\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tinFlight = null;\n\t\t\t\t});\n\t\t}\n\t\treturn inFlight;\n\t};\n}\n\n// Kept local so the adapter does not depend on @fonderie/events just to\n// phrase an error. Mirrors explainDrainFailure() there.\nfunction describeDrainError(err: unknown): string {\n\tconst message = err instanceof Error ? err.message : String(err);\n\treturn /(column|relation) .* does not exist/i.test(message)\n\t\t? `${message} — this deployment is ahead of its migrations. Run them against this database; queued work is durable and delivers once they land.`\n\t\t: message;\n}\n\nexport function drainQueue(bus: IDrainable, options: IDrainQueueOptions = {}): MiddlewareHandler {\n\tconst run = createDrainRunner(bus, options);\n\treturn async (_c, next) => {\n\t\tawait next();\n\t\tvoid background(run());\n\t};\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/adapter-hono",
3
- "version": "6.1.3",
3
+ "version": "6.2.1",
4
4
  "fonderie": { "stability": "stable" },
5
5
  "description": "Hono adapter for fonderiejs \u2014 bridge(), adapt(), mount() to use fonderie middleware in native Hono routes.",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "check": "biome check --write src"
37
37
  },
38
38
  "peerDependencies": {
39
- "@fonderie/core": "^0.14.0",
39
+ "@fonderie/core": "^0.16.0",
40
40
  "@fonderie/workspaces": "^6.0.0",
41
41
  "@fonderie/permissions": "^5.0.0",
42
42
  "@fonderie/billing": "^9.0.0",
@@ -59,7 +59,7 @@
59
59
  "@fonderie/permissions": "../permissions",
60
60
  "@fonderie/billing": "../billing",
61
61
  "hono": "^4.13.7",
62
- "@types/node": "^26.4.1",
62
+ "@types/node": "^26.5.1",
63
63
  "tsx": "^4.23.13",
64
64
  "tsup": "^8.5.1",
65
65
  "typescript": "^6.0.3"