@forinda/kickjs 8.5.0 → 8.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{adapter-989ssCBx.d.mts → adapter-DK8WDonT.d.mts} +138 -12
- package/dist/adapter-DK8WDonT.d.mts.map +1 -0
- package/dist/{app-module-OQsddkor.d.mts → app-module-DCA65GZZ.d.mts} +2 -2
- package/dist/{app-module-OQsddkor.d.mts.map → app-module-DCA65GZZ.d.mts.map} +1 -1
- package/dist/{application-BRvykQ6h.d.mts → application-B6Gp1nqK.d.mts} +113 -16
- package/dist/application-B6Gp1nqK.d.mts.map +1 -0
- package/dist/application-D2scwBz-.mjs +12 -0
- package/dist/application-D2scwBz-.mjs.map +1 -0
- package/dist/bootstrap-Dds2PVAn.mjs +12 -0
- package/dist/bootstrap-Dds2PVAn.mjs.map +1 -0
- package/dist/cache-C0qL0NSD.d.mts.map +1 -1
- package/dist/{client-ip-CW7HwSOm.mjs → client-ip-BuY7_pDD.mjs} +2 -2
- package/dist/{client-ip-CW7HwSOm.mjs.map → client-ip-BuY7_pDD.mjs.map} +1 -1
- package/dist/config/index.d.mts +1 -1
- package/dist/config/index.mjs +2 -2
- package/dist/{config-Cajrc3qr.mjs → config-DBebCpg_.mjs} +3 -3
- package/dist/{config-Cajrc3qr.mjs.map → config-DBebCpg_.mjs.map} +1 -1
- package/dist/container-Cqow_bOU.mjs +132 -0
- package/dist/container-Cqow_bOU.mjs.map +1 -0
- package/dist/context-CHuuc9r6.mjs +12 -0
- package/dist/context-CHuuc9r6.mjs.map +1 -0
- package/dist/{context-errors-Cj2_aDaj.mjs → context-errors-VduncZFQ.mjs} +2 -2
- package/dist/{context-errors-Cj2_aDaj.mjs.map → context-errors-VduncZFQ.mjs.map} +1 -1
- package/dist/{contributor-runner-DQCZLpvk.mjs → contributor-runner-BoVekHyj.mjs} +3 -3
- package/dist/{contributor-runner-DQCZLpvk.mjs.map → contributor-runner-BoVekHyj.mjs.map} +1 -1
- package/dist/core/adapter.d.mts +2 -2
- package/dist/core/adapter.mjs +1 -1
- package/dist/core/app-module.d.mts +1 -1
- package/dist/core/app-module.mjs +1 -1
- package/dist/core/cache.mjs +2 -2
- package/dist/core/cache.mjs.map +1 -1
- package/dist/core/container.mjs +2 -2
- package/dist/core/cron.d.mts +2 -2
- package/dist/core/cron.mjs +2 -2
- package/dist/core/cron.mjs.map +1 -1
- package/dist/core/decorators.d.mts +1 -1
- package/dist/core/decorators.mjs +2 -2
- package/dist/core/decorators.mjs.map +1 -1
- package/dist/core/errors.mjs +1 -1
- package/dist/core/interfaces.mjs +1 -1
- package/dist/core/logger.mjs +1 -1
- package/dist/core/path.mjs +1 -1
- package/dist/core/plugin.d.mts +1 -1
- package/dist/core/plugin.mjs +1 -1
- package/dist/core/reactivity.mjs +1 -1
- package/dist/{core-DZmGThrF.mjs → core-Chc61ahe.mjs} +3 -3
- package/dist/{core-DZmGThrF.mjs.map → core-Chc61ahe.mjs.map} +1 -1
- package/dist/cron-BTGytNg-.d.mts +122 -0
- package/dist/cron-BTGytNg-.d.mts.map +1 -0
- package/dist/{csrf-DDwUOF45.d.mts → csrf-COFjRrUz.d.mts} +3 -3
- package/dist/{csrf-DDwUOF45.d.mts.map → csrf-COFjRrUz.d.mts.map} +1 -1
- package/dist/{csrf-DPPIM72I.mjs → csrf-D-8W1iv7.mjs} +3 -3
- package/dist/csrf-D-8W1iv7.mjs.map +1 -0
- package/dist/{decorate-DHyinlPY.mjs → decorate-D9n58F0b.mjs} +1 -1
- package/dist/{decorators-mg0qJ6Qc.d.mts → decorators-DOcWsXci.d.mts} +2 -2
- package/dist/{decorators-mg0qJ6Qc.d.mts.map → decorators-DOcWsXci.d.mts.map} +1 -1
- package/dist/{define-adapter-0asM4v0B.mjs → define-adapter-BJunWqi0.mjs} +2 -2
- package/dist/{define-adapter-0asM4v0B.mjs.map → define-adapter-BJunWqi0.mjs.map} +1 -1
- package/dist/{disposables-BYYy0y_z.mjs → disposables-BFIEp5VA.mjs} +2 -2
- package/dist/{disposables-BYYy0y_z.mjs.map → disposables-BFIEp5VA.mjs.map} +1 -1
- package/dist/{env-DWP_2owJ.mjs → env-8BLpzy-M.mjs} +3 -3
- package/dist/{env-DWP_2owJ.mjs.map → env-8BLpzy-M.mjs.map} +1 -1
- package/dist/{error-handler-BNpeUs-0.d.mts → error-handler-DxF2EogM.d.mts} +2 -2
- package/dist/{error-handler-BNpeUs-0.d.mts.map → error-handler-DxF2EogM.d.mts.map} +1 -1
- package/dist/{handler-B4MQmJR5.mjs → handler-MSvgGVk0.mjs} +3 -3
- package/dist/handler-MSvgGVk0.mjs.map +1 -0
- package/dist/http/application.d.mts +2 -2
- package/dist/http/application.mjs +2 -2
- package/dist/http/bootstrap.d.mts +1 -1
- package/dist/http/bootstrap.mjs +2 -2
- package/dist/http/context.d.mts +2 -2
- package/dist/http/context.mjs +2 -2
- package/dist/http/middleware/cors.mjs +1 -1
- package/dist/http/middleware/csrf.d.mts +1 -1
- package/dist/http/middleware/csrf.mjs +2 -2
- package/dist/http/middleware/error-handler.d.mts +1 -1
- package/dist/http/middleware/error-handler.mjs +2 -2
- package/dist/http/middleware/helmet.mjs +1 -1
- package/dist/http/middleware/rate-limit.d.mts +1 -1
- package/dist/http/middleware/rate-limit.mjs +2 -2
- package/dist/http/middleware/request-id.mjs +1 -1
- package/dist/http/middleware/request-logger.mjs +2 -2
- package/dist/http/middleware/session.mjs +2 -2
- package/dist/http/middleware/spa.d.mts +1 -1
- package/dist/http/middleware/spa.mjs +2 -2
- package/dist/http/middleware/upload.d.mts +1 -1
- package/dist/http/middleware/upload.mjs +2 -2
- package/dist/http/middleware/validate.mjs +1 -1
- package/dist/http/middleware/views.d.mts +1 -1
- package/dist/http/middleware/views.mjs +2 -2
- package/dist/http/query/index.mjs +2 -2
- package/dist/http/router-builder.d.mts +1 -1
- package/dist/http/router-builder.mjs +2 -2
- package/dist/http/runtimes/fastify.d.mts +1 -1
- package/dist/http/runtimes/fastify.d.mts.map +1 -1
- package/dist/http/runtimes/fastify.mjs +3 -3
- package/dist/http/runtimes/fastify.mjs.map +1 -1
- package/dist/http/runtimes/h3-web.d.mts +1 -1
- package/dist/http/runtimes/h3-web.d.mts.map +1 -1
- package/dist/http/runtimes/h3-web.mjs +2 -2
- package/dist/http/runtimes/h3-web.mjs.map +1 -1
- package/dist/http/runtimes/h3.d.mts +1 -6
- package/dist/http/runtimes/h3.d.mts.map +1 -1
- package/dist/http/runtimes/h3.mjs +2 -2
- package/dist/http/runtimes/h3.mjs.map +1 -1
- package/dist/{index-Btz1W0X5.d.mts → index-ClP0EE9p.d.mts} +2 -2
- package/dist/{index-Btz1W0X5.d.mts.map → index-ClP0EE9p.d.mts.map} +1 -1
- package/dist/index.d.mts +14 -14
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{kv-stores-Dijnf9sF.d.mts → kv-stores-B-1WZ2Fh.d.mts} +36 -6
- package/dist/kv-stores-B-1WZ2Fh.d.mts.map +1 -0
- package/dist/{kv-stores-D2MKbvcu.mjs → kv-stores-BDcvhJBw.mjs} +3 -3
- package/dist/{kv-stores-D2MKbvcu.mjs.map → kv-stores-BDcvhJBw.mjs.map} +1 -1
- package/dist/{metadata-Ceheg5tj.mjs → metadata-BClL-Eb0.mjs} +2 -2
- package/dist/{metadata-Ceheg5tj.mjs.map → metadata-BClL-Eb0.mjs.map} +1 -1
- package/dist/{module-registry-RHo93bJa.mjs → module-registry-kVXS3peW.mjs} +2 -2
- package/dist/{module-registry-RHo93bJa.mjs.map → module-registry-kVXS3peW.mjs.map} +1 -1
- package/dist/observers-C5Jid4FV.mjs +12 -0
- package/dist/observers-C5Jid4FV.mjs.map +1 -0
- package/dist/{query-CWwMw9E6.mjs → query-1Q69dPhY.mjs} +2 -2
- package/dist/{query-CWwMw9E6.mjs.map → query-1Q69dPhY.mjs.map} +1 -1
- package/dist/{rate-limit-D5FHbYKG.d.mts → rate-limit-Bwed3jOV.d.mts} +2 -2
- package/dist/{rate-limit-D5FHbYKG.d.mts.map → rate-limit-Bwed3jOV.d.mts.map} +1 -1
- package/dist/{respond-CI4CCFG4.mjs → respond-DBRvpEI5.mjs} +2 -2
- package/dist/{respond-CI4CCFG4.mjs.map → respond-DBRvpEI5.mjs.map} +1 -1
- package/dist/{route-flag-BMlwj3Nk.d.mts → route-flag-B1lQht6v.d.mts} +14 -2
- package/dist/{route-flag-BMlwj3Nk.d.mts.map → route-flag-B1lQht6v.d.mts.map} +1 -1
- package/dist/{route-flag-B7t2wGJ2.mjs → route-flag-BCBTtP_4.mjs} +3 -3
- package/dist/route-flag-BCBTtP_4.mjs.map +1 -0
- package/dist/{route-policy-MQkq3QEO.mjs → route-policy-FeJHstvF.mjs} +2 -2
- package/dist/route-policy-FeJHstvF.mjs.map +1 -0
- package/dist/{router-builder-CK7_6DLH.mjs → router-builder-Brup4e82.mjs} +3 -3
- package/dist/{router-builder-CK7_6DLH.mjs.map → router-builder-Brup4e82.mjs.map} +1 -1
- package/dist/{router-builder-DPrRk4Pv.d.mts → router-builder-CUs9Gq4X.d.mts} +4 -4
- package/dist/{router-builder-DPrRk4Pv.d.mts.map → router-builder-CUs9Gq4X.d.mts.map} +1 -1
- package/dist/{runtime-B3V_u8FS.mjs → runtime-CV0SS-X5.mjs} +3 -3
- package/dist/runtime-CV0SS-X5.mjs.map +1 -0
- package/dist/{upload-DwGoAQ6R.d.mts → upload-BukLMawc.d.mts} +3 -3
- package/dist/{upload-DwGoAQ6R.d.mts.map → upload-BukLMawc.d.mts.map} +1 -1
- package/dist/{upload-config-C49VFeoh.mjs → upload-config-BBp5QfWQ.mjs} +2 -2
- package/dist/{upload-config-C49VFeoh.mjs.map → upload-config-BBp5QfWQ.mjs.map} +1 -1
- package/dist/web.d.mts +23 -8
- package/dist/web.d.mts.map +1 -1
- package/dist/web.mjs +2 -2
- package/dist/web.mjs.map +1 -1
- package/package.json +17 -12
- package/dist/adapter-989ssCBx.d.mts.map +0 -1
- package/dist/application-BFKE8ac5.mjs +0 -12
- package/dist/application-BFKE8ac5.mjs.map +0 -1
- package/dist/application-BRvykQ6h.d.mts.map +0 -1
- package/dist/bootstrap-CPRgpW3i.mjs +0 -12
- package/dist/bootstrap-CPRgpW3i.mjs.map +0 -1
- package/dist/container-Cj2hqlfm.mjs +0 -117
- package/dist/container-Cj2hqlfm.mjs.map +0 -1
- package/dist/context-B7N5oE7S.mjs +0 -12
- package/dist/context-B7N5oE7S.mjs.map +0 -1
- package/dist/cron-DOOtTY4q.d.mts +0 -30
- package/dist/cron-DOOtTY4q.d.mts.map +0 -1
- package/dist/csrf-DPPIM72I.mjs.map +0 -1
- package/dist/handler-B4MQmJR5.mjs.map +0 -1
- package/dist/kv-stores-Dijnf9sF.d.mts.map +0 -1
- package/dist/route-flag-B7t2wGJ2.mjs.map +0 -1
- package/dist/route-policy-MQkq3QEO.mjs.map +0 -1
- package/dist/runtime-B3V_u8FS.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs v8.
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
import{n as assertFlagTest,s as matchesFlagTest}from"./route-flag-
|
|
12
|
-
//# sourceMappingURL=kv-stores-
|
|
11
|
+
import{n as assertFlagTest,s as matchesFlagTest}from"./route-flag-BCBTtP_4.mjs";var LazyMemoryStore=class{windowMs;hits=new Map;constructor(windowMs){this.windowMs=windowMs}async increment(key){let now=Date.now(),entry=this.hits.get(key);if(entry&&entry.reset>now)return entry.hits++,{totalHits:entry.hits,resetTime:new Date(entry.reset)};if(this.hits.size>1e4)for(let[k,e]of this.hits)e.reset<=now&&this.hits.delete(k);let fresh={hits:1,reset:now+this.windowMs};return this.hits.set(key,fresh),{totalHits:1,resetTime:new Date(fresh.reset)}}async decrement(key){let entry=this.hits.get(key);entry&&entry.hits>0&&entry.hits--}async reset(key){this.hits.delete(key)}};function defaultKey(ctx){return ctx.ip??`global`}function rateLimitGuard(options={}){let max=options.max??100,windowMs=options.windowMs??6e4,message=options.message??`Too Many Requests`,statusCode=options.statusCode??429,keyGenerator=options.keyGenerator??defaultKey,sendHeaders=options.headers??!0,store=options.store??new LazyMemoryStore(windowMs),exemptWhen=options.exemptWhen;return exemptWhen!==void 0&&assertFlagTest(exemptWhen,`rateLimitGuard({ exemptWhen })`),async(ctx,next)=>{if(options.skip?.(ctx)||exemptWhen!==void 0&&matchesFlagTest(exemptWhen,ctx.route?.flags,ctx.route))return next();let{totalHits,resetTime}=await store.increment(keyGenerator(ctx)),remaining=Math.max(0,max-totalHits);if(sendHeaders&&(ctx.setHeader(`X-RateLimit-Limit`,String(max)),ctx.setHeader(`X-RateLimit-Remaining`,String(remaining)),ctx.setHeader(`X-RateLimit-Reset`,String(Math.ceil(resetTime.getTime()/1e3)))),totalHits>max){let retryAfter=Math.max(1,Math.ceil((resetTime.getTime()-Date.now())/1e3));sendHeaders&&ctx.setHeader(`Retry-After`,String(retryAfter)),ctx.json({message},statusCode);return}next()}}function kvTtlSeconds(ms){return Math.max(60,Math.ceil(ms/1e3))}var KvRateLimitStore=class{kv;prefix;windowMs;constructor(kv,options){this.kv=kv,this.windowMs=options.windowMs,this.prefix=options.prefix??`rl:`}async increment(key){let now=Date.now(),entry=await this.read(key);if(entry&&entry.reset>now)return entry.hits++,await this.write(key,entry),{totalHits:entry.hits,resetTime:new Date(entry.reset)};let fresh={hits:1,reset:now+this.windowMs};return await this.write(key,fresh),{totalHits:1,resetTime:new Date(fresh.reset)}}async decrement(key){let entry=await this.read(key);entry&&entry.hits>0&&(entry.hits--,await this.write(key,entry))}async reset(key){await this.kv.delete(this.prefix+key)}async read(key){let raw=await this.kv.get(this.prefix+key);if(raw===null)return null;try{return JSON.parse(raw)}catch{return null}}async write(key,entry){await this.kv.put(this.prefix+key,JSON.stringify(entry),{expirationTtl:kvTtlSeconds(Math.max(entry.reset-Date.now(),1e3))})}},KvSessionStore=class{kv;prefix;constructor(kv,options={}){this.kv=kv,this.prefix=options.prefix??`sess:`}async get(sid){let raw=await this.kv.get(this.prefix+sid);if(raw===null)return null;try{return JSON.parse(raw)}catch{return null}}async set(sid,data,maxAge){await this.kv.put(this.prefix+sid,JSON.stringify(data),{expirationTtl:kvTtlSeconds(maxAge)})}async destroy(sid){await this.kv.delete(this.prefix+sid)}async touch(sid,maxAge){let data=await this.get(sid);data!==null&&await this.set(sid,data,maxAge)}};export{KvSessionStore as n,rateLimitGuard as r,KvRateLimitStore as t};
|
|
12
|
+
//# sourceMappingURL=kv-stores-BDcvhJBw.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"kv-stores-D2MKbvcu.mjs","names":[],"sources":["../src/http/middleware/rate-limit-guard.ts","../src/http/middleware/kv-stores.ts"],"sourcesContent":["// Ctx-style rate limiter — runs on BOTH pipelines: the node runtimes (via\n// `@Middleware()` / class middleware) and the `@forinda/kickjs/web` fetch\n// entry (via `@Middleware()` or `createWebApp({ middleware })`), unlike the\n// connect-style `rateLimit()` which is node-only. Zero runtime imports —\n// part of the edge purity graph.\nimport { assertFlagTest, matchesFlagTest, type RouteFlagTest } from '../../core/route-flag'\nimport type { MiddlewareHandler } from '../../core/decorators'\nimport type { RequestContext } from '../context'\nimport type { RateLimitStore } from './rate-limit'\n\nexport interface RateLimitGuardOptions {\n /** Maximum number of requests per window (default: 100). */\n max?: number\n /** Time window in milliseconds (default: 60_000). */\n windowMs?: number\n /** Response message when the limit is exceeded (default: 'Too Many Requests'). */\n message?: string\n /** HTTP status code when the limit is exceeded (default: 429). */\n statusCode?: number\n /**\n * Rate-limit key per request. Defaults to `ctx.ip`, falling back to\n * `'global'`. Return a user/tenant id here for authenticated quotas.\n *\n * ::: warning Behind a proxy, configure `trustProxy` or every client shares\n * one bucket\n * On a node runtime `ctx.ip` is the runtime's vetted client address —\n * Express derives it from `trust proxy`, Fastify from `trustProxy`. Without\n * that setting it is the SOCKET address, which behind a load balancer or\n * Cloudflare is the proxy's, so every caller lands in the same bucket and one\n * noisy client exhausts the allowance for everyone.\n *\n * `bootstrap({ trustProxy: true })` is the fix. Forwarded headers are\n * deliberately not consulted here: `x-forwarded-for` is client-controllable\n * unless a proxy overwrites it, so trusting it unconditionally would let a\n * direct caller mint a fresh allowance per request by varying the header —\n * evading the limit rather than enforcing it. The header chain\n * (`cf-connecting-ip` → `x-forwarded-for` → `x-real-ip`) applies only on the\n * `@forinda/kickjs/web` edge entry, where there is no socket and the platform\n * has already terminated the connection.\n * :::\n */\n keyGenerator?: (ctx: RequestContext) => string\n /** Send `X-RateLimit-*` / `Retry-After` headers (default: true). */\n headers?: boolean\n /**\n * Counter backend. Defaults to a per-isolate in-memory map — fine for a\n * single node process, useless on edge where isolates recycle: pass a\n * `KvRateLimitStore` (or Redis-backed store) there.\n */\n store?: RateLimitStore\n /** Skip limiting for a request (health checks, allowlists). */\n skip?: (ctx: RequestContext) => boolean\n /**\n * Skip limiting on routes carrying a route flag — a name, a list of names\n * (any-of), or a predicate. Declared on the route rather than restated as a\n * path here, so it cannot drift when `apiPrefix` or a module's `version`\n * changes:\n *\n * ```ts\n * const Public = defineRouteFlag('auth.public')\n * rateLimitGuard({ max: 60, exemptWhen: 'auth.public' })\n * ```\n *\n * Only meaningful where a route has been matched — as `@Middleware()` on a\n * class or method, or in `createWebApp({ middleware })`. Mounted app-wide\n * ahead of routing there is no route to read, and nothing is exempted.\n */\n exemptWhen?: RouteFlagTest\n}\n\n/** Timer-free in-memory store: sweeps expired entries when the map grows. */\nclass LazyMemoryStore implements RateLimitStore {\n private readonly hits = new Map<string, { hits: number; reset: number }>()\n\n constructor(private readonly windowMs: number) {}\n\n async increment(key: string): Promise<{ totalHits: number; resetTime: Date }> {\n const now = Date.now()\n const entry = this.hits.get(key)\n if (entry && entry.reset > now) {\n entry.hits++\n return { totalHits: entry.hits, resetTime: new Date(entry.reset) }\n }\n // ponytail: sweep-on-growth instead of a cleanup interval — no timer to\n // dispose, edge-safe. Bound is 10k live keys before a full sweep.\n if (this.hits.size > 10_000) {\n for (const [k, e] of this.hits) if (e.reset <= now) this.hits.delete(k)\n }\n const fresh = { hits: 1, reset: now + this.windowMs }\n this.hits.set(key, fresh)\n return { totalHits: 1, resetTime: new Date(fresh.reset) }\n }\n\n async decrement(key: string): Promise<void> {\n const entry = this.hits.get(key)\n if (entry && entry.hits > 0) entry.hits--\n }\n\n async reset(key: string): Promise<void> {\n this.hits.delete(key)\n }\n}\n\nfunction defaultKey(ctx: RequestContext): string {\n // `ctx.ip` carries the resolution that used to live here: prefer the\n // runtime-computed address (Express derives it from `trust proxy`), fall\n // back to forwarded headers only for runtimes that compute none.\n return ctx.ip ?? 'global'\n}\n\n/**\n * Rate limiting as a `(ctx, next)` middleware.\n *\n * ```ts\n * // Edge (Cloudflare Workers) — app-wide:\n * const app = createWebApp({\n * h3, modules,\n * middlewares: [rateLimitGuard({ max: 60, windowMs: 60_000, store: new KvRateLimitStore(env.KV, { windowMs: 60_000 }) })],\n * })\n *\n * // Any runtime — per controller/route:\n * @Middleware(rateLimitGuard({ max: 10 }))\n * @Post('/login')\n * ```\n */\nexport function rateLimitGuard(options: RateLimitGuardOptions = {}): MiddlewareHandler {\n const max = options.max ?? 100\n const windowMs = options.windowMs ?? 60_000\n const message = options.message ?? 'Too Many Requests'\n const statusCode = options.statusCode ?? 429\n const keyGenerator = options.keyGenerator ?? defaultKey\n const sendHeaders = options.headers ?? true\n const store = options.store ?? new LazyMemoryStore(windowMs)\n\n const exemptWhen = options.exemptWhen\n if (exemptWhen !== undefined) assertFlagTest(exemptWhen, 'rateLimitGuard({ exemptWhen })')\n\n return async (ctx: RequestContext, next: () => void): Promise<void> => {\n if (options.skip?.(ctx)) return next()\n if (exemptWhen !== undefined && matchesFlagTest(exemptWhen, ctx.route?.flags, ctx.route)) {\n return next()\n }\n\n const { totalHits, resetTime } = await store.increment(keyGenerator(ctx))\n const remaining = Math.max(0, max - totalHits)\n\n if (sendHeaders) {\n ctx.setHeader('X-RateLimit-Limit', String(max))\n ctx.setHeader('X-RateLimit-Remaining', String(remaining))\n ctx.setHeader('X-RateLimit-Reset', String(Math.ceil(resetTime.getTime() / 1000)))\n }\n\n if (totalHits > max) {\n const retryAfter = Math.max(1, Math.ceil((resetTime.getTime() - Date.now()) / 1000))\n if (sendHeaders) ctx.setHeader('Retry-After', String(retryAfter))\n ctx.json({ message }, statusCode)\n return\n }\n\n next()\n }\n}\n","// Edge-safe store adapters over a minimal KV surface. Zero runtime imports —\n// this file must stay importable from the `@forinda/kickjs/web` purity graph.\nimport type { RateLimitStore } from './rate-limit'\nimport type { SessionData, SessionStore } from './session'\n\n/**\n * The minimal async KV surface both stores need. Structurally compatible\n * with a Cloudflare Workers `KVNamespace` binding — pass it straight in:\n *\n * ```ts\n * export default {\n * fetch(req: Request, env: Env) {\n * const store = new KvRateLimitStore(env.MY_KV, { windowMs: 60_000 })\n * // ...\n * },\n * }\n * ```\n *\n * Anything with the same shape works too (Deno KV wrapper, Upstash Redis\n * REST client adapter, a Map-backed fake in tests).\n */\nexport interface KvLike {\n get(key: string): Promise<string | null>\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>\n delete(key: string): Promise<void>\n}\n\n/** Cloudflare KV rejects TTLs under 60 seconds — clamp up, never down. */\nfunction kvTtlSeconds(ms: number): number {\n return Math.max(60, Math.ceil(ms / 1000))\n}\n\nexport interface KvRateLimitStoreOptions {\n /** Window length in ms — must match the `windowMs` given to the limiter. */\n windowMs: number\n /** Key prefix in the KV namespace (default `'rl:'`). */\n prefix?: string\n}\n\n/**\n * {@link RateLimitStore} over a {@link KvLike} namespace, for edge deployments\n * where in-memory counters reset on every isolate.\n *\n * KV is eventually consistent and this is a read-modify-write counter, so\n * limiting is **approximate** under concurrency — bursts racing across\n * isolates can each read the same count. That is the accepted trade-off for\n * abuse throttling; use a Durable Object / Redis store when you need an\n * exact ceiling (billing quotas, strict per-key locks).\n */\nexport class KvRateLimitStore implements RateLimitStore {\n private readonly prefix: string\n private readonly windowMs: number\n\n constructor(\n private readonly kv: KvLike,\n options: KvRateLimitStoreOptions,\n ) {\n this.windowMs = options.windowMs\n this.prefix = options.prefix ?? 'rl:'\n }\n\n async increment(key: string): Promise<{ totalHits: number; resetTime: Date }> {\n const now = Date.now()\n const entry = await this.read(key)\n if (entry && entry.reset > now) {\n entry.hits++\n await this.write(key, entry)\n return { totalHits: entry.hits, resetTime: new Date(entry.reset) }\n }\n const fresh = { hits: 1, reset: now + this.windowMs }\n await this.write(key, fresh)\n return { totalHits: 1, resetTime: new Date(fresh.reset) }\n }\n\n async decrement(key: string): Promise<void> {\n const entry = await this.read(key)\n if (entry && entry.hits > 0) {\n entry.hits--\n await this.write(key, entry)\n }\n }\n\n async reset(key: string): Promise<void> {\n await this.kv.delete(this.prefix + key)\n }\n\n private async read(key: string): Promise<{ hits: number; reset: number } | null> {\n const raw = await this.kv.get(this.prefix + key)\n if (raw === null) return null\n try {\n return JSON.parse(raw) as { hits: number; reset: number }\n } catch {\n return null\n }\n }\n\n private async write(key: string, entry: { hits: number; reset: number }): Promise<void> {\n await this.kv.put(this.prefix + key, JSON.stringify(entry), {\n expirationTtl: kvTtlSeconds(Math.max(entry.reset - Date.now(), 1000)),\n })\n }\n}\n\nexport interface KvSessionStoreOptions {\n /** Key prefix in the KV namespace (default `'sess:'`). */\n prefix?: string\n}\n\n/**\n * {@link SessionStore} over a {@link KvLike} namespace. Plug into the node\n * `session()` middleware today (e.g. backed by Upstash/Cloudflare KV via\n * REST) so sessions survive restarts and horizontal scaling; TTL expiry is\n * delegated to the KV layer via `expirationTtl`.\n *\n * Cost note: `touch()` is a get + put round-trip (KvLike has no TTL-only\n * refresh), so `session({ rolling: true })` doubles KV operations per\n * request — per-operation-billed platforms may prefer `rolling: false`.\n */\nexport class KvSessionStore implements SessionStore {\n private readonly prefix: string\n\n constructor(\n private readonly kv: KvLike,\n options: KvSessionStoreOptions = {},\n ) {\n this.prefix = options.prefix ?? 'sess:'\n }\n\n async get(sid: string): Promise<SessionData | null> {\n const raw = await this.kv.get(this.prefix + sid)\n if (raw === null) return null\n try {\n return JSON.parse(raw) as SessionData\n } catch {\n return null\n }\n }\n\n async set(sid: string, data: SessionData, maxAge: number): Promise<void> {\n await this.kv.put(this.prefix + sid, JSON.stringify(data), {\n expirationTtl: kvTtlSeconds(maxAge),\n })\n }\n\n async destroy(sid: string): Promise<void> {\n await this.kv.delete(this.prefix + sid)\n }\n\n async touch(sid: string, maxAge: number): Promise<void> {\n const data = await this.get(sid)\n if (data !== null) await this.set(sid, data, maxAge)\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"kv-stores-BDcvhJBw.mjs","names":[],"sources":["../src/http/middleware/rate-limit-guard.ts","../src/http/middleware/kv-stores.ts"],"sourcesContent":["// Ctx-style rate limiter — runs on BOTH pipelines: the node runtimes (via\n// `@Middleware()` / class middleware) and the `@forinda/kickjs/web` fetch\n// entry (via `@Middleware()` or `createWebApp({ middleware })`), unlike the\n// connect-style `rateLimit()` which is node-only. Zero runtime imports —\n// part of the edge purity graph.\nimport { assertFlagTest, matchesFlagTest, type RouteFlagTest } from '../../core/route-flag'\nimport type { MiddlewareHandler } from '../../core/decorators'\nimport type { RequestContext } from '../context'\nimport type { RateLimitStore } from './rate-limit'\n\nexport interface RateLimitGuardOptions {\n /** Maximum number of requests per window (default: 100). */\n max?: number\n /** Time window in milliseconds (default: 60_000). */\n windowMs?: number\n /** Response message when the limit is exceeded (default: 'Too Many Requests'). */\n message?: string\n /** HTTP status code when the limit is exceeded (default: 429). */\n statusCode?: number\n /**\n * Rate-limit key per request. Defaults to `ctx.ip`, falling back to\n * `'global'`. Return a user/tenant id here for authenticated quotas.\n *\n * ::: warning Behind a proxy, configure `trustProxy` or every client shares\n * one bucket\n * On a node runtime `ctx.ip` is the runtime's vetted client address —\n * Express derives it from `trust proxy`, Fastify from `trustProxy`. Without\n * that setting it is the SOCKET address, which behind a load balancer or\n * Cloudflare is the proxy's, so every caller lands in the same bucket and one\n * noisy client exhausts the allowance for everyone.\n *\n * `bootstrap({ trustProxy: true })` is the fix. Forwarded headers are\n * deliberately not consulted here: `x-forwarded-for` is client-controllable\n * unless a proxy overwrites it, so trusting it unconditionally would let a\n * direct caller mint a fresh allowance per request by varying the header —\n * evading the limit rather than enforcing it. The header chain\n * (`cf-connecting-ip` → `x-forwarded-for` → `x-real-ip`) applies only on the\n * `@forinda/kickjs/web` edge entry, where there is no socket and the platform\n * has already terminated the connection.\n * :::\n */\n keyGenerator?: (ctx: RequestContext) => string\n /** Send `X-RateLimit-*` / `Retry-After` headers (default: true). */\n headers?: boolean\n /**\n * Counter backend. Defaults to a per-isolate in-memory map — fine for a\n * single node process, useless on edge where isolates recycle: pass a\n * `KvRateLimitStore` (or Redis-backed store) there.\n */\n store?: RateLimitStore\n /** Skip limiting for a request (health checks, allowlists). */\n skip?: (ctx: RequestContext) => boolean\n /**\n * Skip limiting on routes carrying a route flag — a name, a list of names\n * (any-of), or a predicate. Declared on the route rather than restated as a\n * path here, so it cannot drift when `apiPrefix` or a module's `version`\n * changes:\n *\n * ```ts\n * const Public = defineRouteFlag('auth.public')\n * rateLimitGuard({ max: 60, exemptWhen: 'auth.public' })\n * ```\n *\n * Only meaningful where a route has been matched — as `@Middleware()` on a\n * class or method, or in `createWebApp({ middleware })`. Mounted app-wide\n * ahead of routing there is no route to read, and nothing is exempted.\n */\n exemptWhen?: RouteFlagTest\n}\n\n/** Timer-free in-memory store: sweeps expired entries when the map grows. */\nclass LazyMemoryStore implements RateLimitStore {\n private readonly hits = new Map<string, { hits: number; reset: number }>()\n\n constructor(private readonly windowMs: number) {}\n\n async increment(key: string): Promise<{ totalHits: number; resetTime: Date }> {\n const now = Date.now()\n const entry = this.hits.get(key)\n if (entry && entry.reset > now) {\n entry.hits++\n return { totalHits: entry.hits, resetTime: new Date(entry.reset) }\n }\n // ponytail: sweep-on-growth instead of a cleanup interval — no timer to\n // dispose, edge-safe. Bound is 10k live keys before a full sweep.\n if (this.hits.size > 10_000) {\n for (const [k, e] of this.hits) if (e.reset <= now) this.hits.delete(k)\n }\n const fresh = { hits: 1, reset: now + this.windowMs }\n this.hits.set(key, fresh)\n return { totalHits: 1, resetTime: new Date(fresh.reset) }\n }\n\n async decrement(key: string): Promise<void> {\n const entry = this.hits.get(key)\n if (entry && entry.hits > 0) entry.hits--\n }\n\n async reset(key: string): Promise<void> {\n this.hits.delete(key)\n }\n}\n\nfunction defaultKey(ctx: RequestContext): string {\n // `ctx.ip` carries the resolution that used to live here: prefer the\n // runtime-computed address (Express derives it from `trust proxy`), fall\n // back to forwarded headers only for runtimes that compute none.\n return ctx.ip ?? 'global'\n}\n\n/**\n * Rate limiting as a `(ctx, next)` middleware.\n *\n * ```ts\n * // Edge (Cloudflare Workers) — app-wide:\n * const app = createWebApp({\n * h3, modules,\n * middlewares: [rateLimitGuard({ max: 60, windowMs: 60_000, store: new KvRateLimitStore(env.KV, { windowMs: 60_000 }) })],\n * })\n *\n * // Any runtime — per controller/route:\n * @Middleware(rateLimitGuard({ max: 10 }))\n * @Post('/login')\n * ```\n */\nexport function rateLimitGuard(options: RateLimitGuardOptions = {}): MiddlewareHandler {\n const max = options.max ?? 100\n const windowMs = options.windowMs ?? 60_000\n const message = options.message ?? 'Too Many Requests'\n const statusCode = options.statusCode ?? 429\n const keyGenerator = options.keyGenerator ?? defaultKey\n const sendHeaders = options.headers ?? true\n const store = options.store ?? new LazyMemoryStore(windowMs)\n\n const exemptWhen = options.exemptWhen\n if (exemptWhen !== undefined) assertFlagTest(exemptWhen, 'rateLimitGuard({ exemptWhen })')\n\n return async (ctx: RequestContext, next: () => void): Promise<void> => {\n if (options.skip?.(ctx)) return next()\n if (exemptWhen !== undefined && matchesFlagTest(exemptWhen, ctx.route?.flags, ctx.route)) {\n return next()\n }\n\n const { totalHits, resetTime } = await store.increment(keyGenerator(ctx))\n const remaining = Math.max(0, max - totalHits)\n\n if (sendHeaders) {\n ctx.setHeader('X-RateLimit-Limit', String(max))\n ctx.setHeader('X-RateLimit-Remaining', String(remaining))\n ctx.setHeader('X-RateLimit-Reset', String(Math.ceil(resetTime.getTime() / 1000)))\n }\n\n if (totalHits > max) {\n const retryAfter = Math.max(1, Math.ceil((resetTime.getTime() - Date.now()) / 1000))\n if (sendHeaders) ctx.setHeader('Retry-After', String(retryAfter))\n ctx.json({ message }, statusCode)\n return\n }\n\n next()\n }\n}\n","// Edge-safe store adapters over a minimal KV surface. Zero runtime imports —\n// this file must stay importable from the `@forinda/kickjs/web` purity graph.\nimport type { RateLimitStore } from './rate-limit'\nimport type { SessionData, SessionStore } from './session'\n\n/**\n * The minimal async KV surface both stores need. Structurally compatible\n * with a Cloudflare Workers `KVNamespace` binding — pass it straight in:\n *\n * ```ts\n * export default {\n * fetch(req: Request, env: Env) {\n * const store = new KvRateLimitStore(env.MY_KV, { windowMs: 60_000 })\n * // ...\n * },\n * }\n * ```\n *\n * Anything with the same shape works too (Deno KV wrapper, Upstash Redis\n * REST client adapter, a Map-backed fake in tests).\n */\nexport interface KvLike {\n get(key: string): Promise<string | null>\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>\n delete(key: string): Promise<void>\n}\n\n/** Cloudflare KV rejects TTLs under 60 seconds — clamp up, never down. */\nfunction kvTtlSeconds(ms: number): number {\n return Math.max(60, Math.ceil(ms / 1000))\n}\n\nexport interface KvRateLimitStoreOptions {\n /** Window length in ms — must match the `windowMs` given to the limiter. */\n windowMs: number\n /** Key prefix in the KV namespace (default `'rl:'`). */\n prefix?: string\n}\n\n/**\n * {@link RateLimitStore} over a {@link KvLike} namespace, for edge deployments\n * where in-memory counters reset on every isolate.\n *\n * KV is eventually consistent and this is a read-modify-write counter, so\n * limiting is **approximate** under concurrency — bursts racing across\n * isolates can each read the same count. That is the accepted trade-off for\n * abuse throttling; use a Durable Object / Redis store when you need an\n * exact ceiling (billing quotas, strict per-key locks).\n */\nexport class KvRateLimitStore implements RateLimitStore {\n private readonly prefix: string\n private readonly windowMs: number\n\n constructor(\n private readonly kv: KvLike,\n options: KvRateLimitStoreOptions,\n ) {\n this.windowMs = options.windowMs\n this.prefix = options.prefix ?? 'rl:'\n }\n\n async increment(key: string): Promise<{ totalHits: number; resetTime: Date }> {\n const now = Date.now()\n const entry = await this.read(key)\n if (entry && entry.reset > now) {\n entry.hits++\n await this.write(key, entry)\n return { totalHits: entry.hits, resetTime: new Date(entry.reset) }\n }\n const fresh = { hits: 1, reset: now + this.windowMs }\n await this.write(key, fresh)\n return { totalHits: 1, resetTime: new Date(fresh.reset) }\n }\n\n async decrement(key: string): Promise<void> {\n const entry = await this.read(key)\n if (entry && entry.hits > 0) {\n entry.hits--\n await this.write(key, entry)\n }\n }\n\n async reset(key: string): Promise<void> {\n await this.kv.delete(this.prefix + key)\n }\n\n private async read(key: string): Promise<{ hits: number; reset: number } | null> {\n const raw = await this.kv.get(this.prefix + key)\n if (raw === null) return null\n try {\n return JSON.parse(raw) as { hits: number; reset: number }\n } catch {\n return null\n }\n }\n\n private async write(key: string, entry: { hits: number; reset: number }): Promise<void> {\n await this.kv.put(this.prefix + key, JSON.stringify(entry), {\n expirationTtl: kvTtlSeconds(Math.max(entry.reset - Date.now(), 1000)),\n })\n }\n}\n\nexport interface KvSessionStoreOptions {\n /** Key prefix in the KV namespace (default `'sess:'`). */\n prefix?: string\n}\n\n/**\n * {@link SessionStore} over a {@link KvLike} namespace. Plug into the node\n * `session()` middleware today (e.g. backed by Upstash/Cloudflare KV via\n * REST) so sessions survive restarts and horizontal scaling; TTL expiry is\n * delegated to the KV layer via `expirationTtl`.\n *\n * Cost note: `touch()` is a get + put round-trip (KvLike has no TTL-only\n * refresh), so `session({ rolling: true })` doubles KV operations per\n * request — per-operation-billed platforms may prefer `rolling: false`.\n */\nexport class KvSessionStore implements SessionStore {\n private readonly prefix: string\n\n constructor(\n private readonly kv: KvLike,\n options: KvSessionStoreOptions = {},\n ) {\n this.prefix = options.prefix ?? 'sess:'\n }\n\n async get(sid: string): Promise<SessionData | null> {\n const raw = await this.kv.get(this.prefix + sid)\n if (raw === null) return null\n try {\n return JSON.parse(raw) as SessionData\n } catch {\n return null\n }\n }\n\n async set(sid: string, data: SessionData, maxAge: number): Promise<void> {\n await this.kv.put(this.prefix + sid, JSON.stringify(data), {\n expirationTtl: kvTtlSeconds(maxAge),\n })\n }\n\n async destroy(sid: string): Promise<void> {\n await this.kv.delete(this.prefix + sid)\n }\n\n async touch(sid: string, maxAge: number): Promise<void> {\n const data = await this.get(sid)\n if (data !== null) await this.set(sid, data, maxAge)\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs v8.
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
import"reflect-metadata";function setClassMeta(key,value,target){Reflect.defineMetadata(key,value,target)}function setMethodMeta(key,value,target,method){Reflect.defineMetadata(key,value,target,method)}function getClassMeta(key,target,fallback){return Reflect.getMetadata(key,target)??fallback}function getMethodMeta(key,target,method,fallback){return Reflect.getMetadata(key,target,method)??fallback}function hasClassMeta(key,target){return Reflect.hasMetadata(key,target)}function getClassMetaOrUndefined(key,target){return Reflect.getMetadata(key,target)}function getMethodMetaOrUndefined(key,target,method){return Reflect.getMetadata(key,target,method)}function pushClassMeta(key,target,...items){let existing=Reflect.getOwnMetadata(key,target)??[];Reflect.defineMetadata(key,[...existing,...items],target)}function pushMethodMeta(key,target,method,...items){let existing=Reflect.getOwnMetadata(key,target,method)??[];Reflect.defineMetadata(key,[...existing,...items],target,method)}function setInMetaMap(key,target,mapKey,mapValue){let inherited=Reflect.getOwnMetadata(key,target),map=inherited?new Map(inherited):new Map;map.set(mapKey,mapValue),Reflect.defineMetadata(key,map,target)}function getMetaMap(key,target){return Reflect.getMetadata(key,target)??new Map}function setInMetaRecord(key,target,recKey,recValue){let inherited=Reflect.getOwnMetadata(key,target),rec=inherited?{...inherited}:{};rec[recKey]=recValue,Reflect.defineMetadata(key,rec,target)}function getMetaRecord(key,target){return Reflect.getMetadata(key,target)??{}}export{getMethodMeta as a,pushClassMeta as c,setInMetaMap as d,setInMetaRecord as f,getMetaRecord as i,pushMethodMeta as l,getClassMetaOrUndefined as n,getMethodMetaOrUndefined as o,setMethodMeta as p,getMetaMap as r,hasClassMeta as s,getClassMeta as t,setClassMeta as u};
|
|
12
|
-
//# sourceMappingURL=metadata-
|
|
12
|
+
//# sourceMappingURL=metadata-BClL-Eb0.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metadata-
|
|
1
|
+
{"version":3,"file":"metadata-BClL-Eb0.mjs","names":[],"sources":["../src/core/metadata.ts"],"sourcesContent":["import 'reflect-metadata'\nimport type { InjectionToken } from './token'\n\n/**\n * Typed utilities for reflect-metadata operations.\n *\n * Replaces scattered Reflect.defineMetadata/getMetadata calls with a single\n * source of truth. If we later move to Stage 3 decorators or a WeakMap-based\n * store, only this file changes — all 24+ consumer files stay untouched.\n *\n * Keys may be symbols, strings, or `InjectionToken<T>` objects produced by\n * `createToken()`. Using `createToken` is the preferred pattern because the\n * token's phantom type flows through the helpers, enabling type-safe reads.\n */\n\ntype MetaKey = symbol | string | InjectionToken<unknown>\n\n// ── Setters ─────────────────────────────────────────────────────\n\n/** Set metadata on a class (or prototype for property decorators) */\nexport function setClassMeta<T>(key: MetaKey, value: T, target: object): void {\n Reflect.defineMetadata(key, value, target)\n}\n\n/** Set metadata on a specific method */\nexport function setMethodMeta<T>(key: MetaKey, value: T, target: object, method: string): void {\n Reflect.defineMetadata(key, value, target, method)\n}\n\n// ── Getters ─────────────────────────────────────────────────────\n\n/** Get metadata from a class, returning `fallback` if not set */\nexport function getClassMeta<T>(key: MetaKey, target: object, fallback: T): T {\n return (Reflect.getMetadata(key, target) as T) ?? fallback\n}\n\n/** Get metadata from a specific method, returning `fallback` if not set */\nexport function getMethodMeta<T>(key: MetaKey, target: object, method: string, fallback: T): T {\n return (Reflect.getMetadata(key, target, method) as T) ?? fallback\n}\n\n/** Check if metadata exists on a class */\nexport function hasClassMeta(key: MetaKey, target: object): boolean {\n return Reflect.hasMetadata(key, target)\n}\n\n/** Get metadata from a class with no default (returns undefined if absent) */\nexport function getClassMetaOrUndefined<T>(key: MetaKey, target: object): T | undefined {\n return Reflect.getMetadata(key, target) as T | undefined\n}\n\n/** Get metadata from a method with no default (returns undefined if absent) */\nexport function getMethodMetaOrUndefined<T>(\n key: MetaKey,\n target: object,\n method: string,\n): T | undefined {\n return Reflect.getMetadata(key, target, method) as T | undefined\n}\n\n// ── Accumulate: Arrays ──────────────────────────────────────────\n\n/** Append items to an array stored in class metadata */\nexport function pushClassMeta<T>(key: MetaKey, target: object, ...items: T[]): void {\n const existing: T[] = Reflect.getOwnMetadata(key, target) ?? []\n Reflect.defineMetadata(key, [...existing, ...items], target)\n}\n\n/** Append items to an array stored in method metadata */\nexport function pushMethodMeta<T>(\n key: MetaKey,\n target: object,\n method: string,\n ...items: T[]\n): void {\n const existing: T[] = Reflect.getOwnMetadata(key, target, method) ?? []\n Reflect.defineMetadata(key, [...existing, ...items], target, method)\n}\n\n// ── Accumulate: Maps ────────────────────────────────────────────\n\n/** Set a key/value in a Map stored in class metadata */\nexport function setInMetaMap<K, V>(key: MetaKey, target: object, mapKey: K, mapValue: V): void {\n const inherited: Map<K, V> | undefined = Reflect.getOwnMetadata(key, target)\n const map = inherited ? new Map(inherited) : new Map<K, V>()\n map.set(mapKey, mapValue)\n Reflect.defineMetadata(key, map, target)\n}\n\n/** Get a Map from class metadata (returns empty Map if absent) */\nexport function getMetaMap<K, V>(key: MetaKey, target: object): Map<K, V> {\n return Reflect.getMetadata(key, target) ?? new Map()\n}\n\n// ── Accumulate: Records ─────────────────────────────────────────\n\n/** Set a key/value in a Record stored in class metadata */\nexport function setInMetaRecord<V>(\n key: MetaKey,\n target: object,\n recKey: string | number,\n recValue: V,\n): void {\n const inherited: Record<string | number, V> | undefined = Reflect.getOwnMetadata(key, target)\n const rec = inherited ? { ...inherited } : {}\n rec[recKey] = recValue\n Reflect.defineMetadata(key, rec, target)\n}\n\n/** Get a Record from class metadata (returns empty object if absent) */\nexport function getMetaRecord<V>(key: MetaKey, target: object): Record<string | number, V> {\n return Reflect.getMetadata(key, target) ?? {}\n}\n"],"mappings":";;;;;;;;;;yBAoBA,SAAgB,aAAgB,IAAc,MAAU,OAAsB,CAC5E,QAAQ,eAAe,IAAK,MAAO,MAAM,CAC3C,CAGA,SAAgB,cAAiB,IAAc,MAAU,OAAgB,OAAsB,CAC7F,QAAQ,eAAe,IAAK,MAAO,OAAQ,MAAM,CACnD,CAKA,SAAgB,aAAgB,IAAc,OAAgB,SAAgB,CAC5E,OAAQ,QAAQ,YAAY,IAAK,MAAM,GAAW,QACpD,CAGA,SAAgB,cAAiB,IAAc,OAAgB,OAAgB,SAAgB,CAC7F,OAAQ,QAAQ,YAAY,IAAK,OAAQ,MAAM,GAAW,QAC5D,CAGA,SAAgB,aAAa,IAAc,OAAyB,CAClE,OAAO,QAAQ,YAAY,IAAK,MAAM,CACxC,CAGA,SAAgB,wBAA2B,IAAc,OAA+B,CACtF,OAAO,QAAQ,YAAY,IAAK,MAAM,CACxC,CAGA,SAAgB,yBACd,IACA,OACA,OACe,CACf,OAAO,QAAQ,YAAY,IAAK,OAAQ,MAAM,CAChD,CAKA,SAAgB,cAAiB,IAAc,OAAgB,GAAG,MAAkB,CAClF,IAAM,SAAgB,QAAQ,eAAe,IAAK,MAAM,GAAK,CAAC,EAC9D,QAAQ,eAAe,IAAK,CAAC,GAAG,SAAU,GAAG,KAAK,EAAG,MAAM,CAC7D,CAGA,SAAgB,eACd,IACA,OACA,OACA,GAAG,MACG,CACN,IAAM,SAAgB,QAAQ,eAAe,IAAK,OAAQ,MAAM,GAAK,CAAC,EACtE,QAAQ,eAAe,IAAK,CAAC,GAAG,SAAU,GAAG,KAAK,EAAG,OAAQ,MAAM,CACrE,CAKA,SAAgB,aAAmB,IAAc,OAAgB,OAAW,SAAmB,CAC7F,IAAM,UAAmC,QAAQ,eAAe,IAAK,MAAM,EACrE,IAAM,UAAY,IAAI,IAAI,SAAS,EAAI,IAAI,IACjD,IAAI,IAAI,OAAQ,QAAQ,EACxB,QAAQ,eAAe,IAAK,IAAK,MAAM,CACzC,CAGA,SAAgB,WAAiB,IAAc,OAA2B,CACxE,OAAO,QAAQ,YAAY,IAAK,MAAM,GAAK,IAAI,GACjD,CAKA,SAAgB,gBACd,IACA,OACA,OACA,SACM,CACN,IAAM,UAAoD,QAAQ,eAAe,IAAK,MAAM,EACtF,IAAM,UAAY,CAAE,GAAG,SAAU,EAAI,CAAC,EAC5C,IAAI,QAAU,SACd,QAAQ,eAAe,IAAK,IAAK,MAAM,CACzC,CAGA,SAAgB,cAAiB,IAAc,OAA4C,CACzF,OAAO,QAAQ,YAAY,IAAK,MAAM,GAAK,CAAC,CAC9C"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs v8.
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
var MutableModuleRegistry=class{entries=[];mount(module){this.entries.push(module)}};export{MutableModuleRegistry as t};
|
|
12
|
-
//# sourceMappingURL=module-registry-
|
|
12
|
+
//# sourceMappingURL=module-registry-kVXS3peW.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"module-registry-
|
|
1
|
+
{"version":3,"file":"module-registry-kVXS3peW.mjs","names":[],"sources":["../src/core/module-registry.ts"],"sourcesContent":["import type { AppModuleEntry } from './app-module'\n\n/**\n * Imperative receiver passed to {@link KickPlugin.setup} and the\n * top-level `bootstrap({ setup })` callback so adopters can register\n * modules conditionally — based on env flags, runtime config,\n * tenant lists, etc. — instead of being limited to the static\n * `modules: [...]` array.\n *\n * @example\n * ```ts\n * bootstrap({\n * modules: [HelloModule()], // static — always mounted\n * setup(registry) {\n * if (env.ENABLE_ADMIN) registry.mount(AdminModule())\n * for (const tenant of env.TENANTS) {\n * registry.mount(TenantModule.scoped(tenant.id, { id: tenant.id }))\n * }\n * },\n * })\n * ```\n *\n * **Currently exposes only `.mount(module)` — the HTTP-feature path.**\n * A future `.use(module)` for non-HTTP modules (queues, cron, workers,\n * DI-only seeds) is planned but not yet implemented; existing non-HTTP\n * modules continue returning `null` from `routes()` and registering\n * via `.mount()`.\n *\n * Same dispatch rules as the static `modules: [...]` array — class\n * entries get `new`-ed at boot, `defineModule` factory output is used\n * directly. The registry doesn't validate the entry shape itself; the\n * Application loader does the typeof discrimination at mount time.\n */\nexport interface ModuleRegistry {\n /**\n * Register a module for the full HTTP lifecycle: `register()` runs,\n * `import.meta.glob` decorator side-effects fire, `routes()` is\n * called and its result mounted, `contributors()` are merged at\n * `'module'` precedence and applied to the mounted routes.\n *\n * Modules are mounted in registration order (first call → first\n * mount). Order across sources is: plugin static modules → plugin\n * `setup()` calls → user static modules → user `setup()` callback.\n */\n mount(module: AppModuleEntry): void\n}\n\n/**\n * Internal collector implementing {@link ModuleRegistry}. Application\n * passes one of these to every `setup()` callback during bootstrap;\n * the collected `entries` array is then run through the same\n * class-vs-instance dispatch as the static `modules: [...]` array.\n *\n * Not exported from the public surface — adopters interact only\n * through the {@link ModuleRegistry} interface.\n */\nexport class MutableModuleRegistry implements ModuleRegistry {\n readonly entries: AppModuleEntry[] = []\n\n mount(module: AppModuleEntry): void {\n this.entries.push(module)\n }\n}\n"],"mappings":";;;;;;;;;;AAwDA,IAAa,sBAAb,KAA6D,CAC3D,QAAqC,CAAC,EAEtC,MAAM,OAA8B,CAClC,KAAK,QAAQ,KAAK,MAAM,CAC1B,CACF"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) Felix Orinda
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
import{createLogger}from"./core/logger.mjs";const log=createLogger(`Observers`);let observers=[],publisher,publisherWantsResponses=()=>!1;function setObservers(list){return observers=list.filter(o=>o.onError||o.onResponse),observers}function releaseObservers(installed){observers===installed&&(observers=[])}function setObserverPublisher(next,wantsResponses=()=>!1){publisher=next,publisherWantsResponses=wantsResponses}function hasResponseObservers(){return observers.some(o=>o.onResponse)||publisherWantsResponses()}function notify(pick,what){for(let observer of publisher?[...observers,publisher]:observers)try{let result=pick(observer);result&&typeof result.catch==`function`&&result.catch(err=>failed(observer,what,err))}catch(err){failed(observer,what,err)}}function failed(observer,what,err){try{log.error({err},`${observer.name??`observer`}.${what} threw — skipped`)}catch{}}function reportError(error,info){notify(o=>o.onError?.(error,info),`onError`)}function reportResponse(info){notify(o=>o.onResponse?.(info),`onResponse`)}export{setObserverPublisher as a,reportResponse as i,releaseObservers as n,setObservers as o,reportError as r,hasResponseObservers as t};
|
|
12
|
+
//# sourceMappingURL=observers-C5Jid4FV.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"observers-C5Jid4FV.mjs","names":[],"sources":["../src/core/observers.ts"],"sourcesContent":["/**\n * One place every error and every response is reported to — so an error\n * tracker, a metrics exporter, or an APM integration hooks in once instead of\n * replacing the error handler and patching each source.\n *\n * Observers are adapters and plugins with `onError` / `onResponse`, plus\n * whatever the Node server's tracing channels publish (see `http/tracing.ts`).\n * Observe-only: they cannot change the response, and one that throws is\n * logged and skipped — it never breaks a request or another observer.\n *\n * Free of `node:` imports: `waitUntil` (shared with the web entry) reports\n * through here.\n *\n * @module @forinda/kickjs/core/observers\n */\nimport { createLogger } from './logger'\n\nconst log = createLogger('Observers')\n\n/** Where an error came from. Integrations may report their own sources. */\nexport type ErrorSource =\n | 'request'\n | 'uncaught'\n | 'unhandled-rejection'\n | 'background'\n | 'job'\n | (string & {})\n\nexport interface ErrorInfo {\n source: ErrorSource\n requestId?: string\n method?: string\n /** The request URL path, as received. */\n path?: string\n /** The matched route pattern, e.g. `/api/v1/users/:id`, when one matched. */\n route?: string\n /** The status the error was answered with, for request errors. */\n status?: number\n /** Anything else the reporter knows — a queue and job name, a cron schedule. */\n context?: Record<string, unknown>\n}\n\nexport interface ResponseInfo {\n method: string\n path: string\n /** The matched route pattern, or undefined when no route matched (404s). */\n route?: string\n status: number\n durationMs: number\n requestId?: string\n}\n\nexport interface Observer {\n name?: string\n onError?(error: unknown, info: ErrorInfo): void | Promise<void>\n onResponse?(info: ResponseInfo): void | Promise<void>\n}\n\nlet observers: readonly Observer[] = []\nlet publisher: Observer | undefined\nlet publisherWantsResponses: () => boolean = () => false\n\n/**\n * Replace the observer set — called by the Application from its adapters and\n * plugins. Returns the installed set, so its owner can later release exactly\n * that set (see {@link releaseObservers}).\n */\nexport function setObservers(list: readonly Observer[]): readonly Observer[] {\n observers = list.filter((o) => o.onError || o.onResponse)\n return observers\n}\n\n/**\n * Clear the observer set — but only if it is still the one `installed`. The\n * set is process-wide: a newer Application may have replaced it, and an older\n * one shutting down must not clear the newer one's observers.\n */\nexport function releaseObservers(installed: readonly Observer[]): void {\n if (observers === installed) observers = []\n}\n\n/**\n * The Node server's tracing channels, fed from the same reports. `wantsResponses`\n * is checked per request: a channel can gain a subscriber at any time.\n */\nexport function setObserverPublisher(\n next: Observer | undefined,\n wantsResponses: () => boolean = () => false,\n): void {\n publisher = next\n publisherWantsResponses = wantsResponses\n}\n\n/** Whether anything is listening for responses — lets the server skip timing work. */\nexport function hasResponseObservers(): boolean {\n return observers.some((o) => o.onResponse) || publisherWantsResponses()\n}\n\nfunction notify(pick: (o: Observer) => void | Promise<void> | undefined, what: string): void {\n for (const observer of publisher ? [...observers, publisher] : observers) {\n try {\n const result = pick(observer)\n if (result && typeof (result as Promise<void>).catch === 'function') {\n ;(result as Promise<void>).catch((err: unknown) => failed(observer, what, err))\n }\n } catch (err) {\n failed(observer, what, err)\n }\n }\n}\n\nfunction failed(observer: Observer, what: string, err: unknown): void {\n // Logged directly, never re-reported: an observer failing inside onError\n // must not loop back into onError. Guarded too: a custom LoggerProvider that\n // throws would otherwise escape reportError() — or, from an async\n // observer, become an unhandled rejection that bootstrap reports right back\n // here.\n try {\n log.error({ err }, `${observer.name ?? 'observer'}.${what} threw — skipped`)\n } catch {\n // Nothing left to tell; reportError's \"never throws\" contract wins.\n }\n}\n\n/**\n * Report an error to every observer. The framework calls this for request\n * errors, uncaught exceptions, unhandled rejections, `waitUntil` failures and\n * queue job failures; call it from your own integrations too (a cron runner,\n * a message consumer). Never throws.\n */\nexport function reportError(error: unknown, info: ErrorInfo): void {\n notify((o) => o.onError?.(error, info), 'onError')\n}\n\n/** Report a finished response to every observer. Never throws. */\nexport function reportResponse(info: ResponseInfo): void {\n notify((o) => o.onResponse?.(info), 'onResponse')\n}\n"],"mappings":";;;;;;;;;;4CAiBA,MAAM,IAAM,aAAa,WAAW,EAyCpC,IAAI,UAAiC,CAAC,EAClC,UACA,4BAA+C,GAOnD,SAAgB,aAAa,KAAgD,CAE3E,MADA,WAAY,KAAK,OAAQ,GAAM,EAAE,SAAW,EAAE,UAAU,EACjD,SACT,CAOA,SAAgB,iBAAiB,UAAsC,CACjE,YAAc,YAAW,UAAY,CAAC,EAC5C,CAMA,SAAgB,qBACd,KACA,mBAAsC,GAChC,CACN,UAAY,KACZ,wBAA0B,cAC5B,CAGA,SAAgB,sBAAgC,CAC9C,OAAO,UAAU,KAAM,GAAM,EAAE,UAAU,GAAK,wBAAwB,CACxE,CAEA,SAAS,OAAO,KAAyD,KAAoB,CAC3F,IAAK,IAAM,YAAY,UAAY,CAAC,GAAG,UAAW,SAAS,EAAI,UAC7D,GAAI,CACF,IAAM,OAAS,KAAK,QAAQ,EACxB,QAAU,OAAQ,OAAyB,OAAU,YACtD,OAA0B,MAAO,KAAiB,OAAO,SAAU,KAAM,GAAG,CAAC,CAElF,OAAS,IAAK,CACZ,OAAO,SAAU,KAAM,GAAG,CAC5B,CAEJ,CAEA,SAAS,OAAO,SAAoB,KAAc,IAAoB,CAMpE,GAAI,CACF,IAAI,MAAM,CAAE,GAAI,EAAG,GAAG,SAAS,MAAQ,WAAW,GAAG,KAAK,iBAAiB,CAC7E,MAAQ,CAER,CACF,CAQA,SAAgB,YAAY,MAAgB,KAAuB,CACjE,OAAQ,GAAM,EAAE,UAAU,MAAO,IAAI,EAAG,SAAS,CACnD,CAGA,SAAgB,eAAe,KAA0B,CACvD,OAAQ,GAAM,EAAE,aAAa,IAAI,EAAG,YAAY,CAClD"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs v8.
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
const FILTER_OPERATORS=new Set([`eq`,`neq`,`gt`,`gte`,`lt`,`lte`,`between`,`in`,`contains`,`starts`,`ends`]),BUILTIN_DEFAULTS={maxLimit:100,defaultLimit:20,maxSearchLength:200,defaultPage:1},defaults={...BUILTIN_DEFAULTS};function setQueryParsingDefaults(partial){partial.maxLimit!==void 0&&(defaults.maxLimit=partial.maxLimit),partial.defaultLimit!==void 0&&(defaults.defaultLimit=partial.defaultLimit),partial.maxSearchLength!==void 0&&(defaults.maxSearchLength=partial.maxSearchLength)}function getQueryParsingDefaults(){return{...defaults}}function resetQueryParsingDefaults(){Object.assign(defaults,BUILTIN_DEFAULTS)}function parseFilters(filterParam,allowedFields,options){if(!filterParam)return[];let items=Array.isArray(filterParam)?filterParam:[filterParam],results=[],allowed=allowedFields?new Set(allowedFields):null,onReject=options?.onReject;for(let item of items){let firstColon=item.indexOf(`:`);if(firstColon===-1)continue;let secondColon=item.indexOf(`:`,firstColon+1);if(secondColon===-1)continue;let field=item.slice(0,firstColon),operator=item.slice(firstColon+1,secondColon),value=item.slice(secondColon+1);if(field&&value){if(!FILTER_OPERATORS.has(operator)){onReject?.({kind:`filter`,field,reason:`unknown-operator`});continue}if(allowed&&!allowed.has(field)){onReject?.({kind:`filter`,field,reason:`field-not-allowed`});continue}results.push({field,operator,value})}}return results}function parseSort(sortParam,allowedFields,options){if(!sortParam)return[];let items=Array.isArray(sortParam)?sortParam:[sortParam],results=[],allowed=allowedFields?new Set(allowedFields):null,onReject=options?.onReject;for(let item of items){let lastColon=item.lastIndexOf(`:`);if(lastColon===-1)continue;let field=item.slice(0,lastColon),dir=item.slice(lastColon+1).toLowerCase();if(field&&(dir===`asc`||dir===`desc`)){if(allowed&&!allowed.has(field)){onReject?.({kind:`sort`,field,reason:`field-not-allowed`});continue}results.push({field,direction:dir})}}return results}function parsePagination(params,options){let defaultLimit=options?.defaultLimit??defaults.defaultLimit,maxLimit=options?.maxLimit??defaults.maxLimit,page=typeof params.page==`string`?Number.parseInt(params.page,10):params.page??defaults.defaultPage,limit=typeof params.limit==`string`?Number.parseInt(params.limit,10):params.limit??defaultLimit;return(Number.isNaN(page)||page<1)&&(page=defaults.defaultPage),(Number.isNaN(limit)||limit<1)&&(limit=defaultLimit),limit>maxLimit&&(limit=maxLimit),{page,limit,offset:(page-1)*limit}}function parseSearchQuery(q,options){if(!q)return``;let maxLen=options?.maxSearchLength??defaults.maxSearchLength,trimmed=q.trim();return trimmed.length>maxLen?(options?.onReject?.({kind:`search`,field:`q`,reason:`truncated`}),trimmed.slice(0,maxLen)):trimmed}function normalizeFieldConfig(config){if(config)return`columns`in config&&config.columns&&typeof config.columns==`object`?{filterable:Object.keys(config.columns),sortable:config.sortable?Object.keys(config.sortable):void 0,searchable:void 0}:config}function parseQuery(query,fieldConfig,options){let normalized=normalizeFieldConfig(fieldConfig),rejectOpts=options?.onReject?{onReject:options.onReject}:void 0;return{filters:parseFilters(query.filter,normalized?.filterable,rejectOpts),sort:parseSort(query.sort,normalized?.sortable,rejectOpts),pagination:parsePagination({page:query.page,limit:query.limit},options),search:parseSearchQuery(query.q,options)}}function buildQueryParams(parsed){let params={};return parsed.filters?.length&&(params.filter=parsed.filters.map(f=>`${f.field}:${f.operator}:${f.value}`)),parsed.sort?.length&&(params.sort=parsed.sort.map(s=>`${s.field}:${s.direction}`)),parsed.pagination&&(params.page=parsed.pagination.page,params.limit=parsed.pagination.limit),parsed.search&&(params.q=parsed.search),params}export{parseQuery as a,resetQueryParsingDefaults as c,parsePagination as i,setQueryParsingDefaults as l,getQueryParsingDefaults as n,parseSearchQuery as o,parseFilters as r,parseSort as s,buildQueryParams as t,FILTER_OPERATORS as u};
|
|
12
|
-
//# sourceMappingURL=query-
|
|
12
|
+
//# sourceMappingURL=query-1Q69dPhY.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query-CWwMw9E6.mjs","names":[],"sources":["../src/http/query/types.ts","../src/http/query/parse-query.ts"],"sourcesContent":["/** Supported filter operators for query string parsing */\nexport type FilterOperator =\n | 'eq'\n | 'neq'\n | 'gt'\n | 'gte'\n | 'lt'\n | 'lte'\n | 'between'\n | 'in'\n | 'contains'\n | 'starts'\n | 'ends'\n\nexport const FILTER_OPERATORS = new Set<string>([\n 'eq',\n 'neq',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'in',\n 'contains',\n 'starts',\n 'ends',\n])\n\nexport interface FilterItem {\n field: string\n operator: FilterOperator\n value: string\n}\n\nexport interface SortItem {\n field: string\n direction: 'asc' | 'desc'\n}\n\nexport interface PaginationParams {\n page: number\n limit: number\n offset: number\n}\n\n/**\n * The result of parsing a query string. ORM-agnostic — pass this to\n * a query builder adapter (Drizzle, Prisma, Sequelize, etc.) to produce\n * database-specific query objects.\n */\nexport interface ParsedQuery {\n filters: FilterItem[]\n sort: SortItem[]\n pagination: PaginationParams\n search: string\n}\n\n/**\n * Helper that extracts the literal field union from a `QueryFieldConfig`\n * key. When the user passes `{ filterable: ['status', 'category'] } as const`,\n * `FieldsOf<typeof config, 'filterable'>` resolves to `'status' | 'category'`.\n *\n * Without `as const`, the arrays widen to `string[]` and the union falls\n * back to `string`. This is the documented escape hatch.\n */\nexport type FieldsOf<\n TConfig extends QueryFieldConfig | undefined,\n K extends 'filterable' | 'sortable' | 'searchable',\n> = TConfig extends { readonly [P in K]: readonly (infer F)[] }\n ? F extends string\n ? F\n : string\n : TConfig extends { columns: infer C }\n ? K extends 'filterable'\n ? keyof C & string\n : string\n : string\n\n/**\n * Narrowed `ParsedQuery` whose `filters[].field` and `sort[].field`\n * unions are constrained to the literal whitelist passed to\n * `ctx.qs()` / `parseQuery()`. Extends `ParsedQuery`, so any function\n * accepting `ParsedQuery` accepts a `TypedParsedQuery<X>` automatically\n * (subtype assignment, no codegen, backward compatible).\n *\n * @example\n * ```ts\n * const parsed = ctx.qs({\n * filterable: ['status', 'priority'],\n * sortable: ['createdAt'],\n * } as const)\n * parsed.filters[0]?.field // 'status' | 'priority'\n * parsed.sort[0]?.field // 'createdAt'\n * ```\n */\nexport interface TypedParsedQuery<\n TConfig extends QueryFieldConfig | undefined,\n> extends ParsedQuery {\n filters: Array<FilterItem & { field: FieldsOf<TConfig, 'filterable'> }>\n sort: Array<SortItem & { field: FieldsOf<TConfig, 'sortable'> }>\n}\n\n/**\n * Restrict which fields can be filtered, sorted, or searched.\n * Fields not in the allow-list are silently ignored.\n *\n * Accepts two forms:\n * 1. **String arrays** (standard): `{ filterable: ['name', 'status'], sortable: ['createdAt'] }`\n * 2. **Column-object maps** (Drizzle-style): `{ columns: { name: col, status: col }, sortable: { createdAt: col } }`\n *\n * When column-object maps are provided, `Object.keys()` is used to derive the field names.\n */\nexport type QueryFieldConfig = StringQueryFieldConfig | ColumnQueryFieldConfig\n\nexport interface StringQueryFieldConfig {\n filterable?: string[]\n sortable?: string[]\n searchable?: string[]\n}\n\n/**\n * Column-object-based field config (e.g., from DrizzleQueryParamsConfig).\n * Keys are the field names used in query strings; values are ORM column references.\n */\nexport interface ColumnQueryFieldConfig {\n columns: Record<string, any>\n sortable?: Record<string, any>\n searchColumns?: any[]\n [key: string]: any\n}\n\n/** Standardized paginated response shape */\nexport interface PaginatedResponse<T = any> {\n data: T[]\n meta: {\n page: number\n limit: number\n total: number\n totalPages: number\n hasNext: boolean\n hasPrev: boolean\n }\n}\n\n/**\n * Interface for ORM-specific query builder adapters.\n * Implement this to translate a `ParsedQuery` into your ORM's query format.\n *\n * @example\n * ```ts\n * // Drizzle adapter\n * class DrizzleQueryAdapter implements QueryBuilderAdapter<DrizzleQueryResult> {\n * build(parsed: ParsedQuery, config: DrizzleConfig): DrizzleQueryResult {\n * // Convert filters → Drizzle SQL conditions\n * // Convert sort → Drizzle orderBy\n * return { where, orderBy, limit, offset }\n * }\n * }\n *\n * // Prisma adapter\n * class PrismaQueryAdapter implements QueryBuilderAdapter<PrismaQueryResult> {\n * build(parsed: ParsedQuery, config: PrismaConfig): PrismaQueryResult {\n * return { where, orderBy, skip, take }\n * }\n * }\n * ```\n */\nexport interface QueryBuilderAdapter<TResult = any, TConfig = any> {\n /** Human-readable name for debugging */\n readonly name: string\n\n /**\n * Convert a ParsedQuery into an ORM-specific query object.\n * @param parsed - The ORM-agnostic parsed query\n * @param config - ORM-specific configuration (column maps, search columns, etc.)\n */\n build(parsed: ParsedQuery, config: TConfig): TResult\n}\n","import {\n FILTER_OPERATORS,\n type FilterItem,\n type FilterOperator,\n type SortItem,\n type PaginationParams,\n type ParsedQuery,\n type QueryFieldConfig,\n type StringQueryFieldConfig,\n type TypedParsedQuery,\n} from './types'\n\n// ── Configurable defaults ───────────────────────────────────────────────\n\n/**\n * Why a rejected field/value was dropped. Surfaced through\n * {@link ParseQueryOptions.onReject} so a caller can warn, count, or\n * return a 400 instead of the historical silent drop.\n */\nexport type QueryRejectReason = 'field-not-allowed' | 'unknown-operator' | 'truncated'\n\nexport interface QueryRejection {\n kind: 'filter' | 'sort' | 'search'\n field: string\n reason: QueryRejectReason\n}\n\nexport interface ParseQueryOptions {\n /** Hard cap on `limit`. Default: global (100, override via {@link setQueryParsingDefaults}). */\n maxLimit?: number\n /** `limit` when the query omits one. Default: global (20). */\n defaultLimit?: number\n /** Max `q` length before truncation. Default: global (200). */\n maxSearchLength?: number\n /**\n * Called once per dropped filter/sort field or truncated search.\n * The historical behaviour is a silent drop; wire this to a logger or\n * a 400 to make over-broad / typo'd queries visible.\n */\n onReject?: (rejection: QueryRejection) => void\n}\n\ninterface QueryParsingDefaults {\n maxLimit: number\n defaultLimit: number\n maxSearchLength: number\n defaultPage: number\n}\n\nconst BUILTIN_DEFAULTS: QueryParsingDefaults = {\n maxLimit: 100,\n defaultLimit: 20,\n maxSearchLength: 200,\n defaultPage: 1,\n}\n\nconst defaults: QueryParsingDefaults = { ...BUILTIN_DEFAULTS }\n\n/**\n * Globally override the query-parsing limits — call once at bootstrap.\n * Per-call {@link ParseQueryOptions} still take precedence over these.\n */\nexport function setQueryParsingDefaults(\n partial: Partial<Omit<QueryParsingDefaults, 'defaultPage'>>,\n): void {\n if (partial.maxLimit !== undefined) defaults.maxLimit = partial.maxLimit\n if (partial.defaultLimit !== undefined) defaults.defaultLimit = partial.defaultLimit\n if (partial.maxSearchLength !== undefined) defaults.maxSearchLength = partial.maxSearchLength\n}\n\n/** Read the current global query-parsing defaults (a copy). */\nexport function getQueryParsingDefaults(): Readonly<QueryParsingDefaults> {\n return { ...defaults }\n}\n\n/**\n * Restore the built-in query-parsing defaults (maxLimit 100, defaultLimit\n * 20, maxSearchLength 200). Useful in test teardown after a\n * {@link setQueryParsingDefaults} override so global state can't leak\n * between tests.\n */\nexport function resetQueryParsingDefaults(): void {\n Object.assign(defaults, BUILTIN_DEFAULTS)\n}\n\n// ── Individual parsers ──────────────────────────────────────────────────\n\n/** Parse filter strings like \"status:eq:active\" into structured objects */\nexport function parseFilters(\n filterParam: string | string[] | undefined,\n allowedFields?: string[],\n options?: Pick<ParseQueryOptions, 'onReject'>,\n): FilterItem[] {\n if (!filterParam) return []\n\n const items = Array.isArray(filterParam) ? filterParam : [filterParam]\n const results: FilterItem[] = []\n const allowed = allowedFields ? new Set(allowedFields) : null\n const onReject = options?.onReject\n\n for (const item of items) {\n // Split on first two colons only — value may contain colons (e.g. timestamps)\n const firstColon = item.indexOf(':')\n if (firstColon === -1) continue\n\n const secondColon = item.indexOf(':', firstColon + 1)\n if (secondColon === -1) continue\n\n const field = item.slice(0, firstColon)\n const operator = item.slice(firstColon + 1, secondColon)\n const value = item.slice(secondColon + 1)\n\n if (!field || !value) continue\n if (!FILTER_OPERATORS.has(operator)) {\n onReject?.({ kind: 'filter', field, reason: 'unknown-operator' })\n continue\n }\n if (allowed && !allowed.has(field)) {\n onReject?.({ kind: 'filter', field, reason: 'field-not-allowed' })\n continue\n }\n\n results.push({ field, operator: operator as FilterOperator, value })\n }\n\n return results\n}\n\n/** Parse sort strings like \"firstName:asc\" into structured objects */\nexport function parseSort(\n sortParam: string | string[] | undefined,\n allowedFields?: string[],\n options?: Pick<ParseQueryOptions, 'onReject'>,\n): SortItem[] {\n if (!sortParam) return []\n\n const items = Array.isArray(sortParam) ? sortParam : [sortParam]\n const results: SortItem[] = []\n const allowed = allowedFields ? new Set(allowedFields) : null\n const onReject = options?.onReject\n\n for (const item of items) {\n // Split on last colon so field names with colons work\n const lastColon = item.lastIndexOf(':')\n if (lastColon === -1) continue\n\n const field = item.slice(0, lastColon)\n const dir = item.slice(lastColon + 1).toLowerCase()\n\n if (!field) continue\n if (dir !== 'asc' && dir !== 'desc') continue\n if (allowed && !allowed.has(field)) {\n onReject?.({ kind: 'sort', field, reason: 'field-not-allowed' })\n continue\n }\n\n results.push({ field, direction: dir })\n }\n\n return results\n}\n\n/** Parse page/limit into pagination with computed offset */\nexport function parsePagination(\n params: {\n page?: string | number\n limit?: string | number\n },\n options?: Pick<ParseQueryOptions, 'maxLimit' | 'defaultLimit'>,\n): PaginationParams {\n const defaultLimit = options?.defaultLimit ?? defaults.defaultLimit\n const maxLimit = options?.maxLimit ?? defaults.maxLimit\n\n let page =\n typeof params.page === 'string'\n ? Number.parseInt(params.page, 10)\n : (params.page ?? defaults.defaultPage)\n let limit =\n typeof params.limit === 'string'\n ? Number.parseInt(params.limit, 10)\n : (params.limit ?? defaultLimit)\n\n if (Number.isNaN(page) || page < 1) page = defaults.defaultPage\n if (Number.isNaN(limit) || limit < 1) limit = defaultLimit\n if (limit > maxLimit) limit = maxLimit\n\n return { page, limit, offset: (page - 1) * limit }\n}\n\n/** Sanitize and truncate search query */\nexport function parseSearchQuery(\n q: string | undefined,\n options?: Pick<ParseQueryOptions, 'maxSearchLength' | 'onReject'>,\n): string {\n if (!q) return ''\n const maxLen = options?.maxSearchLength ?? defaults.maxSearchLength\n const trimmed = q.trim()\n if (trimmed.length > maxLen) {\n options?.onReject?.({ kind: 'search', field: 'q', reason: 'truncated' })\n return trimmed.slice(0, maxLen)\n }\n return trimmed\n}\n\n// ── Config normalizer ───────────────────────────────────────────────────\n\n/**\n * Normalize a QueryFieldConfig into the string-based form.\n * Detects column-object-based configs (DrizzleQueryParamsConfig) by checking\n * for a `columns` property and extracts `Object.keys()`.\n */\nfunction normalizeFieldConfig(config?: QueryFieldConfig): StringQueryFieldConfig | undefined {\n if (!config) return undefined\n\n // Column-object-based config (e.g., DrizzleQueryParamsConfig)\n if ('columns' in config && config.columns && typeof config.columns === 'object') {\n return {\n filterable: Object.keys(config.columns),\n sortable: config.sortable ? Object.keys(config.sortable) : undefined,\n searchable: undefined, // searchColumns are Column objects, not used for string filtering\n }\n }\n\n // Already string-based\n return config as StringQueryFieldConfig\n}\n\n// ── Combined parser ─────────────────────────────────────────────────────\n\n/**\n * Parse a raw Express query object into a structured, ORM-agnostic ParsedQuery.\n *\n * @param query - Raw query string object from `req.query` or Zod-validated object\n * @param fieldConfig - Optional field restrictions (whitelist filterable/sortable/searchable).\n * Accepts both string-based configs (`{ filterable, sortable, searchable }`) and\n * column-object configs (`{ columns, sortable, searchColumns }`).\n * @param options - Limits + an `onReject` hook for dropped/truncated input.\n *\n * @example\n * ```ts\n * // String-based config\n * const parsed = parseQuery(ctx.query, {\n * filterable: ['status', 'priority'],\n * sortable: ['createdAt', 'title'],\n * })\n *\n * // Column-object config (DrizzleQueryParamsConfig)\n * const parsed = parseQuery(ctx.query, TASK_QUERY_CONFIG)\n * ```\n *\n * Query string format:\n * - Filters: `?filter=field:operator:value` (repeatable)\n * - Sort: `?sort=field:asc|desc` (repeatable)\n * - Pagination: `?page=1&limit=20`\n * - Search: `?q=search+term`\n *\n * Filter operators: eq, neq, gt, gte, lt, lte, between, in, contains, starts, ends\n */\nexport function parseQuery<TConfig extends QueryFieldConfig | undefined = undefined>(\n query: Record<string, any>,\n fieldConfig?: TConfig,\n options?: ParseQueryOptions,\n): TypedParsedQuery<TConfig> {\n const normalized = normalizeFieldConfig(fieldConfig)\n const rejectOpts = options?.onReject ? { onReject: options.onReject } : undefined\n return {\n filters: parseFilters(query.filter, normalized?.filterable, rejectOpts),\n sort: parseSort(query.sort, normalized?.sortable, rejectOpts),\n pagination: parsePagination({ page: query.page, limit: query.limit }, options),\n search: parseSearchQuery(query.q, options),\n } as TypedParsedQuery<TConfig>\n}\n\n// ── Query URL builder (for client-side or testing) ──────────────────────\n\n/** Convert ParsedQuery back into query string parameters */\nexport function buildQueryParams(\n parsed: Partial<ParsedQuery>,\n): Record<string, string | string[] | number> {\n const params: Record<string, string | string[] | number> = {}\n\n if (parsed.filters?.length) {\n params.filter = parsed.filters.map((f) => `${f.field}:${f.operator}:${f.value}`)\n }\n\n if (parsed.sort?.length) {\n params.sort = parsed.sort.map((s) => `${s.field}:${s.direction}`)\n }\n\n if (parsed.pagination) {\n params.page = parsed.pagination.page\n params.limit = parsed.pagination.limit\n }\n\n if (parsed.search) {\n params.q = parsed.search\n }\n\n return params\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"query-1Q69dPhY.mjs","names":[],"sources":["../src/http/query/types.ts","../src/http/query/parse-query.ts"],"sourcesContent":["/** Supported filter operators for query string parsing */\nexport type FilterOperator =\n | 'eq'\n | 'neq'\n | 'gt'\n | 'gte'\n | 'lt'\n | 'lte'\n | 'between'\n | 'in'\n | 'contains'\n | 'starts'\n | 'ends'\n\nexport const FILTER_OPERATORS = new Set<string>([\n 'eq',\n 'neq',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'in',\n 'contains',\n 'starts',\n 'ends',\n])\n\nexport interface FilterItem {\n field: string\n operator: FilterOperator\n value: string\n}\n\nexport interface SortItem {\n field: string\n direction: 'asc' | 'desc'\n}\n\nexport interface PaginationParams {\n page: number\n limit: number\n offset: number\n}\n\n/**\n * The result of parsing a query string. ORM-agnostic — pass this to\n * a query builder adapter (Drizzle, Prisma, Sequelize, etc.) to produce\n * database-specific query objects.\n */\nexport interface ParsedQuery {\n filters: FilterItem[]\n sort: SortItem[]\n pagination: PaginationParams\n search: string\n}\n\n/**\n * Helper that extracts the literal field union from a `QueryFieldConfig`\n * key. When the user passes `{ filterable: ['status', 'category'] } as const`,\n * `FieldsOf<typeof config, 'filterable'>` resolves to `'status' | 'category'`.\n *\n * Without `as const`, the arrays widen to `string[]` and the union falls\n * back to `string`. This is the documented escape hatch.\n */\nexport type FieldsOf<\n TConfig extends QueryFieldConfig | undefined,\n K extends 'filterable' | 'sortable' | 'searchable',\n> = TConfig extends { readonly [P in K]: readonly (infer F)[] }\n ? F extends string\n ? F\n : string\n : TConfig extends { columns: infer C }\n ? K extends 'filterable'\n ? keyof C & string\n : string\n : string\n\n/**\n * Narrowed `ParsedQuery` whose `filters[].field` and `sort[].field`\n * unions are constrained to the literal whitelist passed to\n * `ctx.qs()` / `parseQuery()`. Extends `ParsedQuery`, so any function\n * accepting `ParsedQuery` accepts a `TypedParsedQuery<X>` automatically\n * (subtype assignment, no codegen, backward compatible).\n *\n * @example\n * ```ts\n * const parsed = ctx.qs({\n * filterable: ['status', 'priority'],\n * sortable: ['createdAt'],\n * } as const)\n * parsed.filters[0]?.field // 'status' | 'priority'\n * parsed.sort[0]?.field // 'createdAt'\n * ```\n */\nexport interface TypedParsedQuery<\n TConfig extends QueryFieldConfig | undefined,\n> extends ParsedQuery {\n filters: Array<FilterItem & { field: FieldsOf<TConfig, 'filterable'> }>\n sort: Array<SortItem & { field: FieldsOf<TConfig, 'sortable'> }>\n}\n\n/**\n * Restrict which fields can be filtered, sorted, or searched.\n * Fields not in the allow-list are silently ignored.\n *\n * Accepts two forms:\n * 1. **String arrays** (standard): `{ filterable: ['name', 'status'], sortable: ['createdAt'] }`\n * 2. **Column-object maps** (Drizzle-style): `{ columns: { name: col, status: col }, sortable: { createdAt: col } }`\n *\n * When column-object maps are provided, `Object.keys()` is used to derive the field names.\n */\nexport type QueryFieldConfig = StringQueryFieldConfig | ColumnQueryFieldConfig\n\nexport interface StringQueryFieldConfig {\n filterable?: string[]\n sortable?: string[]\n searchable?: string[]\n}\n\n/**\n * Column-object-based field config (e.g., from DrizzleQueryParamsConfig).\n * Keys are the field names used in query strings; values are ORM column references.\n */\nexport interface ColumnQueryFieldConfig {\n columns: Record<string, any>\n sortable?: Record<string, any>\n searchColumns?: any[]\n [key: string]: any\n}\n\n/** Standardized paginated response shape */\nexport interface PaginatedResponse<T = any> {\n data: T[]\n meta: {\n page: number\n limit: number\n total: number\n totalPages: number\n hasNext: boolean\n hasPrev: boolean\n }\n}\n\n/**\n * Interface for ORM-specific query builder adapters.\n * Implement this to translate a `ParsedQuery` into your ORM's query format.\n *\n * @example\n * ```ts\n * // Drizzle adapter\n * class DrizzleQueryAdapter implements QueryBuilderAdapter<DrizzleQueryResult> {\n * build(parsed: ParsedQuery, config: DrizzleConfig): DrizzleQueryResult {\n * // Convert filters → Drizzle SQL conditions\n * // Convert sort → Drizzle orderBy\n * return { where, orderBy, limit, offset }\n * }\n * }\n *\n * // Prisma adapter\n * class PrismaQueryAdapter implements QueryBuilderAdapter<PrismaQueryResult> {\n * build(parsed: ParsedQuery, config: PrismaConfig): PrismaQueryResult {\n * return { where, orderBy, skip, take }\n * }\n * }\n * ```\n */\nexport interface QueryBuilderAdapter<TResult = any, TConfig = any> {\n /** Human-readable name for debugging */\n readonly name: string\n\n /**\n * Convert a ParsedQuery into an ORM-specific query object.\n * @param parsed - The ORM-agnostic parsed query\n * @param config - ORM-specific configuration (column maps, search columns, etc.)\n */\n build(parsed: ParsedQuery, config: TConfig): TResult\n}\n","import {\n FILTER_OPERATORS,\n type FilterItem,\n type FilterOperator,\n type SortItem,\n type PaginationParams,\n type ParsedQuery,\n type QueryFieldConfig,\n type StringQueryFieldConfig,\n type TypedParsedQuery,\n} from './types'\n\n// ── Configurable defaults ───────────────────────────────────────────────\n\n/**\n * Why a rejected field/value was dropped. Surfaced through\n * {@link ParseQueryOptions.onReject} so a caller can warn, count, or\n * return a 400 instead of the historical silent drop.\n */\nexport type QueryRejectReason = 'field-not-allowed' | 'unknown-operator' | 'truncated'\n\nexport interface QueryRejection {\n kind: 'filter' | 'sort' | 'search'\n field: string\n reason: QueryRejectReason\n}\n\nexport interface ParseQueryOptions {\n /** Hard cap on `limit`. Default: global (100, override via {@link setQueryParsingDefaults}). */\n maxLimit?: number\n /** `limit` when the query omits one. Default: global (20). */\n defaultLimit?: number\n /** Max `q` length before truncation. Default: global (200). */\n maxSearchLength?: number\n /**\n * Called once per dropped filter/sort field or truncated search.\n * The historical behaviour is a silent drop; wire this to a logger or\n * a 400 to make over-broad / typo'd queries visible.\n */\n onReject?: (rejection: QueryRejection) => void\n}\n\ninterface QueryParsingDefaults {\n maxLimit: number\n defaultLimit: number\n maxSearchLength: number\n defaultPage: number\n}\n\nconst BUILTIN_DEFAULTS: QueryParsingDefaults = {\n maxLimit: 100,\n defaultLimit: 20,\n maxSearchLength: 200,\n defaultPage: 1,\n}\n\nconst defaults: QueryParsingDefaults = { ...BUILTIN_DEFAULTS }\n\n/**\n * Globally override the query-parsing limits — call once at bootstrap.\n * Per-call {@link ParseQueryOptions} still take precedence over these.\n */\nexport function setQueryParsingDefaults(\n partial: Partial<Omit<QueryParsingDefaults, 'defaultPage'>>,\n): void {\n if (partial.maxLimit !== undefined) defaults.maxLimit = partial.maxLimit\n if (partial.defaultLimit !== undefined) defaults.defaultLimit = partial.defaultLimit\n if (partial.maxSearchLength !== undefined) defaults.maxSearchLength = partial.maxSearchLength\n}\n\n/** Read the current global query-parsing defaults (a copy). */\nexport function getQueryParsingDefaults(): Readonly<QueryParsingDefaults> {\n return { ...defaults }\n}\n\n/**\n * Restore the built-in query-parsing defaults (maxLimit 100, defaultLimit\n * 20, maxSearchLength 200). Useful in test teardown after a\n * {@link setQueryParsingDefaults} override so global state can't leak\n * between tests.\n */\nexport function resetQueryParsingDefaults(): void {\n Object.assign(defaults, BUILTIN_DEFAULTS)\n}\n\n// ── Individual parsers ──────────────────────────────────────────────────\n\n/** Parse filter strings like \"status:eq:active\" into structured objects */\nexport function parseFilters(\n filterParam: string | string[] | undefined,\n allowedFields?: string[],\n options?: Pick<ParseQueryOptions, 'onReject'>,\n): FilterItem[] {\n if (!filterParam) return []\n\n const items = Array.isArray(filterParam) ? filterParam : [filterParam]\n const results: FilterItem[] = []\n const allowed = allowedFields ? new Set(allowedFields) : null\n const onReject = options?.onReject\n\n for (const item of items) {\n // Split on first two colons only — value may contain colons (e.g. timestamps)\n const firstColon = item.indexOf(':')\n if (firstColon === -1) continue\n\n const secondColon = item.indexOf(':', firstColon + 1)\n if (secondColon === -1) continue\n\n const field = item.slice(0, firstColon)\n const operator = item.slice(firstColon + 1, secondColon)\n const value = item.slice(secondColon + 1)\n\n if (!field || !value) continue\n if (!FILTER_OPERATORS.has(operator)) {\n onReject?.({ kind: 'filter', field, reason: 'unknown-operator' })\n continue\n }\n if (allowed && !allowed.has(field)) {\n onReject?.({ kind: 'filter', field, reason: 'field-not-allowed' })\n continue\n }\n\n results.push({ field, operator: operator as FilterOperator, value })\n }\n\n return results\n}\n\n/** Parse sort strings like \"firstName:asc\" into structured objects */\nexport function parseSort(\n sortParam: string | string[] | undefined,\n allowedFields?: string[],\n options?: Pick<ParseQueryOptions, 'onReject'>,\n): SortItem[] {\n if (!sortParam) return []\n\n const items = Array.isArray(sortParam) ? sortParam : [sortParam]\n const results: SortItem[] = []\n const allowed = allowedFields ? new Set(allowedFields) : null\n const onReject = options?.onReject\n\n for (const item of items) {\n // Split on last colon so field names with colons work\n const lastColon = item.lastIndexOf(':')\n if (lastColon === -1) continue\n\n const field = item.slice(0, lastColon)\n const dir = item.slice(lastColon + 1).toLowerCase()\n\n if (!field) continue\n if (dir !== 'asc' && dir !== 'desc') continue\n if (allowed && !allowed.has(field)) {\n onReject?.({ kind: 'sort', field, reason: 'field-not-allowed' })\n continue\n }\n\n results.push({ field, direction: dir })\n }\n\n return results\n}\n\n/** Parse page/limit into pagination with computed offset */\nexport function parsePagination(\n params: {\n page?: string | number\n limit?: string | number\n },\n options?: Pick<ParseQueryOptions, 'maxLimit' | 'defaultLimit'>,\n): PaginationParams {\n const defaultLimit = options?.defaultLimit ?? defaults.defaultLimit\n const maxLimit = options?.maxLimit ?? defaults.maxLimit\n\n let page =\n typeof params.page === 'string'\n ? Number.parseInt(params.page, 10)\n : (params.page ?? defaults.defaultPage)\n let limit =\n typeof params.limit === 'string'\n ? Number.parseInt(params.limit, 10)\n : (params.limit ?? defaultLimit)\n\n if (Number.isNaN(page) || page < 1) page = defaults.defaultPage\n if (Number.isNaN(limit) || limit < 1) limit = defaultLimit\n if (limit > maxLimit) limit = maxLimit\n\n return { page, limit, offset: (page - 1) * limit }\n}\n\n/** Sanitize and truncate search query */\nexport function parseSearchQuery(\n q: string | undefined,\n options?: Pick<ParseQueryOptions, 'maxSearchLength' | 'onReject'>,\n): string {\n if (!q) return ''\n const maxLen = options?.maxSearchLength ?? defaults.maxSearchLength\n const trimmed = q.trim()\n if (trimmed.length > maxLen) {\n options?.onReject?.({ kind: 'search', field: 'q', reason: 'truncated' })\n return trimmed.slice(0, maxLen)\n }\n return trimmed\n}\n\n// ── Config normalizer ───────────────────────────────────────────────────\n\n/**\n * Normalize a QueryFieldConfig into the string-based form.\n * Detects column-object-based configs (DrizzleQueryParamsConfig) by checking\n * for a `columns` property and extracts `Object.keys()`.\n */\nfunction normalizeFieldConfig(config?: QueryFieldConfig): StringQueryFieldConfig | undefined {\n if (!config) return undefined\n\n // Column-object-based config (e.g., DrizzleQueryParamsConfig)\n if ('columns' in config && config.columns && typeof config.columns === 'object') {\n return {\n filterable: Object.keys(config.columns),\n sortable: config.sortable ? Object.keys(config.sortable) : undefined,\n searchable: undefined, // searchColumns are Column objects, not used for string filtering\n }\n }\n\n // Already string-based\n return config as StringQueryFieldConfig\n}\n\n// ── Combined parser ─────────────────────────────────────────────────────\n\n/**\n * Parse a raw Express query object into a structured, ORM-agnostic ParsedQuery.\n *\n * @param query - Raw query string object from `req.query` or Zod-validated object\n * @param fieldConfig - Optional field restrictions (whitelist filterable/sortable/searchable).\n * Accepts both string-based configs (`{ filterable, sortable, searchable }`) and\n * column-object configs (`{ columns, sortable, searchColumns }`).\n * @param options - Limits + an `onReject` hook for dropped/truncated input.\n *\n * @example\n * ```ts\n * // String-based config\n * const parsed = parseQuery(ctx.query, {\n * filterable: ['status', 'priority'],\n * sortable: ['createdAt', 'title'],\n * })\n *\n * // Column-object config (DrizzleQueryParamsConfig)\n * const parsed = parseQuery(ctx.query, TASK_QUERY_CONFIG)\n * ```\n *\n * Query string format:\n * - Filters: `?filter=field:operator:value` (repeatable)\n * - Sort: `?sort=field:asc|desc` (repeatable)\n * - Pagination: `?page=1&limit=20`\n * - Search: `?q=search+term`\n *\n * Filter operators: eq, neq, gt, gte, lt, lte, between, in, contains, starts, ends\n */\nexport function parseQuery<TConfig extends QueryFieldConfig | undefined = undefined>(\n query: Record<string, any>,\n fieldConfig?: TConfig,\n options?: ParseQueryOptions,\n): TypedParsedQuery<TConfig> {\n const normalized = normalizeFieldConfig(fieldConfig)\n const rejectOpts = options?.onReject ? { onReject: options.onReject } : undefined\n return {\n filters: parseFilters(query.filter, normalized?.filterable, rejectOpts),\n sort: parseSort(query.sort, normalized?.sortable, rejectOpts),\n pagination: parsePagination({ page: query.page, limit: query.limit }, options),\n search: parseSearchQuery(query.q, options),\n } as TypedParsedQuery<TConfig>\n}\n\n// ── Query URL builder (for client-side or testing) ──────────────────────\n\n/** Convert ParsedQuery back into query string parameters */\nexport function buildQueryParams(\n parsed: Partial<ParsedQuery>,\n): Record<string, string | string[] | number> {\n const params: Record<string, string | string[] | number> = {}\n\n if (parsed.filters?.length) {\n params.filter = parsed.filters.map((f) => `${f.field}:${f.operator}:${f.value}`)\n }\n\n if (parsed.sort?.length) {\n params.sort = parsed.sort.map((s) => `${s.field}:${s.direction}`)\n }\n\n if (parsed.pagination) {\n params.page = parsed.pagination.page\n params.limit = parsed.pagination.limit\n }\n\n if (parsed.search) {\n params.q = parsed.search\n }\n\n return params\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { d as RouteFlagTest } from "./route-flag-
|
|
2
|
+
import { d as RouteFlagTest } from "./route-flag-B1lQht6v.mjs";
|
|
3
3
|
import { NextFunction, Request, Response } from "express";
|
|
4
4
|
//#region src/http/middleware/rate-limit.d.ts
|
|
5
5
|
interface RateLimitStore {
|
|
@@ -72,4 +72,4 @@ interface RateLimitOptions {
|
|
|
72
72
|
declare function rateLimit(options?: RateLimitOptions): (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
73
73
|
//#endregion
|
|
74
74
|
export { RateLimitStore as n, rateLimit as r, RateLimitOptions as t };
|
|
75
|
-
//# sourceMappingURL=rate-limit-
|
|
75
|
+
//# sourceMappingURL=rate-limit-Bwed3jOV.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit-
|
|
1
|
+
{"version":3,"file":"rate-limit-Bwed3jOV.d.mts","names":[],"sources":["../src/http/middleware/rate-limit.ts"],"mappings":";;;;UAQiB;EACf,UAAU,cAAc;IAAU;IAAmB,WAAW;;EAChE,UAAU,cAAc;EACxB,MAAM,cAAc;;UAGL;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA,gBAAgB,KAAK;;EAErB;;EAEA,QAAQ;;EAER,QAAQ,KAAK;;EAEb;;;;;;;;;;;;;;;;;;;EAmBA,aAAa;;;;;;;;;;;;;;;;;;;;;;iBAsFC,UAAU,UAAS,oBAAqB,KAwB1B,SAAO,KAAO,UAAQ,MAAQ,iBAAY"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs v8.
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
function readCookies(req){let source=req;if(source.cookies)return source.cookies;let header=source.headers?.cookie;return typeof header==`string`?parseCookieHeader(header):{}}function parseCookieHeader(header){let out={};for(let pair of header.split(`;`)){let eq=pair.indexOf(`=`);if(eq===-1)continue;let key=pair.slice(0,eq).trim();if(!key)continue;let value=pair.slice(eq+1).trim();value.startsWith(`"`)&&value.endsWith(`"`)&&(value=value.slice(1,-1));try{out[key]=decodeURIComponent(value)}catch{out[key]=value}}return out}function serializeCookie(name,value,options){let parts=[`${name}=${encodeURIComponent(value)}`];if(options.maxAge!==void 0&&parts.push(`Max-Age=${Math.floor(options.maxAge/1e3)}`),options.expires&&parts.push(`Expires=${options.expires.toUTCString()}`),options.domain&&parts.push(`Domain=${options.domain}`),parts.push(`Path=${options.path??`/`}`),options.httpOnly&&parts.push(`HttpOnly`),options.secure&&parts.push(`Secure`),options.sameSite){let value=options.sameSite===!0?`Strict`:options.sameSite;parts.push(`SameSite=${value.charAt(0).toUpperCase()}${value.slice(1)}`)}return parts.join(`; `)}function setCookie(res,name,value,options={}){let target=res;if(typeof target.cookie==`function`){target.cookie(name,value,options);return}let cookie=serializeCookie(name,value,options),prior=target.getHeader?.(`Set-Cookie`),all=prior===void 0?[cookie]:[...Array.isArray(prior)?prior:[prior],cookie];target.setHeader?.(`Set-Cookie`,all)}function sendJson(res,status,body){let target=res;if(typeof target.status==`function`){let chained=target.status(status);if(typeof chained?.json==`function`){chained.json(body);return}}target.statusCode=status,target.getHeader?.(`content-type`)||target.setHeader?.(`content-type`,`application/json; charset=utf-8`),target.end?.(JSON.stringify(body))}export{setCookie as i,readCookies as n,sendJson as r,parseCookieHeader as t};
|
|
12
|
-
//# sourceMappingURL=respond-
|
|
12
|
+
//# sourceMappingURL=respond-DBRvpEI5.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"respond-
|
|
1
|
+
{"version":3,"file":"respond-DBRvpEI5.mjs","names":[],"sources":["../src/http/middleware/respond.ts"],"sourcesContent":["/**\n * Response helpers that work on every runtime.\n *\n * Connect middleware receives an Express response under Express and a raw\n * `ServerResponse` under Fastify and h3. `res.cookie()` and `res.status().json()`\n * are Express conveniences that do not exist on the raw object, so a shipped\n * middleware reaching for them works on one engine and throws on the other two:\n *\n * csrf → `res.cookie()` threw before any check ran, so EVERY request\n * became a 500 — including safe methods the middleware is\n * supposed to wave through\n * rateLimit → `res.status()` threw only once the limit was hit, and the\n * throw left the request hanging rather than answering 429\n * session → `res.cookie()` threw on every response that issued a session\n *\n * These take the Express path when it is there — so behaviour under Express is\n * byte-identical to before — and fall back to the Node primitives otherwise.\n *\n * @module @forinda/kickjs/http/middleware/respond\n */\n\n/**\n * Cookies on the request, whoever parsed them.\n *\n * `req.cookies` exists only when an upstream cookie parser populated it —\n * `cookie-parser` under Express, nothing at all under Fastify and h3, which\n * pass a raw node request. A middleware that reads only `req.cookies` therefore\n * sees no cookies on those runtimes, and none under Express either unless the\n * app happens to mount a parser.\n *\n * For `csrf` that broke the double-submit flow outright: with no cookie visible\n * it minted a fresh token on every request and compared the submitted header\n * against that new value, so a client could never complete a protected request.\n */\nexport function readCookies(req: unknown): Record<string, string> {\n const source = req as { cookies?: Record<string, string>; headers?: Record<string, unknown> }\n if (source.cookies) return source.cookies\n const header = source.headers?.cookie\n return typeof header === 'string' ? parseCookieHeader(header) : {}\n}\n\n/** `a=1; b=2` → `{ a: '1', b: '2' }`. */\nexport function parseCookieHeader(header: string): Record<string, string> {\n const out: Record<string, string> = {}\n for (const pair of header.split(';')) {\n const eq = pair.indexOf('=')\n if (eq === -1) continue\n const key = pair.slice(0, eq).trim()\n if (!key) continue\n let value = pair.slice(eq + 1).trim()\n if (value.startsWith('\"') && value.endsWith('\"')) {\n value = value.slice(1, -1)\n }\n try {\n out[key] = decodeURIComponent(value)\n } catch {\n // A malformed escape is not a reason to lose the whole cookie jar.\n out[key] = value\n }\n }\n return out\n}\n\n/** Cookie attributes, matching the subset of Express's `res.cookie` we use. */\nexport interface CookieOptions {\n maxAge?: number\n expires?: Date\n domain?: string\n path?: string\n httpOnly?: boolean\n secure?: boolean\n sameSite?: boolean | 'lax' | 'strict' | 'none'\n}\n\ninterface AnyResponse {\n cookie?: (name: string, value: string, options?: CookieOptions) => unknown\n status?: (code: number) => { json?: (body: unknown) => unknown }\n statusCode?: number\n getHeader?: (name: string) => unknown\n setHeader?: (name: string, value: unknown) => unknown\n end?: (chunk?: unknown) => unknown\n}\n\n/** `Set-Cookie` value for one cookie, per RFC 6265 §4.1. */\nfunction serializeCookie(name: string, value: string, options: CookieOptions): string {\n const parts = [`${name}=${encodeURIComponent(value)}`]\n // Express takes `maxAge` in milliseconds; `Max-Age` is seconds.\n if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge / 1000)}`)\n if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`)\n if (options.domain) parts.push(`Domain=${options.domain}`)\n parts.push(`Path=${options.path ?? '/'}`)\n if (options.httpOnly) parts.push('HttpOnly')\n if (options.secure) parts.push('Secure')\n if (options.sameSite) {\n // `true` means Strict, matching Express.\n const value = options.sameSite === true ? 'Strict' : options.sameSite\n parts.push(`SameSite=${value.charAt(0).toUpperCase()}${value.slice(1)}`)\n }\n return parts.join('; ')\n}\n\n/** Set a cookie without assuming Express. Appends, never replaces. */\nexport function setCookie(\n res: unknown,\n name: string,\n value: string,\n options: CookieOptions = {},\n): void {\n const target = res as AnyResponse\n if (typeof target.cookie === 'function') {\n target.cookie(name, value, options)\n return\n }\n const cookie = serializeCookie(name, value, options)\n const prior = target.getHeader?.('Set-Cookie')\n const all = prior === undefined ? [cookie] : [...(Array.isArray(prior) ? prior : [prior]), cookie]\n target.setHeader?.('Set-Cookie', all)\n}\n\n/** Send a JSON body with a status, without assuming Express. */\nexport function sendJson(res: unknown, status: number, body: unknown): void {\n const target = res as AnyResponse\n if (typeof target.status === 'function') {\n const chained = target.status(status)\n if (typeof chained?.json === 'function') {\n chained.json(body)\n return\n }\n }\n target.statusCode = status\n if (!target.getHeader?.('content-type')) {\n target.setHeader?.('content-type', 'application/json; charset=utf-8')\n }\n target.end?.(JSON.stringify(body))\n}\n"],"mappings":";;;;;;;;;;AAkCA,SAAgB,YAAY,IAAsC,CAChE,IAAM,OAAS,IACf,GAAI,OAAO,QAAS,OAAO,OAAO,QAClC,IAAM,OAAS,OAAO,SAAS,OAC/B,OAAO,OAAO,QAAW,SAAW,kBAAkB,MAAM,EAAI,CAAC,CACnE,CAGA,SAAgB,kBAAkB,OAAwC,CACxE,IAAM,IAA8B,CAAC,EACrC,IAAK,IAAM,QAAQ,OAAO,MAAM,GAAG,EAAG,CACpC,IAAM,GAAK,KAAK,QAAQ,GAAG,EAC3B,GAAI,KAAO,GAAI,SACf,IAAM,IAAM,KAAK,MAAM,EAAG,EAAE,CAAC,CAAC,KAAK,EACnC,GAAI,CAAC,IAAK,SACV,IAAI,MAAQ,KAAK,MAAM,GAAK,CAAC,CAAC,CAAC,KAAK,EAChC,MAAM,WAAW,GAAG,GAAK,MAAM,SAAS,GAAG,IAC7C,MAAQ,MAAM,MAAM,EAAG,EAAE,GAE3B,GAAI,CACF,IAAI,KAAO,mBAAmB,KAAK,CACrC,MAAQ,CAEN,IAAI,KAAO,KACb,CACF,CACA,OAAO,GACT,CAuBA,SAAS,gBAAgB,KAAc,MAAe,QAAgC,CACpF,IAAM,MAAQ,CAAC,GAAG,KAAK,GAAG,mBAAmB,KAAK,GAAG,EAQrD,GANI,QAAQ,SAAW,IAAA,IAAW,MAAM,KAAK,WAAW,KAAK,MAAM,QAAQ,OAAS,GAAI,GAAG,EACvF,QAAQ,SAAS,MAAM,KAAK,WAAW,QAAQ,QAAQ,YAAY,GAAG,EACtE,QAAQ,QAAQ,MAAM,KAAK,UAAU,QAAQ,QAAQ,EACzD,MAAM,KAAK,QAAQ,QAAQ,MAAQ,KAAK,EACpC,QAAQ,UAAU,MAAM,KAAK,UAAU,EACvC,QAAQ,QAAQ,MAAM,KAAK,QAAQ,EACnC,QAAQ,SAAU,CAEpB,IAAM,MAAQ,QAAQ,WAAa,GAAO,SAAW,QAAQ,SAC7D,MAAM,KAAK,YAAY,MAAM,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,MAAM,MAAM,CAAC,GAAG,CACzE,CACA,OAAO,MAAM,KAAK,IAAI,CACxB,CAGA,SAAgB,UACd,IACA,KACA,MACA,QAAyB,CAAC,EACpB,CACN,IAAM,OAAS,IACf,GAAI,OAAO,OAAO,QAAW,WAAY,CACvC,OAAO,OAAO,KAAM,MAAO,OAAO,EAClC,MACF,CACA,IAAM,OAAS,gBAAgB,KAAM,MAAO,OAAO,EAC7C,MAAQ,OAAO,YAAY,YAAY,EACvC,IAAM,QAAU,IAAA,GAAY,CAAC,MAAM,EAAI,CAAC,GAAI,MAAM,QAAQ,KAAK,EAAI,MAAQ,CAAC,KAAK,EAAI,MAAM,EACjG,OAAO,YAAY,aAAc,GAAG,CACtC,CAGA,SAAgB,SAAS,IAAc,OAAgB,KAAqB,CAC1E,IAAM,OAAS,IACf,GAAI,OAAO,OAAO,QAAW,WAAY,CACvC,IAAM,QAAU,OAAO,OAAO,MAAM,EACpC,GAAI,OAAO,SAAS,MAAS,WAAY,CACvC,QAAQ,KAAK,IAAI,EACjB,MACF,CACF,CACA,OAAO,WAAa,OACf,OAAO,YAAY,cAAc,GACpC,OAAO,YAAY,eAAgB,iCAAiC,EAEtE,OAAO,MAAM,KAAK,UAAU,IAAI,CAAC,CACnC"}
|
|
@@ -218,6 +218,18 @@ type RouteFlagTest = RouteFlagName | NegatedRouteFlagName | readonly RouteFlagNa
|
|
|
218
218
|
* means the same thing everywhere, and a list is any-of in all of them.
|
|
219
219
|
*/
|
|
220
220
|
declare function matchesFlagTest(test: RouteFlagTest, flags: RouteFlags | undefined, route?: RouteFlagContext['route']): boolean;
|
|
221
|
+
/**
|
|
222
|
+
* Reject a mixed-polarity list at construction time.
|
|
223
|
+
*
|
|
224
|
+
* A list like `['auth.public', '!metered']` is a static mistake, so it fails
|
|
225
|
+
* where it is written — when the contributor or guard is built — rather than
|
|
226
|
+
* on the first request that reaches it. Same reasoning as the contributor
|
|
227
|
+
* pipeline failing a dependency cycle at boot.
|
|
228
|
+
*
|
|
229
|
+
* Call from every factory that accepts a {@link RouteFlagTest}; the check in
|
|
230
|
+
* {@link matchesFlagTest} stays as a backstop for direct callers.
|
|
231
|
+
*/
|
|
232
|
+
declare function assertFlagTest(test: RouteFlagTest, site: string): void;
|
|
221
233
|
/**
|
|
222
234
|
* Resolve the flags in force for one handler, straight from the controller
|
|
223
235
|
* class — the same method-over-class result `buildRouteTable` computes.
|
|
@@ -254,5 +266,5 @@ type RouteFlagRecord = { [K in RouteFlagName]?: RouteFlagValue<K, true>; };
|
|
|
254
266
|
*/
|
|
255
267
|
type RouteFlagDeclarations = readonly RouteFlagName[] | RouteFlagRecord;
|
|
256
268
|
//#endregion
|
|
257
|
-
export {
|
|
258
|
-
//# sourceMappingURL=route-flag-
|
|
269
|
+
export { getRouteFlags as _, RouteFlagContext as a, RouteFlagName as c, RouteFlagTest as d, RouteFlagValue as f, defineRouteFlag as g, assertFlagTest as h, RouteFlag as i, RouteFlagPredicate as l, ValuedRouteFlag as m, KickRouteFlags as n, RouteFlagDeclaration as o, RouteFlags as p, NegatedRouteFlagName as r, RouteFlagDeclarations as s, BareRouteFlag as t, RouteFlagRecord as u, matchesFlagTest as v, resolveRouteFlags as y };
|
|
270
|
+
//# sourceMappingURL=route-flag-B1lQht6v.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-flag-
|
|
1
|
+
{"version":3,"file":"route-flag-B1lQht6v.d.mts","names":[],"sources":["../src/core/route-flag.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAiDiB;;;;;;;KAQL,uBAAuB,iDAEzB;;;;;KAME,eAAe,kBAAkB,sBAAsB,gBAAgB,iBAC/E,eAAe,KACf;;UAmBa;WACN;;WAEA;;;;;;;;;;;;;;UAeM;;;;;;;GAOd,QAAQ;;;;;GAKR,gBAAgB,8BAA8B,aAAa;;;UAI7C,gBAAgB;GAC9B,OAAO,KAAK,gBAAgB;;;;;;;;;WASpB,KAAK;WACL;;;;;;;;;;;KAYC,UAAU,yBAAyB,IAC3C,gBAAgB,gBAAgB,KAChC,gBAAgB;;;;;;;;;;;;;;;;;;;;;;iBA2CJ,sBAAsB,UAAU,eAC9C,MAAM,IACL,UAAU,eAAe;iBACZ,gBAAgB,GAAG,MAAM,gBAAgB,UAAU;;;;;;;;;;;iBAkEnD,kBACd,4BAA4B,wBAC5B,6BAA6B,wBAC7B,6BAA4B,yBAC3B;;;;;;;;;UAgCc,mBAAmB;EAClC,IAAI,MAAM;EACV,IAAI,UAAU,eAAe,MAAM,IAAI,eAAe;;;;;;;;UASvC;WACN,OAAO;WACP;aACE;aACA;aACA;aACA;;;;;;;;;;;;;;;;KAiBD,sBAAsB,KAAK;;;;;;;;;;;;;;;;;;;;;KAsB3B,2BAA2B;;;;;;;;;;;;;;;KAgB3B,gBACR,gBACA,gCACS,2BACA,yBACT;;;;;;;;iBASY,gBACd,MAAM,eACN,OAAO,wBACP,QAAQ;;;;;;;;;;;;iBA2CM,eAAe,MAAM,eAAe;;;;;;;;;;;;;;;;;;;iBAiDpC,cAAc,yBAAyB,sBAAsB;;;;;;;;KAiCjE,qBAAqB,KAAK,iBAAiB,eAAe;;;;;;;;KAS1D,iCAAiC,kBAAkB"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs v8.
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
import{METADATA}from"./core/interfaces.mjs";import{a as getMethodMeta,c as pushClassMeta,l as pushMethodMeta,t as getClassMeta}from"./metadata-
|
|
12
|
-
//# sourceMappingURL=route-flag-
|
|
11
|
+
import{METADATA}from"./core/interfaces.mjs";import{a as getMethodMeta,c as pushClassMeta,l as pushMethodMeta,t as getClassMeta}from"./metadata-BClL-Eb0.mjs";const FLAG_UNSET=Symbol(`kick.flagUnset`);function isDecoratorApplication(args){let[target,propertyKey]=args;return typeof propertyKey==`string`||typeof propertyKey==`symbol`||args.length===1&&typeof target==`function`}function defineRouteFlag(name){if(name.startsWith(`!`))throw Error(`defineRouteFlag('${name}'): a flag name cannot start with '!' — that prefix is reserved for negated tests ('!${name.slice(1)}' means "does not carry ${name.slice(1)}"). Rename the flag.`);function apply(value,target,propertyKey){let declaration={name,value};if(propertyKey===void 0){pushClassMeta(METADATA.CLASS_FLAGS,target,declaration);return}pushMethodMeta(METADATA.METHOD_FLAGS,target.constructor,String(propertyKey),declaration)}let flag=(...args)=>{if(isDecoratorApplication(args)){apply(!0,args[0],args[1]);return}let value=args[0];return(target,propertyKey)=>{apply(value,target,propertyKey)}};return Object.defineProperty(flag,"flagName",{value:name,enumerable:!0}),Object.defineProperty(flag,"off",{value:(target,propertyKey)=>{apply(FLAG_UNSET,target,propertyKey)},enumerable:!0}),flag}function resolveRouteFlags(classDeclarations,methodDeclarations,mountDeclarations=[]){let resolved=new Map;for(let{name,value}of[...mountDeclarations,...classDeclarations,...methodDeclarations]){if(value===FLAG_UNSET){resolved.delete(name);continue}resolved.set(name,value)}return resolved}function matchesFlagTest(test,flags,route){let resolved=flags??EMPTY_FLAGS;if(typeof test==`function`)return test({flags:resolved,route});if(typeof test==`string`)return isNegated(test)?!resolved.has(stripNegation(test)):resolved.has(test);if(test.length===0)return!1;let negated=isNegated(test[0]);if(test.some(name=>isNegated(name)!==negated))throw Error(`Route flag test mixes polarities: [${test.map(n=>`'${n}'`).join(`, `)}]. A list is either all names ("carries any of these") or all negated ("carries none of these") — for anything else, pass a predicate.`);return negated?test.every(name=>!resolved.has(stripNegation(name))):test.some(name=>resolved.has(name))}function assertFlagTest(test,site){if(typeof test==`function`||typeof test==`string`||test.length===0)return;let negated=isNegated(test[0]);if(test.some(name=>isNegated(name)!==negated))throw Error(`${site}: route flag test mixes polarities: [${test.map(n=>`'${n}'`).join(`, `)}]. A list is either all names ("carries any of these") or all negated ("carries none of these") — for anything else, pass a predicate.`)}const isNegated=name=>name.startsWith(`!`),stripNegation=name=>name.slice(1),EMPTY_FLAGS=new Map,ROUTE_SLOT=Symbol.for(`kick.route`);function getRouteFlags(controllerClass,handlerName){return resolveRouteFlags(getClassFlagDeclarations(controllerClass),getMethodFlagDeclarations(controllerClass,handlerName),getMountFlagDeclarations(controllerClass))}function getClassFlagDeclarations(controllerClass){return getClassMeta(METADATA.CLASS_FLAGS,controllerClass,[])}function getMethodFlagDeclarations(controllerClass,handlerName){return getMethodMeta(METADATA.METHOD_FLAGS,controllerClass,handlerName,[])}function toFlagDeclarations(flags,site){return flags?(Array.isArray(flags)?flags.map(name=>[name,!0]):Object.entries(flags)).map(([name,value])=>{if(name.startsWith(`!`))throw Error(`${site}: a flag name cannot start with '!' — that prefix is reserved for negated tests, and a declaration has no negative form. Remove the flag instead of declaring '${name}'.`);return{name,value}}):[]}const MOUNT_FLAGS=new WeakMap;function registerMountFlags(controllerClass,declarations){if(declarations.length===0){MOUNT_FLAGS.delete(controllerClass);return}MOUNT_FLAGS.set(controllerClass,[...declarations])}function getMountFlagDeclarations(controllerClass){return MOUNT_FLAGS.get(controllerClass)??[]}export{getMethodFlagDeclarations as a,registerMountFlags as c,getClassFlagDeclarations as i,resolveRouteFlags as l,assertFlagTest as n,getRouteFlags as o,defineRouteFlag as r,matchesFlagTest as s,ROUTE_SLOT as t,toFlagDeclarations as u};
|
|
12
|
+
//# sourceMappingURL=route-flag-BCBTtP_4.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-flag-BCBTtP_4.mjs","names":[],"sources":["../src/core/route-flag.ts"],"sourcesContent":["/**\n * Route flags — a named, inheritable fact about a route.\n *\n * A flag carries no behaviour. It records something about the route that any\n * consumer may read: `auth.public`, `csrf.exempt`, `rate.limit`. Auth reads it\n * to skip a contributor, a guard reads it to return early, Swagger reads it to\n * mark a security scheme — none of them needs to know about the others.\n *\n * The rule that makes presence checks safe: a flag resolves to **absent**, or\n * **present with a value**. `@Public.off` on a method removes what the class\n * set, rather than storing something falsy — so `flags.has(name)` is always the\n * right question for a boolean flag. Without that, `has('auth.public')` could\n * answer `true` for the route that just opted back IN, which is an\n * authorization bypass rather than a papercut.\n */\nimport { METADATA } from './interfaces'\nimport { getClassMeta, getMethodMeta, pushClassMeta, pushMethodMeta } from './metadata'\n\n/**\n * Augmentable route-flag registry — the flag counterpart of `ContextMeta`.\n *\n * Declare a project's flags here and every use of them narrows: a misspelt\n * name is a compile error rather than a flag that silently never matches, and\n * `flags.get('rate.limit')` comes back typed instead of `unknown`.\n *\n * ```ts\n * declare module '@forinda/kickjs' {\n * interface KickRouteFlags {\n * 'auth.public': true\n * 'rate.limit': { rpm: number }\n * }\n * }\n * ```\n *\n * The interface is empty by default, and everything below falls back to plain\n * `string` while it stays that way — a project that has not augmented anything\n * keeps compiling exactly as before. The narrowing switches on with the first\n * declaration.\n *\n * The framework itself declares nothing here: it ships the mechanism and names\n * no flags, so `auth.public` above is a name you chose.\n *\n * Two constraints on what you can declare:\n * - A name cannot start with `!` — that prefix means \"does not carry this flag\"\n * in a {@link RouteFlagTest}, and `defineRouteFlag` rejects it.\n * - Removal is `@Flag.off`, not `@Flag(false)` — so `false` stays usable as a\n * real value for a flag declared `boolean`.\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-interface\nexport interface KickRouteFlags {}\n\n/**\n * A flag name. Narrows to the declared keys once {@link KickRouteFlags} is\n * augmented, and stays `string` until then — the same\n * `[Known] extends [never]` fallback `ContextMetaKey` uses, for the same\n * reason: first-day code must keep compiling.\n */\nexport type RouteFlagName = [keyof KickRouteFlags] extends [never]\n ? string\n : keyof KickRouteFlags & string\n\n/**\n * The value type declared for a flag, defaulting to `true` for a bare flag and\n * `unknown` for a name the registry does not know.\n */\nexport type RouteFlagValue<K extends string, Fallback = unknown> = K extends keyof KickRouteFlags\n ? KickRouteFlags[K]\n : Fallback\n\n/**\n * Marks a declaration as \"remove this flag\" during resolution.\n *\n * A dedicated sentinel rather than `false`: `false` is a perfectly good flag\n * VALUE (a registry entry typed `boolean`), and overloading it meant\n * `@Enabled(false)` deleted the flag while `flags.get('enabled')` could never\n * return `false`. With the sentinel, `false` stores like any other value and\n * removal is spelled `@Enabled.off`.\n *\n * Module-private and NOT `Symbol.for`: a registry-global symbol is reachable\n * by application code, so a flag declared with a `symbol` value could be handed\n * this exact sentinel and be deleted instead of stored. It is created and\n * consumed in this module, so it never needs to cross a realm.\n */\nconst FLAG_UNSET: unique symbol = Symbol('kick.flagUnset') as never\n\n/** One `@Flag` application, before precedence resolution. */\nexport interface RouteFlagDeclaration {\n readonly name: string\n /** `false` means \"remove whatever a lower-precedence site declared\". */\n readonly value: unknown\n}\n\n/**\n * A flag decorator. Usable bare (`@Public`) or called with a value\n * (`@Public(false)`, `@RateLimit({ rpm: 10 })`), on a class or a method.\n */\n/**\n * The bare application forms — `@Public` with no call.\n *\n * Split out so they can be withheld from a flag whose declared value is not\n * assignable from `true`: applying `@Limit` bare would store `true` while\n * `flags.get('rate.limit')` promises `{ rpm: number }`, and nothing would catch\n * the mismatch until a consumer read the wrong shape at runtime.\n */\nexport interface BareRouteFlag {\n /**\n * Bare on a class. Typed against `Function` rather than `object` so a flag\n * VALUE that happens to be an object (`@Limit({ rpm: 10 })`) cannot match\n * this signature: overload resolution would otherwise pick it, return\n * `void`, and fail with \"Type 'void' has no call signatures\".\n */\n (target: Function): void\n /**\n * Bare on a method. `propertyKey` is required for the same reason — it makes\n * a one-argument call unambiguous.\n */\n (target: object, propertyKey: string | symbol, descriptor?: PropertyDescriptor): void\n}\n\n/** The called form — `@Limit({ rpm: 10 })` — plus removal via `.off`. */\nexport interface ValuedRouteFlag<V> {\n (value: V): (target: object, propertyKey?: string | symbol) => void\n /**\n * Remove the flag on this route — `@Public.off` on a method drops what the\n * controller set.\n *\n * Applied bare, like the flag itself. Spelled as its own member rather than\n * `@Public(false)` so that `false` stays available as a real value: a flag\n * declared `boolean` can store it, and `flags.get()` can return it.\n */\n readonly off: BareRouteFlag\n readonly flagName: string\n}\n\n/**\n * A flag decorator.\n *\n * Bare application is available only when `true` is a valid value for the flag\n * — which is every flag until {@link KickRouteFlags} declares a value type for\n * it. Declare `'rate.limit': { rpm: number }` and `@RateLimit` on its own stops\n * compiling, because the value it would store is not the value the registry\n * promises readers.\n */\nexport type RouteFlag<V = true> = true extends V\n ? BareRouteFlag & ValuedRouteFlag<V>\n : ValuedRouteFlag<V>\n\n/**\n * True when the arguments look like a decorator application rather than a\n * value. The two forms are `@Public` (TS calls it for us) and `@Public(v)`\n * (we return the decorator).\n *\n * Discrimination has to be exact, because a value can be an object:\n * `@RateLimit({ rpm: 10 })` is a single object argument, and treating it as a\n * class-decorator target silently drops the flag.\n *\n * - method decorator → `(prototype, 'name', descriptor?)`, so arg 1 is a\n * string or symbol\n * - class decorator → `(constructor)`, a single *function* argument\n * - anything else → a value\n */\nfunction isDecoratorApplication(args: unknown[]): boolean {\n const [target, propertyKey] = args\n if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') return true\n return args.length === 1 && typeof target === 'function'\n}\n\n/**\n * Define a route flag.\n *\n * ```ts\n * export const Public = defineRouteFlag('auth.public')\n * export const RateLimit = defineRouteFlag<{ rpm: number }>('rate.limit')\n * ```\n *\n * Once {@link KickRouteFlags} declares the name, the value type comes from the\n * registry and the explicit generic is redundant:\n *\n * ```ts\n * declare module '@forinda/kickjs' {\n * interface KickRouteFlags {\n * 'rate.limit': { rpm: number }\n * }\n * }\n *\n * const RateLimit = defineRouteFlag('rate.limit') // RouteFlag<{ rpm: number }>\n * ```\n */\nexport function defineRouteFlag<const N extends RouteFlagName>(\n name: N,\n): RouteFlag<RouteFlagValue<N, true>>\nexport function defineRouteFlag<V>(name: RouteFlagName): RouteFlag<V>\nexport function defineRouteFlag(name: string): RouteFlag<never> {\n // `!` is reserved: a test string starting with it means \"does NOT carry this\n // flag\", so a flag literally named '!x' would be indistinguishable from the\n // negation of 'x' — `exemptWhen: '!x'` would silently stop meaning what the\n // author wrote. Rejecting at definition is the only point where the two can\n // still be told apart.\n if (name.startsWith('!')) {\n throw new Error(\n `defineRouteFlag('${name}'): a flag name cannot start with '!' — that prefix is reserved ` +\n `for negated tests ('!${name.slice(1)}' means \"does not carry ${name.slice(1)}\"). ` +\n `Rename the flag.`,\n )\n }\n\n function apply(value: unknown, target: object, propertyKey?: string | symbol): void {\n const declaration: RouteFlagDeclaration = { name, value }\n if (propertyKey === undefined) {\n pushClassMeta<RouteFlagDeclaration>(METADATA.CLASS_FLAGS, target, declaration)\n return\n }\n // Method decorators receive the prototype; metadata helpers key off the\n // constructor so class + method lookups land in the same place.\n pushMethodMeta<RouteFlagDeclaration>(\n METADATA.METHOD_FLAGS,\n (target as { constructor: object }).constructor,\n String(propertyKey),\n declaration,\n )\n }\n\n const flag = (...args: unknown[]): unknown => {\n if (isDecoratorApplication(args)) {\n // Bare form: @Public\n apply(true, args[0] as object, args[1] as string | symbol | undefined)\n return undefined\n }\n // Called form: @Public(false) / @RateLimit({ rpm: 10 })\n const value = args[0]\n return (target: object, propertyKey?: string | symbol): void => {\n apply(value, target, propertyKey)\n }\n }\n\n Object.defineProperty(flag, 'flagName', { value: name, enumerable: true })\n // `.off` is the removal decorator: bare-applicable, and the only way to drop\n // an inherited flag now that `false` is an ordinary value.\n Object.defineProperty(flag, 'off', {\n value: (target: object, propertyKey?: string | symbol): void => {\n apply(FLAG_UNSET, target, propertyKey)\n },\n enumerable: true,\n })\n return flag as unknown as RouteFlag<never>\n}\n\n/**\n * Resolve the flags in force for one route.\n *\n * Precedence is method > class > mount, matching the contributor pipeline's top\n * three levels. Within a level the last declaration wins (decorators apply\n * bottom-up, so the one nearest the handler is applied last).\n *\n * A resolved `false` deletes the key rather than storing it — see the module\n * docblock.\n */\nexport function resolveRouteFlags(\n classDeclarations: readonly RouteFlagDeclaration[],\n methodDeclarations: readonly RouteFlagDeclaration[],\n mountDeclarations: readonly RouteFlagDeclaration[] = [],\n): RouteFlags {\n const resolved = new Map<string, unknown>()\n\n // Lowest precedence first, so higher levels overwrite. Mount comes first\n // despite being the last parameter — it was added after the other two, and\n // moving it to the front would break every existing caller for no gain.\n for (const { name, value } of [\n ...mountDeclarations,\n ...classDeclarations,\n ...methodDeclarations,\n ]) {\n if (value === FLAG_UNSET) {\n resolved.delete(name)\n continue\n }\n resolved.set(name, value)\n }\n\n // The map is built from declarations, so its value types are whatever the\n // registry says they are — this cast is the one place that knowledge crosses\n // from runtime data into the type system.\n return resolved as RouteFlags\n}\n\n/**\n * The resolved flags for a route.\n *\n * A `ReadonlyMap` whose `has` is narrowed to known names and whose `get`\n * returns the declared value type — so `flags.get('rate.limit')` is\n * `{ rpm: number } | undefined` rather than `unknown`, once the registry\n * declares it. Until then it behaves like `ReadonlyMap<string, unknown>`.\n */\nexport interface RouteFlags extends ReadonlyMap<string, unknown> {\n has(name: RouteFlagName): boolean\n get<K extends RouteFlagName>(name: K): RouteFlagValue<K> | undefined\n}\n\n/**\n * Argument handed to a flag predicate.\n *\n * `route` is undefined only where flags are consulted outside a matched route,\n * which today means a non-HTTP transport running the same contributor pipeline.\n */\nexport interface RouteFlagContext {\n readonly flags: RouteFlags\n readonly route?: {\n readonly method: string\n readonly path: string\n readonly controller?: unknown\n readonly handlerName?: string\n }\n}\n\n/**\n * A predicate form of a flag test, for conditions a single name cannot express:\n * two flags together, a flag's *value*, a path or controller check, an env\n * switch.\n *\n * ```ts\n * skipWhen: ({ flags }) => flags.has('auth.public') || flags.has('internal'),\n * skipWhen: ({ flags }) => (flags.get('rate.limit') as { rpm: number })?.rpm === 0,\n * skipWhen: ({ route }) => route?.path.startsWith('/webhooks') ?? false,\n * ```\n *\n * Keep predicates pure and cheap — they run per request, per contributor.\n */\nexport type RouteFlagPredicate = (ctx: RouteFlagContext) => boolean\n\n/**\n * How a consumer names the routes it cares about.\n *\n * A bare name for the common case, a list when several flags mean the same\n * thing (matched **any-of**), a predicate for everything else — all-of, a\n * flag's value, the route's path.\n *\n * ```ts\n * skipWhen: 'auth.public'\n * skipWhen: ['auth.public', 'health.probe'] // either one\n * skipWhen: ({ flags }) => flags.has('a') && flags.has('b') // both\n * ```\n */\n/**\n * A flag name with `!` in front — matches routes that do **not** carry it.\n *\n * Narrows with the registry like the positive form: once `KickRouteFlags`\n * declares `'auth.public'`, this is `'!auth.public' | …`, so a misspelt\n * negation is a compile error too.\n */\nexport type NegatedRouteFlagName = `!${RouteFlagName}`\n\n/**\n * How a consumer names the routes it cares about.\n *\n * - `'auth.public'` — carries the flag\n * - `'!auth.public'` — does **not** carry it\n * - `['a', 'b']` — carries **any** of them\n * - `['!a', '!b']` — carries **none** of them\n * - a predicate — anything else: all-of, a flag's value, the route's path\n *\n * Lists are single-polarity by construction: `['a', '!b']` matches neither\n * array member of this union, so it is a compile error. Mixed polarity has no\n * reading everyone agrees on — under any-of it means \"a present OR b absent\",\n * which most readers parse as \"and\" — and a predicate says it unambiguously.\n */\nexport type RouteFlagTest =\n | RouteFlagName\n | NegatedRouteFlagName\n | readonly RouteFlagName[]\n | readonly NegatedRouteFlagName[]\n | RouteFlagPredicate\n\n/**\n * Evaluate a {@link RouteFlagTest} against a route's resolved flags.\n *\n * Shared by every consumer that can be exempted per route — the contributor\n * runner, `csrfGuard`, `rateLimitGuard` — so \"which routes does this apply to\"\n * means the same thing everywhere, and a list is any-of in all of them.\n */\nexport function matchesFlagTest(\n test: RouteFlagTest,\n flags: RouteFlags | undefined,\n route?: RouteFlagContext['route'],\n): boolean {\n const resolved = flags ?? EMPTY_FLAGS\n if (typeof test === 'function') return test({ flags: resolved, route })\n\n if (typeof test === 'string') {\n return isNegated(test)\n ? !resolved.has(stripNegation(test))\n : resolved.has(test as RouteFlagName)\n }\n\n if (test.length === 0) return false\n\n // Single polarity per list — the type forbids mixing, and this guards the\n // untyped callers (plain JS, a value read from config) that the type cannot.\n const negated = isNegated(test[0])\n if (test.some((name) => isNegated(name) !== negated)) {\n throw new Error(\n `Route flag test mixes polarities: [${test.map((n) => `'${n}'`).join(', ')}]. ` +\n `A list is either all names (\"carries any of these\") or all negated ` +\n `(\"carries none of these\") — for anything else, pass a predicate.`,\n )\n }\n\n // Positive list is any-of; negated list is its complement, none-of. The two\n // read as opposites, which is what makes flipping every entry do what a\n // reader expects.\n return negated\n ? test.every((name) => !resolved.has(stripNegation(name)))\n : test.some((name) => resolved.has(name as RouteFlagName))\n}\n\n/**\n * Reject a mixed-polarity list at construction time.\n *\n * A list like `['auth.public', '!metered']` is a static mistake, so it fails\n * where it is written — when the contributor or guard is built — rather than\n * on the first request that reaches it. Same reasoning as the contributor\n * pipeline failing a dependency cycle at boot.\n *\n * Call from every factory that accepts a {@link RouteFlagTest}; the check in\n * {@link matchesFlagTest} stays as a backstop for direct callers.\n */\nexport function assertFlagTest(test: RouteFlagTest, site: string): void {\n if (typeof test === 'function' || typeof test === 'string' || test.length === 0) return\n const negated = isNegated(test[0])\n if (test.some((name) => isNegated(name) !== negated)) {\n throw new Error(\n `${site}: route flag test mixes polarities: [${test.map((n) => `'${n}'`).join(', ')}]. ` +\n `A list is either all names (\"carries any of these\") or all negated ` +\n `(\"carries none of these\") — for anything else, pass a predicate.`,\n )\n }\n}\n\nconst isNegated = (name: string): boolean => name.startsWith('!')\nconst stripNegation = (name: string): RouteFlagName => name.slice(1) as RouteFlagName\n\nconst EMPTY_FLAGS: RouteFlags = new Map()\n\n/**\n * Where the matched route is stashed for `ctx.route` to read.\n *\n * It lives on the **request object**, not on the RequestContext: the Express\n * runtime constructs a fresh `RequestContext` per middleware, so anything\n * written to one ctx is invisible to the next and to the handler. Fastify and\n * h3 build a single ctx per request and would not have shown the difference —\n * the cross-runtime test is what surfaced it.\n *\n * `Symbol.for` rather than a private symbol so a duplicated module instance\n * (pnpm hoisting, a bundled copy) still reads the same slot.\n */\nexport const ROUTE_SLOT: unique symbol = Symbol.for('kick.route') as never\n\n/**\n * Resolve the flags in force for one handler, straight from the controller\n * class — the same method-over-class result `buildRouteTable` computes.\n *\n * For consumers that see a controller and a method name rather than a live\n * request: an adapter's `onRouteMount`, an OpenAPI generator, a DevTools route\n * listing. Inside a request, read `ctx.route.flags` instead — it is already\n * resolved.\n *\n * ```ts\n * // Mark flagged routes public in the OpenAPI spec:\n * SwaggerAdapter({\n * bearerAuth: true,\n * securityResolver: ({ controllerClass, handlerName }) =>\n * getRouteFlags(controllerClass, handlerName).has('auth.public') ? null : undefined,\n * })\n * ```\n */\nexport function getRouteFlags(controllerClass: object, handlerName: string): RouteFlags {\n return resolveRouteFlags(\n getClassFlagDeclarations(controllerClass),\n getMethodFlagDeclarations(controllerClass, handlerName),\n getMountFlagDeclarations(controllerClass),\n )\n}\n\n/** Read the class-level declarations off a controller. */\nexport function getClassFlagDeclarations(controllerClass: object): RouteFlagDeclaration[] {\n return getClassMeta<RouteFlagDeclaration[]>(METADATA.CLASS_FLAGS, controllerClass, [])\n}\n\n/** Read the method-level declarations off a controller method. */\nexport function getMethodFlagDeclarations(\n controllerClass: object,\n handlerName: string,\n): RouteFlagDeclaration[] {\n return getMethodMeta<RouteFlagDeclaration[]>(\n METADATA.METHOD_FLAGS,\n controllerClass,\n handlerName,\n [],\n )\n}\n\n/**\n * Flags declared as data rather than as a decorator, keyed by name.\n *\n * ```ts\n * { 'auth.public': true, 'rate.limit': { rpm: 10 } }\n * ```\n */\nexport type RouteFlagRecord = { [K in RouteFlagName]?: RouteFlagValue<K, true> }\n\n/**\n * What a non-decorator registration site accepts.\n *\n * A list is the common case — bare flags, each stored as `true`:\n * `flags: ['auth.public']`. The record form exists for flags that carry a\n * value. Both narrow against {@link KickRouteFlags} once it is augmented.\n */\nexport type RouteFlagDeclarations = readonly RouteFlagName[] | RouteFlagRecord\n\n/**\n * Normalise either input form into declarations.\n *\n * @param site Where the flags came from, for the error message — a name is\n * validated here rather than at read time so a typo'd `!` fails at boot.\n */\nexport function toFlagDeclarations(\n flags: RouteFlagDeclarations | undefined,\n site: string,\n): RouteFlagDeclaration[] {\n if (!flags) return []\n const entries: [string, unknown][] = Array.isArray(flags)\n ? flags.map((name) => [name as string, true])\n : Object.entries(flags as Record<string, unknown>)\n\n return entries.map(([name, value]) => {\n // Same reservation as `defineRouteFlag`: `!name` is a negated *test*, and a\n // flag named that way could never be tested for. Declaring one is always a\n // mistake — usually someone reaching for \"not public\" on the wrong side.\n if (name.startsWith('!')) {\n throw new Error(\n `${site}: a flag name cannot start with '!' — that prefix is reserved for negated ` +\n `tests, and a declaration has no negative form. Remove the flag instead of ` +\n `declaring '${name}'.`,\n )\n }\n return { name, value }\n })\n}\n\n/**\n * Mount-level declarations, keyed by controller class.\n *\n * A module declares flags for every route it mounts (`ModuleRoutes.flags`), so\n * unlike class and method declarations they are not written by a decorator and\n * have nowhere on the class to live. They are recorded here at mount time so\n * that {@link getRouteFlags} — which sees only a controller and a method name —\n * reports the same flags the runtime resolved. Without it an OpenAPI spec or a\n * DevTools route listing would disagree with `ctx.route.flags`.\n *\n * A `WeakMap`, so a controller from a torn-down app (a test, an HMR reload) is\n * collectable. Mounting the same controller twice replaces rather than appends:\n * a reload re-declares the same flags, and appending would grow without bound.\n */\nconst MOUNT_FLAGS = new WeakMap<object, RouteFlagDeclaration[]>()\n\n/**\n * Record the flags a mount declares for `controllerClass`.\n *\n * @internal Called by the route-table builder; not part of the app-facing API.\n */\nexport function registerMountFlags(\n controllerClass: object,\n declarations: readonly RouteFlagDeclaration[],\n): void {\n if (declarations.length === 0) {\n MOUNT_FLAGS.delete(controllerClass)\n return\n }\n MOUNT_FLAGS.set(controllerClass, [...declarations])\n}\n\n/** Read the mount-level declarations recorded for a controller. */\nexport function getMountFlagDeclarations(controllerClass: object): RouteFlagDeclaration[] {\n return MOUNT_FLAGS.get(controllerClass) ?? []\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs v8.
|
|
2
|
+
* @forinda/kickjs v8.7.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
const EMPTY=new Map;function compile(path){let source=path.replace(/[.+?^${}()|[\]\\]/g,`\\$&`).replace(/\/:[^/]+/g,`/([^/]+)`).replace(/\*/g,`.*`);return RegExp(`^${source}/?$`)}var RoutePolicyTable=class{byMethod=new Map;add(method,path,flags){if(!flags||flags.size===0)return;let verb=method.toUpperCase(),bucket=this.byMethod.get(verb)??[];bucket.push({pattern:compile(path),flags}),this.byMethod.set(verb,bucket)}lookup(method,pathname){let bucket=this.byMethod.get(method.toUpperCase());if(!bucket)return EMPTY;for(let entry of bucket)if(entry.pattern.test(pathname))return entry.flags;return EMPTY}clear(){this.byMethod.clear()}get size(){let total=0;for(let bucket of this.byMethod.values())total+=bucket.length;return total}};const ROUTE_POLICY_SLOT=Symbol.for(`kick.routePolicy`);function bindRoutePolicy(handler,receive){return Object.defineProperty(handler,ROUTE_POLICY_SLOT,{value:receive,enumerable:!1}),handler}function offerRoutePolicy(handler,table){let receive=handler?.[ROUTE_POLICY_SLOT];typeof receive==`function`&&receive(table)}export{bindRoutePolicy as n,offerRoutePolicy as r,RoutePolicyTable as t};
|
|
12
|
-
//# sourceMappingURL=route-policy-
|
|
12
|
+
//# sourceMappingURL=route-policy-FeJHstvF.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-policy-FeJHstvF.mjs","names":[],"sources":["../src/core/route-policy.ts"],"sourcesContent":["/**\n * The route policy table — route flags for pre-match middleware.\n *\n * Middleware mounted app-wide (the connect-style `rateLimit()`, `csrf()`) runs\n * *before* a route is matched, so it cannot read `ctx.route.flags`: there is no\n * route yet. That is also the reason to keep it there — an abuse control has to\n * see traffic that matches nothing, which a route-scoped guard never does.\n *\n * So the flags come to it instead. Every mounted route registers its method,\n * full path and resolved flags here at boot; a global middleware asks the table\n * what flags *would* apply to an incoming method + pathname.\n *\n * The table is per-Application, handed to middleware that opts in via\n * {@link bindRoutePolicy}. Nothing global: two apps in one process (a test file,\n * an embedded admin app) each get their own.\n */\n\nimport type { RouteFlags } from './route-flag'\n\n/** Flags for one mounted route, keyed for lookup by an incoming request. */\ninterface PolicyEntry {\n /** Compiled from the mounted path — `/users/:id` → `^/users/([^/]+)$`. */\n readonly pattern: RegExp\n readonly flags: RouteFlags\n}\n\nconst EMPTY: RouteFlags = new Map() as RouteFlags\n\n/**\n * Turn a mounted path into a matcher.\n *\n * Deliberately small: `:param` matches one segment, `*` matches the rest, and\n * everything else is literal. This is a *lookup* over paths the engine already\n * chose to mount, not a second router — if it ever disagrees with the engine,\n * the answer is fewer features here, not more.\n */\nfunction compile(path: string): RegExp {\n const source = path\n .replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&')\n .replace(/\\/:[^/]+/g, '/([^/]+)')\n .replace(/\\*/g, '.*')\n return new RegExp(`^${source}/?$`)\n}\n\nexport class RoutePolicyTable {\n /** Bucketed by method: an incoming request only scans its own verb. */\n private readonly byMethod = new Map<string, PolicyEntry[]>()\n\n add(method: string, path: string, flags: RouteFlags | undefined): void {\n if (!flags || flags.size === 0) return // nothing to say about this route\n const verb = method.toUpperCase()\n const bucket = this.byMethod.get(verb) ?? []\n bucket.push({ pattern: compile(path), flags })\n this.byMethod.set(verb, bucket)\n }\n\n /**\n * Flags that apply to `method pathname`, or an empty map when the path\n * matches no flagged route — including when it matches no route at all,\n * which is exactly the traffic this exists to keep visible.\n */\n lookup(method: string, pathname: string): RouteFlags {\n const bucket = this.byMethod.get(method.toUpperCase())\n if (!bucket) return EMPTY\n for (const entry of bucket) {\n if (entry.pattern.test(pathname)) return entry.flags\n }\n return EMPTY\n }\n\n /** Drop everything — the Application rebuilds on every HMR reload. */\n clear(): void {\n this.byMethod.clear()\n }\n\n get size(): number {\n let total = 0\n for (const bucket of this.byMethod.values()) total += bucket.length\n return total\n }\n}\n\n/**\n * Slot a middleware exposes to say \"hand me the policy table once routes are\n * mounted\". The Application calls it during setup; middleware that never\n * declares it is untouched.\n */\nexport const ROUTE_POLICY_SLOT: unique symbol = Symbol.for('kick.routePolicy') as never\n\n/** A middleware that accepts the policy table. */\nexport interface RoutePolicyAware {\n [ROUTE_POLICY_SLOT]?: (table: RoutePolicyTable) => void\n}\n\n/**\n * Declare that `handler` wants the policy table.\n *\n * Explicit hand-off rather than a module-level singleton, so the table a\n * middleware reads is always the one belonging to the app that mounted it.\n */\nexport function bindRoutePolicy<T extends object>(\n handler: T,\n receive: (table: RoutePolicyTable) => void,\n): T {\n Object.defineProperty(handler, ROUTE_POLICY_SLOT, { value: receive, enumerable: false })\n return handler\n}\n\n/** Hand the table to a middleware if it declared the slot. */\nexport function offerRoutePolicy(handler: unknown, table: RoutePolicyTable): void {\n const receive = (handler as RoutePolicyAware | null)?.[ROUTE_POLICY_SLOT]\n if (typeof receive === 'function') receive(table)\n}\n"],"mappings":";;;;;;;;;;AA0BA,MAAM,MAAoB,IAAI,IAU9B,SAAS,QAAQ,KAAsB,CACrC,IAAM,OAAS,KACZ,QAAQ,qBAAsB,MAAM,CAAC,CACrC,QAAQ,YAAa,UAAU,CAAC,CAChC,QAAQ,MAAO,IAAI,EACtB,OAAW,OAAO,IAAI,OAAO,IAAI,CACnC,CAEA,IAAa,iBAAb,KAA8B,CAE5B,SAA4B,IAAI,IAEhC,IAAI,OAAgB,KAAc,MAAqC,CACrE,GAAI,CAAC,OAAS,MAAM,OAAS,EAAG,OAChC,IAAM,KAAO,OAAO,YAAY,EAC1B,OAAS,KAAK,SAAS,IAAI,IAAI,GAAK,CAAC,EAC3C,OAAO,KAAK,CAAE,QAAS,QAAQ,IAAI,EAAG,KAAM,CAAC,EAC7C,KAAK,SAAS,IAAI,KAAM,MAAM,CAChC,CAOA,OAAO,OAAgB,SAA8B,CACnD,IAAM,OAAS,KAAK,SAAS,IAAI,OAAO,YAAY,CAAC,EACrD,GAAI,CAAC,OAAQ,OAAO,MACpB,IAAK,IAAM,SAAS,OAClB,GAAI,MAAM,QAAQ,KAAK,QAAQ,EAAG,OAAO,MAAM,MAEjD,OAAO,KACT,CAGA,OAAc,CACZ,KAAK,SAAS,MAAM,CACtB,CAEA,IAAI,MAAe,CACjB,IAAI,MAAQ,EACZ,IAAK,IAAM,UAAU,KAAK,SAAS,OAAO,EAAG,OAAS,OAAO,OAC7D,OAAO,KACT,CACF,EAOA,MAAa,kBAAmC,OAAO,IAAI,kBAAkB,EAa7E,SAAgB,gBACd,QACA,QACG,CAEH,OADA,OAAO,eAAe,QAAS,kBAAmB,CAAE,MAAO,QAAS,WAAY,EAAM,CAAC,EAChF,OACT,CAGA,SAAgB,iBAAiB,QAAkB,MAA+B,CAChF,IAAM,QAAW,UAAsC,mBACnD,OAAO,SAAY,YAAY,QAAQ,KAAK,CAClD"}
|