@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
// src/module.ts
|
|
2
|
+
function defineModule(spec) {
|
|
3
|
+
if (spec.routes && spec.prefix) {
|
|
4
|
+
return {
|
|
5
|
+
name: spec.name,
|
|
6
|
+
provides: spec.provides,
|
|
7
|
+
requires: spec.requires,
|
|
8
|
+
routes: spec.routes,
|
|
9
|
+
prefix: spec.prefix,
|
|
10
|
+
consumer: spec.consumer
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
name: spec.name,
|
|
15
|
+
provides: spec.provides,
|
|
16
|
+
requires: spec.requires,
|
|
17
|
+
consumer: spec.consumer
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/app.ts
|
|
22
|
+
import { Hono } from "hono";
|
|
23
|
+
|
|
24
|
+
// src/container.ts
|
|
25
|
+
function makeResolver(registry, containerView) {
|
|
26
|
+
const cache = /* @__PURE__ */ new Map();
|
|
27
|
+
const inProgress = /* @__PURE__ */ new Set();
|
|
28
|
+
return function resolve(key) {
|
|
29
|
+
if (cache.has(key)) {
|
|
30
|
+
return cache.get(key);
|
|
31
|
+
}
|
|
32
|
+
if (inProgress.has(key)) {
|
|
33
|
+
const stack = [...inProgress, key].join(" -> ");
|
|
34
|
+
throw new Error(
|
|
35
|
+
`[katajs] Circular dependency detected while resolving '${key}'. Resolution stack: ${stack}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const factory = registry.get(key);
|
|
39
|
+
if (!factory) {
|
|
40
|
+
const known = [...registry.keys()].sort().join(", ") || "(none)";
|
|
41
|
+
throw new Error(
|
|
42
|
+
`[katajs] No service registered for key '${key}'. Registered keys: ${known}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
inProgress.add(key);
|
|
46
|
+
try {
|
|
47
|
+
const instance = factory(containerView);
|
|
48
|
+
cache.set(key, instance);
|
|
49
|
+
return instance;
|
|
50
|
+
} finally {
|
|
51
|
+
inProgress.delete(key);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function buildRegistry(providesMaps) {
|
|
56
|
+
const registry = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const provides of providesMaps) {
|
|
58
|
+
for (const [key, factory] of Object.entries(provides)) {
|
|
59
|
+
registry.set(key, factory);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return registry;
|
|
63
|
+
}
|
|
64
|
+
var queueContextStub = new Proxy({}, {
|
|
65
|
+
get(_, prop) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[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.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
function buildContainer(args) {
|
|
72
|
+
const container = {
|
|
73
|
+
env: args.env,
|
|
74
|
+
c: args.c ?? queueContextStub,
|
|
75
|
+
requestId: args.requestId,
|
|
76
|
+
db: args.db
|
|
77
|
+
};
|
|
78
|
+
container.resolve = makeResolver(
|
|
79
|
+
args.registry,
|
|
80
|
+
container
|
|
81
|
+
);
|
|
82
|
+
container.withTransaction = async (fn) => {
|
|
83
|
+
if (args.inTransaction) {
|
|
84
|
+
return fn(container);
|
|
85
|
+
}
|
|
86
|
+
if (!args.runTransaction) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"[katajs] withTransaction was called but the configured db adapter does not support transactions. Use an adapter with a 'runTransaction' method."
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return args.runTransaction(args.db, async (txDb) => {
|
|
92
|
+
const txContainer = buildContainer({
|
|
93
|
+
...args,
|
|
94
|
+
db: txDb,
|
|
95
|
+
inTransaction: true
|
|
96
|
+
});
|
|
97
|
+
return fn(txContainer);
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
return container;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/errors.ts
|
|
104
|
+
var AppError = class extends Error {
|
|
105
|
+
/**
|
|
106
|
+
* Optional structured payload merged into the response body. Subclasses may
|
|
107
|
+
* override as either a property or a getter.
|
|
108
|
+
*/
|
|
109
|
+
get publicPayload() {
|
|
110
|
+
return void 0;
|
|
111
|
+
}
|
|
112
|
+
constructor(message) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = this.constructor.name;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
var ValidationError = class extends AppError {
|
|
118
|
+
constructor(issues) {
|
|
119
|
+
super(`Validation failed: ${issues.length} issue(s)`);
|
|
120
|
+
this.issues = issues;
|
|
121
|
+
}
|
|
122
|
+
issues;
|
|
123
|
+
status = 400;
|
|
124
|
+
code = "validation_failed";
|
|
125
|
+
publicMessage = "Request validation failed";
|
|
126
|
+
get publicPayload() {
|
|
127
|
+
return { issues: this.issues };
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
function errorMapper(opts = {}) {
|
|
131
|
+
return (err, c) => {
|
|
132
|
+
const requestId = c.get("requestId") ?? c.req.header("X-Request-Id") ?? "unknown";
|
|
133
|
+
if (err instanceof AppError) {
|
|
134
|
+
return c.json(
|
|
135
|
+
{
|
|
136
|
+
error: err.code,
|
|
137
|
+
message: err.publicMessage,
|
|
138
|
+
...err.publicPayload ?? {},
|
|
139
|
+
requestId
|
|
140
|
+
},
|
|
141
|
+
err.status
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
opts.onUnhandled?.(err, { c, requestId });
|
|
145
|
+
return c.json(
|
|
146
|
+
{
|
|
147
|
+
error: "internal_error",
|
|
148
|
+
message: "Something went wrong. Please try again.",
|
|
149
|
+
requestId
|
|
150
|
+
},
|
|
151
|
+
500
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/queues-producer.ts
|
|
157
|
+
function buildTypedQueues(declarations, env) {
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const [name, decl] of Object.entries(declarations)) {
|
|
160
|
+
out[name] = makeTypedQueue(name, decl, env);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function makeTypedQueue(name, decl, env) {
|
|
165
|
+
const binding = env?.[decl.binding];
|
|
166
|
+
return {
|
|
167
|
+
async send(body, options) {
|
|
168
|
+
const validated = validateOrThrow(name, decl, body);
|
|
169
|
+
assertBinding(name, decl.binding, binding);
|
|
170
|
+
await binding.send(validated, options);
|
|
171
|
+
},
|
|
172
|
+
async sendBatch(bodies, options) {
|
|
173
|
+
const validated = bodies.map((b) => validateOrThrow(name, decl, b));
|
|
174
|
+
assertBinding(name, decl.binding, binding);
|
|
175
|
+
if (typeof binding.sendBatch === "function") {
|
|
176
|
+
const messages = validated.map((body) => ({ body }));
|
|
177
|
+
await binding.sendBatch(messages, options);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const body of validated) {
|
|
181
|
+
await binding.send(body, options);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function validateOrThrow(queueName, decl, body) {
|
|
187
|
+
try {
|
|
188
|
+
return decl.schema.parse(body);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const issues = err && typeof err === "object" && "issues" in err && Array.isArray(err.issues) ? err.issues.map((i) => ({
|
|
191
|
+
path: [
|
|
192
|
+
`queues.${queueName}`,
|
|
193
|
+
...Array.isArray(i.path) ? i.path : []
|
|
194
|
+
],
|
|
195
|
+
message: typeof i.message === "string" ? i.message : "Invalid"
|
|
196
|
+
})) : [
|
|
197
|
+
{
|
|
198
|
+
path: [`queues.${queueName}`],
|
|
199
|
+
message: err instanceof Error ? err.message : String(err)
|
|
200
|
+
}
|
|
201
|
+
];
|
|
202
|
+
throw new ValidationError(issues);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function assertBinding(queueName, bindingName, binding) {
|
|
206
|
+
if (!binding || typeof binding.send !== "function") {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`[katajs] Queue '${queueName}': binding '${bindingName}' is not registered as a producer in wrangler.jsonc, or env doesn't have it. Add a producer entry for this binding in your wrangler config.`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/middleware.ts
|
|
214
|
+
function containerMiddleware(config) {
|
|
215
|
+
return async (c, next) => {
|
|
216
|
+
const requestId = config.generateRequestId ? config.generateRequestId() : crypto.randomUUID();
|
|
217
|
+
const db = config.db.create(c.env);
|
|
218
|
+
const container = buildContainer({
|
|
219
|
+
env: c.env,
|
|
220
|
+
c,
|
|
221
|
+
requestId,
|
|
222
|
+
db,
|
|
223
|
+
registry: config.registry,
|
|
224
|
+
runTransaction: config.db.runTransaction,
|
|
225
|
+
inTransaction: false
|
|
226
|
+
});
|
|
227
|
+
c.set("container", container);
|
|
228
|
+
c.set("requestId", requestId);
|
|
229
|
+
c.set("resolve", container.resolve);
|
|
230
|
+
c.set("withTransaction", container.withTransaction);
|
|
231
|
+
if (config.queues && Object.keys(config.queues).length > 0) {
|
|
232
|
+
c.set(
|
|
233
|
+
"queues",
|
|
234
|
+
buildTypedQueues(config.queues, c.env)
|
|
235
|
+
);
|
|
236
|
+
} else {
|
|
237
|
+
c.set("queues", {});
|
|
238
|
+
}
|
|
239
|
+
c.header("X-Request-Id", requestId);
|
|
240
|
+
await next();
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function defineMiddleware(handler) {
|
|
244
|
+
return handler;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/queue.ts
|
|
248
|
+
function defineConsumer(spec) {
|
|
249
|
+
return spec;
|
|
250
|
+
}
|
|
251
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
252
|
+
function buildQueueHandler(config) {
|
|
253
|
+
const consumersByQueue = /* @__PURE__ */ new Map();
|
|
254
|
+
for (const m of config.modules) {
|
|
255
|
+
if (!m.consumer) continue;
|
|
256
|
+
if (consumersByQueue.has(m.consumer.queue)) {
|
|
257
|
+
const owner = consumersByQueue.get(m.consumer.queue).module.name;
|
|
258
|
+
throw new Error(
|
|
259
|
+
`[katajs] Duplicate queue consumer for binding '${m.consumer.queue}': modules '${owner}' and '${m.name}' both consume it.`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
consumersByQueue.set(m.consumer.queue, { module: m, consumer: m.consumer });
|
|
263
|
+
}
|
|
264
|
+
if (consumersByQueue.size === 0) return void 0;
|
|
265
|
+
return async function queue(batch, env) {
|
|
266
|
+
const entry = consumersByQueue.get(batch.queue);
|
|
267
|
+
if (!entry) {
|
|
268
|
+
const known = [...consumersByQueue.keys()].join(", ") || "(none)";
|
|
269
|
+
throw new Error(
|
|
270
|
+
`[katajs] No consumer registered for queue '${batch.queue}'. Known consumer bindings: ${known}.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const { consumer } = entry;
|
|
274
|
+
const db = config.db.create(env);
|
|
275
|
+
const sharedContainerArgs = {
|
|
276
|
+
env,
|
|
277
|
+
registry: config.registry,
|
|
278
|
+
db,
|
|
279
|
+
runTransaction: config.db.runTransaction,
|
|
280
|
+
inTransaction: false
|
|
281
|
+
};
|
|
282
|
+
if ("handleBatch" in consumer && consumer.handleBatch) {
|
|
283
|
+
const requestId = config.generateRequestId?.() ?? cryptoRandomUUID();
|
|
284
|
+
const validated = [];
|
|
285
|
+
for (const msg of batch.messages) {
|
|
286
|
+
try {
|
|
287
|
+
const body = consumer.schema.parse(msg.body);
|
|
288
|
+
validated.push(makeValidatedMessage(msg, body));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
await onMessageFailure(msg, consumer, env, err, config.errorMapper, batch.queue);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (validated.length === 0) return;
|
|
294
|
+
const container = buildContainer({
|
|
295
|
+
...sharedContainerArgs,
|
|
296
|
+
requestId
|
|
297
|
+
});
|
|
298
|
+
const validatedBatch = {
|
|
299
|
+
queue: batch.queue,
|
|
300
|
+
messages: validated,
|
|
301
|
+
ackAll: () => batch.ackAll(),
|
|
302
|
+
retryAll: (opts) => batch.retryAll(opts)
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
await consumer.handleBatch(validatedBatch, container);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
config.errorMapper?.onUnhandled?.(err, { queue: batch.queue });
|
|
308
|
+
batch.retryAll();
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!("handle" in consumer) || !consumer.handle) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[katajs] Consumer for '${batch.queue}' (module '${entry.module.name}') has neither 'handle' nor 'handleBatch'.`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const handle = consumer.handle;
|
|
318
|
+
for (const msg of batch.messages) {
|
|
319
|
+
let body;
|
|
320
|
+
try {
|
|
321
|
+
body = consumer.schema.parse(msg.body);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
const wrapped = err instanceof ValidationError ? err : err;
|
|
324
|
+
await onMessageFailure(msg, consumer, env, wrapped, config.errorMapper, batch.queue);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const container = buildContainer({
|
|
328
|
+
...sharedContainerArgs,
|
|
329
|
+
requestId: msg.id
|
|
330
|
+
});
|
|
331
|
+
try {
|
|
332
|
+
await handle(makeValidatedMessage(msg, body), container);
|
|
333
|
+
msg.ack();
|
|
334
|
+
} catch (err) {
|
|
335
|
+
await onMessageFailure(msg, consumer, env, err, config.errorMapper, batch.queue);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function makeValidatedMessage(raw, body) {
|
|
341
|
+
return {
|
|
342
|
+
id: raw.id,
|
|
343
|
+
timestamp: raw.timestamp,
|
|
344
|
+
body,
|
|
345
|
+
attempts: raw.attempts,
|
|
346
|
+
ack: () => raw.ack(),
|
|
347
|
+
retry: (opts) => raw.retry(opts)
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
async function onMessageFailure(msg, consumer, env, err, errorMapper2, queue) {
|
|
351
|
+
errorMapper2?.onUnhandled?.(err, {
|
|
352
|
+
queue,
|
|
353
|
+
messageId: msg.id,
|
|
354
|
+
attempts: msg.attempts
|
|
355
|
+
});
|
|
356
|
+
const maxRetries = consumer.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
357
|
+
if (msg.attempts >= maxRetries) {
|
|
358
|
+
if (consumer.dlq) {
|
|
359
|
+
const dlqBinding = env?.[consumer.dlq];
|
|
360
|
+
if (dlqBinding && typeof dlqBinding.send === "function") {
|
|
361
|
+
try {
|
|
362
|
+
await dlqBinding.send({
|
|
363
|
+
originalQueue: queue,
|
|
364
|
+
messageId: msg.id,
|
|
365
|
+
body: msg.body,
|
|
366
|
+
error: err instanceof Error ? err.message : String(err),
|
|
367
|
+
attempts: msg.attempts,
|
|
368
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
369
|
+
});
|
|
370
|
+
msg.ack();
|
|
371
|
+
return;
|
|
372
|
+
} catch (dlqErr) {
|
|
373
|
+
errorMapper2?.onUnhandled?.(dlqErr, {
|
|
374
|
+
queue: consumer.dlq,
|
|
375
|
+
messageId: msg.id
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
errorMapper2?.onUnhandled?.(
|
|
380
|
+
new Error(
|
|
381
|
+
`[katajs] DLQ binding '${consumer.dlq}' not found on env. Acking message ${msg.id} to avoid loops.`
|
|
382
|
+
),
|
|
383
|
+
{ queue, messageId: msg.id }
|
|
384
|
+
);
|
|
385
|
+
msg.ack();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
msg.ack();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
msg.retry();
|
|
394
|
+
}
|
|
395
|
+
function cryptoRandomUUID() {
|
|
396
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
397
|
+
return crypto.randomUUID();
|
|
398
|
+
}
|
|
399
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/app.ts
|
|
403
|
+
function createApp(config) {
|
|
404
|
+
validateModules(config.modules);
|
|
405
|
+
const registry = buildRegistry(config.modules.map((m) => m.provides));
|
|
406
|
+
const base = new Hono();
|
|
407
|
+
base.use(
|
|
408
|
+
"*",
|
|
409
|
+
containerMiddleware({
|
|
410
|
+
registry,
|
|
411
|
+
db: config.db,
|
|
412
|
+
generateRequestId: config.generateRequestId,
|
|
413
|
+
queues: config.queues
|
|
414
|
+
})
|
|
415
|
+
);
|
|
416
|
+
for (const mw of config.middleware ?? []) {
|
|
417
|
+
base.use("*", mw);
|
|
418
|
+
}
|
|
419
|
+
base.onError(errorMapper(config.errorMapper));
|
|
420
|
+
const app = config.routes ? config.routes(base) : base;
|
|
421
|
+
const queue = buildQueueHandler({
|
|
422
|
+
modules: config.modules,
|
|
423
|
+
registry,
|
|
424
|
+
db: config.db,
|
|
425
|
+
errorMapper: config.queueErrorMapper,
|
|
426
|
+
generateRequestId: config.generateRequestId
|
|
427
|
+
});
|
|
428
|
+
return { app, modules: config.modules, queue };
|
|
429
|
+
}
|
|
430
|
+
function validateModules(modules) {
|
|
431
|
+
const provideOwners = /* @__PURE__ */ new Map();
|
|
432
|
+
for (const m of modules) {
|
|
433
|
+
for (const key of Object.keys(m.provides)) {
|
|
434
|
+
const existing = provideOwners.get(key);
|
|
435
|
+
if (existing) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`[katajs] Duplicate provides key '${key}'.
|
|
438
|
+
Provided by: '${existing}' and '${m.name}'.
|
|
439
|
+
Pick one module to own this key.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
provideOwners.set(key, m.name);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const allKeys = [...provideOwners.keys()];
|
|
446
|
+
const moduleNames = modules.map((m) => m.name);
|
|
447
|
+
for (const m of modules) {
|
|
448
|
+
for (const key of m.requires) {
|
|
449
|
+
if (!provideOwners.has(key)) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`[katajs] Module '${m.name}' requires '${key}', but no module provides it.
|
|
452
|
+
Modules registered: ${moduleNames.join(", ") || "(none)"}.
|
|
453
|
+
Provided keys: ${allKeys.join(", ") || "(none)"}.
|
|
454
|
+
Did you forget to add the providing module to createApp({ modules: [...] })?`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const moduleByName = new Map(modules.map((m) => [m.name, m]));
|
|
460
|
+
const edges = /* @__PURE__ */ new Map();
|
|
461
|
+
for (const m of modules) {
|
|
462
|
+
const deps = /* @__PURE__ */ new Set();
|
|
463
|
+
for (const key of m.requires) {
|
|
464
|
+
const ownerName = provideOwners.get(key);
|
|
465
|
+
if (ownerName && ownerName !== m.name) deps.add(ownerName);
|
|
466
|
+
}
|
|
467
|
+
edges.set(m.name, deps);
|
|
468
|
+
}
|
|
469
|
+
const cycle = findCycle(edges);
|
|
470
|
+
if (cycle) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`[katajs] Module dependency cycle detected.
|
|
473
|
+
Cycle: ${cycle.join(" -> ")}.
|
|
474
|
+
Modules cannot transitively require services from each other.`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
void moduleByName;
|
|
478
|
+
}
|
|
479
|
+
function findCycle(edges) {
|
|
480
|
+
const VISITING = 1;
|
|
481
|
+
const VISITED = 2;
|
|
482
|
+
const state = /* @__PURE__ */ new Map();
|
|
483
|
+
const stack = [];
|
|
484
|
+
function visit(node) {
|
|
485
|
+
const s = state.get(node);
|
|
486
|
+
if (s === VISITED) return void 0;
|
|
487
|
+
if (s === VISITING) {
|
|
488
|
+
const idx = stack.indexOf(node);
|
|
489
|
+
return [...stack.slice(idx), node];
|
|
490
|
+
}
|
|
491
|
+
state.set(node, VISITING);
|
|
492
|
+
stack.push(node);
|
|
493
|
+
for (const next of edges.get(node) ?? []) {
|
|
494
|
+
const found = visit(next);
|
|
495
|
+
if (found) return found;
|
|
496
|
+
}
|
|
497
|
+
stack.pop();
|
|
498
|
+
state.set(node, VISITED);
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
501
|
+
for (const node of edges.keys()) {
|
|
502
|
+
const found = visit(node);
|
|
503
|
+
if (found) return found;
|
|
504
|
+
}
|
|
505
|
+
return void 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/validate.ts
|
|
509
|
+
import { zValidator } from "@hono/zod-validator";
|
|
510
|
+
var targetByLabel = {
|
|
511
|
+
body: "json",
|
|
512
|
+
query: "query",
|
|
513
|
+
param: "param"
|
|
514
|
+
};
|
|
515
|
+
function makeHook(label) {
|
|
516
|
+
return (result, _c) => {
|
|
517
|
+
if (!result.success && result.error) {
|
|
518
|
+
const issues = result.error.issues.map((i) => ({
|
|
519
|
+
path: [label, ...i.path],
|
|
520
|
+
message: i.message
|
|
521
|
+
}));
|
|
522
|
+
throw new ValidationError(issues);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
function validate(opts) {
|
|
527
|
+
const middlewares = [];
|
|
528
|
+
if (opts.body) {
|
|
529
|
+
middlewares.push(zValidator(targetByLabel.body, opts.body, makeHook("body")));
|
|
530
|
+
}
|
|
531
|
+
if (opts.query) {
|
|
532
|
+
middlewares.push(zValidator(targetByLabel.query, opts.query, makeHook("query")));
|
|
533
|
+
}
|
|
534
|
+
if (opts.param) {
|
|
535
|
+
middlewares.push(zValidator(targetByLabel.param, opts.param, makeHook("param")));
|
|
536
|
+
}
|
|
537
|
+
return middlewares;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/inspect.ts
|
|
541
|
+
function inspectModules(modules, options = {}) {
|
|
542
|
+
const provideOwners = /* @__PURE__ */ new Map();
|
|
543
|
+
for (const m of modules) {
|
|
544
|
+
for (const key of Object.keys(m.provides)) {
|
|
545
|
+
provideOwners.set(key, m.name);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const inspectedModules = modules.map((m) => {
|
|
549
|
+
const consumer = m.consumer ? { queue: m.consumer.queue, dlq: m.consumer.dlq } : void 0;
|
|
550
|
+
return {
|
|
551
|
+
name: m.name,
|
|
552
|
+
provides: Object.keys(m.provides).sort(),
|
|
553
|
+
requires: [...m.requires].sort(),
|
|
554
|
+
prefix: isRoutedModule(m) ? m.prefix : void 0,
|
|
555
|
+
hasRoutes: isRoutedModule(m),
|
|
556
|
+
...consumer ? { consumer } : {}
|
|
557
|
+
};
|
|
558
|
+
});
|
|
559
|
+
const producers = options.producers ? Object.entries(options.producers).map(([name, p]) => ({ name, binding: p.binding })).sort((a, b) => a.name.localeCompare(b.name)) : [];
|
|
560
|
+
const edges = [];
|
|
561
|
+
for (const m of modules) {
|
|
562
|
+
for (const key of m.requires) {
|
|
563
|
+
const owner = provideOwners.get(key);
|
|
564
|
+
if (owner && owner !== m.name) {
|
|
565
|
+
edges.push({ from: m.name, to: owner, via: key });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const routes = [];
|
|
570
|
+
const seenRoutes = /* @__PURE__ */ new Set();
|
|
571
|
+
for (const m of modules) {
|
|
572
|
+
if (!isRoutedModule(m)) continue;
|
|
573
|
+
const honoRoutes = m.routes.routes;
|
|
574
|
+
if (!Array.isArray(honoRoutes)) continue;
|
|
575
|
+
for (const r of honoRoutes) {
|
|
576
|
+
if (typeof r.method !== "string" || typeof r.path !== "string") continue;
|
|
577
|
+
if (r.method === "ALL" && r.path === "*") continue;
|
|
578
|
+
const fullPath = mergePath(m.prefix, r.path);
|
|
579
|
+
const key = `${r.method} ${fullPath} ${m.name}`;
|
|
580
|
+
if (seenRoutes.has(key)) continue;
|
|
581
|
+
seenRoutes.add(key);
|
|
582
|
+
routes.push({ method: r.method, path: fullPath, module: m.name });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
routes.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
586
|
+
edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
587
|
+
return {
|
|
588
|
+
modules: inspectedModules,
|
|
589
|
+
edges,
|
|
590
|
+
routes,
|
|
591
|
+
producers,
|
|
592
|
+
mermaid: () => buildMermaid(inspectedModules, edges),
|
|
593
|
+
json: () => JSON.stringify(
|
|
594
|
+
{ modules: inspectedModules, edges, routes, producers },
|
|
595
|
+
null,
|
|
596
|
+
2
|
|
597
|
+
),
|
|
598
|
+
html: (opts) => buildHtml(inspectedModules, edges, routes, opts)
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function isRoutedModule(m) {
|
|
602
|
+
return "routes" in m && "prefix" in m;
|
|
603
|
+
}
|
|
604
|
+
function mergePath(base, sub) {
|
|
605
|
+
if (!base || base === "/") return sub === "" ? "/" : sub;
|
|
606
|
+
if (!sub || sub === "/") return base;
|
|
607
|
+
const a = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
608
|
+
const b = sub.startsWith("/") ? sub : `/${sub}`;
|
|
609
|
+
return `${a}${b}`;
|
|
610
|
+
}
|
|
611
|
+
function buildMermaid(modules, edges) {
|
|
612
|
+
const lines = ["graph TD"];
|
|
613
|
+
for (const m of modules) {
|
|
614
|
+
const meta = [];
|
|
615
|
+
if (m.prefix) meta.push(m.prefix);
|
|
616
|
+
if (m.provides.length > 0) meta.push(`+${m.provides.length} services`);
|
|
617
|
+
const sub = meta.length > 0 ? `<br/><small>${meta.join(" \u2022 ")}</small>` : "";
|
|
618
|
+
lines.push(` ${m.name}["<b>${m.name}</b>${sub}"]`);
|
|
619
|
+
}
|
|
620
|
+
for (const e of edges) {
|
|
621
|
+
lines.push(` ${e.from} -->|${e.via}| ${e.to}`);
|
|
622
|
+
}
|
|
623
|
+
return lines.join("\n");
|
|
624
|
+
}
|
|
625
|
+
function buildHtml(modules, edges, routes, opts = {}) {
|
|
626
|
+
const title = opts.title ?? "katajs \u2014 module graph";
|
|
627
|
+
const mermaidSrc = buildMermaid(modules, edges);
|
|
628
|
+
return `<!doctype html>
|
|
629
|
+
<html lang="en">
|
|
630
|
+
<head>
|
|
631
|
+
<meta charset="utf-8">
|
|
632
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
633
|
+
<title>${escapeHtml(title)}</title>
|
|
634
|
+
<style>
|
|
635
|
+
:root {
|
|
636
|
+
--bg: #0f1115;
|
|
637
|
+
--panel: #161922;
|
|
638
|
+
--panel-2: #1d2230;
|
|
639
|
+
--text: #e8ebf2;
|
|
640
|
+
--muted: #8b93a7;
|
|
641
|
+
--accent: #7aa2ff;
|
|
642
|
+
--accent-2: #57c7b9;
|
|
643
|
+
--border: #2a3041;
|
|
644
|
+
--get: #57c7b9;
|
|
645
|
+
--post: #7aa2ff;
|
|
646
|
+
--put: #f5a623;
|
|
647
|
+
--patch: #f5a623;
|
|
648
|
+
--delete: #ff6b6b;
|
|
649
|
+
}
|
|
650
|
+
* { box-sizing: border-box; }
|
|
651
|
+
body {
|
|
652
|
+
font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
653
|
+
margin: 0;
|
|
654
|
+
background: var(--bg);
|
|
655
|
+
color: var(--text);
|
|
656
|
+
}
|
|
657
|
+
header {
|
|
658
|
+
padding: 1.75rem 2rem 1.25rem;
|
|
659
|
+
border-bottom: 1px solid var(--border);
|
|
660
|
+
}
|
|
661
|
+
header h1 {
|
|
662
|
+
margin: 0;
|
|
663
|
+
font-size: 1.4rem;
|
|
664
|
+
font-weight: 600;
|
|
665
|
+
}
|
|
666
|
+
header .meta {
|
|
667
|
+
color: var(--muted);
|
|
668
|
+
margin-top: 0.25rem;
|
|
669
|
+
font-size: 0.85rem;
|
|
670
|
+
}
|
|
671
|
+
main {
|
|
672
|
+
padding: 1.5rem 2rem 4rem;
|
|
673
|
+
max-width: 1280px;
|
|
674
|
+
margin: 0 auto;
|
|
675
|
+
}
|
|
676
|
+
section + section { margin-top: 2.5rem; }
|
|
677
|
+
section h2 {
|
|
678
|
+
font-size: 0.85rem;
|
|
679
|
+
text-transform: uppercase;
|
|
680
|
+
letter-spacing: 0.08em;
|
|
681
|
+
color: var(--muted);
|
|
682
|
+
font-weight: 600;
|
|
683
|
+
margin: 0 0 0.75rem;
|
|
684
|
+
}
|
|
685
|
+
.graph {
|
|
686
|
+
background: var(--panel);
|
|
687
|
+
border: 1px solid var(--border);
|
|
688
|
+
border-radius: 12px;
|
|
689
|
+
padding: 1.5rem;
|
|
690
|
+
overflow: auto;
|
|
691
|
+
}
|
|
692
|
+
.graph .mermaid {
|
|
693
|
+
text-align: center;
|
|
694
|
+
color: var(--text);
|
|
695
|
+
}
|
|
696
|
+
table {
|
|
697
|
+
width: 100%;
|
|
698
|
+
border-collapse: collapse;
|
|
699
|
+
background: var(--panel);
|
|
700
|
+
border: 1px solid var(--border);
|
|
701
|
+
border-radius: 12px;
|
|
702
|
+
overflow: hidden;
|
|
703
|
+
}
|
|
704
|
+
th, td {
|
|
705
|
+
text-align: left;
|
|
706
|
+
padding: 0.7rem 1rem;
|
|
707
|
+
border-bottom: 1px solid var(--border);
|
|
708
|
+
}
|
|
709
|
+
tr:last-child td { border-bottom: none; }
|
|
710
|
+
th {
|
|
711
|
+
background: var(--panel-2);
|
|
712
|
+
color: var(--muted);
|
|
713
|
+
font-size: 0.75rem;
|
|
714
|
+
text-transform: uppercase;
|
|
715
|
+
letter-spacing: 0.06em;
|
|
716
|
+
font-weight: 600;
|
|
717
|
+
}
|
|
718
|
+
td.method { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
|
719
|
+
td.method[data-m="GET"] { color: var(--get); }
|
|
720
|
+
td.method[data-m="POST"] { color: var(--post); }
|
|
721
|
+
td.method[data-m="PUT"] { color: var(--put); }
|
|
722
|
+
td.method[data-m="PATCH"] { color: var(--patch); }
|
|
723
|
+
td.method[data-m="DELETE"] { color: var(--delete); }
|
|
724
|
+
code {
|
|
725
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
726
|
+
font-size: 0.9em;
|
|
727
|
+
background: var(--panel-2);
|
|
728
|
+
padding: 0.1rem 0.4rem;
|
|
729
|
+
border-radius: 4px;
|
|
730
|
+
}
|
|
731
|
+
.pill {
|
|
732
|
+
display: inline-block;
|
|
733
|
+
padding: 0.1rem 0.5rem;
|
|
734
|
+
background: var(--panel-2);
|
|
735
|
+
border: 1px solid var(--border);
|
|
736
|
+
border-radius: 999px;
|
|
737
|
+
font-size: 0.8rem;
|
|
738
|
+
margin-right: 0.25rem;
|
|
739
|
+
margin-bottom: 0.25rem;
|
|
740
|
+
}
|
|
741
|
+
.pill.requires { color: var(--accent); border-color: rgba(122, 162, 255, 0.3); }
|
|
742
|
+
.pill.provides { color: var(--accent-2); border-color: rgba(87, 199, 185, 0.3); }
|
|
743
|
+
.empty { color: var(--muted); font-style: italic; }
|
|
744
|
+
footer {
|
|
745
|
+
padding: 2rem;
|
|
746
|
+
text-align: center;
|
|
747
|
+
color: var(--muted);
|
|
748
|
+
font-size: 0.8rem;
|
|
749
|
+
border-top: 1px solid var(--border);
|
|
750
|
+
}
|
|
751
|
+
</style>
|
|
752
|
+
</head>
|
|
753
|
+
<body>
|
|
754
|
+
<header>
|
|
755
|
+
<h1>${escapeHtml(title)}</h1>
|
|
756
|
+
<div class="meta">${modules.length} modules \u2022 ${edges.length} dependencies \u2022 ${routes.length} routes</div>
|
|
757
|
+
</header>
|
|
758
|
+
<main>
|
|
759
|
+
<section>
|
|
760
|
+
<h2>Module graph</h2>
|
|
761
|
+
<div class="graph">
|
|
762
|
+
<pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
|
|
763
|
+
</div>
|
|
764
|
+
</section>
|
|
765
|
+
|
|
766
|
+
<section>
|
|
767
|
+
<h2>Modules</h2>
|
|
768
|
+
<table>
|
|
769
|
+
<thead><tr><th>Name</th><th>Prefix</th><th>Provides</th><th>Requires</th></tr></thead>
|
|
770
|
+
<tbody>
|
|
771
|
+
${modules.map(
|
|
772
|
+
(m) => ` <tr>
|
|
773
|
+
<td><strong>${escapeHtml(m.name)}</strong></td>
|
|
774
|
+
<td>${m.prefix ? `<code>${escapeHtml(m.prefix)}</code>` : '<span class="empty">\u2014</span>'}</td>
|
|
775
|
+
<td>${m.provides.length === 0 ? '<span class="empty">\u2014</span>' : m.provides.map((p) => `<span class="pill provides">${escapeHtml(p)}</span>`).join("")}</td>
|
|
776
|
+
<td>${m.requires.length === 0 ? '<span class="empty">\u2014</span>' : m.requires.map((p) => `<span class="pill requires">${escapeHtml(p)}</span>`).join("")}</td>
|
|
777
|
+
</tr>`
|
|
778
|
+
).join("\n")}
|
|
779
|
+
</tbody>
|
|
780
|
+
</table>
|
|
781
|
+
</section>
|
|
782
|
+
|
|
783
|
+
<section>
|
|
784
|
+
<h2>Routes</h2>
|
|
785
|
+
${routes.length === 0 ? '<p class="empty">No routes mounted.</p>' : `<table>
|
|
786
|
+
<thead><tr><th>Method</th><th>Path</th><th>Module</th></tr></thead>
|
|
787
|
+
<tbody>
|
|
788
|
+
${routes.map(
|
|
789
|
+
(r) => ` <tr>
|
|
790
|
+
<td class="method" data-m="${escapeHtml(r.method)}">${escapeHtml(r.method)}</td>
|
|
791
|
+
<td><code>${escapeHtml(r.path)}</code></td>
|
|
792
|
+
<td>${escapeHtml(r.module)}</td>
|
|
793
|
+
</tr>`
|
|
794
|
+
).join("\n")}
|
|
795
|
+
</tbody>
|
|
796
|
+
</table>`}
|
|
797
|
+
</section>
|
|
798
|
+
</main>
|
|
799
|
+
<footer>
|
|
800
|
+
Generated by <code>inspectModules()</code> from <code>@katajs/core</code>
|
|
801
|
+
</footer>
|
|
802
|
+
<script type="module">
|
|
803
|
+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
804
|
+
mermaid.initialize({
|
|
805
|
+
startOnLoad: true,
|
|
806
|
+
securityLevel: 'loose',
|
|
807
|
+
theme: 'dark',
|
|
808
|
+
themeVariables: {
|
|
809
|
+
darkMode: true,
|
|
810
|
+
background: '#161922',
|
|
811
|
+
primaryColor: '#1d2230',
|
|
812
|
+
primaryTextColor: '#e8ebf2',
|
|
813
|
+
primaryBorderColor: '#2a3041',
|
|
814
|
+
lineColor: '#7aa2ff',
|
|
815
|
+
secondaryColor: '#57c7b9',
|
|
816
|
+
tertiaryColor: '#1d2230',
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
</script>
|
|
820
|
+
</body>
|
|
821
|
+
</html>
|
|
822
|
+
`;
|
|
823
|
+
}
|
|
824
|
+
function escapeHtml(s) {
|
|
825
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
826
|
+
}
|
|
827
|
+
export {
|
|
828
|
+
AppError,
|
|
829
|
+
ValidationError,
|
|
830
|
+
createApp,
|
|
831
|
+
defineConsumer,
|
|
832
|
+
defineMiddleware,
|
|
833
|
+
defineModule,
|
|
834
|
+
errorMapper,
|
|
835
|
+
inspectModules,
|
|
836
|
+
validate
|
|
837
|
+
};
|
|
838
|
+
//# sourceMappingURL=index.js.map
|