@katajs/core 0.1.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/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/index.d.ts +408 -0
- package/dist/index.js +838 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +57 -0
- package/dist/testing.js +29 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-B_SUwInq.d.ts +217 -0
- package/package.json +68 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/module.ts","../src/app.ts","../src/container.ts","../src/errors.ts","../src/queues-producer.ts","../src/middleware.ts","../src/queue.ts","../src/validate.ts","../src/inspect.ts"],"sourcesContent":["import type { Hono } from 'hono';\nimport type {\n ConsumerSpec,\n ModuleContainer,\n ProvidesMap,\n RequiresList,\n ServiceFactory,\n} from './types';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type AnyHono = Hono<any, any, any>;\n\n/**\n * A services-only module: defines the registry contributions and dependency\n * graph for a feature, but mounts no HTTP routes. Use for cross-cutting\n * concerns like an `events` recorder or an `audit` logger that other modules\n * fan out into. Optionally carries a queue `consumer` so a services-only\n * module can also process queue messages (notifications, indexing, etc.).\n */\nexport type ServiceOnlyModule<\n Provides extends ProvidesMap = ProvidesMap,\n Requires extends RequiresList = RequiresList,\n> = {\n readonly name: string;\n readonly provides: Provides;\n readonly requires: Requires;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n readonly consumer?: ConsumerSpec<any>;\n};\n\n/**\n * A routed module: services + HTTP routes mounted at a fixed prefix. The\n * routes and prefix are co-required — they always come as a pair. May also\n * carry a queue `consumer` for modules that own both an HTTP surface and a\n * queue handler (e.g., an `orders` module with CRUD routes plus a consumer\n * that processes order events).\n */\nexport type RoutedModule<\n Provides extends ProvidesMap = ProvidesMap,\n Requires extends RequiresList = RequiresList,\n Routes extends AnyHono = AnyHono,\n Prefix extends string = string,\n> = ServiceOnlyModule<Provides, Requires> & {\n readonly routes: Routes;\n readonly prefix: Prefix;\n};\n\n/** Either kind. Used internally by `createApp` and the boot validation. */\nexport type Module<\n Provides extends ProvidesMap = ProvidesMap,\n Requires extends RequiresList = RequiresList,\n> = ServiceOnlyModule<Provides, Requires> | RoutedModule<Provides, Requires>;\n\n/** Map of service keys to their resolved (return) types. */\ntype ProvidesReturns<P extends Record<string, unknown>> = {\n readonly [K in keyof P]: ServiceFactory<P[K]>;\n};\n\ntype DefineSpecBase<\n PReturns extends Record<string, unknown>,\n Requires extends RequiresList,\n> = {\n readonly name: string;\n readonly provides: {\n readonly [K in keyof PReturns]: (\n c: ModuleContainer<PReturns, Requires[number]>,\n ) => PReturns[K];\n };\n readonly requires: Requires;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n readonly consumer?: ConsumerSpec<any>;\n};\n\n/**\n * Declare a feature module. Returns either a `RoutedModule` (when `routes`\n * and `prefix` are provided) or a `ServiceOnlyModule` (when they aren't).\n * The two-overload signature means TypeScript can prove at the type level\n * that `routedModule.routes` is non-optional — no `!` at the call site.\n *\n * DX caveat: in a module with exactly one factory in `provides`,\n * TypeScript's self-referential inference of `PReturns` weakens — `c.resolve`\n * may not reject undeclared keys at compile time inside that lone factory.\n * The runtime boot-time checks (§10) still catch every error.\n */\nexport function defineModule<\n PReturns extends Record<string, unknown>,\n Requires extends RequiresList,\n Routes extends AnyHono,\n Prefix extends string,\n>(\n spec: DefineSpecBase<PReturns, Requires> & {\n readonly routes: Routes;\n readonly prefix: Prefix;\n },\n): RoutedModule<ProvidesReturns<PReturns>, Requires, Routes, Prefix>;\nexport function defineModule<\n PReturns extends Record<string, unknown>,\n Requires extends RequiresList,\n>(\n spec: DefineSpecBase<PReturns, Requires>,\n): ServiceOnlyModule<ProvidesReturns<PReturns>, Requires>;\nexport function defineModule(spec: {\n name: string;\n provides: Record<string, unknown>;\n requires: readonly string[];\n routes?: AnyHono;\n prefix?: string;\n consumer?: ConsumerSpec;\n}): Module {\n if (spec.routes && spec.prefix) {\n return {\n name: spec.name,\n provides: spec.provides as ProvidesMap,\n requires: spec.requires,\n routes: spec.routes,\n prefix: spec.prefix,\n consumer: spec.consumer,\n } as RoutedModule;\n }\n return {\n name: spec.name,\n provides: spec.provides as ProvidesMap,\n requires: spec.requires,\n consumer: spec.consumer,\n } as ServiceOnlyModule;\n}\n","import { Hono, type MiddlewareHandler } from 'hono';\nimport { buildRegistry } from './container';\nimport {\n containerMiddleware,\n type DbAdapter,\n type RequestVariables,\n} from './middleware';\nimport { errorMapper, type ErrorMapperOptions } from './errors';\nimport type { Module } from './module';\nimport {\n buildQueueHandler,\n type QueueErrorMapperOptions,\n type QueueHandler,\n} from './queue';\nimport type { QueueDeclaration } from './types';\n\n/** Base Hono app produced by `createApp` (middleware + onError, no routes mounted). */\nexport type BaseApp = Hono<{ Variables: RequestVariables }>;\n\nexport type AppConfig<\n Modules extends readonly Module[] = readonly Module[],\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ChainResult extends Hono<any, any, any> = BaseApp,\n> = {\n /**\n * Type-only marker for the bindings shape (Cloudflare env). Pass `{} as YourBindings`.\n * v0.1 doesn't propagate bindings to the Hono generic — augment `AppEnv` if you need it.\n */\n bindings?: unknown;\n\n /** The DB adapter (e.g., `drizzleAdapter()`). */\n db: DbAdapter;\n\n /** Modules to compose. Order doesn't affect behaviour but is the boot validation order. */\n modules: Modules;\n\n /**\n * User middleware that runs after the container middleware and before any\n * route handler. Use this for cross-cutting concerns like logging or auth\n * gates. The error handler is wired automatically via `app.onError`.\n */\n middleware?: MiddlewareHandler[];\n\n /** Options for the auto-wired errorMapper. Pass `onUnhandled` to log to Sentry, etc. */\n errorMapper?: ErrorMapperOptions;\n\n /**\n * Options for the queue-side error mapper. Distinct from the HTTP `errorMapper`\n * because queue failures don't render an HTTP response — they retry or DLQ.\n * Pass `onUnhandled` to log queue handler failures to Sentry / Logflare / etc.\n */\n queueErrorMapper?: QueueErrorMapperOptions;\n\n /** Override `crypto.randomUUID` for deterministic tests. */\n generateRequestId?: () => string;\n\n /**\n * Producer manifest. Each entry registers a queue this app sends to,\n * surfaced as a typed wrapper at `c.var.queues.<name>.send(body)`.\n * Validates body against the schema before delegating to the underlying\n * Cloudflare binding.\n *\n * queues: {\n * orders: { binding: 'ORDER_QUEUE', schema: OrderEventSchema },\n * }\n *\n * Independent of consumer modules — the consumer can live in this app\n * (single-Worker), in `apps/worker` (monorepo), or be external entirely.\n */\n queues?: Record<string, QueueDeclaration>;\n\n /**\n * Define the app's HTTP surface. Receives the framework-prepared base app\n * (with container middleware + error mapper already wired) and returns the\n * fully chained app. The chain happens via Hono's native `.route()` /\n * `.get()` / `.post()` etc., so Hono RPC end-to-end types are preserved.\n *\n * routes: (base) => base\n * .get('/health', (c) => c.json({ ok: true }))\n * .route(postsModule.prefix, postsModule.routes)\n *\n * Omit this option to get the bare base app back; you can then chain\n * routes externally (the original two-step pattern).\n */\n routes?: (base: BaseApp) => ChainResult;\n};\n\n/**\n * Compose modules into a Hono app. Performs three boot-time checks (per spec §10.2):\n * 1. No duplicate `provides` keys across modules.\n * 2. Every key in any module's `requires` is provided by some module.\n * 3. No module dependency cycles.\n *\n * Throws with a self-explanatory message on any failure (per spec §10.4).\n *\n * const { app } = createApp({\n * db: drizzleAdapter({ schema }),\n * modules: [eventsModule, auditModule, postsModule],\n * routes: (base) => base\n * .get('/health', (c) => c.json({ ok: true }))\n * .route(postsModule.prefix, postsModule.routes),\n * });\n * export type AppType = typeof app; // RPC types preserved\n * export default app;\n */\nexport function createApp<\n const Modules extends readonly Module[],\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ChainResult extends Hono<any, any, any> = BaseApp,\n>(\n config: AppConfig<Modules, ChainResult>,\n): {\n app: ChainResult;\n modules: Modules;\n /**\n * Cloudflare Queues handler. Defined when at least one module declares a\n * `consumer:` field; `undefined` otherwise. Wire it into the Worker default\n * export alongside `fetch` to consume queue messages:\n *\n * export default { fetch: app.fetch, queue };\n */\n queue: QueueHandler | undefined;\n} {\n validateModules(config.modules);\n\n const registry = buildRegistry(config.modules.map((m) => m.provides));\n\n const base = new Hono<{ Variables: RequestVariables }>();\n\n base.use(\n '*',\n containerMiddleware({\n registry,\n db: config.db,\n generateRequestId: config.generateRequestId,\n queues: config.queues,\n }),\n );\n\n for (const mw of config.middleware ?? []) {\n base.use('*', mw);\n }\n\n base.onError(errorMapper(config.errorMapper));\n\n const app = (config.routes ? config.routes(base) : base) as ChainResult;\n\n const queue = buildQueueHandler({\n modules: config.modules,\n registry,\n db: config.db,\n errorMapper: config.queueErrorMapper,\n generateRequestId: config.generateRequestId,\n });\n\n return { app, modules: config.modules, queue };\n}\n\nfunction validateModules(modules: readonly Module[]): void {\n // 1. Duplicate provides\n const provideOwners = new Map<string, string>();\n for (const m of modules) {\n for (const key of Object.keys(m.provides)) {\n const existing = provideOwners.get(key);\n if (existing) {\n throw new Error(\n `[katajs] Duplicate provides key '${key}'.\\n` +\n `Provided by: '${existing}' and '${m.name}'.\\n` +\n `Pick one module to own this key.`,\n );\n }\n provideOwners.set(key, m.name);\n }\n }\n\n // 2. Missing requires\n const allKeys = [...provideOwners.keys()];\n const moduleNames = modules.map((m) => m.name);\n for (const m of modules) {\n for (const key of m.requires) {\n if (!provideOwners.has(key)) {\n throw new Error(\n `[katajs] Module '${m.name}' requires '${key}', but no module provides it.\\n` +\n `Modules registered: ${moduleNames.join(', ') || '(none)'}.\\n` +\n `Provided keys: ${allKeys.join(', ') || '(none)'}.\\n` +\n `Did you forget to add the providing module to createApp({ modules: [...] })?`,\n );\n }\n }\n }\n\n // 3. Module dependency cycle (M depends on N if M requires a key N provides).\n const moduleByName = new Map<string, Module>(modules.map((m) => [m.name, m]));\n const edges = new Map<string, Set<string>>();\n for (const m of modules) {\n const deps = new Set<string>();\n for (const key of m.requires) {\n const ownerName = provideOwners.get(key);\n if (ownerName && ownerName !== m.name) deps.add(ownerName);\n }\n edges.set(m.name, deps);\n }\n\n const cycle = findCycle(edges);\n if (cycle) {\n throw new Error(\n `[katajs] Module dependency cycle detected.\\n` +\n `Cycle: ${cycle.join(' -> ')}.\\n` +\n `Modules cannot transitively require services from each other.`,\n );\n }\n // Reference moduleByName so unused-vars stays clean if we later need it.\n void moduleByName;\n}\n\nfunction findCycle(edges: Map<string, Set<string>>): string[] | undefined {\n const VISITING = 1;\n const VISITED = 2;\n const state = new Map<string, number>();\n const stack: string[] = [];\n\n function visit(node: string): string[] | undefined {\n const s = state.get(node);\n if (s === VISITED) return undefined;\n if (s === VISITING) {\n const idx = stack.indexOf(node);\n return [...stack.slice(idx), node];\n }\n state.set(node, VISITING);\n stack.push(node);\n for (const next of edges.get(node) ?? []) {\n const found = visit(next);\n if (found) return found;\n }\n stack.pop();\n state.set(node, VISITED);\n return undefined;\n }\n\n for (const node of edges.keys()) {\n const found = visit(node);\n if (found) return found;\n }\n return undefined;\n}\n","import type { Context } from 'hono';\nimport type {\n AppDb,\n ProvidesMap,\n RequestContainer,\n ServiceFactory,\n} from './types';\n\n/**\n * Build a `resolve` function backed by the given registry. Per spec §4.3:\n * - First call to `resolve(key)` constructs via the factory and caches.\n * - Subsequent calls return the cached instance.\n * - In-progress detection throws on circular dependencies with the resolution stack.\n * - Missing keys throw with the list of registered keys.\n *\n * `containerView` is whatever object the factories should receive as their\n * single argument. The container that owns this resolver passes itself in.\n */\nexport function makeResolver(\n registry: ReadonlyMap<string, ServiceFactory<unknown>>,\n containerView: object,\n): (key: string) => unknown {\n const cache = new Map<string, unknown>();\n const inProgress = new Set<string>();\n\n return function resolve(key: string): unknown {\n if (cache.has(key)) {\n return cache.get(key);\n }\n\n if (inProgress.has(key)) {\n const stack = [...inProgress, key].join(' -> ');\n throw new Error(\n `[katajs] Circular dependency detected while resolving '${key}'. ` +\n `Resolution stack: ${stack}`,\n );\n }\n\n const factory = registry.get(key);\n if (!factory) {\n const known = [...registry.keys()].sort().join(', ') || '(none)';\n throw new Error(\n `[katajs] No service registered for key '${key}'. Registered keys: ${known}`,\n );\n }\n\n inProgress.add(key);\n try {\n const instance = factory(containerView as never);\n cache.set(key, instance);\n return instance;\n } finally {\n inProgress.delete(key);\n }\n };\n}\n\n/** Flatten a list of `Module.provides` maps into a single registry. */\nexport function buildRegistry(\n providesMaps: readonly ProvidesMap[],\n): Map<string, ServiceFactory<unknown>> {\n const registry = new Map<string, ServiceFactory<unknown>>();\n for (const provides of providesMaps) {\n for (const [key, factory] of Object.entries(provides)) {\n registry.set(key, factory as ServiceFactory<unknown>);\n }\n }\n return registry;\n}\n\n/**\n * Stub Hono Context used in queue containers. Queue handlers don't have a\n * real HTTP context — accessing one would be a programming error in user\n * code (a service trying to read `c.req` from a queue context). The proxy\n * throws on any access to surface the bug clearly.\n */\nconst queueContextStub: Context = new Proxy({} as Context, {\n get(_, prop) {\n throw new Error(\n `[katajs] This service tried to access Hono Context property '${String(prop)}', but it's running in a queue handler (no HTTP context exists). Refactor the service to read what it needs from \\`container.env\\` / \\`container.requestId\\` instead.`,\n );\n },\n}) as Context;\n\nexport type BuildContainerArgs = {\n readonly env: unknown;\n /** Hono Context for HTTP requests; omit for queue/cron contexts. */\n readonly c?: Context;\n readonly requestId: string;\n readonly db: AppDb;\n readonly registry: ReadonlyMap<string, ServiceFactory<unknown>>;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n readonly runTransaction?: (db: any, fn: (txDb: any) => Promise<any>) => Promise<any>;\n readonly inTransaction: boolean;\n};\n\n/**\n * Build a `RequestContainer` for either an HTTP request or a queue message.\n * Each invocation produces a fresh container with its own resolve cache and\n * `withTransaction` closure.\n */\nexport function buildContainer(args: BuildContainerArgs): RequestContainer {\n const container = {\n env: args.env as never,\n c: args.c ?? queueContextStub,\n requestId: args.requestId,\n db: args.db,\n } as unknown as RequestContainer;\n\n (container as { resolve: RequestContainer['resolve'] }).resolve = makeResolver(\n args.registry,\n container,\n ) as RequestContainer['resolve'];\n\n (container as { withTransaction: RequestContainer['withTransaction'] }).withTransaction =\n async (fn) => {\n if (args.inTransaction) {\n return fn(container);\n }\n if (!args.runTransaction) {\n throw new Error(\n '[katajs] withTransaction was called but the configured db adapter ' +\n \"does not support transactions. Use an adapter with a 'runTransaction' method.\",\n );\n }\n return args.runTransaction(args.db, async (txDb) => {\n const txContainer = buildContainer({\n ...args,\n db: txDb,\n inTransaction: true,\n });\n return fn(txContainer);\n });\n };\n\n return container;\n}\n","import type { Context } from 'hono';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\n\nexport type ErrorContext = {\n c: Context;\n requestId: string;\n};\n\n/**\n * Base class for every domain error in user code.\n *\n * - `super(message)` is the *internal* message — for logs and stack traces.\n * - `publicMessage` is what the client sees.\n * - `publicPayload` is optional structured data merged into the response body.\n */\nexport abstract class AppError extends Error {\n abstract readonly status: number;\n abstract readonly code: string;\n abstract readonly publicMessage: string;\n\n /**\n * Optional structured payload merged into the response body. Subclasses may\n * override as either a property or a getter.\n */\n get publicPayload(): Record<string, unknown> | undefined {\n return undefined;\n }\n\n constructor(message: string) {\n super(message);\n this.name = this.constructor.name;\n }\n}\n\nexport type ValidationIssue = {\n path: (string | number)[];\n message: string;\n};\n\nexport class ValidationError extends AppError {\n override readonly status = 400;\n override readonly code = 'validation_failed';\n override readonly publicMessage = 'Request validation failed';\n\n constructor(public readonly issues: ValidationIssue[]) {\n super(`Validation failed: ${issues.length} issue(s)`);\n }\n\n override get publicPayload(): Record<string, unknown> {\n return { issues: this.issues };\n }\n}\n\nexport type ErrorMapperOptions = {\n /** Hook invoked for unhandled (non-AppError) errors. Use to log to Sentry, etc. */\n onUnhandled?: (err: unknown, ctx: ErrorContext) => void;\n};\n\nexport type ErrorMapperHandler = (err: Error, c: Context) => Response | Promise<Response>;\n\n/**\n * Build a Hono `onError` handler that renders thrown errors as JSON.\n *\n * - `AppError` subclasses → `{error, message, ...publicPayload, requestId}` at the error's status.\n * - Other errors → safe 500 with `requestId`; `onUnhandled` invoked with the original error.\n *\n * Wired automatically by `createApp` via `app.onError(errorMapper(opts))`.\n */\nexport function errorMapper(opts: ErrorMapperOptions = {}): ErrorMapperHandler {\n return (err, c) => {\n const requestId =\n c.get('requestId') ?? c.req.header('X-Request-Id') ?? 'unknown';\n\n if (err instanceof AppError) {\n return c.json(\n {\n error: err.code,\n message: err.publicMessage,\n ...(err.publicPayload ?? {}),\n requestId,\n },\n err.status as ContentfulStatusCode,\n );\n }\n\n opts.onUnhandled?.(err, { c, requestId });\n\n return c.json(\n {\n error: 'internal_error',\n message: 'Something went wrong. Please try again.',\n requestId,\n },\n 500,\n );\n };\n}\n","import { ValidationError } from './errors';\nimport type {\n QueueDeclaration,\n SendBatchOptions,\n SendOptions,\n TypedQueue,\n} from './types';\n\n/**\n * Build the runtime `c.var.queues` object from the producer manifest.\n *\n * Each entry becomes a `TypedQueue` that validates with the declared schema\n * before delegating to the underlying Cloudflare `env[binding].send(...)`.\n * Validation failure throws `ValidationError` — the same shape consumers\n * see — so producer-side bugs surface synchronously at the call site.\n */\nexport function buildTypedQueues(\n declarations: Record<string, QueueDeclaration>,\n env: unknown,\n): Record<string, TypedQueue<unknown>> {\n const out: Record<string, TypedQueue<unknown>> = {};\n for (const [name, decl] of Object.entries(declarations)) {\n out[name] = makeTypedQueue(name, decl, env);\n }\n return out;\n}\n\nfunction makeTypedQueue(\n name: string,\n decl: QueueDeclaration,\n env: unknown,\n): TypedQueue<unknown> {\n const binding = (env as Record<string, unknown> | null | undefined)?.[\n decl.binding\n ] as\n | {\n send(body: unknown, options?: unknown): Promise<void>;\n sendBatch?(bodies: unknown, options?: unknown): Promise<void>;\n }\n | undefined;\n\n return {\n async send(body: unknown, options?: SendOptions): Promise<void> {\n const validated = validateOrThrow(name, decl, body);\n assertBinding(name, decl.binding, binding);\n await binding.send(validated, options);\n },\n async sendBatch(bodies: readonly unknown[], options?: SendBatchOptions): Promise<void> {\n const validated = bodies.map((b) => validateOrThrow(name, decl, b));\n assertBinding(name, decl.binding, binding);\n if (typeof binding.sendBatch === 'function') {\n // Cloudflare's MessageSendRequest shape: { body, contentType?, delaySeconds? }\n const messages = validated.map((body) => ({ body }));\n await binding.sendBatch(messages, options);\n return;\n }\n // Fallback: send one-by-one if the binding doesn't expose sendBatch.\n for (const body of validated) {\n await binding.send(body, options);\n }\n },\n };\n}\n\nfunction validateOrThrow(\n queueName: string,\n decl: QueueDeclaration,\n body: unknown,\n): unknown {\n try {\n return decl.schema.parse(body);\n } catch (err) {\n // Wrap in our ValidationError so error mapping is consistent. Path is\n // prefixed with the queue name so producer-side validation issues are\n // distinguishable from HTTP body issues in logs.\n const issues =\n err && typeof err === 'object' && 'issues' in err && Array.isArray((err as { issues: unknown }).issues)\n ? (err as { issues: Array<{ path?: unknown; message?: unknown }> }).issues.map((i) => ({\n path: [\n `queues.${queueName}`,\n ...(Array.isArray(i.path) ? (i.path as (string | number)[]) : []),\n ],\n message: typeof i.message === 'string' ? i.message : 'Invalid',\n }))\n : [\n {\n path: [`queues.${queueName}`],\n message: err instanceof Error ? err.message : String(err),\n },\n ];\n throw new ValidationError(issues);\n }\n}\n\nfunction assertBinding(\n queueName: string,\n bindingName: string,\n binding: unknown,\n): asserts binding is { send(body: unknown, options?: unknown): Promise<void> } {\n if (!binding || typeof (binding as { send?: unknown }).send !== 'function') {\n throw new Error(\n `[katajs] Queue '${queueName}': binding '${bindingName}' is not registered as a producer in wrangler.jsonc, or env doesn't have it. ` +\n `Add a producer entry for this binding in your wrangler config.`,\n );\n }\n}\n","import type { MiddlewareHandler } from 'hono';\nimport { buildContainer } from './container';\nimport { buildTypedQueues } from './queues-producer';\nimport type {\n AppDb,\n ProvidesMap,\n QueueDeclaration,\n QueuesRegistry,\n RequestContainer,\n ServiceFactory,\n} from './types';\n\n/**\n * Hono Variables shape contributed by katajs's container middleware.\n *\n * `resolve`, `withTransaction`, and `queues` are convenience handles mounted\n * directly on `c.var` so route handlers can write\n * `c.var.resolve('postService')` and `c.var.queues.orders.send(...)` without\n * digging into `c.var.container`.\n */\nexport type RequestVariables = {\n container: RequestContainer;\n requestId: string;\n resolve: RequestContainer['resolve'];\n withTransaction: RequestContainer['withTransaction'];\n queues: QueuesRegistry;\n};\n\n/**\n * Adapter contract for the database layer. `create` is called once per request\n * to produce the per-request client; `runTransaction` (optional) runs a callback\n * inside a transaction with a transaction-bound client (`txDb`).\n *\n * The `db` and `txDb` types are intentionally loose (`any`) at the adapter\n * boundary because, e.g., Drizzle's `DrizzleClient` and `DrizzleTx` are\n * different types with the same query API. User code should rely on the\n * augmented `AppDb` interface to type `c.db`, treating the client and tx as\n * interchangeable for repository code (per spec §6.4).\n */\nexport type DbAdapter = {\n create(env: unknown): unknown;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n runTransaction?<T>(db: any, fn: (txDb: any) => Promise<T>): Promise<T>;\n};\n\nexport type ContainerMiddlewareConfig = {\n registry: ReadonlyMap<string, ServiceFactory<unknown>>;\n db: DbAdapter;\n /** Override `crypto.randomUUID` for deterministic tests. */\n generateRequestId?: () => string;\n /** Producer manifest. Each entry becomes a typed queue on `c.var.queues`. */\n queues?: Record<string, QueueDeclaration>;\n};\n\n/**\n * Build a fresh container per request. Sets `container`, `requestId`,\n * `resolve`, and `withTransaction` on `c.var` (so handlers can use either\n * the full `c.var.container` or the shortcuts directly), and writes the\n * `X-Request-Id` header. Installed first by `createApp`.\n */\nexport function containerMiddleware(config: ContainerMiddlewareConfig): MiddlewareHandler {\n return async (c, next) => {\n const requestId = config.generateRequestId\n ? config.generateRequestId()\n : crypto.randomUUID();\n\n const db = config.db.create(c.env) as AppDb;\n\n const container = buildContainer({\n env: c.env,\n c,\n requestId,\n db,\n registry: config.registry,\n runTransaction: config.db.runTransaction,\n inTransaction: false,\n });\n\n c.set('container', container);\n c.set('requestId', requestId);\n c.set('resolve', container.resolve);\n c.set('withTransaction', container.withTransaction);\n if (config.queues && Object.keys(config.queues).length > 0) {\n c.set(\n 'queues',\n buildTypedQueues(config.queues, c.env) as unknown as QueuesRegistry,\n );\n } else {\n c.set('queues', {} as QueuesRegistry);\n }\n c.header('X-Request-Id', requestId);\n\n await next();\n };\n}\n\n/**\n * Typed-identity helper for user-defined middleware. Lets the user write\n * `c.var.container` (and the shortcuts) without authoring the env generics inline.\n */\nexport function defineMiddleware<\n Env extends { Variables: RequestVariables } = { Variables: RequestVariables },\n>(handler: MiddlewareHandler<Env>): MiddlewareHandler<Env> {\n return handler;\n}\n\nexport type { ProvidesMap };\n","import { buildContainer } from './container';\nimport { ValidationError } from './errors';\nimport type { Module } from './module';\nimport type {\n AppDb,\n ConsumerSpec,\n ServiceFactory,\n ValidatedBatch,\n ValidatedMessage,\n} from './types';\nimport type { DbAdapter } from './middleware';\n\n/**\n * Helper for defining a queue consumer with full contextual typing on the\n * `handle` / `handleBatch` parameters. Drives contextual typing for the\n * inner functions so `message.body` is inferred from the schema instead of\n * widening to `unknown`.\n *\n * export const ordersConsumer = defineConsumer({\n * queue: 'ORDER_QUEUE',\n * schema: OrderEventSchema,\n * async handle(message, c) {\n * message.body; // typed as z.infer<typeof OrderEventSchema>\n * },\n * });\n */\nexport function defineConsumer<TBody>(\n spec: ConsumerSpec<TBody>,\n): ConsumerSpec<TBody> {\n return spec;\n}\n\n/**\n * Cloudflare Queues `Message<Body>` shape, duck-typed to avoid a hard dep on\n * `@cloudflare/workers-types`. Only the fields the framework consumes are\n * declared.\n */\ntype RawMessage<Body = unknown> = {\n readonly id: string;\n readonly timestamp: Date;\n readonly body: Body;\n readonly attempts: number;\n ack(): void;\n retry(options?: { delaySeconds?: number }): void;\n};\n\ntype RawBatch<Body = unknown> = {\n readonly queue: string;\n readonly messages: readonly RawMessage<Body>[];\n ackAll(): void;\n retryAll(options?: { delaySeconds?: number }): void;\n};\n\n/** Sender shape for sending DLQ messages. */\ntype DlqQueueBinding = {\n send(message: unknown, options?: unknown): Promise<void>;\n};\n\n/** Public Worker `queue` handler signature. */\nexport type QueueHandler = (\n batch: RawBatch,\n env: unknown,\n ctx: unknown,\n) => Promise<void>;\n\nexport type QueueErrorContext = {\n readonly queue: string;\n readonly messageId?: string;\n readonly attempts?: number;\n};\n\nexport type QueueErrorMapperOptions = {\n /** Hook invoked when a handler throws. Use for Sentry / Logflare / etc. */\n onUnhandled?: (err: unknown, ctx: QueueErrorContext) => void;\n};\n\nexport type BuildQueueHandlerConfig = {\n readonly modules: readonly Module[];\n readonly registry: ReadonlyMap<string, ServiceFactory<unknown>>;\n readonly db: DbAdapter;\n readonly errorMapper?: QueueErrorMapperOptions;\n /** Override `crypto.randomUUID` for deterministic tests. Used as fallback when message has no `id`. */\n readonly generateRequestId?: () => string;\n};\n\nconst DEFAULT_MAX_RETRIES = 3;\n\n/**\n * Build a `queue` handler that dispatches incoming batches to the right\n * consumer module based on `batch.queue`. Returns `undefined` if no module\n * has a `consumer:` field — in which case the Worker should not export\n * `queue` at all.\n *\n * Per-message handlers (consumer.handle) get one container per message and\n * the framework auto-acks on success, auto-retries on throw, and routes to\n * the DLQ after `maxRetries` attempts.\n *\n * Batch handlers (consumer.handleBatch) get one container per batch. The\n * user is responsible for calling `msg.ack()` / `msg.retry()` per message.\n */\nexport function buildQueueHandler(\n config: BuildQueueHandlerConfig,\n): QueueHandler | undefined {\n const consumersByQueue = new Map<string, { module: Module; consumer: ConsumerSpec }>();\n for (const m of config.modules) {\n if (!m.consumer) continue;\n if (consumersByQueue.has(m.consumer.queue)) {\n const owner = consumersByQueue.get(m.consumer.queue)!.module.name;\n throw new Error(\n `[katajs] Duplicate queue consumer for binding '${m.consumer.queue}': ` +\n `modules '${owner}' and '${m.name}' both consume it.`,\n );\n }\n consumersByQueue.set(m.consumer.queue, { module: m, consumer: m.consumer });\n }\n\n if (consumersByQueue.size === 0) return undefined;\n\n return async function queue(batch, env) {\n const entry = consumersByQueue.get(batch.queue);\n if (!entry) {\n // No consumer registered for this queue. Best we can do is retry — the\n // user almost certainly mis-configured wrangler.jsonc.\n const known = [...consumersByQueue.keys()].join(', ') || '(none)';\n throw new Error(\n `[katajs] No consumer registered for queue '${batch.queue}'. ` +\n `Known consumer bindings: ${known}.`,\n );\n }\n\n const { consumer } = entry;\n const db = config.db.create(env) as AppDb;\n const sharedContainerArgs = {\n env,\n registry: config.registry,\n db,\n runTransaction: config.db.runTransaction,\n inTransaction: false,\n };\n\n if ('handleBatch' in consumer && consumer.handleBatch) {\n // Batch mode: one container, user controls ack/retry per message.\n const requestId = config.generateRequestId?.() ?? cryptoRandomUUID();\n\n // Validate every message body up-front. Invalid messages are routed\n // to DLQ (or acked) so they don't poison the batch.\n const validated: ValidatedMessage<unknown>[] = [];\n for (const msg of batch.messages) {\n try {\n const body = consumer.schema.parse(msg.body);\n validated.push(makeValidatedMessage(msg, body));\n } catch (err) {\n await onMessageFailure(msg, consumer, env, err, config.errorMapper, batch.queue);\n }\n }\n\n if (validated.length === 0) return;\n\n const container = buildContainer({\n ...sharedContainerArgs,\n requestId,\n });\n\n const validatedBatch: ValidatedBatch<unknown> = {\n queue: batch.queue,\n messages: validated,\n ackAll: () => batch.ackAll(),\n retryAll: (opts) => batch.retryAll(opts),\n };\n\n try {\n await consumer.handleBatch(validatedBatch, container);\n } catch (err) {\n config.errorMapper?.onUnhandled?.(err, { queue: batch.queue });\n // User didn't catch — retry the whole batch.\n batch.retryAll();\n }\n return;\n }\n\n // Per-message mode: one container per message, auto-ack on success.\n if (!('handle' in consumer) || !consumer.handle) {\n throw new Error(\n `[katajs] Consumer for '${batch.queue}' (module '${entry.module.name}') has neither 'handle' nor 'handleBatch'.`,\n );\n }\n\n const handle = consumer.handle;\n\n for (const msg of batch.messages) {\n // Validate first — bad bodies route through DLQ machinery.\n let body: unknown;\n try {\n body = consumer.schema.parse(msg.body);\n } catch (err) {\n const wrapped = err instanceof ValidationError ? err : err;\n await onMessageFailure(msg, consumer, env, wrapped, config.errorMapper, batch.queue);\n continue;\n }\n\n const container = buildContainer({\n ...sharedContainerArgs,\n requestId: msg.id,\n });\n\n try {\n await handle(makeValidatedMessage(msg, body), container);\n msg.ack();\n } catch (err) {\n await onMessageFailure(msg, consumer, env, err, config.errorMapper, batch.queue);\n }\n }\n };\n}\n\nfunction makeValidatedMessage<Body>(\n raw: RawMessage<unknown>,\n body: Body,\n): ValidatedMessage<Body> {\n return {\n id: raw.id,\n timestamp: raw.timestamp,\n body,\n attempts: raw.attempts,\n ack: () => raw.ack(),\n retry: (opts) => raw.retry(opts),\n };\n}\n\n/**\n * Handle a single message failure: report via errorMapper, then either\n * retry or send to DLQ depending on attempts vs maxRetries.\n */\nasync function onMessageFailure(\n msg: RawMessage<unknown>,\n consumer: ConsumerSpec,\n env: unknown,\n err: unknown,\n errorMapper: QueueErrorMapperOptions | undefined,\n queue: string,\n): Promise<void> {\n errorMapper?.onUnhandled?.(err, {\n queue,\n messageId: msg.id,\n attempts: msg.attempts,\n });\n\n const maxRetries = consumer.maxRetries ?? DEFAULT_MAX_RETRIES;\n\n // CF Queues `attempts` is 1-based on the first delivery attempt.\n if (msg.attempts >= maxRetries) {\n if (consumer.dlq) {\n const dlqBinding = (env as Record<string, unknown> | null | undefined)?.[\n consumer.dlq\n ] as DlqQueueBinding | undefined;\n if (dlqBinding && typeof dlqBinding.send === 'function') {\n try {\n await dlqBinding.send({\n originalQueue: queue,\n messageId: msg.id,\n body: msg.body,\n error: err instanceof Error ? err.message : String(err),\n attempts: msg.attempts,\n failedAt: new Date().toISOString(),\n });\n msg.ack();\n return;\n } catch (dlqErr) {\n errorMapper?.onUnhandled?.(dlqErr, {\n queue: consumer.dlq,\n messageId: msg.id,\n });\n // DLQ send failed — fall through to retry so we don't lose the message.\n }\n } else {\n // DLQ binding not present at runtime — drop with a clear error log.\n errorMapper?.onUnhandled?.(\n new Error(\n `[katajs] DLQ binding '${consumer.dlq}' not found on env. Acking message ${msg.id} to avoid loops.`,\n ),\n { queue, messageId: msg.id },\n );\n msg.ack();\n return;\n }\n } else {\n // No DLQ — ack to break the retry loop.\n msg.ack();\n return;\n }\n }\n\n msg.retry();\n}\n\nfunction cryptoRandomUUID(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n // Fallback for very minimal runtimes.\n return Math.random().toString(36).slice(2) + Date.now().toString(36);\n}\n","import { zValidator } from '@hono/zod-validator';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport type { ZodSchema } from 'zod';\nimport { ValidationError } from './errors';\n\ntype Source = 'body' | 'query' | 'param';\n\nconst targetByLabel: Record<Source, 'json' | 'query' | 'param'> = {\n body: 'json',\n query: 'query',\n param: 'param',\n};\n\nfunction makeHook(label: Source) {\n return (\n result: { success: boolean; error?: import('zod').ZodError },\n _c: Context,\n ) => {\n if (!result.success && result.error) {\n const issues = result.error.issues.map((i) => ({\n path: [label, ...i.path] as (string | number)[],\n message: i.message,\n }));\n throw new ValidationError(issues);\n }\n };\n}\n\ntype V<\n T extends ZodSchema | undefined,\n Target extends 'json' | 'query' | 'param',\n> = T extends ZodSchema\n ? [ReturnType<typeof zValidator<T, Target, any, string>>]\n : [];\n\n/**\n * Validate `body`, `query`, and/or `param` against Zod schemas.\n *\n * Returns a tuple of Hono middlewares that the user spreads into a route.\n * Each middleware preserves Hono RPC's typed surface so `c.req.valid('json' |\n * 'query' | 'param')` is correctly typed in handlers downstream.\n *\n * app.post('/posts',\n * ...validate({ body: CreatePostSchema, param: PostIdParam }),\n * async (c) => {\n * const body = c.req.valid('json'); // typed as z.infer<typeof CreatePostSchema>\n * const params = c.req.valid('param'); // typed as z.infer<typeof PostIdParam>\n * }\n * );\n *\n * On validation failure, throws a `ValidationError` (rendered as 400 by the\n * `errorMapper` middleware).\n */\nexport function validate<\n TBody extends ZodSchema | undefined = undefined,\n TQuery extends ZodSchema | undefined = undefined,\n TParam extends ZodSchema | undefined = undefined,\n>(opts: {\n body?: TBody;\n query?: TQuery;\n param?: TParam;\n}): [...V<TBody, 'json'>, ...V<TQuery, 'query'>, ...V<TParam, 'param'>] {\n const middlewares: MiddlewareHandler[] = [];\n if (opts.body) {\n middlewares.push(zValidator(targetByLabel.body, opts.body, makeHook('body')));\n }\n if (opts.query) {\n middlewares.push(zValidator(targetByLabel.query, opts.query, makeHook('query')));\n }\n if (opts.param) {\n middlewares.push(zValidator(targetByLabel.param, opts.param, makeHook('param')));\n }\n return middlewares as never;\n}\n","import type { Module, RoutedModule } from './module';\n\n/**\n * Static snapshot of a module's contribution to the app graph. Doesn't\n * carry runtime state — purely shape data extracted from the user's\n * `defineModule(...)` calls.\n */\nexport type GraphModule = {\n name: string;\n provides: string[];\n requires: string[];\n prefix?: string;\n hasRoutes: boolean;\n /** Present when the module declares `consumer:` — points at a queue binding. */\n consumer?: GraphConsumer;\n};\n\n/** Queue consumer attached to a module. */\nexport type GraphConsumer = {\n /** wrangler binding name for the queue this module consumes. */\n queue: string;\n /** Optional dead-letter binding name. */\n dlq?: string;\n};\n\n/**\n * Producer manifest entry from `createApp({ queues })`. Producers are app-level\n * (not module-level) so they're inspected separately from modules.\n */\nexport type GraphProducer = {\n /** The key under `queues:` (also the wrapper name on `c.var.queues.<name>`). */\n name: string;\n /** wrangler binding name (e.g. 'ORDER_QUEUE'). */\n binding: string;\n};\n\n/** Directed dependency edge: `from` requires service `via`, which `to` provides. */\nexport type GraphEdge = {\n from: string;\n to: string;\n via: string;\n};\n\n/** Flattened HTTP route, prefixed with its owning module's mount path. */\nexport type GraphRoute = {\n method: string;\n path: string;\n module: string;\n};\n\nexport type Inspection = {\n modules: GraphModule[];\n edges: GraphEdge[];\n routes: GraphRoute[];\n /** App-level producer manifests, optional. Empty array when no producers passed. */\n producers: GraphProducer[];\n /** A `graph TD` Mermaid source string suitable for embedding in markdown or HTML. */\n mermaid(): string;\n /** Pretty-printed JSON for piping to other tooling. */\n json(): string;\n /** Self-contained HTML page that renders the graph + tables in a browser. */\n html(opts?: HtmlOptions): string;\n};\n\nexport type InspectOptions = {\n /**\n * App-level producer manifests, normally the contents of `createApp({ queues })`.\n * Each entry maps a producer name to its wrangler binding. Pass-through to the\n * Inspection's `producers` array so devtools can render producer/consumer pairs.\n */\n producers?: Record<string, { binding: string }>;\n};\n\nexport type HtmlOptions = {\n /** Title shown at the top of the page. Default: \"katajs — module graph\". */\n title?: string;\n};\n\n/**\n * Inspect a list of modules and return everything needed to render the app's\n * structure (graph, dependency edges, route table) without booting the app.\n *\n * Pure / synchronous — safe to call at build time. Use the returned `.html()`\n * to write a self-contained snapshot file, or `.mermaid()` to embed in docs.\n *\n * TODO(Shape B): when we build the live devtools server, it will read the\n * same `Inspection` shape from a `__katajs/graph` debug endpoint and render\n * with an interactive Cytoscape canvas instead of a Mermaid snapshot.\n */\nexport function inspectModules(\n modules: readonly Module[],\n options: InspectOptions = {},\n): Inspection {\n const provideOwners = new Map<string, string>();\n for (const m of modules) {\n for (const key of Object.keys(m.provides)) {\n provideOwners.set(key, m.name);\n }\n }\n\n const inspectedModules: GraphModule[] = modules.map((m) => {\n const consumer = m.consumer\n ? ({ queue: m.consumer.queue, dlq: m.consumer.dlq } satisfies GraphConsumer)\n : undefined;\n return {\n name: m.name,\n provides: Object.keys(m.provides).sort(),\n requires: [...m.requires].sort(),\n prefix: isRoutedModule(m) ? m.prefix : undefined,\n hasRoutes: isRoutedModule(m),\n ...(consumer ? { consumer } : {}),\n };\n });\n\n const producers: GraphProducer[] = options.producers\n ? Object.entries(options.producers)\n .map(([name, p]) => ({ name, binding: p.binding }))\n .sort((a, b) => a.name.localeCompare(b.name))\n : [];\n\n const edges: GraphEdge[] = [];\n for (const m of modules) {\n for (const key of m.requires) {\n const owner = provideOwners.get(key);\n if (owner && owner !== m.name) {\n edges.push({ from: m.name, to: owner, via: key });\n }\n }\n }\n\n const routes: GraphRoute[] = [];\n // Hono lists one entry in `.routes` per middleware in a route's chain\n // (validators + handler). Dedupe by `method + path + module` so each\n // user-visible route appears once.\n const seenRoutes = new Set<string>();\n for (const m of modules) {\n if (!isRoutedModule(m)) continue;\n const honoRoutes = (m.routes as { routes?: unknown }).routes;\n if (!Array.isArray(honoRoutes)) continue;\n for (const r of honoRoutes as Array<{ method?: unknown; path?: unknown }>) {\n if (typeof r.method !== 'string' || typeof r.path !== 'string') continue;\n // Skip Hono's catch-all internal entries that aren't real routes.\n if (r.method === 'ALL' && r.path === '*') continue;\n const fullPath = mergePath(m.prefix, r.path);\n const key = `${r.method} ${fullPath} ${m.name}`;\n if (seenRoutes.has(key)) continue;\n seenRoutes.add(key);\n routes.push({ method: r.method, path: fullPath, module: m.name });\n }\n }\n\n // Stable order: sort routes by path then method, edges by from/to.\n routes.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));\n edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));\n\n return {\n modules: inspectedModules,\n edges,\n routes,\n producers,\n mermaid: () => buildMermaid(inspectedModules, edges),\n json: () =>\n JSON.stringify(\n { modules: inspectedModules, edges, routes, producers },\n null,\n 2,\n ),\n html: (opts) => buildHtml(inspectedModules, edges, routes, opts),\n };\n}\n\nfunction isRoutedModule(m: Module): m is RoutedModule {\n return 'routes' in m && 'prefix' in m;\n}\n\nfunction mergePath(base: string, sub: string): string {\n if (!base || base === '/') return sub === '' ? '/' : sub;\n if (!sub || sub === '/') return base;\n const a = base.endsWith('/') ? base.slice(0, -1) : base;\n const b = sub.startsWith('/') ? sub : `/${sub}`;\n return `${a}${b}`;\n}\n\nfunction buildMermaid(modules: GraphModule[], edges: GraphEdge[]): string {\n const lines = ['graph TD'];\n for (const m of modules) {\n const meta: string[] = [];\n if (m.prefix) meta.push(m.prefix);\n if (m.provides.length > 0) meta.push(`+${m.provides.length} services`);\n const sub = meta.length > 0 ? `<br/><small>${meta.join(' • ')}</small>` : '';\n lines.push(` ${m.name}[\"<b>${m.name}</b>${sub}\"]`);\n }\n for (const e of edges) {\n lines.push(` ${e.from} -->|${e.via}| ${e.to}`);\n }\n return lines.join('\\n');\n}\n\nfunction buildHtml(\n modules: GraphModule[],\n edges: GraphEdge[],\n routes: GraphRoute[],\n opts: HtmlOptions = {},\n): string {\n const title = opts.title ?? 'katajs — module graph';\n const mermaidSrc = buildMermaid(modules, edges);\n // TODO(Shape B): Shape B's devtools server replaces this static template with\n // a hot-reloading SPA that fetches /__katajs/graph.json from a running app.\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>${escapeHtml(title)}</title>\n<style>\n :root {\n --bg: #0f1115;\n --panel: #161922;\n --panel-2: #1d2230;\n --text: #e8ebf2;\n --muted: #8b93a7;\n --accent: #7aa2ff;\n --accent-2: #57c7b9;\n --border: #2a3041;\n --get: #57c7b9;\n --post: #7aa2ff;\n --put: #f5a623;\n --patch: #f5a623;\n --delete: #ff6b6b;\n }\n * { box-sizing: border-box; }\n body {\n font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;\n margin: 0;\n background: var(--bg);\n color: var(--text);\n }\n header {\n padding: 1.75rem 2rem 1.25rem;\n border-bottom: 1px solid var(--border);\n }\n header h1 {\n margin: 0;\n font-size: 1.4rem;\n font-weight: 600;\n }\n header .meta {\n color: var(--muted);\n margin-top: 0.25rem;\n font-size: 0.85rem;\n }\n main {\n padding: 1.5rem 2rem 4rem;\n max-width: 1280px;\n margin: 0 auto;\n }\n section + section { margin-top: 2.5rem; }\n section h2 {\n font-size: 0.85rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--muted);\n font-weight: 600;\n margin: 0 0 0.75rem;\n }\n .graph {\n background: var(--panel);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 1.5rem;\n overflow: auto;\n }\n .graph .mermaid {\n text-align: center;\n color: var(--text);\n }\n table {\n width: 100%;\n border-collapse: collapse;\n background: var(--panel);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n }\n th, td {\n text-align: left;\n padding: 0.7rem 1rem;\n border-bottom: 1px solid var(--border);\n }\n tr:last-child td { border-bottom: none; }\n th {\n background: var(--panel-2);\n color: var(--muted);\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: 600;\n }\n td.method { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }\n td.method[data-m=\"GET\"] { color: var(--get); }\n td.method[data-m=\"POST\"] { color: var(--post); }\n td.method[data-m=\"PUT\"] { color: var(--put); }\n td.method[data-m=\"PATCH\"] { color: var(--patch); }\n td.method[data-m=\"DELETE\"] { color: var(--delete); }\n code {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 0.9em;\n background: var(--panel-2);\n padding: 0.1rem 0.4rem;\n border-radius: 4px;\n }\n .pill {\n display: inline-block;\n padding: 0.1rem 0.5rem;\n background: var(--panel-2);\n border: 1px solid var(--border);\n border-radius: 999px;\n font-size: 0.8rem;\n margin-right: 0.25rem;\n margin-bottom: 0.25rem;\n }\n .pill.requires { color: var(--accent); border-color: rgba(122, 162, 255, 0.3); }\n .pill.provides { color: var(--accent-2); border-color: rgba(87, 199, 185, 0.3); }\n .empty { color: var(--muted); font-style: italic; }\n footer {\n padding: 2rem;\n text-align: center;\n color: var(--muted);\n font-size: 0.8rem;\n border-top: 1px solid var(--border);\n }\n</style>\n</head>\n<body>\n<header>\n <h1>${escapeHtml(title)}</h1>\n <div class=\"meta\">${modules.length} modules • ${edges.length} dependencies • ${routes.length} routes</div>\n</header>\n<main>\n <section>\n <h2>Module graph</h2>\n <div class=\"graph\">\n <pre class=\"mermaid\">${escapeHtml(mermaidSrc)}</pre>\n </div>\n </section>\n\n <section>\n <h2>Modules</h2>\n <table>\n <thead><tr><th>Name</th><th>Prefix</th><th>Provides</th><th>Requires</th></tr></thead>\n <tbody>\n${modules\n .map(\n (m) => ` <tr>\n <td><strong>${escapeHtml(m.name)}</strong></td>\n <td>${m.prefix ? `<code>${escapeHtml(m.prefix)}</code>` : '<span class=\"empty\">—</span>'}</td>\n <td>${\n m.provides.length === 0\n ? '<span class=\"empty\">—</span>'\n : m.provides.map((p) => `<span class=\"pill provides\">${escapeHtml(p)}</span>`).join('')\n }</td>\n <td>${\n m.requires.length === 0\n ? '<span class=\"empty\">—</span>'\n : m.requires.map((p) => `<span class=\"pill requires\">${escapeHtml(p)}</span>`).join('')\n }</td>\n </tr>`,\n )\n .join('\\n')}\n </tbody>\n </table>\n </section>\n\n <section>\n <h2>Routes</h2>\n ${\n routes.length === 0\n ? '<p class=\"empty\">No routes mounted.</p>'\n : `<table>\n <thead><tr><th>Method</th><th>Path</th><th>Module</th></tr></thead>\n <tbody>\n${routes\n .map(\n (r) => ` <tr>\n <td class=\"method\" data-m=\"${escapeHtml(r.method)}\">${escapeHtml(r.method)}</td>\n <td><code>${escapeHtml(r.path)}</code></td>\n <td>${escapeHtml(r.module)}</td>\n </tr>`,\n )\n .join('\\n')}\n </tbody>\n </table>`\n }\n </section>\n</main>\n<footer>\n Generated by <code>inspectModules()</code> from <code>@katajs/core</code>\n</footer>\n<script type=\"module\">\n import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';\n mermaid.initialize({\n startOnLoad: true,\n securityLevel: 'loose',\n theme: 'dark',\n themeVariables: {\n darkMode: true,\n background: '#161922',\n primaryColor: '#1d2230',\n primaryTextColor: '#e8ebf2',\n primaryBorderColor: '#2a3041',\n lineColor: '#7aa2ff',\n secondaryColor: '#57c7b9',\n tertiaryColor: '#1d2230',\n },\n });\n</script>\n</body>\n</html>\n`;\n}\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n"],"mappings":"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","names":["errorMapper"]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { i as RegistryKey, A as AppDb, c as AppEnv, a as RequestContainer, h as Registry } from './types-B_SUwInq.js';
|
|
2
|
+
import 'hono';
|
|
3
|
+
|
|
4
|
+
type TestContainerOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* Map of service keys to their fake instances. Looked up by `c.resolve(key)`.
|
|
7
|
+
* Untyped intentionally — pass whatever shape your service-under-test expects.
|
|
8
|
+
*/
|
|
9
|
+
services?: Partial<Record<RegistryKey, unknown>>;
|
|
10
|
+
/** Fake DB handle. Defaults to an empty object. */
|
|
11
|
+
db?: AppDb;
|
|
12
|
+
/** Fake bindings (`c.env`). Defaults to empty. */
|
|
13
|
+
env?: AppEnv;
|
|
14
|
+
/** Request id seen by the service under test. Defaults to `'test-request'`. */
|
|
15
|
+
requestId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Custom transaction runner. If omitted, `withTransaction(fn)` calls
|
|
18
|
+
* `fn(thisContainer)` synchronously — i.e., transactions become no-ops.
|
|
19
|
+
* Override when you want to assert tx-only behaviour.
|
|
20
|
+
*/
|
|
21
|
+
runTransaction?: <T>(fn: (tx: RequestContainer) => Promise<T>) => Promise<T>;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Build a fake `RequestContainer` for unit-testing services without spinning
|
|
25
|
+
* up Hono or a real DB. Locks the public shape of `RequestContainer` — if the
|
|
26
|
+
* framework changes the container's surface, this helper signal-fails next
|
|
27
|
+
* compile, so test files don't silently rot.
|
|
28
|
+
*
|
|
29
|
+
* import { makeTestContainer } from '@katajs/core/testing';
|
|
30
|
+
*
|
|
31
|
+
* const insert = vi.fn().mockResolvedValue({ id: '1', title: 't' });
|
|
32
|
+
* const log = vi.fn();
|
|
33
|
+
*
|
|
34
|
+
* const c = makeTestContainer({
|
|
35
|
+
* services: {
|
|
36
|
+
* postRepository: { findById: vi.fn(), insert },
|
|
37
|
+
* auditService: { log },
|
|
38
|
+
* },
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* const service = makePostService(c);
|
|
42
|
+
* await service.create({ title: 't', body: 'b', authorId: 'alice' });
|
|
43
|
+
*
|
|
44
|
+
* expect(insert).toHaveBeenCalled();
|
|
45
|
+
* expect(log).toHaveBeenCalled();
|
|
46
|
+
*
|
|
47
|
+
* Best for testing service logic in isolation. Use the showcase's
|
|
48
|
+
* integration-test pattern (real Postgres) when you need to verify SQL
|
|
49
|
+
* behaviour — that's outside this helper's scope.
|
|
50
|
+
*/
|
|
51
|
+
declare function makeTestContainer(opts?: TestContainerOptions): RequestContainer;
|
|
52
|
+
/** Reference type for opting in to `Registry` keys without `as` casts in tests. */
|
|
53
|
+
type TestServices = Partial<{
|
|
54
|
+
[K in RegistryKey]: Registry[K];
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
export { type TestContainerOptions, type TestServices, makeTestContainer };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/testing.ts
|
|
2
|
+
function makeTestContainer(opts = {}) {
|
|
3
|
+
const services = opts.services ?? {};
|
|
4
|
+
const container = {
|
|
5
|
+
env: opts.env ?? {},
|
|
6
|
+
c: {},
|
|
7
|
+
requestId: opts.requestId ?? "test-request",
|
|
8
|
+
db: opts.db ?? {}
|
|
9
|
+
};
|
|
10
|
+
const resolve = ((key) => {
|
|
11
|
+
if (!(key in services)) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`[makeTestContainer] No service registered for key '${key}'. Pass it via \`services: { ${key}: ... }\`. Currently registered: ${Object.keys(services).join(", ") || "(none)"}.`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return services[key];
|
|
17
|
+
});
|
|
18
|
+
const withTransaction = (async (fn) => {
|
|
19
|
+
if (opts.runTransaction) return opts.runTransaction(fn);
|
|
20
|
+
return fn(container);
|
|
21
|
+
});
|
|
22
|
+
container.resolve = resolve;
|
|
23
|
+
container.withTransaction = withTransaction;
|
|
24
|
+
return container;
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
makeTestContainer
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/testing.ts"],"sourcesContent":["import type { Context } from 'hono';\nimport type { AppDb, AppEnv, RegistryKey, Registry, RequestContainer } from './types';\n\nexport type TestContainerOptions = {\n /**\n * Map of service keys to their fake instances. Looked up by `c.resolve(key)`.\n * Untyped intentionally — pass whatever shape your service-under-test expects.\n */\n services?: Partial<Record<RegistryKey, unknown>>;\n /** Fake DB handle. Defaults to an empty object. */\n db?: AppDb;\n /** Fake bindings (`c.env`). Defaults to empty. */\n env?: AppEnv;\n /** Request id seen by the service under test. Defaults to `'test-request'`. */\n requestId?: string;\n /**\n * Custom transaction runner. If omitted, `withTransaction(fn)` calls\n * `fn(thisContainer)` synchronously — i.e., transactions become no-ops.\n * Override when you want to assert tx-only behaviour.\n */\n runTransaction?: <T>(fn: (tx: RequestContainer) => Promise<T>) => Promise<T>;\n};\n\n/**\n * Build a fake `RequestContainer` for unit-testing services without spinning\n * up Hono or a real DB. Locks the public shape of `RequestContainer` — if the\n * framework changes the container's surface, this helper signal-fails next\n * compile, so test files don't silently rot.\n *\n * import { makeTestContainer } from '@katajs/core/testing';\n *\n * const insert = vi.fn().mockResolvedValue({ id: '1', title: 't' });\n * const log = vi.fn();\n *\n * const c = makeTestContainer({\n * services: {\n * postRepository: { findById: vi.fn(), insert },\n * auditService: { log },\n * },\n * });\n *\n * const service = makePostService(c);\n * await service.create({ title: 't', body: 'b', authorId: 'alice' });\n *\n * expect(insert).toHaveBeenCalled();\n * expect(log).toHaveBeenCalled();\n *\n * Best for testing service logic in isolation. Use the showcase's\n * integration-test pattern (real Postgres) when you need to verify SQL\n * behaviour — that's outside this helper's scope.\n */\nexport function makeTestContainer(opts: TestContainerOptions = {}): RequestContainer {\n const services = (opts.services ?? {}) as Record<string, unknown>;\n\n const container = {\n env: (opts.env ?? {}) as AppEnv,\n c: {} as Context,\n requestId: opts.requestId ?? 'test-request',\n db: (opts.db ?? ({} as AppDb)),\n } as RequestContainer;\n\n const resolve = ((key: string) => {\n if (!(key in services)) {\n throw new Error(\n `[makeTestContainer] No service registered for key '${key}'. ` +\n `Pass it via \\`services: { ${key}: ... }\\`. ` +\n `Currently registered: ${Object.keys(services).join(', ') || '(none)'}.`,\n );\n }\n return services[key];\n }) as RequestContainer['resolve'];\n\n const withTransaction = (async <T>(fn: (tx: RequestContainer) => Promise<T>): Promise<T> => {\n if (opts.runTransaction) return opts.runTransaction(fn);\n return fn(container);\n }) as RequestContainer['withTransaction'];\n\n (container as { resolve: RequestContainer['resolve'] }).resolve = resolve;\n (container as { withTransaction: RequestContainer['withTransaction'] }).withTransaction =\n withTransaction;\n\n return container;\n}\n\n/** Reference type for opting in to `Registry` keys without `as` casts in tests. */\nexport type TestServices = Partial<{\n [K in RegistryKey]: Registry[K];\n}>;\n"],"mappings":";AAmDO,SAAS,kBAAkB,OAA6B,CAAC,GAAqB;AACnF,QAAM,WAAY,KAAK,YAAY,CAAC;AAEpC,QAAM,YAAY;AAAA,IAChB,KAAM,KAAK,OAAO,CAAC;AAAA,IACnB,GAAG,CAAC;AAAA,IACJ,WAAW,KAAK,aAAa;AAAA,IAC7B,IAAK,KAAK,MAAO,CAAC;AAAA,EACpB;AAEA,QAAM,WAAW,CAAC,QAAgB;AAChC,QAAI,EAAE,OAAO,WAAW;AACtB,YAAM,IAAI;AAAA,QACR,sDAAsD,GAAG,gCAC1B,GAAG,oCACP,OAAO,KAAK,QAAQ,EAAE,KAAK,IAAI,KAAK,QAAQ;AAAA,MACzE;AAAA,IACF;AACA,WAAO,SAAS,GAAG;AAAA,EACrB;AAEA,QAAM,mBAAmB,OAAU,OAAyD;AAC1F,QAAI,KAAK,eAAgB,QAAO,KAAK,eAAe,EAAE;AACtD,WAAO,GAAG,SAAS;AAAA,EACrB;AAEA,EAAC,UAAuD,UAAU;AAClE,EAAC,UAAuE,kBACtE;AAEF,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Context } from 'hono';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Globally-augmentable registry mapping service keys to their resolved types.
|
|
5
|
+
*
|
|
6
|
+
* **Required.** Augment in your app's `types.d.ts`:
|
|
7
|
+
*
|
|
8
|
+
* declare module '@katajs/core' {
|
|
9
|
+
* interface Registry extends PostsRegistry, EventsRegistry {}
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* Without augmentation, `Registry` is empty, `RegistryKey` is `never`, and
|
|
13
|
+
* every `c.var.container.resolve(...)` call fails to compile — by design.
|
|
14
|
+
* That's the loud-failure mode that tells you "wire up your registry."
|
|
15
|
+
*/
|
|
16
|
+
interface Registry {
|
|
17
|
+
}
|
|
18
|
+
/** Bindings shape (Cloudflare env). Augment via module declaration to type `c.env`. */
|
|
19
|
+
interface AppEnv {
|
|
20
|
+
}
|
|
21
|
+
/** Drizzle (or compatible) client shape. Augment via module declaration to type `c.db`. */
|
|
22
|
+
interface AppDb {
|
|
23
|
+
}
|
|
24
|
+
type RegistryKey = keyof Registry & string;
|
|
25
|
+
type ResolveOf<K extends string> = K extends RegistryKey ? Registry[K] : unknown;
|
|
26
|
+
type ServiceFactory<T = unknown> = (c: any) => T;
|
|
27
|
+
type ProvidesMap = Record<string, ServiceFactory<unknown>>;
|
|
28
|
+
/** Common members shared by every container view. */
|
|
29
|
+
interface BaseContainer {
|
|
30
|
+
readonly env: AppEnv;
|
|
31
|
+
readonly c: Context;
|
|
32
|
+
readonly requestId: string;
|
|
33
|
+
readonly db: AppDb;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Registry-scoped container view used by route handlers, middleware, and any
|
|
37
|
+
* code outside a module's `provides` factory. `resolve` accepts only keys in
|
|
38
|
+
* the augmented `Registry` — typos and unknown keys are TS errors.
|
|
39
|
+
*/
|
|
40
|
+
interface RequestContainer extends BaseContainer {
|
|
41
|
+
resolve<K extends RegistryKey>(key: K): Registry[K];
|
|
42
|
+
withTransaction<T>(fn: (tx: RequestContainer) => Promise<T>): Promise<T>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Module-scoped container view passed to a module's service factories.
|
|
46
|
+
* Adds narrow overloads on top of `RequestContainer.resolve` so calls with
|
|
47
|
+
* keys in own `provides` or declared `requires` resolve to specific types.
|
|
48
|
+
* Calls with any other key fall through to the inherited Registry-scoped
|
|
49
|
+
* resolve — typos are still rejected.
|
|
50
|
+
*/
|
|
51
|
+
interface ModuleContainer<PSelf extends Record<string, unknown> = Record<string, unknown>, RKeys extends string = never> extends RequestContainer {
|
|
52
|
+
resolve<K extends keyof PSelf & string>(key: K): PSelf[K];
|
|
53
|
+
resolve<K extends RKeys>(key: K): K extends RegistryKey ? Registry[K] : unknown;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Transactional view of the container, passed to the `withTransaction`
|
|
57
|
+
* callback. Same shape as `RequestContainer`; `db` and any repo resolved via
|
|
58
|
+
* `tx.resolve` are bound to the transaction handle.
|
|
59
|
+
*/
|
|
60
|
+
type TransactionalContainer = RequestContainer;
|
|
61
|
+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
62
|
+
/** Merge all `provides` maps from a tuple of modules into a single map. */
|
|
63
|
+
type MergeProvides<Mods extends readonly {
|
|
64
|
+
provides: ProvidesMap;
|
|
65
|
+
}[]> = UnionToIntersection<Mods[number]['provides']> extends infer M ? M extends ProvidesMap ? M : ProvidesMap : ProvidesMap;
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a `provides` map to its return-type map: every factory is replaced
|
|
68
|
+
* by the type of value it produces. The shape `Registry` should augment to.
|
|
69
|
+
*/
|
|
70
|
+
type ResolveProvides<P extends ProvidesMap> = {
|
|
71
|
+
[K in keyof P]: P[K] extends ServiceFactory<infer T> ? T : never;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Build a `Registry`-compatible type from a tuple of modules. Use it to
|
|
75
|
+
* auto-derive the framework's augmentable `Registry` interface from your
|
|
76
|
+
* `modules` array — adding a module flows its services through automatically:
|
|
77
|
+
*
|
|
78
|
+
* const modules = [postsModule, authModule] as const;
|
|
79
|
+
*
|
|
80
|
+
* declare module '@katajs/core' {
|
|
81
|
+
* interface Registry extends ResolvedProvides<typeof modules> {}
|
|
82
|
+
* }
|
|
83
|
+
*
|
|
84
|
+
* const { app: base } = createApp({ db, modules });
|
|
85
|
+
*/
|
|
86
|
+
type ResolvedProvides<Mods extends readonly {
|
|
87
|
+
provides: ProvidesMap;
|
|
88
|
+
}[]> = ResolveProvides<MergeProvides<Mods>>;
|
|
89
|
+
/**
|
|
90
|
+
* A list of required service keys, always declared with `as const`.
|
|
91
|
+
*
|
|
92
|
+
* Constrained to `RegistryKey` so the editor autocompletes valid services
|
|
93
|
+
* inside `requires: ['▌']` and a typo or fake service name fails to compile.
|
|
94
|
+
*
|
|
95
|
+
* requires: ['auditService'] as const // ✓
|
|
96
|
+
* requires: ['auditServeice'] as const // ✗ TS error: not assignable to RegistryKey
|
|
97
|
+
* requires: [] as const // ✓ no cross-module deps
|
|
98
|
+
*/
|
|
99
|
+
type RequiresList = readonly RegistryKey[];
|
|
100
|
+
/**
|
|
101
|
+
* A queue message after `consumer.schema` has parsed its body. Mirrors the
|
|
102
|
+
* Cloudflare Queues `Message<Body>` shape, with `body` typed as the parsed
|
|
103
|
+
* value.
|
|
104
|
+
*/
|
|
105
|
+
interface ValidatedMessage<Body> {
|
|
106
|
+
readonly id: string;
|
|
107
|
+
readonly timestamp: Date;
|
|
108
|
+
readonly body: Body;
|
|
109
|
+
readonly attempts: number;
|
|
110
|
+
ack(): void;
|
|
111
|
+
retry(options?: {
|
|
112
|
+
delaySeconds?: number;
|
|
113
|
+
}): void;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A batch of validated messages. Mirrors Cloudflare's `MessageBatch<Body>`.
|
|
117
|
+
* Used by `handleBatch` consumers that want full control over per-message
|
|
118
|
+
* acking / retry instead of the per-message auto-ack default.
|
|
119
|
+
*/
|
|
120
|
+
interface ValidatedBatch<Body> {
|
|
121
|
+
readonly queue: string;
|
|
122
|
+
readonly messages: readonly ValidatedMessage<Body>[];
|
|
123
|
+
ackAll(): void;
|
|
124
|
+
retryAll(options?: {
|
|
125
|
+
delaySeconds?: number;
|
|
126
|
+
}): void;
|
|
127
|
+
}
|
|
128
|
+
/** Per-message handler: framework auto-acks on return, auto-retries on throw. */
|
|
129
|
+
type ConsumerHandler<Body> = (message: ValidatedMessage<Body>, c: RequestContainer) => Promise<void>;
|
|
130
|
+
/** Batch handler: user controls ack/retry by calling msg.ack() / msg.retry(). */
|
|
131
|
+
type ConsumerBatchHandler<Body> = (batch: ValidatedBatch<Body>, c: RequestContainer) => Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* Minimal `ZodSchema`-like shape so the framework doesn't take a hard dep on
|
|
134
|
+
* `zod`. Any `ZodSchema` instance satisfies it.
|
|
135
|
+
*/
|
|
136
|
+
interface MessageSchema<Output = unknown> {
|
|
137
|
+
parse(input: unknown): Output;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Declares a queue consumer for a module. Provide exactly one of `handle`
|
|
141
|
+
* (per-message, auto-ack) or `handleBatch` (batch, manual control). The
|
|
142
|
+
* runtime validates that one is present and throws a clear error otherwise;
|
|
143
|
+
* if both are provided, `handleBatch` wins.
|
|
144
|
+
*
|
|
145
|
+
* On retry exhaustion (attempts >= maxRetries), if `dlq` is set, the message
|
|
146
|
+
* is sent to that binding and the original is acked. Otherwise the message
|
|
147
|
+
* is acked (dropped) so it doesn't loop forever.
|
|
148
|
+
*/
|
|
149
|
+
type ConsumerSpec<TBody = unknown> = {
|
|
150
|
+
/** wrangler binding name for the queue this module consumes. */
|
|
151
|
+
readonly queue: string;
|
|
152
|
+
/** Zod (or compatible) schema validating message bodies. */
|
|
153
|
+
readonly schema: MessageSchema<TBody>;
|
|
154
|
+
/** Optional wrangler binding name for the dead-letter queue. */
|
|
155
|
+
readonly dlq?: string;
|
|
156
|
+
/** Max attempts before routing to DLQ (or dropping). Default: 3. */
|
|
157
|
+
readonly maxRetries?: number;
|
|
158
|
+
/** Per-message handler. Auto-acks on return, auto-retries on throw. */
|
|
159
|
+
readonly handle?: ConsumerHandler<TBody>;
|
|
160
|
+
/** Batch handler. User controls ack/retry per message. */
|
|
161
|
+
readonly handleBatch?: ConsumerBatchHandler<TBody>;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Options accepted by `TypedQueue.send` / `sendBatch`. Mirrors the Cloudflare
|
|
165
|
+
* `Queue.send` options surface, narrowed to fields the framework cares about.
|
|
166
|
+
*/
|
|
167
|
+
type SendOptions = {
|
|
168
|
+
/** Optional content type. Default: 'json'. */
|
|
169
|
+
contentType?: 'text' | 'bytes' | 'json' | 'v8';
|
|
170
|
+
/** Delay delivery by N seconds. */
|
|
171
|
+
delaySeconds?: number;
|
|
172
|
+
};
|
|
173
|
+
type SendBatchOptions = {
|
|
174
|
+
delaySeconds?: number;
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* Typed producer wrapper for a single queue binding. Validates the body
|
|
178
|
+
* against the registered schema before delegating to `env[binding].send(...)`.
|
|
179
|
+
*/
|
|
180
|
+
interface TypedQueue<TBody> {
|
|
181
|
+
/**
|
|
182
|
+
* Send a single message. Validates body against the schema synchronously;
|
|
183
|
+
* throws `ValidationError` on failure. On success, delegates to the
|
|
184
|
+
* underlying Cloudflare binding.
|
|
185
|
+
*/
|
|
186
|
+
send(body: TBody, options?: SendOptions): Promise<void>;
|
|
187
|
+
/**
|
|
188
|
+
* Send a batch. Each body is validated; first validation failure aborts
|
|
189
|
+
* with `ValidationError` (no messages sent).
|
|
190
|
+
*/
|
|
191
|
+
sendBatch(bodies: readonly TBody[], options?: SendBatchOptions): Promise<void>;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Globally-augmentable producer registry. Mirrors the `Registry` pattern.
|
|
195
|
+
* Each entry maps a queue name to a `TypedQueue<TBody>` view.
|
|
196
|
+
*
|
|
197
|
+
* declare module '@katajs/core' {
|
|
198
|
+
* interface QueuesRegistry {
|
|
199
|
+
* orders: TypedQueue<OrderEvent>;
|
|
200
|
+
* notifications: TypedQueue<NotificationEvent>;
|
|
201
|
+
* }
|
|
202
|
+
* }
|
|
203
|
+
*
|
|
204
|
+
* Empty by default. The augmentation flows the `body` shape into
|
|
205
|
+
* `c.var.queues.<name>.send(body)` at the call site.
|
|
206
|
+
*/
|
|
207
|
+
interface QueuesRegistry {
|
|
208
|
+
}
|
|
209
|
+
/** Declaration shape used by `createApp({ queues: {...} })`. */
|
|
210
|
+
type QueueDeclaration<TBody = unknown> = {
|
|
211
|
+
/** wrangler binding name (e.g. 'ORDER_QUEUE'). */
|
|
212
|
+
readonly binding: string;
|
|
213
|
+
/** Schema validated against the message body before send. */
|
|
214
|
+
readonly schema: MessageSchema<TBody>;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type { AppDb as A, BaseContainer as B, ConsumerSpec as C, ModuleContainer as M, ProvidesMap as P, QueuesRegistry as Q, RequiresList as R, ServiceFactory as S, TransactionalContainer as T, ValidatedBatch as V, RequestContainer as a, QueueDeclaration as b, AppEnv as c, ConsumerBatchHandler as d, ConsumerHandler as e, MergeProvides as f, MessageSchema as g, Registry as h, RegistryKey as i, ResolveOf as j, ResolveProvides as k, ResolvedProvides as l, SendBatchOptions as m, SendOptions as n, TypedQueue as o, ValidatedMessage as p };
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@katajs/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An opinionated framework for Hono on Cloudflare Workers — runtime.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"hono",
|
|
7
|
+
"cloudflare",
|
|
8
|
+
"workers",
|
|
9
|
+
"framework",
|
|
10
|
+
"typescript",
|
|
11
|
+
"katajs"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Yaseer A. Okino <yaseerokino@gmail.com>",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ookino/katajs.git",
|
|
18
|
+
"directory": "packages/core"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/ookino/katajs/tree/main/packages/core#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/ookino/katajs/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"module": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./testing": {
|
|
34
|
+
"types": "./dist/testing.d.ts",
|
|
35
|
+
"import": "./dist/testing.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@hono/zod-validator": ">=0.4.0",
|
|
46
|
+
"hono": "^4.0.0",
|
|
47
|
+
"zod": "^3.22.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@hono/zod-validator": "^0.4.1",
|
|
51
|
+
"expect-type": "^1.1.0",
|
|
52
|
+
"hono": "^4.6.10",
|
|
53
|
+
"tsup": "^8.3.5",
|
|
54
|
+
"typescript": "^5.6.3",
|
|
55
|
+
"vitest": "^2.1.5",
|
|
56
|
+
"zod": "^3.23.8"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsup",
|
|
63
|
+
"dev": "tsup --watch",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"test:watch": "vitest",
|
|
66
|
+
"typecheck": "tsc --noEmit"
|
|
67
|
+
}
|
|
68
|
+
}
|