typespec-hono 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.
@@ -0,0 +1,609 @@
1
+ import { renderSecurity } from "./security.js";
2
+ import { isRawBinaryMediaType, objectKey, } from "typespec-http-zod";
3
+ /** The header every emitted file carries. */
4
+ const GENERATED_BANNER = `// GENERATED by typespec-hono from the TypeSpec service definition. DO NOT EDIT.
5
+ // Recompile the spec that produced it; edits here are overwritten on the next run.
6
+ `;
7
+ /** A parameter name Hono can carry verbatim — measured against Hono, not assumed. */
8
+ const PLAIN_PATH_PARAMETER = /^[A-Za-z0-9_.~-]+$/;
9
+ /**
10
+ * TypeSpec publishes `/widgets/{widget-id}`; Hono routes on `/widgets/:widget-id`.
11
+ *
12
+ * ⚠️ **This used to match `\w+`, so any parameter carrying a hyphen was left ALONE.**
13
+ * `@path("thing-id")` produced the literal route `/things/{thing-id}` — mounted, counted by every arm
14
+ * that counts routes, and reachable by nobody. It answered 404 to the only requests it was for. Hono
15
+ * handles `:thing-id` and `:x.y` perfectly well; the narrow character class was ours.
16
+ *
17
+ * A name that is not plain is REFUSED rather than approximated. Hono reads a parameter up to the next
18
+ * `/`, so an RFC 6570 modifier would survive into the name and `*` would become Hono's wildcard — a
19
+ * route that matches the wrong requests and answers them, which is worse than one that fails.
20
+ *
21
+ * ⚠️ **This runs at RENDER time, not during collection.** It used to run inside `collectRoutes`, which
22
+ * put one framework's spelling into the shared intermediate representation and refused the whole
23
+ * operation — validators included — over a template no router could mount. What a request body must
24
+ * look like does not depend on that.
25
+ */
26
+ export function toHonoPath(template, refuse) {
27
+ return template.replace(/\{([^}]+)\}/g, (match, name) => {
28
+ if (PLAIN_PATH_PARAMETER.test(name))
29
+ return `:${name}`;
30
+ refuse(template, name);
31
+ return match;
32
+ });
33
+ }
34
+ /**
35
+ * Verbs Hono cannot dispatch to, whatever they are registered with.
36
+ *
37
+ * ⚠️ **`HEAD` is rewritten to `GET` before matching** — `hono-base.js` does it unconditionally at the
38
+ * top of `#dispatch` — so a route registered under it is never reached. See the `unroutable-verb`
39
+ * diagnostic for the measurements.
40
+ */
41
+ const UNROUTABLE_VERBS = new Set(["HEAD"]);
42
+ /** `GET` → `get`. Hono's per-verb helpers are the idiom; `app.on` is the escape hatch. */
43
+ const HONO_METHOD = {
44
+ GET: "get",
45
+ POST: "post",
46
+ PUT: "put",
47
+ PATCH: "patch",
48
+ DELETE: "delete",
49
+ };
50
+ /**
51
+ * A request location, as the DOCUMENT names it, mapped to the target `@hono/zod-validator` reads.
52
+ *
53
+ * ⚠️ **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
54
+ * `path`, `query`, `header` and a request body; zValidator says `param`, `query`, `header` and
55
+ * `json`. The library publishes the first because that is what the document states; translating is
56
+ * this package's job, and it is a map rather than a coincidence.
57
+ */
58
+ const VALIDATOR_TARGET = {
59
+ path: "param",
60
+ query: "query",
61
+ header: "header",
62
+ body: "json",
63
+ };
64
+ /**
65
+ * The `@hono/zod-validator` target for a request body, decided by what the wire actually carries.
66
+ *
67
+ * ⚠️ **This was `"json"` unconditionally, and that made every non-JSON body unservable.**
68
+ * `zValidator("json", …)` reads `c.req.json()`, so a `multipart/form-data` upload was parsed as JSON
69
+ * and refused. Measured: 17 such registrations in `payload/multipart` alone, and **11 of the 17
70
+ * request bodies in the Swagger Petstore are not JSON** — 5 urlencoded, 5 XML, 1 octet-stream.
71
+ *
72
+ * Hono's own target for both multipart and urlencoded is `"form"` — `c.req.parseBody()` handles the
73
+ * two together, which is why one branch covers both.
74
+ *
75
+ * ⚠️ **Anything else stays `"json"` deliberately.** A media type this function does not recognise is
76
+ * not a licence to guess: `application/xml` has no Hono target and no Zod representation the document
77
+ * justifies, so it keeps the existing behaviour rather than acquiring a new one on the way past. That
78
+ * gap is real and is stated in the README rather than papered over here.
79
+ */
80
+ function bodyTargetFor(contentTypes) {
81
+ /**
82
+ * ⚠️ **EVERY, not some, and the difference is a regression I shipped for one measurement.**
83
+ * `addPet` in the Swagger Petstore accepts `application/json`, `application/xml` **and**
84
+ * `application/x-www-form-urlencoded`. Choosing `"form"` because one member is form-ish made
85
+ * `zValidator` call `c.req.parseBody()` on JSON bodies, and a request that answered **200** before
86
+ * the change answered **400** after it. Measured both ways against the same server.
87
+ *
88
+ * ⚠️ **A route offering several media types cannot be validated by one target at all**, because
89
+ * which parser applies is decided by the caller's `Content-Type` at REQUEST time and `zValidator`
90
+ * is chosen at generation time. So a mixed body keeps `"json"`: unchanged behaviour, and a stated
91
+ * limitation rather than a guess that breaks the common case to fix the rare one. Only a body that
92
+ * is form-encoded in every form it may take is unambiguous enough to switch.
93
+ */
94
+ const form = contentTypes.length > 0 &&
95
+ contentTypes.every((type) => type.startsWith("multipart/") || type === "application/x-www-form-urlencoded");
96
+ return form ? "form" : VALIDATOR_TARGET.body;
97
+ }
98
+ /**
99
+ * How an unparsed body reaches the handler, and as what.
100
+ *
101
+ * ⚠️ **This was always `c.req.text()` typed as `string`, and for a binary body that silently
102
+ * corrupts.** `text()` UTF-8-decodes, so every byte outside ASCII becomes U+FFFD. Measured against a
103
+ * Petstore `application/octet-stream` upload under `wrangler dev`:
104
+ *
105
+ * ```
106
+ * sent : 89 50 4e 47 0d 0a 1a 0a ff d8 ff e0 00 10 4a 46 49 46 (18 bytes)
107
+ * received : fffd 50 4e 47 0d 0a 1a 0a fffd fffd fffd fffd 00 10 4a 46 49 46
108
+ * ```
109
+ *
110
+ * Five bytes destroyed, unrecoverably, and the request answered **200**. Success status, corrupt
111
+ * payload, no signal — the worst shape a defect can take.
112
+ *
113
+ * ⚠️ **`rawBodyProperty` conflated two different requirements, which is why one reader looked
114
+ * sufficient.** Its docblock justifies `text()` by a webhook's MAC covering exactly what arrived — true,
115
+ * and true only when what arrived is text. An upload is the other case, and it needs the bytes.
116
+ * `isRawBinaryMediaType` is the library's own rule for telling them apart, already applied on the
117
+ * response side; importing it rather than re-deriving it is what keeps the two halves from disagreeing
118
+ * again.
119
+ */
120
+ function rawBodyReaderFor(contentTypes) {
121
+ return isRawBinaryMediaType(contentTypes)
122
+ ? { call: "await c.req.arrayBuffer()", type: "ArrayBuffer" }
123
+ : { call: "await c.req.text()", type: "string" };
124
+ }
125
+ /**
126
+ * Everything an operation's input is made of, as a TypeScript type built from the schemas the library
127
+ * declared.
128
+ *
129
+ * `z.infer` rather than a restatement: the handler receives exactly what the validators produced, so
130
+ * deriving the type from the same consts is what makes the call site check itself. A restated
131
+ * interface would be a second source of truth that drifts.
132
+ */
133
+ function inputTypeOf(entry) {
134
+ const parts = entry.validators.map(([, name]) => `z.infer<typeof ${name}>`);
135
+ if (entry.route.rawBodyProperty !== undefined) {
136
+ const reader = rawBodyReaderFor(entry.route.requestContentTypes);
137
+ parts.push(`{ ${objectKey(entry.route.rawBodyProperty)}: ${reader.type} }`);
138
+ }
139
+ return parts.length === 0 ? undefined : parts.join(" & ");
140
+ }
141
+ /**
142
+ * `getWidget` -> `GetWidget`, `Avatar_getAvatarAsJson` -> `Avatar_getAvatarAsJson`.
143
+ *
144
+ * Only the first character moves. An operation id is already the name the DOCUMENT publishes, and
145
+ * rewriting more of it would invent a second spelling of a contract fact for the sake of house style
146
+ * — the `_` that `resolveOperationId` inserts is part of the published name.
147
+ */
148
+ function capitaliseId(operationId) {
149
+ return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
150
+ }
151
+ /**
152
+ * The resource a route belongs to — its first path segment — or `undefined` when it has none.
153
+ *
154
+ * ⚠️ **A parameter is not a resource.** `/{id}/things` has no groupable head: mounting a sub-app at
155
+ * `/:id` would make the parameter the resource name, which is not what the document says and not what
156
+ * anybody would write.
157
+ */
158
+ function resourceOf(path) {
159
+ const segment = path.split("/").filter((part) => part !== "")[0];
160
+ if (segment === undefined || segment.startsWith(":") || segment.startsWith("{"))
161
+ return undefined;
162
+ return segment;
163
+ }
164
+ /**
165
+ * A safe identifier for a resource's sub-app.
166
+ *
167
+ * Suffixed rather than named for the resource alone: every validator this file imports is named from
168
+ * an operation id, and a bare `widgets` could collide with one. A collision here is a file that does
169
+ * not compile, which is cheap to prevent and expensive to debug in generated output.
170
+ */
171
+ function subAppNameOf(resource) {
172
+ const cleaned = resource.replace(/[^A-Za-z0-9]+(.)?/g, (_match, next) => next === undefined ? "" : next.toUpperCase());
173
+ const head = cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
174
+ return `${/^[A-Za-z_$]/.test(head) ? head : `r${head}`}Routes`;
175
+ }
176
+ /**
177
+ * The generated Hono server.
178
+ *
179
+ * ⚠️ **This replaces a data table that a hand-written loop interpreted at run time.** The emitter knew,
180
+ * per operation, which validators applied and what the call looked like — and then flattened all of it
181
+ * into one homogeneous array, so the consumer had to recover it dynamically and could not. Emitting
182
+ * the call sites keeps that knowledge, and every one of them is monomorphic and checked.
183
+ *
184
+ * ⚠️ **Plain `Hono` and `@hono/zod-validator`, deliberately not `@hono/zod-openapi`.** The latter is
185
+ * the same validation plus a document generated FROM the code — spec-last, and a second source of
186
+ * truth competing with the one `@typespec/openapi3` publishes from the spec. We want its validation,
187
+ * not its documentation.
188
+ *
189
+ * ⚠️ **It declares no schema of its own.** Every validator this file names was declared by
190
+ * `typespec-http-zod` into `schemas.gen.ts` and is imported by name. That is what makes the two
191
+ * packages one emitter rather than two that must agree by coincidence — and it is why a consumer who
192
+ * wants the validators without a server can simply not install this one.
193
+ */
194
+ export function renderApp(emitted, refuse,
195
+ /**
196
+ * The path the DOCUMENT says this service is served under, when it says one unambiguously.
197
+ *
198
+ * ⚠️ **An OpenAPI path is relative to its server**, so `@server("/api/v1")` plus `/accounts` means
199
+ * the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
200
+ * the document, and every "try it" in a rendered document, 404.
201
+ */
202
+ basePath,
203
+ /**
204
+ * What the DOCUMENT says a caller must satisfy, per operation id.
205
+ *
206
+ * ⚠️ **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
207
+ * operation accepts is a fact about the HTTP program and not part of the validator IR the library
208
+ * publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
209
+ * audience is wider.
210
+ */
211
+ securityFor) {
212
+ const entries = emitted.routes.flatMap((route) => {
213
+ /**
214
+ * ⚠️ **Refused AND skipped, in that order.** Emitting the route anyway would put a registration
215
+ * in the file that `app.routes` lists and Hono never dispatches to — which is precisely how
216
+ * fifteen unreachable routes passed a differential written to catch unreachable routes.
217
+ */
218
+ if (UNROUTABLE_VERBS.has(route.verb)) {
219
+ refuse.unroutableVerb(route);
220
+ return [];
221
+ }
222
+ const names = emitted.schemaNames.get(route.operationId);
223
+ // The library declares a `Responses` const for every operation, so a missing entry is a bug in
224
+ // this package's pairing rather than a spec the emitter chose not to serve.
225
+ if (names === undefined)
226
+ return [];
227
+ const validators = [];
228
+ for (const location of ["path", "query", "header", "body"]) {
229
+ const identifier = names[location];
230
+ if (identifier === undefined)
231
+ continue;
232
+ // The body's target depends on what the document says the wire carries; the rest are fixed.
233
+ const target = location === "body" ? bodyTargetFor(route.requestContentTypes) : VALIDATOR_TARGET[location];
234
+ validators.push([target, identifier]);
235
+ }
236
+ return [{ route, names, validators }];
237
+ });
238
+ /**
239
+ * ⚠️ **One registration per verb+path, not per operation.**
240
+ *
241
+ * TypeSpec models content negotiation as several operations on one route; OpenAPI models it as one
242
+ * path entry whose response lists every media type. This emitter used to register all of them, and
243
+ * Hono matches in registration order — so every operation after the first was dead code that looked
244
+ * mounted, and the only symptom was an operation count two higher than the document's.
245
+ */
246
+ const grouped = new Map();
247
+ for (const entry of entries) {
248
+ const slot = `${entry.route.verb} ${entry.route.path}`;
249
+ grouped.set(slot, [...(grouped.get(slot) ?? []), entry]);
250
+ }
251
+ const methods = entries.map((entry) => {
252
+ const { route, names } = entry;
253
+ /**
254
+ * A negotiated member's `accept` is not in its validator — the negotiation supplies it — but it
255
+ * IS in the operation's declared input, so the interface has to keep it. The literal is known
256
+ * exactly: it is the only value that reaches this member.
257
+ */
258
+ const negotiated = (grouped.get(`${route.verb} ${route.path}`)?.length ?? 0) > 1 && route.accept !== undefined
259
+ ? `{ ${objectKey(route.accept.name)}: ${JSON.stringify(route.accept.value)} }`
260
+ : undefined;
261
+ const validated = inputTypeOf(entry);
262
+ const input = negotiated === undefined
263
+ ? validated
264
+ : validated === undefined
265
+ ? negotiated
266
+ : `${validated} & ${negotiated}`;
267
+ const output = names.response === undefined ? "void" : `z.infer<typeof ${names.response}>`;
268
+ const signature = input === undefined ? "ctx: Ctx" : `ctx: Ctx, input: ${input}`;
269
+ const doc = route.summary === undefined ? "" : `\t/** ${route.summary} */\n`;
270
+ return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
271
+ });
272
+ const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler = Operations[${JSON.stringify(entry.route.operationId)}];`);
273
+ /**
274
+ * **Which resources get a sub-app, and which routes stay on the root.**
275
+ *
276
+ * ⚠️ **Hono's own guidance, followed rather than guessed at.** Its best-practices page says to use
277
+ * `app.route()` to build a larger application instead of Ruby-on-Rails-like controllers — while
278
+ * writing handlers *directly after the path definitions*, because a handler in a separate file
279
+ * cannot infer its path parameters. Both halves are honoured here: routes are grouped by resource,
280
+ * and every handler stays inline where `c.req.valid()` is typed.
281
+ *
282
+ * ⚠️ **Measured before relying on it:** `app.route(prefix, sub)` composes paths exactly, including a
283
+ * parameter in the prefix, and the PARENT's `app.routes` reports the fully composed path — so a
284
+ * route mounted through a sub-app is still countable, which is what every arm that counts routes
285
+ * depends on.
286
+ *
287
+ * A resource with ONE route is not a group, and gets no sub-app: that is what a Hono author writes,
288
+ * and a one-route sub-app is ceremony around a single line.
289
+ */
290
+ const slotsByResource = new Map();
291
+ for (const slot of grouped.keys()) {
292
+ const resource = resourceOf(slot.slice(slot.indexOf(" ") + 1));
293
+ if (resource === undefined)
294
+ continue;
295
+ slotsByResource.set(resource, [...(slotsByResource.get(resource) ?? []), slot]);
296
+ }
297
+ const subApps = new Map();
298
+ for (const [resource, slots] of slotsByResource) {
299
+ if (slots.length > 1)
300
+ subApps.set(resource, subAppNameOf(resource));
301
+ }
302
+ const registrations = [...grouped.values()].map((group) => {
303
+ const entry = group[0];
304
+ const { route, validators } = entry;
305
+ const method = HONO_METHOD[route.verb] ?? "on";
306
+ const path = toHonoPath(route.path, (template, name) => refuse.unsupportedPathTemplate(route, template, name));
307
+ /**
308
+ * A route inside a sub-app is registered RELATIVE to the prefix it is mounted at.
309
+ *
310
+ * ⚠️ **The collection route is `"/"`, never the empty string.** Measured: `sub.get("/")` under
311
+ * `app.route("/widgets", sub)` answers `/widgets`, and `/widgets/` is a 404 — so the composed
312
+ * path carries no trailing slash and the document's own path is what a caller reaches.
313
+ */
314
+ const resource = resourceOf(path);
315
+ const mountOn = resource === undefined ? undefined : subApps.get(resource);
316
+ const target = mountOn ?? "app";
317
+ const registeredPath = mountOn === undefined ? path : path.slice(`/${resource ?? ""}`.length) || "/";
318
+ /**
319
+ * The scope gate goes FIRST, before any validator.
320
+ *
321
+ * A caller without the scope must be refused whatever their body looks like: validating first
322
+ * answers `400` to a request the contract says is not theirs to make, which tells somebody who
323
+ * may not call the operation at all which payloads are well-formed.
324
+ */
325
+ /**
326
+ * ⚠️ **Emitted whenever the document declares ANY security, not only when it declares scopes.**
327
+ * `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]` — no scopes — so a
328
+ * scopes-only gate covered OAuth2 and nothing else. Bearer, api-key and basic carried no gate at
329
+ * all and rested entirely on `deps.context` returning null, which answers "is somebody here"
330
+ * rather than "did they satisfy the scheme the contract names".
331
+ */
332
+ const requirements = securityFor?.(route.verb, route.path) ?? [];
333
+ const gate = requirements.length === 0 ? [] : [`\t\tdeps.authorize(${renderSecurity(requirements)}),`];
334
+ const middleware = [
335
+ ...gate,
336
+ ...validators.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
337
+ ];
338
+ /**
339
+ * Whether the operation requires a caller — and NOTHING else about the caller.
340
+ *
341
+ * ⚠️ **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
342
+ * route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
343
+ * `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
344
+ * account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
345
+ * path-parameter heuristic happened to fit the first consumer.
346
+ */
347
+ const body = [];
348
+ if (route.noAuth !== true) {
349
+ body.push(`\t\t\tconst ctx = deps.context(c, "required");`);
350
+ body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
351
+ }
352
+ else {
353
+ /**
354
+ * ⚠️ **The same null check as an authenticated route, and it is what removes a CAST from
355
+ * generated output.** This used to emit `deps.context(c, "none") as Ctx`, because one
356
+ * signature returning `C | null` cannot express "this argument makes null impossible". A cast
357
+ * in generated code is worse than one in hand-written code: nobody reviews it, and it
358
+ * reappears on every compile.
359
+ *
360
+ * ⚠️ **Overloading `context` was tried and is worse.** It removes the cast from here and puts
361
+ * one in every consumer's `deps`, because an overloaded property type stops contextually
362
+ * typing a single implementation — measured, the wiring consumer lost inference on every
363
+ * hook. Trading a cast in generated code for a cast in hand-written code is the wrong
364
+ * direction.
365
+ *
366
+ * Checking is also more honest than asserting: an app that returns null here has said it
367
+ * could not build a context, and the old cast handed the handler that null typed as `Ctx`.
368
+ */
369
+ body.push(`\t\t\tconst ctx = deps.context(c, "none");`);
370
+ body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
371
+ }
372
+ const pieces = validators.map(([target]) => `...c.req.valid(${JSON.stringify(target)})`);
373
+ if (route.rawBodyProperty !== undefined) {
374
+ // The bytes ARE the contract: a signature covers exactly what arrived, so parsing and
375
+ // re-serialising would verify a different string than the sender signed. WHICH reader
376
+ // preserves them depends on the media type — see `rawBodyReaderFor`.
377
+ const reader = rawBodyReaderFor(route.requestContentTypes);
378
+ pieces.push(`${objectKey(route.rawBodyProperty)}: ${reader.call}`);
379
+ }
380
+ const invoke = (member) => {
381
+ // The member's own `accept` literal, which its input type requires and which the shared
382
+ // validator no longer supplies. We know it exactly: it is the branch we are in.
383
+ const own = group.length > 1 && member.route.accept !== undefined
384
+ ? [`${objectKey(member.route.accept.name)}: ${JSON.stringify(member.route.accept.value)}`]
385
+ : [];
386
+ const input = [...pieces, ...own];
387
+ /**
388
+ * ⚠️ **Broken across lines rather than emitted as one.** Generated code is read far more often
389
+ * than it is written — in review, in a stack trace, in a diff — and a single call reached 219
390
+ * characters on a real service, against the 60-to-80 of every example in Hono's own
391
+ * documentation. Nothing about the behaviour changes; a reader's ability to see it does.
392
+ *
393
+ * A call with no input stays on one line, because wrapping it would add ceremony to something
394
+ * already short.
395
+ */
396
+ /**
397
+ * ⚠️ **Indented literally, because the surrounding `+1 tab` only reaches the FIRST physical
398
+ * line.** A multi-line fragment keeps whatever tabs it was written with, so the depths here
399
+ * are absolute: the `return` sits at four, its arguments at five, and the handler's input
400
+ * properties at six.
401
+ */
402
+ const call = input.length === 0
403
+ ? `handlersFor(c).${member.route.operationId}(ctx)`
404
+ : [
405
+ `handlersFor(c).${member.route.operationId}(ctx, {`,
406
+ ...input.map((piece) => `\t\t\t\t\t\t${piece},`),
407
+ "\t\t\t\t\t})",
408
+ ].join("\n");
409
+ return input.length === 0
410
+ ? `deps.respond(c, ${member.names.responses}, await ${call})`
411
+ : [
412
+ `deps.respond(`,
413
+ `\t\t\t\t\tc,`,
414
+ `\t\t\t\t\t${member.names.responses},`,
415
+ `\t\t\t\t\tawait ${call},`,
416
+ "\t\t\t\t)",
417
+ ].join("\n");
418
+ };
419
+ if (group.length === 1) {
420
+ body.push(`\t\t\treturn ${invoke(entry)};`);
421
+ }
422
+ else {
423
+ /**
424
+ * Several operations, one route: the caller's `Accept` chooses which one answers.
425
+ *
426
+ * The offered list and which operation serves each type are both read from the document.
427
+ * `selectContentType` applies RFC 9110 §12.5.1 to them — it lives in the runtime rather than
428
+ * in `deps` because both halves are derivable, and an app forced to supply it would be
429
+ * re-implementing the standard.
430
+ */
431
+ const offers = group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })));
432
+ const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
433
+ body.push(`\t\t\tconst served = selectContentType(c.req.header("accept"), ${offered});`);
434
+ body.push(`\t\t\tif (served === undefined) return deps.notAcceptable(c, ${offered});`);
435
+ for (const offer of offers) {
436
+ body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) return ${invoke(offer.member)};`);
437
+ }
438
+ // `selectContentType` only ever returns a member of the list it was given, so this is
439
+ // unreachable — and stating that is cheaper than a cast that would hide it if it were not.
440
+ body.push(`\t\t\treturn deps.notAcceptable(c, ${offered});`);
441
+ }
442
+ /**
443
+ * ⚠️ **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
444
+ * dedicated Hono method; everything else — `HEAD`, `OPTIONS`, anything a spec invents — fell
445
+ * through to `app.on(path, handler)`, which Hono reads as `on(method, path)`. The route was
446
+ * emitted, counted by every arm that counted rows, and mounted nowhere.
447
+ */
448
+ /**
449
+ * ⚠️ **Emitted as a CHAINED call fragment rather than a statement, and that is what makes Hono's
450
+ * RPC client work at all.** `hc<typeof app>` derives its entire surface from the `Schema` type
451
+ * parameter Hono accumulates through chaining — not from what is registered at run time. As
452
+ * separate statements each call's type was discarded, `registerRoutes` returned `void`, and
453
+ * measured in a fresh project `hc<typeof app>` resolved to **`unknown`**: not an empty client, an
454
+ * unusable one. A hand-chained app with the same sub-app-per-resource shape supports it
455
+ * perfectly, so the shape was never the obstacle — only the statements were.
456
+ */
457
+ const text = [
458
+ `\t\t.${method}(`,
459
+ ...(method === "on" ? [`\t\t\t${JSON.stringify(route.verb)},`] : []),
460
+ `\t\t\t${JSON.stringify(registeredPath)},`,
461
+ ...middleware.map((line) => `\t${line}`),
462
+ "\t\t\tasync (c) => {",
463
+ ...body.map((line) => `\t${line}`),
464
+ "\t\t\t},",
465
+ "\t\t)",
466
+ ].join("\n");
467
+ return { target, text };
468
+ });
469
+ /**
470
+ * Every identifier this file names, imported from the module that declares it.
471
+ *
472
+ * ⚠️ **Derived from what the rendered text actually references, not from what was available.** An
473
+ * unused import fails the lint a generated file has to pass like any other, and a missing one is a
474
+ * file that does not compile. Both have happened.
475
+ */
476
+ const rendered = [...registrations.map((r) => r.text), ...methods, ...aliases].join("\n");
477
+ const referenced = [
478
+ ...new Set(entries.flatMap((entry) => [
479
+ entry.names.path,
480
+ entry.names.query,
481
+ entry.names.header,
482
+ entry.names.body,
483
+ entry.names.response,
484
+ entry.names.responses,
485
+ ].filter((name) => name !== undefined))),
486
+ ]
487
+ .filter((identifier) => new RegExp(`\\b${identifier}\\b`).test(rendered))
488
+ .toSorted();
489
+ const imports = referenced.length === 0
490
+ ? ""
491
+ : `import {\n${referenced.map((id) => `\t${id},`).join("\n")}\n} from "./schemas.gen.js";\n`;
492
+ /**
493
+ * One `Hono` per resource, declared before the routes and mounted after them.
494
+ *
495
+ * ⚠️ **Mounted AFTER, deliberately.** `app.route()` copies the sub-app's routes into the parent at
496
+ * the moment it is called, so mounting before the routes are registered mounts nothing — the same
497
+ * shape of defect as Hono middleware, which only applies to routes registered after it. Declaring
498
+ * at the top and mounting at the bottom is the arrangement that cannot be got wrong by reordering.
499
+ */
500
+ /**
501
+ * ⚠️ **One chained expression per app, because that is the only shape `hc` can read.**
502
+ *
503
+ * Hono accumulates its `Schema` type through the chain, so a sub-app declared and then registered
504
+ * across separate statements throws every route's type away. Declaring it AS the chain keeps them,
505
+ * and mounting with a chained `.route()` carries them up into the parent.
506
+ *
507
+ * ⚠️ **The ordering property that used to need a comment is now structural.** Mounting before the
508
+ * routes were registered mounted nothing, and the fix was "declare at the top, mount at the
509
+ * bottom" — a rule a reordering could break. A single expression cannot be reordered wrongly: the
510
+ * sub-app is complete at the point it is mounted because it is its own initialiser.
511
+ */
512
+ const byTarget = new Map();
513
+ for (const registration of registrations) {
514
+ byTarget.set(registration.target, [
515
+ ...(byTarget.get(registration.target) ?? []),
516
+ registration.text,
517
+ ]);
518
+ }
519
+ const subAppDeclarations = subApps.size === 0
520
+ ? ""
521
+ : `${[...subApps.values()]
522
+ .map((name) => `\tconst ${name} = new Hono<AppEnv>()\n${(byTarget.get(name) ?? []).join("\n")};`)
523
+ .join("\n\n")}\n\n`;
524
+ const subAppMounts = [...subApps].map(([resource, name]) => `\t\t.route(${JSON.stringify(`/${resource}`)}, ${name})`);
525
+ /**
526
+ * Everything that stays on the root, then every mount, as one returned chain.
527
+ *
528
+ * Returned rather than discarded: `registerRoutes` used to be `void`, and measured in a fresh
529
+ * project that made `hc<typeof app>` resolve to **`unknown`** — Hono's RPC client, which is one of
530
+ * the framework's headline features, was categorically unavailable to anything this emitter
531
+ * produced. Handing back the chained value costs nothing and restores it.
532
+ */
533
+ const rootChain = [...(byTarget.get("app") ?? []), ...subAppMounts];
534
+ // Imported only where a route actually negotiates: an unused import fails the repo's own lint.
535
+ const negotiates = [...grouped.values()].some((group) => group.length > 1);
536
+ const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
537
+ /**
538
+ * ⚠️ **One base sub-app, mounted with `app.route()` — Hono's own nesting, not a rewritten path on
539
+ * every registration.** Prefixing each path individually would fight the resource grouping, and
540
+ * would put the prefix in `hc`'s type surface as part of every route name rather than once. A
541
+ * nested `.route()` composes exactly, and the parent's `app.routes` still reports the fully
542
+ * composed path, which every arm that counts routes depends on.
543
+ */
544
+ const usesBasePath = basePath !== undefined && basePath !== "";
545
+ const needsHonoValue = subApps.size > 0 || usesBasePath;
546
+ return `${GENERATED_BANNER}
547
+ import { zValidator } from "@hono/zod-validator";
548
+ ${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}
549
+ import { z } from "zod";
550
+ import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}
551
+ ${imports}
552
+ /**
553
+ * One method per operation, each concretely typed from the schemas it validates against.
554
+ *
555
+ * There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
556
+ * method, so an implementation whose input or output does not match the contract fails to compile.
557
+ */
558
+ export interface Operations {
559
+ ${methods.join("\n")}
560
+ }
561
+
562
+ /**
563
+ * One alias per operation, so a handler can live beside the module it belongs to.
564
+ *
565
+ * ⚠️ **Derived by indexed access, never restated.** An indexed access into Operations cannot drift
566
+ * from the interface; a second hand-written signature could, and a signature that silently disagrees
567
+ * with the contract is the whole failure mode this emitter exists to remove.
568
+ */
569
+ ${aliases.join("\n")}
570
+
571
+ /**
572
+ * ⚠️ **There is deliberately NO exported HandlersFor alias to annotate the factory with.**
573
+ *
574
+ * There was one, and it silently disabled the guard below. Annotating widens the value to
575
+ * Operations, so T infers as Operations, Exclude<keyof T, keyof Operations> is never, and the
576
+ * surplus-key constraint evaporates. Measured: a handler for an operation the spec no longer
577
+ * declares produced no error at all through the annotated form, and TS2345 without it.
578
+ *
579
+ * Write the factory unannotated — inference then carries the real shape into registerRoutes:
580
+ *
581
+ * const handlersFor = (c) => backendFor(c.env);
582
+ * registerRoutes(app, handlersFor, deps);
583
+ */
584
+
585
+ /**
586
+ * Refuses a handler set carrying an operation the spec no longer declares.
587
+ *
588
+ * ⚠️ **Removal is the change the type system misses, and it is the one that matters most.** Adding an
589
+ * operation is a missing property; changing one is a wrong signature; both fail loudly. Removing one
590
+ * leaves a handler that compiles forever against a route nobody mounts.
591
+ *
592
+ * The excess-property check catches it, but ONLY on an object literal assigned straight to an
593
+ * annotated target. Mapping every surplus key to never makes the refusal structural instead: a key
594
+ * outside Operations can only be satisfied by a value that cannot exist.
595
+ */
596
+ export type Exhaustive<T> = T & Record<Exclude<keyof T, keyof Operations>, never>;
597
+
598
+ /** Mount every operation the service declares. */
599
+ export function registerRoutes<T extends Operations>(
600
+ app: Hono<AppEnv>,
601
+ handlersFor: <P extends string, I extends Input>(c: Context<AppEnv, P, I>) => Exhaustive<T>,
602
+ deps: RouteDeps,
603
+ ) {
604
+ ${subAppDeclarations}${usesBasePath
605
+ ? `\tconst basePathRoutes = new Hono<AppEnv>()\n${rootChain.join("\n")};\n\n\treturn app.route(${JSON.stringify(basePath)}, basePathRoutes);`
606
+ : `\treturn app\n${rootChain.join("\n")};`}
607
+ }
608
+ `;
609
+ }
@@ -0,0 +1,16 @@
1
+ import type { Namespace, Program } from "@typespec/compiler";
2
+ export interface BasePathResolution {
3
+ /** The prefix to mount every route under, or `undefined` to mount at the root. */
4
+ readonly basePath: string | undefined;
5
+ /** The distinct paths found, when the document declares more than one and they disagree. */
6
+ readonly ambiguous: readonly string[];
7
+ }
8
+ /**
9
+ * Read the service's declared servers and decide what to mount under.
10
+ *
11
+ * - no `@server`, or a templated one → the root, which is what the document means;
12
+ * - one static path, or several that agree → that path;
13
+ * - several that DISAGREE → the root, and the caller reports it. There is no answer that serves all
14
+ * of them, and picking one would silently serve the wrong URLs for the others.
15
+ */
16
+ export declare function resolveBasePath(program: Program, namespace: Namespace): BasePathResolution;