@classytic/arc 1.1.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BD2U0BTc.d.mts +100 -0
- package/dist/EventTransport-BD2U0BTc.d.mts.map +1 -0
- package/dist/HookSystem-BsGV-j2l.mjs +405 -0
- package/dist/HookSystem-BsGV-j2l.mjs.map +1 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs +250 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs.map +1 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +82 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +276 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-C3T5DTUx.mjs +141 -0
- package/dist/audited-C3T5DTUx.mjs.map +1 -0
- package/dist/auth/index.d.mts +189 -0
- package/dist/auth/index.d.mts.map +1 -0
- package/dist/auth/index.mjs +1102 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth/redis-session.d.mts +44 -0
- package/dist/auth/redis-session.d.mts.map +1 -0
- package/dist/auth/redis-session.mjs +76 -0
- package/dist/auth/redis-session.mjs.map +1 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs +250 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs.map +1 -0
- package/dist/cache/index.d.mts +146 -0
- package/dist/cache/index.d.mts.map +1 -0
- package/dist/cache/index.mjs +92 -0
- package/dist/cache/index.mjs.map +1 -0
- package/dist/caching-Bl28lYsR.mjs +94 -0
- package/dist/caching-Bl28lYsR.mjs.map +1 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs +1097 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs.map +1 -0
- package/dist/cli/commands/describe.d.mts +19 -0
- package/dist/cli/commands/describe.d.mts.map +1 -0
- package/dist/cli/commands/describe.mjs +239 -0
- package/dist/cli/commands/describe.mjs.map +1 -0
- package/dist/cli/commands/docs.d.mts +14 -0
- package/dist/cli/commands/docs.d.mts.map +1 -0
- package/dist/cli/commands/docs.mjs +53 -0
- package/dist/cli/commands/docs.mjs.map +1 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -1
- package/dist/cli/commands/generate.d.mts.map +1 -0
- package/dist/cli/commands/generate.mjs +358 -0
- package/dist/cli/commands/generate.mjs.map +1 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +12 -8
- package/dist/cli/commands/init.d.mts.map +1 -0
- package/dist/cli/commands/{init.js → init.mjs} +807 -616
- package/dist/cli/commands/init.mjs.map +1 -0
- package/dist/cli/commands/introspect.d.mts +11 -0
- package/dist/cli/commands/introspect.d.mts.map +1 -0
- package/dist/cli/commands/introspect.mjs +76 -0
- package/dist/cli/commands/introspect.mjs.map +1 -0
- package/dist/cli/index.d.mts +17 -0
- package/dist/cli/index.d.mts.map +1 -0
- package/dist/cli/index.mjs +157 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/constants-DdXFXQtN.mjs +85 -0
- package/dist/constants-DdXFXQtN.mjs.map +1 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-CUgNqegw.mjs +560 -0
- package/dist/createApp-CUgNqegw.mjs.map +1 -0
- package/dist/defineResource-k0_BDn8v.mjs +2197 -0
- package/dist/defineResource-k0_BDn8v.mjs.map +1 -0
- package/dist/discovery/index.d.mts +47 -0
- package/dist/discovery/index.d.mts.map +1 -0
- package/dist/discovery/index.mjs +110 -0
- package/dist/discovery/index.mjs.map +1 -0
- package/dist/docs/index.d.mts +163 -0
- package/dist/docs/index.d.mts.map +1 -0
- package/dist/docs/index.mjs +73 -0
- package/dist/docs/index.mjs.map +1 -0
- package/dist/elevation-BRy3yFWT.mjs +113 -0
- package/dist/elevation-BRy3yFWT.mjs.map +1 -0
- package/dist/elevation-B_2dRLVP.d.mts +88 -0
- package/dist/elevation-B_2dRLVP.d.mts.map +1 -0
- package/dist/errorHandler-BbcgBmIH.d.mts +73 -0
- package/dist/errorHandler-BbcgBmIH.d.mts.map +1 -0
- package/dist/errorHandler-C1okiriz.mjs +109 -0
- package/dist/errorHandler-C1okiriz.mjs.map +1 -0
- package/dist/errors-B9bZok84.mjs +212 -0
- package/dist/errors-B9bZok84.mjs.map +1 -0
- package/dist/errors-ChKiFz62.d.mts +125 -0
- package/dist/errors-ChKiFz62.d.mts.map +1 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts +125 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts.map +1 -0
- package/dist/eventPlugin-DGR_B2on.mjs +230 -0
- package/dist/eventPlugin-DGR_B2on.mjs.map +1 -0
- package/dist/events/index.d.mts +54 -0
- package/dist/events/index.d.mts.map +1 -0
- package/dist/events/index.mjs +52 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +178 -0
- package/dist/events/transports/redis-stream-entry.mjs.map +1 -0
- package/dist/events/transports/redis.d.mts +77 -0
- package/dist/events/transports/redis.d.mts.map +1 -0
- package/dist/events/transports/redis.mjs +125 -0
- package/dist/events/transports/redis.mjs.map +1 -0
- package/dist/externalPaths-DlINfKbP.d.mts +51 -0
- package/dist/externalPaths-DlINfKbP.d.mts.map +1 -0
- package/dist/factory/index.d.mts +64 -0
- package/dist/factory/index.d.mts.map +1 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts +217 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts.map +1 -0
- package/dist/fields-DyaDVX4J.d.mts +110 -0
- package/dist/fields-DyaDVX4J.d.mts.map +1 -0
- package/dist/fields-iagOozy0.mjs +115 -0
- package/dist/fields-iagOozy0.mjs.map +1 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +97 -0
- package/dist/idempotency/index.d.mts.map +1 -0
- package/dist/idempotency/index.mjs +320 -0
- package/dist/idempotency/index.mjs.map +1 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +115 -0
- package/dist/idempotency/mongodb.mjs.map +1 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +104 -0
- package/dist/idempotency/redis.mjs.map +1 -0
- package/dist/index.d.mts +261 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +105 -0
- package/dist/index.mjs.map +1 -0
- package/dist/integrations/event-gateway.d.mts +47 -0
- package/dist/integrations/event-gateway.d.mts.map +1 -0
- package/dist/integrations/event-gateway.mjs +44 -0
- package/dist/integrations/event-gateway.mjs.map +1 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +104 -0
- package/dist/integrations/jobs.d.mts.map +1 -0
- package/dist/integrations/jobs.mjs +124 -0
- package/dist/integrations/jobs.mjs.map +1 -0
- package/dist/integrations/streamline.d.mts +61 -0
- package/dist/integrations/streamline.d.mts.map +1 -0
- package/dist/integrations/streamline.mjs +126 -0
- package/dist/integrations/streamline.mjs.map +1 -0
- package/dist/integrations/websocket.d.mts +83 -0
- package/dist/integrations/websocket.d.mts.map +1 -0
- package/dist/integrations/websocket.mjs +289 -0
- package/dist/integrations/websocket.mjs.map +1 -0
- package/dist/interface-B01JvPVc.d.mts +78 -0
- package/dist/interface-B01JvPVc.d.mts.map +1 -0
- package/dist/interface-CZe8IkMf.d.mts +55 -0
- package/dist/interface-CZe8IkMf.d.mts.map +1 -0
- package/dist/interface-Ch8HU9uM.d.mts +1098 -0
- package/dist/interface-Ch8HU9uM.d.mts.map +1 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs +54 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs.map +1 -0
- package/dist/keys-BqNejWup.mjs +43 -0
- package/dist/keys-BqNejWup.mjs.map +1 -0
- package/dist/logger-Df2O2WsW.mjs +79 -0
- package/dist/logger-Df2O2WsW.mjs.map +1 -0
- package/dist/memory-cQgelFOj.mjs +144 -0
- package/dist/memory-cQgelFOj.mjs.map +1 -0
- package/dist/migrations/index.d.mts +157 -0
- package/dist/migrations/index.d.mts.map +1 -0
- package/dist/migrations/index.mjs +261 -0
- package/dist/migrations/index.mjs.map +1 -0
- package/dist/mongodb-BfJVlUJH.mjs +94 -0
- package/dist/mongodb-BfJVlUJH.mjs.map +1 -0
- package/dist/mongodb-CGzRbfAK.d.mts +119 -0
- package/dist/mongodb-CGzRbfAK.d.mts.map +1 -0
- package/dist/mongodb-JN-9JA7K.d.mts +72 -0
- package/dist/mongodb-JN-9JA7K.d.mts.map +1 -0
- package/dist/openapi-G3Cw7XuM.mjs +524 -0
- package/dist/openapi-G3Cw7XuM.mjs.map +1 -0
- package/dist/org/index.d.mts +69 -0
- package/dist/org/index.d.mts.map +1 -0
- package/dist/org/index.mjs +514 -0
- package/dist/org/index.mjs.map +1 -0
- package/dist/org/types.d.mts +83 -0
- package/dist/org/types.d.mts.map +1 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +279 -0
- package/dist/permissions/index.d.mts.map +1 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/permissions/index.mjs.map +1 -0
- package/dist/plugins/index.d.mts +173 -0
- package/dist/plugins/index.d.mts.map +1 -0
- package/dist/plugins/index.mjs +523 -0
- package/dist/plugins/index.mjs.map +1 -0
- package/dist/plugins/response-cache.d.mts +88 -0
- package/dist/plugins/response-cache.d.mts.map +1 -0
- package/dist/plugins/response-cache.mjs +284 -0
- package/dist/plugins/response-cache.mjs.map +1 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +186 -0
- package/dist/plugins/tracing-entry.mjs.map +1 -0
- package/dist/pluralize-CEweyOEm.mjs +87 -0
- package/dist/pluralize-CEweyOEm.mjs.map +1 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -169
- package/dist/policies/index.d.mts.map +1 -0
- package/dist/policies/index.mjs +322 -0
- package/dist/policies/index.mjs.map +1 -0
- package/dist/presets/{index.d.ts → index.d.mts} +63 -131
- package/dist/presets/index.d.mts.map +1 -0
- package/dist/presets/index.mjs +144 -0
- package/dist/presets/index.mjs.map +1 -0
- package/dist/presets/multiTenant.d.mts +25 -0
- package/dist/presets/multiTenant.d.mts.map +1 -0
- package/dist/presets/multiTenant.mjs +114 -0
- package/dist/presets/multiTenant.mjs.map +1 -0
- package/dist/presets-BITljm96.mjs +120 -0
- package/dist/presets-BITljm96.mjs.map +1 -0
- package/dist/presets-DzSMwlKj.d.mts +58 -0
- package/dist/presets-DzSMwlKj.d.mts.map +1 -0
- package/dist/prisma-DJbMt3yf.mjs +628 -0
- package/dist/prisma-DJbMt3yf.mjs.map +1 -0
- package/dist/prisma-Dg9GoVdj.d.mts +275 -0
- package/dist/prisma-Dg9GoVdj.d.mts.map +1 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts +72 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts.map +1 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs +139 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs.map +1 -0
- package/dist/redis-D-JAeLtm.d.mts +50 -0
- package/dist/redis-D-JAeLtm.d.mts.map +1 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts +104 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts.map +1 -0
- package/dist/registry/index.d.mts +12 -0
- package/dist/registry/index.d.mts.map +1 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-QQD6ROJc.mjs +56 -0
- package/dist/requestContext-QQD6ROJc.mjs.map +1 -0
- package/dist/schemaConverter-BwrmWroW.mjs +99 -0
- package/dist/schemaConverter-BwrmWroW.mjs.map +1 -0
- package/dist/schemas/index.d.mts +64 -0
- package/dist/schemas/index.d.mts.map +1 -0
- package/dist/schemas/index.mjs +83 -0
- package/dist/schemas/index.mjs.map +1 -0
- package/dist/scope/index.d.mts +22 -0
- package/dist/scope/index.d.mts.map +1 -0
- package/dist/scope/index.mjs +66 -0
- package/dist/scope/index.mjs.map +1 -0
- package/dist/sessionManager-jPKLbHE0.d.mts +187 -0
- package/dist/sessionManager-jPKLbHE0.d.mts.map +1 -0
- package/dist/sse-B3c3_yZp.mjs +124 -0
- package/dist/sse-B3c3_yZp.mjs.map +1 -0
- package/dist/testing/index.d.mts +908 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +1977 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/tracing-Cc7vVQPp.d.mts +71 -0
- package/dist/tracing-Cc7vVQPp.d.mts.map +1 -0
- package/dist/typeGuards-DhMNLuvU.mjs +10 -0
- package/dist/typeGuards-DhMNLuvU.mjs.map +1 -0
- package/dist/types/index.d.mts +947 -0
- package/dist/types/index.d.mts.map +1 -0
- package/dist/types/index.mjs +15 -0
- package/dist/types/index.mjs.map +1 -0
- package/dist/types-Beqn1Un7.mjs +39 -0
- package/dist/types-Beqn1Un7.mjs.map +1 -0
- package/dist/types-CIgB7UUl.d.mts +446 -0
- package/dist/types-CIgB7UUl.d.mts.map +1 -0
- package/dist/types-aYB4V7uN.d.mts +87 -0
- package/dist/types-aYB4V7uN.d.mts.map +1 -0
- package/dist/utils/index.d.mts +748 -0
- package/dist/utils/index.d.mts.map +1 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- package/dist/utils/index.js +0 -931
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/plugins/requestId.ts","../../src/plugins/health.ts","../../src/plugins/gracefulShutdown.ts","../../src/plugins/createPlugin.ts","../../src/core/arcCorePlugin.ts"],"sourcesContent":["/**\n * Request ID Plugin\n *\n * Propagates request IDs for distributed tracing.\n * - Accepts incoming x-request-id header\n * - Generates UUID if not provided\n * - Attaches to request.id and response header\n *\n * @example\n * import { requestIdPlugin } from '@classytic/arc';\n *\n * await fastify.register(requestIdPlugin);\n *\n * // In handlers, access via request.id\n * fastify.get('/', async (request) => {\n * console.log(request.id); // UUID\n * });\n */\n\nimport fp from 'fastify-plugin';\nimport { randomUUID } from 'crypto';\nimport type { FastifyInstance, FastifyPluginAsync } from 'fastify';\n\nexport interface RequestIdOptions {\n /** Header name to read/write request ID (default: 'x-request-id') */\n header?: string;\n /** Custom ID generator (default: crypto.randomUUID) */\n generator?: () => string;\n /** Whether to set response header (default: true) */\n setResponseHeader?: boolean;\n}\n\ndeclare module 'fastify' {\n interface FastifyRequest {\n /** Unique request identifier for tracing */\n requestId: string;\n }\n}\n\nconst requestIdPlugin: FastifyPluginAsync<RequestIdOptions> = async (\n fastify: FastifyInstance,\n opts: RequestIdOptions = {}\n) => {\n const {\n header = 'x-request-id',\n generator = randomUUID,\n setResponseHeader = true,\n } = opts;\n\n // Decorate request with requestId\n if (!fastify.hasRequestDecorator('requestId')) {\n fastify.decorateRequest('requestId', '');\n }\n\n // Assign request ID on each request\n fastify.addHook('onRequest', async (request) => {\n const incomingId = request.headers[header];\n // Sanitize incoming ID: max 128 chars, alphanumeric + dashes/underscores/dots only.\n // Rejects crafted values that could pollute logs or headers.\n const sanitized = typeof incomingId === 'string' ? incomingId.trim() : '';\n const isValid = sanitized.length > 0\n && sanitized.length <= 128\n && /^[\\w.:-]+$/.test(sanitized);\n const requestId = isValid ? sanitized : generator();\n\n // Set on request object (Fastify's native id)\n (request as { id: string }).id = requestId;\n // Set on our decorated property\n request.requestId = requestId;\n });\n\n // Add to response headers\n if (setResponseHeader) {\n fastify.addHook('onSend', async (request, reply) => {\n reply.header(header, request.requestId);\n });\n }\n\n fastify.log?.debug?.('Request ID plugin registered');\n};\n\nexport default fp(requestIdPlugin, {\n name: 'arc-request-id',\n fastify: '5.x',\n});\n\nexport { requestIdPlugin };\n","/**\n * Health Check Plugin\n *\n * Kubernetes-ready health endpoints:\n * - /health/live - Liveness probe (is the process alive?)\n * - /health/ready - Readiness probe (can we serve traffic?)\n * - /health/metrics - Prometheus metrics (optional)\n *\n * @example\n * import { healthPlugin } from '@classytic/arc';\n *\n * await fastify.register(healthPlugin, {\n * prefix: '/_health',\n * checks: [\n * { name: 'mongodb', check: async () => mongoose.connection.readyState === 1 },\n * { name: 'redis', check: async () => redis.ping() === 'PONG' },\n * ],\n * });\n */\n\nimport fp from 'fastify-plugin';\nimport type { FastifyInstance, FastifyPluginAsync } from 'fastify';\n\n// Plugin-local augmentation for HTTP metrics timing\ndeclare module 'fastify' {\n interface FastifyRequest {\n _startTime?: number;\n }\n}\n\nexport interface HealthCheck {\n /** Name of the dependency */\n name: string;\n /** Function that returns true if healthy, false otherwise */\n check: () => Promise<boolean> | boolean;\n /** Optional timeout in ms (default: 5000) */\n timeout?: number;\n /** Whether this check is critical for readiness (default: true) */\n critical?: boolean;\n}\n\nexport interface HealthOptions {\n /** Route prefix (default: '/_health') */\n prefix?: string;\n /** Health check dependencies */\n checks?: HealthCheck[];\n /** Enable metrics endpoint (default: false) */\n metrics?: boolean;\n /** Custom metrics collector function */\n metricsCollector?: () => Promise<string> | string;\n /** Version info to include in responses */\n version?: string;\n /** Collect HTTP request metrics (default: true if metrics enabled) */\n collectHttpMetrics?: boolean;\n}\n\ninterface CheckResult {\n name: string;\n healthy: boolean;\n duration: number;\n error?: string;\n}\n\n// Metrics storage (instance-scoped to avoid contamination between app instances)\ninterface HttpMetrics {\n requestsTotal: Record<string, number>;\n requestDurations: number[];\n /** Write index for ring buffer — wraps modulo capacity */\n _ringIndex: number;\n startTime: number;\n}\n\nfunction createHttpMetrics(): HttpMetrics {\n return {\n requestsTotal: {},\n requestDurations: [],\n _ringIndex: 0,\n startTime: Date.now(),\n };\n}\n\nconst healthPlugin: FastifyPluginAsync<HealthOptions> = async (\n fastify: FastifyInstance,\n opts: HealthOptions = {}\n) => {\n const {\n prefix = '/_health',\n checks = [],\n metrics = false,\n metricsCollector,\n version,\n collectHttpMetrics = metrics,\n } = opts;\n\n // Instance-scoped metrics — each Fastify instance gets its own counters\n const httpMetrics = createHttpMetrics();\n\n // ========================================\n // Liveness Probe\n // ========================================\n\n fastify.get(`${prefix}/live`, {\n schema: {\n tags: ['Health'],\n summary: 'Liveness probe',\n description: 'Returns 200 if the process is alive',\n response: {\n 200: {\n type: 'object',\n properties: {\n status: { type: 'string', enum: ['ok'] },\n timestamp: { type: 'string' },\n version: { type: 'string' },\n },\n },\n },\n },\n }, async () => {\n return {\n status: 'ok',\n timestamp: new Date().toISOString(),\n ...(version ? { version } : {}),\n };\n });\n\n // ========================================\n // Readiness Probe\n // ========================================\n\n fastify.get(`${prefix}/ready`, {\n schema: {\n tags: ['Health'],\n summary: 'Readiness probe',\n description: 'Returns 200 if all dependencies are healthy',\n response: {\n 200: {\n type: 'object',\n properties: {\n status: { type: 'string', enum: ['ready', 'not_ready'] },\n timestamp: { type: 'string' },\n checks: {\n type: 'array',\n items: {\n type: 'object',\n properties: {\n name: { type: 'string' },\n healthy: { type: 'boolean' },\n duration: { type: 'number' },\n error: { type: 'string' },\n },\n },\n },\n },\n },\n 503: {\n type: 'object',\n properties: {\n status: { type: 'string', enum: ['not_ready'] },\n timestamp: { type: 'string' },\n checks: { type: 'array' },\n },\n },\n },\n },\n }, async (_, reply) => {\n const results = await runChecks(checks);\n const criticalFailed = results.some(\n (r) => !r.healthy && (checks.find((c) => c.name === r.name)?.critical ?? true)\n );\n\n const response = {\n status: criticalFailed ? 'not_ready' : 'ready',\n timestamp: new Date().toISOString(),\n checks: results,\n };\n\n if (criticalFailed) {\n reply.code(503);\n }\n\n return response;\n });\n\n // ========================================\n // Metrics Endpoint (Optional)\n // ========================================\n\n if (metrics) {\n fastify.get(`${prefix}/metrics`, async (_, reply) => {\n reply.type('text/plain; charset=utf-8');\n\n if (metricsCollector) {\n return await metricsCollector();\n }\n\n // Default Prometheus metrics\n const uptime = process.uptime();\n const memory = process.memoryUsage();\n const cpu = process.cpuUsage();\n\n const lines = [\n '# HELP process_uptime_seconds Process uptime in seconds',\n '# TYPE process_uptime_seconds gauge',\n `process_uptime_seconds ${uptime.toFixed(2)}`,\n '',\n '# HELP process_memory_heap_bytes Heap memory usage in bytes',\n '# TYPE process_memory_heap_bytes gauge',\n `process_memory_heap_bytes{type=\"used\"} ${memory.heapUsed}`,\n `process_memory_heap_bytes{type=\"total\"} ${memory.heapTotal}`,\n '',\n '# HELP process_memory_rss_bytes RSS memory in bytes',\n '# TYPE process_memory_rss_bytes gauge',\n `process_memory_rss_bytes ${memory.rss}`,\n '',\n '# HELP process_memory_external_bytes External memory in bytes',\n '# TYPE process_memory_external_bytes gauge',\n `process_memory_external_bytes ${memory.external}`,\n '',\n '# HELP process_cpu_user_microseconds User CPU time in microseconds',\n '# TYPE process_cpu_user_microseconds counter',\n `process_cpu_user_microseconds ${cpu.user}`,\n '',\n '# HELP process_cpu_system_microseconds System CPU time in microseconds',\n '# TYPE process_cpu_system_microseconds counter',\n `process_cpu_system_microseconds ${cpu.system}`,\n '',\n ];\n\n // HTTP request metrics\n if (collectHttpMetrics && Object.keys(httpMetrics.requestsTotal).length > 0) {\n lines.push(\n '# HELP http_requests_total Total HTTP requests by status code',\n '# TYPE http_requests_total counter'\n );\n for (const [status, count] of Object.entries(httpMetrics.requestsTotal)) {\n lines.push(`http_requests_total{status=\"${status}\"} ${count}`);\n }\n lines.push('');\n\n // Request duration histogram\n if (httpMetrics.requestDurations.length > 0) {\n const sorted = [...httpMetrics.requestDurations].sort((a, b) => a - b);\n const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0;\n const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0;\n const p99 = sorted[Math.floor(sorted.length * 0.99)] || 0;\n const sum = sorted.reduce((a, b) => a + b, 0);\n\n lines.push(\n '# HELP http_request_duration_milliseconds HTTP request duration',\n '# TYPE http_request_duration_milliseconds summary',\n `http_request_duration_milliseconds{quantile=\"0.5\"} ${p50.toFixed(2)}`,\n `http_request_duration_milliseconds{quantile=\"0.95\"} ${p95.toFixed(2)}`,\n `http_request_duration_milliseconds{quantile=\"0.99\"} ${p99.toFixed(2)}`,\n `http_request_duration_milliseconds_sum ${sum.toFixed(2)}`,\n `http_request_duration_milliseconds_count ${sorted.length}`,\n ''\n );\n }\n }\n\n return lines.join('\\n');\n });\n }\n\n // Collect HTTP metrics\n if (collectHttpMetrics) {\n fastify.addHook('onRequest', async (request) => {\n request._startTime = Date.now();\n });\n\n fastify.addHook('onResponse', async (request, reply) => {\n const duration = Date.now() - (request._startTime ?? Date.now());\n\n // Track by status code bucket (2xx, 3xx, 4xx, 5xx)\n const statusBucket = `${Math.floor(reply.statusCode / 100)}xx`;\n httpMetrics.requestsTotal[statusBucket] = (httpMetrics.requestsTotal[statusBucket] || 0) + 1;\n\n // Store duration in ring buffer (O(1) vs O(n) for Array.shift)\n if (httpMetrics.requestDurations.length < 10000) {\n httpMetrics.requestDurations.push(duration);\n } else {\n httpMetrics.requestDurations[httpMetrics._ringIndex % 10000] = duration;\n }\n httpMetrics._ringIndex = httpMetrics._ringIndex + 1;\n });\n }\n\n fastify.log?.debug?.(`Health plugin registered at ${prefix}`);\n};\n\n/**\n * Run all health checks with timeout\n */\nasync function runChecks(checks: HealthCheck[]): Promise<CheckResult[]> {\n const results: CheckResult[] = [];\n\n for (const check of checks) {\n const start = Date.now();\n const timeout = check.timeout ?? 5000;\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n try {\n const checkPromise = Promise.resolve(check.check());\n const timeoutPromise = new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new Error('Health check timeout')), timeout);\n });\n\n const healthy = await Promise.race([checkPromise, timeoutPromise]);\n\n results.push({\n name: check.name,\n healthy: Boolean(healthy),\n duration: Date.now() - start,\n });\n } catch (err) {\n results.push({\n name: check.name,\n healthy: false,\n duration: Date.now() - start,\n error: (err as Error).message,\n });\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n\n return results;\n}\n\nexport default fp(healthPlugin, {\n name: 'arc-health',\n fastify: '5.x',\n});\n\nexport { healthPlugin };\n","/**\n * Graceful Shutdown Plugin\n *\n * Handles SIGTERM and SIGINT signals for clean shutdown:\n * - Stops accepting new connections\n * - Waits for in-flight requests to complete\n * - Closes database connections\n * - Exits cleanly\n *\n * Essential for Kubernetes deployments.\n *\n * @example\n * import { gracefulShutdownPlugin } from '@classytic/arc';\n *\n * // Production\n * await fastify.register(gracefulShutdownPlugin, {\n * timeout: 30000, // 30 seconds max\n * onShutdown: async () => {\n * await mongoose.disconnect();\n * await redis.quit();\n * },\n * });\n *\n * // Tests — prevent process.exit from killing the runner\n * await fastify.register(gracefulShutdownPlugin, {\n * onForceExit: () => {},\n * });\n */\n\nimport fp from 'fastify-plugin';\nimport type { FastifyInstance, FastifyPluginAsync } from 'fastify';\n\nexport interface GracefulShutdownOptions {\n /** Maximum time to wait for graceful shutdown in ms (default: 30000) */\n timeout?: number;\n /** Custom cleanup function called before exit */\n onShutdown?: () => Promise<void> | void;\n /** Signals to handle (default: ['SIGTERM', 'SIGINT']) */\n signals?: NodeJS.Signals[];\n /** Whether to log shutdown events (default: true) */\n logEvents?: boolean;\n /**\n * Called when shutdown times out or encounters an error.\n * Defaults to `process.exit(1)` — appropriate for production but dangerous in:\n * - **Tests**: kills the test runner. Pass `() => {}` or `() => { throw … }`.\n * - **Shared runtimes** (e.g., serverless): may kill unrelated handlers.\n *\n * @param reason - `'timeout'` if shutdown exceeded `timeout` ms,\n * `'error'` if `onShutdown` or `fastify.close()` threw.\n */\n onForceExit?: (reason: 'timeout' | 'error') => void;\n}\n\nconst gracefulShutdownPlugin: FastifyPluginAsync<GracefulShutdownOptions> = async (\n fastify: FastifyInstance,\n opts: GracefulShutdownOptions = {}\n) => {\n const {\n timeout = 30000,\n onShutdown,\n signals = ['SIGTERM', 'SIGINT'],\n logEvents = true,\n onForceExit = () => process.exit(1),\n } = opts;\n\n let isShuttingDown = false;\n\n // Keep references to signal handlers so we can remove them on close\n const signalHandlers = new Map<string, () => void>();\n\n const shutdown = async (signal: string): Promise<void> => {\n // Prevent multiple shutdown attempts\n if (isShuttingDown) {\n if (logEvents) {\n fastify.log?.warn?.({ signal }, 'Shutdown already in progress, ignoring signal');\n }\n return;\n }\n isShuttingDown = true;\n\n if (logEvents) {\n fastify.log?.info?.({ signal, timeout }, 'Shutdown signal received, starting graceful shutdown');\n }\n\n // Set a hard timeout — force-exit only as last resort\n const forceExitTimer = setTimeout(() => {\n if (logEvents) {\n fastify.log?.error?.('Graceful shutdown timeout exceeded, forcing exit');\n }\n onForceExit('timeout');\n }, timeout);\n\n // Don't keep the process alive just for this timer\n forceExitTimer.unref();\n\n try {\n // 1. Stop accepting new connections and wait for in-flight requests\n if (logEvents) {\n fastify.log?.info?.('Closing server to new connections');\n }\n await fastify.close();\n\n // 2. Run custom cleanup (database connections, Redis, etc.)\n if (onShutdown) {\n if (logEvents) {\n fastify.log?.info?.('Running custom shutdown handler');\n }\n await onShutdown();\n }\n\n if (logEvents) {\n fastify.log?.info?.('Graceful shutdown complete');\n }\n\n clearTimeout(forceExitTimer);\n // Let Node.js exit naturally when the event loop drains\n // instead of calling process.exit(0) which skips cleanup\n } catch (err) {\n if (logEvents) {\n fastify.log?.error?.({ error: (err as Error).message }, 'Error during shutdown');\n }\n clearTimeout(forceExitTimer);\n onForceExit('error');\n }\n };\n\n // Register signal handlers (with references for cleanup)\n for (const signal of signals) {\n const handler = () => { void shutdown(signal); };\n signalHandlers.set(signal, handler);\n process.on(signal, handler);\n }\n\n // Cleanup signal handlers on close to prevent test pollution\n fastify.addHook('onClose', async () => {\n for (const [signal, handler] of signalHandlers) {\n process.removeListener(signal, handler);\n }\n signalHandlers.clear();\n });\n\n // Decorate fastify with manual shutdown trigger\n fastify.decorate('shutdown', async () => {\n await shutdown('MANUAL');\n });\n\n if (logEvents) {\n fastify.log?.debug?.({ signals }, 'Graceful shutdown plugin registered');\n }\n};\n\n// Extend Fastify types\ndeclare module 'fastify' {\n interface FastifyInstance {\n /** Trigger graceful shutdown manually */\n shutdown: () => Promise<void>;\n }\n}\n\nexport default fp(gracefulShutdownPlugin, {\n name: 'arc-graceful-shutdown',\n fastify: '5.x',\n});\n\nexport { gracefulShutdownPlugin };\n","/**\n * createPlugin() — forRoot/forFeature Pattern\n *\n * Standard pattern for plugins that need both global setup and per-resource configuration.\n * Inspired by NestJS forRoot/forFeature but simpler — plain functions, no decorators.\n *\n * @example\n * ```typescript\n * // Define a plugin with global + per-resource config\n * const analytics = createPlugin('analytics', {\n * forRoot: async (fastify, opts) => {\n * // Global setup: connect to analytics service, add decorators\n * const client = new AnalyticsClient(opts.apiKey);\n * fastify.decorate('analytics', client);\n * },\n * forResource: (resourceConfig, opts) => {\n * // Per-resource: return hooks, middleware, or routes\n * return {\n * hooks: [{\n * operation: 'create', phase: 'after', priority: 100,\n * handler: (ctx) => client.track('created', ctx.result),\n * }],\n * };\n * },\n * });\n *\n * // Usage — register globally once\n * await app.register(analytics.forRoot({ apiKey: 'xxx' }));\n *\n * // Then apply per-resource\n * const productResource = defineResource({\n * name: 'product',\n * adapter: productAdapter,\n * ...analytics.forResource({ trackEvents: true }),\n * });\n * ```\n */\n\nimport fp from 'fastify-plugin';\nimport type { FastifyInstance, FastifyPluginAsync } from 'fastify';\nimport type { AnyRecord, AdditionalRoute, PresetHook, MiddlewareConfig, RouteSchemaOptions } from '../types/index.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PluginResourceResult {\n /** Additional routes to add to the resource */\n additionalRoutes?: AdditionalRoute[];\n /** Middlewares per operation */\n middlewares?: MiddlewareConfig;\n /** Hooks to register */\n hooks?: PresetHook[];\n /** Schema options to merge */\n schemaOptions?: RouteSchemaOptions;\n}\n\nexport interface CreatePluginDefinition<\n TRootOpts extends AnyRecord = AnyRecord,\n TResourceOpts extends AnyRecord = AnyRecord,\n> {\n /**\n * Global setup function. Called once when the plugin is registered on the Fastify instance.\n * Use this for database connections, decorators, shared state, etc.\n */\n forRoot?: (fastify: FastifyInstance, opts: TRootOpts) => void | Promise<void>;\n\n /**\n * Per-resource configuration function. Called for each resource that uses this plugin.\n * Returns hooks, routes, middlewares, etc. to merge into the resource config.\n */\n forResource?: (resourceConfig: AnyRecord, opts: TResourceOpts) => PluginResourceResult;\n}\n\nexport interface ArcPlugin<\n TRootOpts extends AnyRecord = AnyRecord,\n TResourceOpts extends AnyRecord = AnyRecord,\n> {\n /** Plugin name */\n readonly name: string;\n\n /**\n * Register the plugin globally on a Fastify instance.\n * Returns a Fastify plugin that can be passed to `app.register()`.\n */\n forRoot(opts?: TRootOpts): FastifyPluginAsync<TRootOpts>;\n\n /**\n * Apply per-resource configuration.\n * Returns a partial resource config to spread into `defineResource()`.\n */\n forResource(opts?: TResourceOpts): PluginResourceResult;\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a structured plugin with forRoot (global) and forResource (per-resource) support.\n *\n * @param name - Plugin name (used for Fastify registration and debugging)\n * @param definition - Plugin setup functions\n * @returns ArcPlugin with forRoot() and forResource() methods\n */\nexport function createPlugin<\n TRootOpts extends AnyRecord = AnyRecord,\n TResourceOpts extends AnyRecord = AnyRecord,\n>(\n name: string,\n definition: CreatePluginDefinition<TRootOpts, TResourceOpts>,\n): ArcPlugin<TRootOpts, TResourceOpts> {\n return {\n name,\n\n forRoot(opts?: TRootOpts): FastifyPluginAsync<TRootOpts> {\n const plugin: FastifyPluginAsync<TRootOpts> = async (fastify, pluginOpts) => {\n const mergedOpts = { ...opts, ...pluginOpts } as TRootOpts;\n if (definition.forRoot) {\n await definition.forRoot(fastify, mergedOpts);\n }\n };\n\n return fp(plugin, {\n name: `arc-plugin-${name}`,\n fastify: '5.x',\n });\n },\n\n forResource(opts?: TResourceOpts): PluginResourceResult {\n if (!definition.forResource) {\n return {};\n }\n return definition.forResource({} as AnyRecord, (opts ?? {}) as TResourceOpts);\n },\n };\n}\n","/**\r\n * Arc Core Plugin\r\n *\r\n * Sets up instance-scoped Arc systems:\r\n * - HookSystem: Lifecycle hooks per app instance\r\n * - ResourceRegistry: Resource tracking per app instance\r\n * - Event integration: Wires CRUD operations to fastify.events\r\n *\r\n * This solves the global singleton leak problem where multiple\r\n * app instances (e.g., in tests) would share state.\r\n *\r\n * @example\r\n * import { arcCorePlugin } from '@classytic/arc';\r\n *\r\n * const app = Fastify();\r\n * await app.register(arcCorePlugin);\r\n *\r\n * // Now use instance-scoped hooks\r\n * app.arc.hooks.before('product', 'create', async (ctx) => {\r\n * ctx.data.slug = slugify(ctx.data.name);\r\n * });\r\n */\r\n\r\nimport fp from 'fastify-plugin';\r\nimport type { FastifyInstance, FastifyPluginAsync } from 'fastify';\r\nimport { HookSystem } from '../hooks/HookSystem.js';\r\nimport { ResourceRegistry } from '../registry/ResourceRegistry.js';\r\nimport { requestContext } from '../context/requestContext.js';\r\nimport type { RequestStore } from '../context/requestContext.js';\r\nimport type { ExternalOpenApiPaths } from '../docs/externalPaths.js';\r\nimport type { RequestScope } from '../scope/types.js';\r\nimport { getOrgId } from '../scope/types.js';\r\nimport { MUTATION_OPERATIONS } from '../constants.js';\r\nimport { hasEvents } from '../utils/typeGuards.js';\r\n\r\nexport interface ArcCorePluginOptions {\r\n /** Enable event emission for CRUD operations (requires eventPlugin) */\r\n emitEvents?: boolean;\r\n /** Hook system instance (for testing/custom setup) */\r\n hookSystem?: HookSystem;\r\n /** Resource registry instance (for testing/custom setup) */\r\n registry?: ResourceRegistry;\r\n}\r\n\r\nexport interface PluginMeta {\r\n name: string;\r\n version?: string;\r\n options?: Record<string, unknown>;\r\n registeredAt: string;\r\n}\r\n\r\nexport interface ArcCore {\r\n /** Instance-scoped hook system */\r\n hooks: HookSystem;\r\n /** Instance-scoped resource registry */\r\n registry: ResourceRegistry;\r\n /** Whether event emission is enabled */\r\n emitEvents: boolean;\r\n /** External OpenAPI paths contributed by auth adapters or third-party integrations */\r\n externalOpenApiPaths: ExternalOpenApiPaths[];\r\n /** Registered plugins for introspection */\r\n plugins: Map<string, PluginMeta>;\r\n}\r\n\r\ndeclare module 'fastify' {\r\n interface FastifyInstance {\r\n arc: ArcCore;\r\n }\r\n}\r\n\r\nconst arcCorePlugin: FastifyPluginAsync<ArcCorePluginOptions> = async (\r\n fastify: FastifyInstance,\r\n opts: ArcCorePluginOptions = {}\r\n) => {\r\n const {\r\n emitEvents = true,\r\n hookSystem,\r\n registry,\r\n } = opts;\r\n\r\n // Always use instance-scoped systems — no global singletons\r\n const actualHookSystem = hookSystem ?? new HookSystem();\r\n const actualRegistry = registry ?? new ResourceRegistry();\r\n\r\n // Decorate with instance-scoped Arc core\r\n fastify.decorate('arc', {\r\n hooks: actualHookSystem,\r\n registry: actualRegistry,\r\n emitEvents,\r\n externalOpenApiPaths: [],\r\n plugins: new Map<string, PluginMeta>(),\r\n });\r\n\r\n // Request context via AsyncLocalStorage — zero-cost per request.\r\n // storage.run(store, done) wraps the ENTIRE remaining request lifecycle\r\n // so any code in the call stack can access user/org/requestId.\r\n fastify.addHook('onRequest', (request, _reply, done) => {\r\n const store: RequestStore = {\r\n requestId: request.id,\r\n startTime: performance.now(),\r\n };\r\n requestContext.storage.run(store, done);\r\n });\r\n\r\n // Populate user/org after auth middleware runs (user isn't set during onRequest)\r\n fastify.addHook('preHandler', (request, _reply, done) => {\r\n const store = requestContext.get();\r\n if (store) {\r\n const req = request as unknown as Record<string, unknown>;\r\n store.user = req.user as RequestStore['user'] ?? null;\r\n store.organizationId = request.scope?.kind === 'member' ? request.scope.organizationId\r\n : request.scope?.kind === 'elevated' ? request.scope.organizationId\r\n : undefined;\r\n }\r\n done();\r\n });\r\n\r\n // Wire events into hooks if event plugin is available and events enabled\r\n if (emitEvents) {\r\n // Register after hooks that emit events\r\n const eventOperations = MUTATION_OPERATIONS;\r\n\r\n for (const operation of eventOperations) {\r\n actualHookSystem.after('*', operation, async (ctx) => {\r\n // Check if events plugin is registered using type guard\r\n if (!hasEvents(fastify)) return;\r\n\r\n const store = requestContext.get();\r\n const eventType = `${ctx.resource}.${operation}d`; // e.g., 'product.created'\r\n const userId = ctx.user?.id ?? ctx.user?._id;\r\n const organizationId = ctx.context?._scope\r\n ? getOrgId(ctx.context._scope as RequestScope)\r\n : undefined;\r\n const payload = {\r\n resource: ctx.resource,\r\n operation: ctx.operation,\r\n data: ctx.result,\r\n userId,\r\n organizationId,\r\n timestamp: new Date().toISOString(),\r\n };\r\n\r\n try {\r\n await fastify.events.publish(eventType, payload, {\r\n correlationId: store?.requestId,\r\n resource: ctx.resource,\r\n resourceId: extractId(ctx.result),\r\n userId: userId ? String(userId) : undefined,\r\n organizationId,\r\n });\r\n } catch (error) {\r\n // Log but don't fail the request\r\n fastify.log?.warn?.(\r\n { eventType, error },\r\n 'Failed to emit event'\r\n );\r\n }\r\n });\r\n }\r\n }\r\n\r\n // Emit arc.ready lifecycle event when all resources are registered\r\n fastify.addHook('onReady', async () => {\r\n if (!hasEvents(fastify)) return;\r\n try {\r\n await fastify.events.publish('arc.ready', {\r\n resources: actualRegistry.getAll().length,\r\n hooks: actualHookSystem.getAll().length,\r\n timestamp: new Date().toISOString(),\r\n });\r\n } catch {\r\n // Lifecycle events are best-effort\r\n }\r\n });\r\n\r\n // Cleanup on close\r\n fastify.addHook('onClose', async () => {\r\n actualHookSystem.clear();\r\n actualRegistry._clear();\r\n });\r\n\r\n fastify.log?.debug?.('Arc core plugin enabled (instance-scoped hooks & registry)');\r\n};\r\n\r\n/** Extract document ID from a result (handles Mongoose docs and plain objects) */\r\nfunction extractId(doc: unknown): string | undefined {\r\n if (!doc || typeof doc !== 'object') return undefined;\r\n const d = doc as Record<string, unknown>;\r\n const rawId = d._id ?? d.id;\r\n return rawId ? String(rawId) : undefined;\r\n}\r\n\r\nexport default fp(arcCorePlugin, {\r\n name: 'arc-core',\r\n fastify: '5.x',\r\n});\r\n\r\nexport { arcCorePlugin };\r\n"],"mappings":"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"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
|
2
|
+
|
|
3
|
+
//#region src/plugins/response-cache.d.ts
|
|
4
|
+
interface ResponseCacheRule {
|
|
5
|
+
/** Path prefix to match (e.g., '/api/products') */
|
|
6
|
+
match: string;
|
|
7
|
+
/** TTL in seconds for this path (0 = don't cache) */
|
|
8
|
+
ttl: number;
|
|
9
|
+
}
|
|
10
|
+
interface ResponseCacheOptions {
|
|
11
|
+
/** Maximum number of cached entries (default: 500). LRU eviction when exceeded. */
|
|
12
|
+
maxEntries?: number;
|
|
13
|
+
/** Default TTL in seconds (default: 30). Set to 0 to require explicit rules. */
|
|
14
|
+
defaultTTL?: number;
|
|
15
|
+
/** Per-path cache rules */
|
|
16
|
+
rules?: ResponseCacheRule[];
|
|
17
|
+
/** Paths to exclude from caching (prefix match) */
|
|
18
|
+
exclude?: string[];
|
|
19
|
+
/** HTTP methods that trigger cache invalidation (default: POST, PUT, PATCH, DELETE) */
|
|
20
|
+
invalidateOn?: string[];
|
|
21
|
+
/** Whether to add X-Cache header (HIT/MISS) to responses (default: true) */
|
|
22
|
+
xCacheHeader?: boolean;
|
|
23
|
+
/** Enable stats endpoint at this path (default: null = disabled) */
|
|
24
|
+
statsPath?: string | null;
|
|
25
|
+
/** Custom cache key function (default: method + url + userId + orgId) */
|
|
26
|
+
keyFn?: (request: FastifyRequest) => string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Auto-invalidate cache entries when CRUD domain events fire (requires eventPlugin).
|
|
29
|
+
*
|
|
30
|
+
* - `true`: Invalidate resource prefix on its own CRUD events
|
|
31
|
+
* - `{ patterns: { 'order.*': ['/api/products'] } }`: Cross-resource invalidation rules
|
|
32
|
+
* - `false` / omitted: Disabled (default)
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* await fastify.register(responseCachePlugin, {
|
|
37
|
+
* eventInvalidation: {
|
|
38
|
+
* patterns: {
|
|
39
|
+
* 'order.*': ['/api/products', '/api/inventory'],
|
|
40
|
+
* },
|
|
41
|
+
* },
|
|
42
|
+
* });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
eventInvalidation?: boolean | {
|
|
46
|
+
patterns?: Record<string, string[]>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
interface ResponseCacheStats {
|
|
50
|
+
entries: number;
|
|
51
|
+
maxEntries: number;
|
|
52
|
+
hits: number;
|
|
53
|
+
misses: number;
|
|
54
|
+
hitRate: number;
|
|
55
|
+
evictions: number;
|
|
56
|
+
}
|
|
57
|
+
declare module "fastify" {
|
|
58
|
+
interface FastifyInstance {
|
|
59
|
+
responseCache: {
|
|
60
|
+
/** Invalidate all cached responses matching a path prefix */invalidate: (pathPrefix: string) => number; /** Clear the entire cache */
|
|
61
|
+
invalidateAll: () => void; /** Get cache statistics */
|
|
62
|
+
stats: () => ResponseCacheStats;
|
|
63
|
+
/**
|
|
64
|
+
* Route-level preHandler for cache lookup.
|
|
65
|
+
* Wire AFTER authenticate in the preHandler chain so that
|
|
66
|
+
* `request.user` / `request.scope` are populated before the
|
|
67
|
+
* cache key is computed.
|
|
68
|
+
*
|
|
69
|
+
* `createCrudRouter` injects this automatically for GET routes.
|
|
70
|
+
* For custom routes, add it manually:
|
|
71
|
+
* ```typescript
|
|
72
|
+
* fastify.get('/data', {
|
|
73
|
+
* preHandler: [fastify.authenticate, fastify.responseCache.middleware],
|
|
74
|
+
* }, handler);
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
middleware: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
interface FastifyRequest {
|
|
81
|
+
/** @internal Cache TTL in seconds — set by onRequest, consumed by middleware + onSend */
|
|
82
|
+
__arcCacheTTL?: number;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
declare const responseCachePlugin: FastifyPluginAsync<ResponseCacheOptions>;
|
|
86
|
+
//#endregion
|
|
87
|
+
export { ResponseCacheOptions, ResponseCacheRule, ResponseCacheStats, responseCachePlugin as default, responseCachePlugin };
|
|
88
|
+
//# sourceMappingURL=response-cache.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"response-cache.d.mts","names":[],"sources":["../../src/plugins/response-cache.ts"],"mappings":";;;UA6EiB,iBAAA;EAqON;EAnOT,KAAA;EAwOE;EAtOF,GAAA;AAAA;AAAA,UAGe,oBAAA;EAoeJ;EAleX,UAAA;;EAEA,UAAA;EAgeuE;EA9dvE,KAAA,GAAQ,iBAAA;;EAER,OAAA;;EAEA,YAAA;;EAEA,YAAA;;EAEA,SAAA;;EAEA,KAAA,IAAS,OAAA,EAAS,cAAA;;;;;;;;;;;;;;;;;;;EAmBlB,iBAAA;IAGM,QAAA,GAAW,MAAA;EAAA;AAAA;AAAA,UAYF,kBAAA;EACf,OAAA;EACA,UAAA;EACA,IAAA;EACA,MAAA;EACA,OAAA;EACA,SAAA;AAAA;AAAA;EAAA,UA6IU,eAAA;IACR,aAAA;mEAEE,UAAA,GAAa,UAAA;MAEb,aAAA;MAEA,KAAA,QAAa,kBAAA;;;;;;;;;;;;;;;MAeb,UAAA,GACE,OAAA,EAAS,cAAA,EACT,KAAA,EAAO,YAAA,KACJ,OAAA;IAAA;EAAA;EAAA,UAGC,cAAA;;IAER,aAAA;EAAA;AAAA;AAAA,cAiQS,mBAAA,EAAqB,kBAAA,CAAmB,oBAAA"}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { t as hasEvents } from "../typeGuards-DhMNLuvU.mjs";
|
|
2
|
+
import fp from "fastify-plugin";
|
|
3
|
+
|
|
4
|
+
//#region src/plugins/response-cache.ts
|
|
5
|
+
/**
|
|
6
|
+
* Response Cache Plugin for Arc
|
|
7
|
+
*
|
|
8
|
+
* In-memory LRU/TTL response cache that sits in front of your database.
|
|
9
|
+
* Caches serialized responses for GET requests, dramatically reducing DB load
|
|
10
|
+
* for frequently accessed resources.
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - LRU eviction with configurable max entries
|
|
14
|
+
* - Per-route TTL configuration
|
|
15
|
+
* - Automatic invalidation on mutations (POST/PUT/PATCH/DELETE)
|
|
16
|
+
* - Manual invalidation via `fastify.responseCache.invalidate()`
|
|
17
|
+
* - Cache stats endpoint for monitoring
|
|
18
|
+
* - Zero external deps — pure in-memory, serverless-safe
|
|
19
|
+
*
|
|
20
|
+
* NOTE: This cache is per-instance (in-memory). In multi-instance deployments,
|
|
21
|
+
* each instance maintains its own cache. For cross-instance invalidation,
|
|
22
|
+
* wire `fastify.responseCache.invalidate()` to your event bus manually.
|
|
23
|
+
*
|
|
24
|
+
* ## Auth Safety
|
|
25
|
+
*
|
|
26
|
+
* The cache check runs as a **route-level middleware** (`responseCache.middleware`)
|
|
27
|
+
* that must be wired AFTER authentication in the preHandler chain. Arc's
|
|
28
|
+
* `createCrudRouter` does this automatically. For custom routes, wire it
|
|
29
|
+
* manually:
|
|
30
|
+
*
|
|
31
|
+
* ```typescript
|
|
32
|
+
* fastify.get('/data', {
|
|
33
|
+
* preHandler: [fastify.authenticate, fastify.responseCache.middleware],
|
|
34
|
+
* }, handler);
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* This ensures cached responses are never served before auth validates the
|
|
38
|
+
* caller's identity. The default cache key includes `userId` and `orgId`
|
|
39
|
+
* to prevent cross-caller data leaks.
|
|
40
|
+
*
|
|
41
|
+
* This is a SEPARATE subpath import — only loaded when explicitly used:
|
|
42
|
+
* import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
|
|
47
|
+
*
|
|
48
|
+
* await fastify.register(responseCachePlugin, {
|
|
49
|
+
* maxEntries: 1000,
|
|
50
|
+
* defaultTTL: 30, // 30 seconds
|
|
51
|
+
* rules: [
|
|
52
|
+
* { match: '/api/products', ttl: 120 }, // 2 min for products
|
|
53
|
+
* { match: '/api/categories', ttl: 300 }, // 5 min for categories
|
|
54
|
+
* { match: '/api/users', ttl: 0 }, // never cache users
|
|
55
|
+
* ],
|
|
56
|
+
* invalidateOn: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* // Manual invalidation
|
|
60
|
+
* fastify.responseCache.invalidate('/api/products');
|
|
61
|
+
* fastify.responseCache.invalidateAll();
|
|
62
|
+
*
|
|
63
|
+
* // Stats
|
|
64
|
+
* const stats = fastify.responseCache.stats();
|
|
65
|
+
* // { entries: 42, hits: 1250, misses: 180, hitRate: 0.87, evictions: 5 }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Simple LRU cache using Map iteration order.
|
|
70
|
+
* Map in JS preserves insertion order — we re-insert on access to make it LRU.
|
|
71
|
+
*/
|
|
72
|
+
var LRUCache = class {
|
|
73
|
+
cache = /* @__PURE__ */ new Map();
|
|
74
|
+
maxEntries;
|
|
75
|
+
invalidatedPrefixes = /* @__PURE__ */ new Map();
|
|
76
|
+
hits = 0;
|
|
77
|
+
misses = 0;
|
|
78
|
+
evictions = 0;
|
|
79
|
+
constructor(maxEntries) {
|
|
80
|
+
this.maxEntries = maxEntries;
|
|
81
|
+
}
|
|
82
|
+
get(key) {
|
|
83
|
+
const entry = this.cache.get(key);
|
|
84
|
+
if (!entry) {
|
|
85
|
+
this.misses++;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() - entry.createdAt > entry.ttl) {
|
|
89
|
+
this.cache.delete(key);
|
|
90
|
+
this.misses++;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
this.cache.delete(key);
|
|
94
|
+
this.cache.set(key, entry);
|
|
95
|
+
this.hits++;
|
|
96
|
+
return entry;
|
|
97
|
+
}
|
|
98
|
+
set(key, entry) {
|
|
99
|
+
if (this.isPrefixLocked(key)) return;
|
|
100
|
+
if (this.cache.has(key)) this.cache.delete(key);
|
|
101
|
+
while (this.cache.size >= this.maxEntries) {
|
|
102
|
+
const firstKey = this.cache.keys().next().value;
|
|
103
|
+
if (firstKey !== void 0) {
|
|
104
|
+
this.cache.delete(firstKey);
|
|
105
|
+
this.evictions++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
this.cache.set(key, entry);
|
|
109
|
+
}
|
|
110
|
+
/** Invalidate entries matching a path prefix and lock it from caching to allow DB replicas to catch up */
|
|
111
|
+
invalidatePrefix(prefix, jitterMs = 1500) {
|
|
112
|
+
let count = 0;
|
|
113
|
+
for (const key of this.cache.keys()) {
|
|
114
|
+
const colonIdx = key.indexOf(":");
|
|
115
|
+
if ((colonIdx >= 0 ? key.slice(colonIdx + 1) : key).split("?")[0].startsWith(prefix)) {
|
|
116
|
+
this.cache.delete(key);
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (jitterMs > 0) this.invalidatedPrefixes.set(prefix, Date.now() + jitterMs);
|
|
121
|
+
return count;
|
|
122
|
+
}
|
|
123
|
+
/** Check if a key falls under a recently invalidated prefix */
|
|
124
|
+
isPrefixLocked(key) {
|
|
125
|
+
if (this.invalidatedPrefixes.size === 0) return false;
|
|
126
|
+
const colonIdx = key.indexOf(":");
|
|
127
|
+
const pathOnly = (colonIdx >= 0 ? key.slice(colonIdx + 1) : key).split("?")[0];
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
for (const [prefix, expiresAt] of this.invalidatedPrefixes.entries()) if (now > expiresAt) this.invalidatedPrefixes.delete(prefix);
|
|
130
|
+
else if (pathOnly.startsWith(prefix)) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** Clear all entries */
|
|
134
|
+
clear() {
|
|
135
|
+
this.cache.clear();
|
|
136
|
+
}
|
|
137
|
+
get size() {
|
|
138
|
+
return this.cache.size;
|
|
139
|
+
}
|
|
140
|
+
getStats(maxEntries) {
|
|
141
|
+
const total = this.hits + this.misses;
|
|
142
|
+
return {
|
|
143
|
+
entries: this.cache.size,
|
|
144
|
+
maxEntries,
|
|
145
|
+
hits: this.hits,
|
|
146
|
+
misses: this.misses,
|
|
147
|
+
hitRate: total > 0 ? Math.round(this.hits / total * 100) / 100 : 0,
|
|
148
|
+
evictions: this.evictions
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const responseCachePluginImpl = async (fastify, opts = {}) => {
|
|
153
|
+
const { maxEntries = 500, defaultTTL = 30, rules = [], exclude = [], invalidateOn = [
|
|
154
|
+
"POST",
|
|
155
|
+
"PUT",
|
|
156
|
+
"PATCH",
|
|
157
|
+
"DELETE"
|
|
158
|
+
], xCacheHeader = true, statsPath = null, keyFn } = opts;
|
|
159
|
+
const cache = new LRUCache(maxEntries);
|
|
160
|
+
const invalidateMethods = new Set(invalidateOn.map((m) => m.toUpperCase()));
|
|
161
|
+
/** Find TTL for a given URL path (seconds) */
|
|
162
|
+
function getTTL(url) {
|
|
163
|
+
const path = url.split("?")[0];
|
|
164
|
+
for (const rule of rules) if (path.startsWith(rule.match)) return rule.ttl;
|
|
165
|
+
return defaultTTL;
|
|
166
|
+
}
|
|
167
|
+
/** Check if a URL should be excluded */
|
|
168
|
+
function isExcluded(url) {
|
|
169
|
+
return exclude.some((p) => url.startsWith(p));
|
|
170
|
+
}
|
|
171
|
+
/** Build cache key — includes user/org scope by default to prevent cross-caller leaks */
|
|
172
|
+
function buildKey(request) {
|
|
173
|
+
if (keyFn) return keyFn(request);
|
|
174
|
+
const user = request.user;
|
|
175
|
+
const userId = user?.id ?? user?._id ?? "anon";
|
|
176
|
+
const orgId = request.scope?.organizationId ?? "no-org";
|
|
177
|
+
return `${request.method}:${request.url}:u=${userId}:o=${orgId}`;
|
|
178
|
+
}
|
|
179
|
+
fastify.addHook("onRequest", async (request) => {
|
|
180
|
+
if (request.method !== "GET" && request.method !== "HEAD") return;
|
|
181
|
+
if (isExcluded(request.url)) return;
|
|
182
|
+
const ttl = getTTL(request.url);
|
|
183
|
+
if (ttl <= 0) return;
|
|
184
|
+
request.__arcCacheTTL = ttl;
|
|
185
|
+
});
|
|
186
|
+
fastify.addHook("onResponse", async (request, reply) => {
|
|
187
|
+
if (!invalidateMethods.has(request.method.toUpperCase())) return;
|
|
188
|
+
const statusCode = reply.statusCode;
|
|
189
|
+
if (statusCode < 200 || statusCode >= 300) return;
|
|
190
|
+
const path = request.url.split("?")[0];
|
|
191
|
+
const segments = path.split("/").filter(Boolean);
|
|
192
|
+
const lastSegment = segments[segments.length - 1];
|
|
193
|
+
if (segments.length >= 2 && lastSegment != null && /^[0-9a-f]{8,}$|^\d+$/.test(lastSegment)) {
|
|
194
|
+
const resourceRoot = "/" + segments.slice(0, -1).join("/");
|
|
195
|
+
cache.invalidatePrefix(resourceRoot);
|
|
196
|
+
cache.invalidatePrefix(path);
|
|
197
|
+
} else cache.invalidatePrefix(path);
|
|
198
|
+
});
|
|
199
|
+
const cacheMiddleware = async (request, reply) => {
|
|
200
|
+
const ttl = request.__arcCacheTTL;
|
|
201
|
+
if (!ttl || ttl <= 0) return;
|
|
202
|
+
if (request.method !== "GET" && request.method !== "HEAD") return;
|
|
203
|
+
const key = buildKey(request);
|
|
204
|
+
if (!key) return;
|
|
205
|
+
const entry = cache.get(key);
|
|
206
|
+
if (!entry) return;
|
|
207
|
+
if (xCacheHeader) reply.header("x-cache", "HIT");
|
|
208
|
+
for (const [name, value] of Object.entries(entry.headers)) reply.header(name, value);
|
|
209
|
+
request.__arcCacheTTL = 0;
|
|
210
|
+
reply.code(entry.statusCode).send(entry.body);
|
|
211
|
+
};
|
|
212
|
+
fastify.addHook("onSend", async (request, reply, payload) => {
|
|
213
|
+
const ttl = request.__arcCacheTTL;
|
|
214
|
+
if (!ttl || ttl <= 0) return payload;
|
|
215
|
+
if (request.method !== "GET" && request.method !== "HEAD") return payload;
|
|
216
|
+
const statusCode = reply.statusCode;
|
|
217
|
+
if (statusCode < 200 || statusCode >= 300) return payload;
|
|
218
|
+
const key = buildKey(request);
|
|
219
|
+
if (!key) return payload;
|
|
220
|
+
if (xCacheHeader) reply.header("x-cache", "MISS");
|
|
221
|
+
let body;
|
|
222
|
+
if (typeof payload === "string") body = payload;
|
|
223
|
+
else if (Buffer.isBuffer(payload)) body = payload.toString("utf-8");
|
|
224
|
+
else if (payload != null) body = JSON.stringify(payload);
|
|
225
|
+
else body = "";
|
|
226
|
+
const headers = {};
|
|
227
|
+
const contentType = reply.getHeader("content-type");
|
|
228
|
+
if (contentType) headers["content-type"] = String(contentType);
|
|
229
|
+
const etag = reply.getHeader("etag");
|
|
230
|
+
if (etag) headers["etag"] = String(etag);
|
|
231
|
+
cache.set(key, {
|
|
232
|
+
body,
|
|
233
|
+
statusCode,
|
|
234
|
+
headers,
|
|
235
|
+
createdAt: Date.now(),
|
|
236
|
+
ttl: ttl * 1e3
|
|
237
|
+
});
|
|
238
|
+
return payload;
|
|
239
|
+
});
|
|
240
|
+
fastify.decorate("responseCache", {
|
|
241
|
+
invalidate: (pathPrefix) => cache.invalidatePrefix(pathPrefix),
|
|
242
|
+
invalidateAll: () => cache.clear(),
|
|
243
|
+
stats: () => cache.getStats(maxEntries),
|
|
244
|
+
middleware: cacheMiddleware
|
|
245
|
+
});
|
|
246
|
+
if (statsPath) fastify.get(statsPath, async () => {
|
|
247
|
+
return cache.getStats(maxEntries);
|
|
248
|
+
});
|
|
249
|
+
const evtInv = opts.eventInvalidation;
|
|
250
|
+
if (evtInv && hasEvents(fastify)) {
|
|
251
|
+
const crossResourcePatterns = typeof evtInv === "object" ? evtInv.patterns ?? {} : {};
|
|
252
|
+
fastify.events.subscribe("*", async (event) => {
|
|
253
|
+
const parts = event.type.split(".");
|
|
254
|
+
if (parts.length !== 2) return;
|
|
255
|
+
const [resource, action] = parts;
|
|
256
|
+
if (!resource || ![
|
|
257
|
+
"created",
|
|
258
|
+
"updated",
|
|
259
|
+
"deleted"
|
|
260
|
+
].includes(action)) return;
|
|
261
|
+
cache.invalidatePrefix(`/${resource}s`);
|
|
262
|
+
cache.invalidatePrefix(`/${resource}`);
|
|
263
|
+
for (const [pattern, prefixes] of Object.entries(crossResourcePatterns)) if (eventMatchesPattern(event.type, pattern)) for (const prefix of prefixes) cache.invalidatePrefix(prefix);
|
|
264
|
+
}).catch((err) => {
|
|
265
|
+
fastify.log?.warn?.({ err }, "Response cache: failed to subscribe to events for invalidation");
|
|
266
|
+
});
|
|
267
|
+
fastify.log?.debug?.("Response cache: event-driven invalidation enabled");
|
|
268
|
+
} else if (evtInv) fastify.log?.warn?.("Response cache: eventInvalidation enabled but eventPlugin not registered.");
|
|
269
|
+
fastify.log?.debug?.(`Response cache: registered (max=${maxEntries}, defaultTTL=${defaultTTL}s, rules=${rules.length})`);
|
|
270
|
+
};
|
|
271
|
+
/** Check if an event type matches a pattern (supports wildcards) */
|
|
272
|
+
function eventMatchesPattern(type, pattern) {
|
|
273
|
+
if (pattern === "*") return true;
|
|
274
|
+
if (pattern.endsWith(".*")) return type.startsWith(pattern.slice(0, -1));
|
|
275
|
+
return type === pattern;
|
|
276
|
+
}
|
|
277
|
+
const responseCachePlugin = fp(responseCachePluginImpl, {
|
|
278
|
+
name: "arc-response-cache",
|
|
279
|
+
fastify: "5.x"
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
//#endregion
|
|
283
|
+
export { responseCachePlugin as default, responseCachePlugin };
|
|
284
|
+
//# sourceMappingURL=response-cache.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"response-cache.mjs","names":[],"sources":["../../src/plugins/response-cache.ts"],"sourcesContent":["/**\n * Response Cache Plugin for Arc\n *\n * In-memory LRU/TTL response cache that sits in front of your database.\n * Caches serialized responses for GET requests, dramatically reducing DB load\n * for frequently accessed resources.\n *\n * Features:\n * - LRU eviction with configurable max entries\n * - Per-route TTL configuration\n * - Automatic invalidation on mutations (POST/PUT/PATCH/DELETE)\n * - Manual invalidation via `fastify.responseCache.invalidate()`\n * - Cache stats endpoint for monitoring\n * - Zero external deps — pure in-memory, serverless-safe\n *\n * NOTE: This cache is per-instance (in-memory). In multi-instance deployments,\n * each instance maintains its own cache. For cross-instance invalidation,\n * wire `fastify.responseCache.invalidate()` to your event bus manually.\n *\n * ## Auth Safety\n *\n * The cache check runs as a **route-level middleware** (`responseCache.middleware`)\n * that must be wired AFTER authentication in the preHandler chain. Arc's\n * `createCrudRouter` does this automatically. For custom routes, wire it\n * manually:\n *\n * ```typescript\n * fastify.get('/data', {\n * preHandler: [fastify.authenticate, fastify.responseCache.middleware],\n * }, handler);\n * ```\n *\n * This ensures cached responses are never served before auth validates the\n * caller's identity. The default cache key includes `userId` and `orgId`\n * to prevent cross-caller data leaks.\n *\n * This is a SEPARATE subpath import — only loaded when explicitly used:\n * import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';\n *\n * @example\n * ```typescript\n * import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';\n *\n * await fastify.register(responseCachePlugin, {\n * maxEntries: 1000,\n * defaultTTL: 30, // 30 seconds\n * rules: [\n * { match: '/api/products', ttl: 120 }, // 2 min for products\n * { match: '/api/categories', ttl: 300 }, // 5 min for categories\n * { match: '/api/users', ttl: 0 }, // never cache users\n * ],\n * invalidateOn: ['POST', 'PUT', 'PATCH', 'DELETE'],\n * });\n *\n * // Manual invalidation\n * fastify.responseCache.invalidate('/api/products');\n * fastify.responseCache.invalidateAll();\n *\n * // Stats\n * const stats = fastify.responseCache.stats();\n * // { entries: 42, hits: 1250, misses: 180, hitRate: 0.87, evictions: 5 }\n * ```\n */\n\nimport fp from \"fastify-plugin\";\nimport type {\n FastifyInstance,\n FastifyPluginAsync,\n FastifyReply,\n FastifyRequest,\n} from \"fastify\";\nimport { hasEvents } from \"../utils/typeGuards.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface ResponseCacheRule {\n /** Path prefix to match (e.g., '/api/products') */\n match: string;\n /** TTL in seconds for this path (0 = don't cache) */\n ttl: number;\n}\n\nexport interface ResponseCacheOptions {\n /** Maximum number of cached entries (default: 500). LRU eviction when exceeded. */\n maxEntries?: number;\n /** Default TTL in seconds (default: 30). Set to 0 to require explicit rules. */\n defaultTTL?: number;\n /** Per-path cache rules */\n rules?: ResponseCacheRule[];\n /** Paths to exclude from caching (prefix match) */\n exclude?: string[];\n /** HTTP methods that trigger cache invalidation (default: POST, PUT, PATCH, DELETE) */\n invalidateOn?: string[];\n /** Whether to add X-Cache header (HIT/MISS) to responses (default: true) */\n xCacheHeader?: boolean;\n /** Enable stats endpoint at this path (default: null = disabled) */\n statsPath?: string | null;\n /** Custom cache key function (default: method + url + userId + orgId) */\n keyFn?: (request: FastifyRequest) => string | null;\n /**\n * Auto-invalidate cache entries when CRUD domain events fire (requires eventPlugin).\n *\n * - `true`: Invalidate resource prefix on its own CRUD events\n * - `{ patterns: { 'order.*': ['/api/products'] } }`: Cross-resource invalidation rules\n * - `false` / omitted: Disabled (default)\n *\n * @example\n * ```typescript\n * await fastify.register(responseCachePlugin, {\n * eventInvalidation: {\n * patterns: {\n * 'order.*': ['/api/products', '/api/inventory'],\n * },\n * },\n * });\n * ```\n */\n eventInvalidation?:\n | boolean\n | {\n patterns?: Record<string, string[]>;\n };\n}\n\ninterface CacheEntry {\n body: string;\n statusCode: number;\n headers: Record<string, string>;\n createdAt: number;\n ttl: number; // ms\n}\n\nexport interface ResponseCacheStats {\n entries: number;\n maxEntries: number;\n hits: number;\n misses: number;\n hitRate: number;\n evictions: number;\n}\n\n// ============================================================================\n// LRU Cache (minimal, zero-dep)\n// ============================================================================\n\n/**\n * Simple LRU cache using Map iteration order.\n * Map in JS preserves insertion order — we re-insert on access to make it LRU.\n */\nclass LRUCache {\n private cache = new Map<string, CacheEntry>();\n private maxEntries: number;\n\n // Locks to prevent caching stale replica data immediately after mutation\n private invalidatedPrefixes = new Map<string, number>();\n\n // Stats\n hits = 0;\n misses = 0;\n evictions = 0;\n\n constructor(maxEntries: number) {\n this.maxEntries = maxEntries;\n }\n\n get(key: string): CacheEntry | null {\n const entry = this.cache.get(key);\n if (!entry) {\n this.misses++;\n return null;\n }\n\n // Check TTL\n if (Date.now() - entry.createdAt > entry.ttl) {\n this.cache.delete(key);\n this.misses++;\n return null;\n }\n\n // Move to end (most recently used)\n this.cache.delete(key);\n this.cache.set(key, entry);\n this.hits++;\n return entry;\n }\n\n set(key: string, entry: CacheEntry): void {\n // If prefix is locked due to recent invalidation, do not cache (prevents stale replica reads)\n if (this.isPrefixLocked(key)) {\n return;\n }\n\n // Delete first to reset position\n if (this.cache.has(key)) {\n this.cache.delete(key);\n }\n\n // Evict oldest (first in Map) if at capacity\n while (this.cache.size >= this.maxEntries) {\n const firstKey = this.cache.keys().next().value;\n if (firstKey !== undefined) {\n this.cache.delete(firstKey);\n this.evictions++;\n }\n }\n\n this.cache.set(key, entry);\n }\n\n /** Invalidate entries matching a path prefix and lock it from caching to allow DB replicas to catch up */\n invalidatePrefix(prefix: string, jitterMs = 1500): number {\n let count = 0;\n for (const key of this.cache.keys()) {\n // Keys are formatted as \"GET:/api/products?page=1:u=...:o=...\"\n // Extract path after method\n const colonIdx = key.indexOf(\":\");\n const path = colonIdx >= 0 ? key.slice(colonIdx + 1) : key;\n // Strip query string and user/org suffix for prefix matching\n const pathOnly = path.split(\"?\")[0]!;\n if (pathOnly.startsWith(prefix)) {\n this.cache.delete(key);\n count++;\n }\n }\n\n // Lock this prefix from being cached for `jitterMs` milliseconds\n if (jitterMs > 0) {\n this.invalidatedPrefixes.set(prefix, Date.now() + jitterMs);\n }\n\n return count;\n }\n\n /** Check if a key falls under a recently invalidated prefix */\n private isPrefixLocked(key: string): boolean {\n if (this.invalidatedPrefixes.size === 0) return false;\n\n const colonIdx = key.indexOf(\":\");\n const path = colonIdx >= 0 ? key.slice(colonIdx + 1) : key;\n const pathOnly = path.split(\"?\")[0]!;\n\n const now = Date.now();\n for (const [prefix, expiresAt] of this.invalidatedPrefixes.entries()) {\n if (now > expiresAt) {\n this.invalidatedPrefixes.delete(prefix);\n } else if (pathOnly.startsWith(prefix)) {\n return true;\n }\n }\n return false;\n }\n\n /** Clear all entries */\n clear(): void {\n this.cache.clear();\n }\n\n get size(): number {\n return this.cache.size;\n }\n\n getStats(maxEntries: number): ResponseCacheStats {\n const total = this.hits + this.misses;\n return {\n entries: this.cache.size,\n maxEntries,\n hits: this.hits,\n misses: this.misses,\n hitRate: total > 0 ? Math.round((this.hits / total) * 100) / 100 : 0,\n evictions: this.evictions,\n };\n }\n}\n\n// ============================================================================\n// Fastify Type Extensions\n// ============================================================================\n\ndeclare module \"fastify\" {\n interface FastifyInstance {\n responseCache: {\n /** Invalidate all cached responses matching a path prefix */\n invalidate: (pathPrefix: string) => number;\n /** Clear the entire cache */\n invalidateAll: () => void;\n /** Get cache statistics */\n stats: () => ResponseCacheStats;\n /**\n * Route-level preHandler for cache lookup.\n * Wire AFTER authenticate in the preHandler chain so that\n * `request.user` / `request.scope` are populated before the\n * cache key is computed.\n *\n * `createCrudRouter` injects this automatically for GET routes.\n * For custom routes, add it manually:\n * ```typescript\n * fastify.get('/data', {\n * preHandler: [fastify.authenticate, fastify.responseCache.middleware],\n * }, handler);\n * ```\n */\n middleware: (\n request: FastifyRequest,\n reply: FastifyReply,\n ) => Promise<void>;\n };\n }\n interface FastifyRequest {\n /** @internal Cache TTL in seconds — set by onRequest, consumed by middleware + onSend */\n __arcCacheTTL?: number;\n }\n}\n\n// ============================================================================\n// Plugin Implementation\n// ============================================================================\n\nconst responseCachePluginImpl: FastifyPluginAsync<\n ResponseCacheOptions\n> = async (fastify: FastifyInstance, opts: ResponseCacheOptions = {}) => {\n const {\n maxEntries = 500,\n defaultTTL = 30,\n rules = [],\n exclude = [],\n invalidateOn = [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n xCacheHeader = true,\n statsPath = null,\n keyFn,\n } = opts;\n\n const cache = new LRUCache(maxEntries);\n const invalidateMethods = new Set(invalidateOn.map((m) => m.toUpperCase()));\n\n /** Find TTL for a given URL path (seconds) */\n function getTTL(url: string): number {\n const path = url.split(\"?\")[0]!;\n for (const rule of rules) {\n if (path.startsWith(rule.match)) {\n return rule.ttl;\n }\n }\n return defaultTTL;\n }\n\n /** Check if a URL should be excluded */\n function isExcluded(url: string): boolean {\n return exclude.some((p) => url.startsWith(p));\n }\n\n /** Build cache key — includes user/org scope by default to prevent cross-caller leaks */\n function buildKey(request: FastifyRequest): string | null {\n if (keyFn) return keyFn(request);\n // Scope the cache key to the user and org to prevent serving\n // User A's response to User B, or Org A's data to Org B\n const user = request.user as { id?: string; _id?: string } | undefined;\n const userId = user?.id ?? user?._id ?? \"anon\";\n const scope = request.scope as\n | { kind: string; organizationId?: string }\n | undefined;\n const orgId = scope?.organizationId ?? \"no-org\";\n return `${request.method}:${request.url}:u=${userId}:o=${orgId}`;\n }\n\n // ---- onRequest hook: mark cacheable GET/HEAD requests ----\n fastify.addHook(\"onRequest\", async (request: FastifyRequest) => {\n // Only mark GET/HEAD requests as cacheable (TTL computed early, key deferred to after auth)\n if (request.method !== \"GET\" && request.method !== \"HEAD\") return;\n if (isExcluded(request.url)) return;\n\n const ttl = getTTL(request.url);\n if (ttl <= 0) return;\n\n // Store TTL for downstream middleware + onSend\n request.__arcCacheTTL = ttl;\n });\n\n // ---- onResponse hook: invalidate cache only on successful (2xx) mutations ----\n // This runs AFTER the request completes, so failed/unauthorized mutations\n // do NOT purge the cache (prevents cache-purge DoS attacks).\n fastify.addHook(\n \"onResponse\",\n async (request: FastifyRequest, reply: FastifyReply) => {\n if (!invalidateMethods.has(request.method.toUpperCase())) return;\n\n // Only invalidate on successful responses\n const statusCode = reply.statusCode;\n if (statusCode < 200 || statusCode >= 300) return;\n\n const path = request.url.split(\"?\")[0]!;\n const segments = path.split(\"/\").filter(Boolean);\n\n // Detect item-scoped paths by checking if the last segment looks like\n // a resource ID (not a collection name). This handles both prefixed\n // routes like /api/products/123 (3 segments) and non-prefixed routes\n // like /products/123 (2 segments).\n const lastSegment = segments[segments.length - 1];\n const isItemScoped =\n segments.length >= 2 &&\n lastSegment != null &&\n /^[0-9a-f]{8,}$|^\\d+$/.test(lastSegment);\n\n if (isItemScoped) {\n // Item-level mutation — invalidate both the item and its collection\n const resourceRoot = \"/\" + segments.slice(0, -1).join(\"/\");\n cache.invalidatePrefix(resourceRoot);\n cache.invalidatePrefix(path);\n } else {\n // Collection-level mutation (e.g., POST /api/products)\n cache.invalidatePrefix(path);\n }\n },\n );\n\n // ---- Route-level middleware: serve from cache (AFTER auth) ----\n const cacheMiddleware = async (\n request: FastifyRequest,\n reply: FastifyReply,\n ): Promise<void> => {\n // Only check cache for cacheable requests\n const ttl = request.__arcCacheTTL;\n if (!ttl || ttl <= 0) return;\n if (request.method !== \"GET\" && request.method !== \"HEAD\") return;\n\n const key = buildKey(request);\n if (!key) return;\n\n const entry = cache.get(key);\n if (!entry) return; // Cache MISS — let handler run, onSend will store\n\n // Cache HIT — serve directly (auth has already validated the caller)\n if (xCacheHeader) {\n reply.header(\"x-cache\", \"HIT\");\n }\n\n for (const [name, value] of Object.entries(entry.headers)) {\n reply.header(name, value);\n }\n\n // Clear TTL so the onSend hook doesn't overwrite x-cache to MISS\n request.__arcCacheTTL = 0;\n reply.code(entry.statusCode).send(entry.body);\n };\n\n // ---- onSend hook: store in cache (recompute key — user is now populated) ----\n fastify.addHook(\n \"onSend\",\n async (request: FastifyRequest, reply: FastifyReply, payload) => {\n const ttl = request.__arcCacheTTL;\n if (!ttl || ttl <= 0) return payload;\n\n if (request.method !== \"GET\" && request.method !== \"HEAD\") return payload;\n\n // Only cache 2xx responses\n const statusCode = reply.statusCode;\n if (statusCode < 200 || statusCode >= 300) return payload;\n\n // Recompute key with now-populated user identity (auth has run by this point)\n const key = buildKey(request);\n if (!key) return payload;\n\n if (xCacheHeader) {\n reply.header(\"x-cache\", \"MISS\");\n }\n\n // Store in cache — handle Buffer correctly (String(buffer) produces '[object Buffer]')\n let body: string;\n if (typeof payload === \"string\") {\n body = payload;\n } else if (Buffer.isBuffer(payload)) {\n body = payload.toString(\"utf-8\");\n } else if (payload != null) {\n body = JSON.stringify(payload);\n } else {\n body = \"\";\n }\n\n // Capture cacheable headers\n const headers: Record<string, string> = {};\n const contentType = reply.getHeader(\"content-type\");\n if (contentType) headers[\"content-type\"] = String(contentType);\n const etag = reply.getHeader(\"etag\");\n if (etag) headers[\"etag\"] = String(etag);\n\n cache.set(key, {\n body,\n statusCode,\n headers,\n createdAt: Date.now(),\n ttl: ttl * 1000, // Convert to ms\n });\n\n return payload;\n },\n );\n\n // ---- Decorator ----\n fastify.decorate(\"responseCache\", {\n invalidate: (pathPrefix: string) => cache.invalidatePrefix(pathPrefix),\n invalidateAll: () => cache.clear(),\n stats: () => cache.getStats(maxEntries),\n middleware: cacheMiddleware,\n });\n\n // ---- Optional stats endpoint ----\n if (statsPath) {\n fastify.get(statsPath, async () => {\n return cache.getStats(maxEntries);\n });\n }\n\n // ---- Event-driven invalidation (requires eventPlugin) ----\n const evtInv = opts.eventInvalidation;\n if (evtInv && hasEvents(fastify)) {\n const crossResourcePatterns =\n typeof evtInv === \"object\" ? (evtInv.patterns ?? {}) : {};\n\n fastify.events\n .subscribe(\"*\", async (event) => {\n const parts = event.type.split(\".\");\n if (parts.length !== 2) return;\n const [resource, action] = parts;\n if (!resource || ![\"created\", \"updated\", \"deleted\"].includes(action!))\n return;\n\n // Invalidate the resource's own cache prefix (both singular and plural)\n cache.invalidatePrefix(`/${resource}s`);\n cache.invalidatePrefix(`/${resource}`);\n\n // Apply cross-resource invalidation rules\n for (const [pattern, prefixes] of Object.entries(\n crossResourcePatterns,\n )) {\n if (eventMatchesPattern(event.type, pattern)) {\n for (const prefix of prefixes) {\n cache.invalidatePrefix(prefix);\n }\n }\n }\n })\n .catch((err) => {\n fastify.log?.warn?.(\n { err },\n \"Response cache: failed to subscribe to events for invalidation\",\n );\n });\n\n fastify.log?.debug?.(\"Response cache: event-driven invalidation enabled\");\n } else if (evtInv) {\n fastify.log?.warn?.(\n \"Response cache: eventInvalidation enabled but eventPlugin not registered.\",\n );\n }\n\n fastify.log?.debug?.(\n `Response cache: registered (max=${maxEntries}, defaultTTL=${defaultTTL}s, rules=${rules.length})`,\n );\n};\n\n/** Check if an event type matches a pattern (supports wildcards) */\nfunction eventMatchesPattern(type: string, pattern: string): boolean {\n if (pattern === \"*\") return true;\n if (pattern.endsWith(\".*\")) return type.startsWith(pattern.slice(0, -1));\n return type === pattern;\n}\n\nexport const responseCachePlugin: FastifyPluginAsync<ResponseCacheOptions> = fp(\n responseCachePluginImpl,\n {\n name: \"arc-response-cache\",\n fastify: \"5.x\",\n },\n) as unknown as FastifyPluginAsync<ResponseCacheOptions>;\n\nexport default responseCachePlugin;\n"],"mappings":"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"}
|