diff --git a/.changeset/dull-pens-love.md b/.changeset/dull-pens-love.md new file mode 100644 index 0000000..92d0ba5 --- /dev/null +++ b/.changeset/dull-pens-love.md @@ -0,0 +1,5 @@ +--- +'@dphonys/nuxt-typed-handler': minor +--- + +Initial release. `@dphonys/nuxt-typed-handler` is one Nuxt module installed instead of `@dphonys/nuxt-handler-errors` and `@dphonys/nuxt-handler-validation`, composing both through their `internals/*` entries. `defineTypedEventHandler({ validate, errors }, fn)` declares a route's request schemas and its expected failures in one place and hands the handler one flat context - the validated sources, plus `fail` when errors were declared - with a built-in `validation-failed` variant that every validating route carries and no route may declare. The Typed fetch family (`useTypedFetch`, `useLazyTypedFetch`, `useRequestTypedFetch`, `useTypedAsyncData`, `useLazyTypedAsyncData`, `$typedFetch` with `.try`, and `event.$typedFetch`) types every call site per route and method for what it may send - `body` and `query` from the schemas' input types - and for what it can fail with. Both parents' public surfaces are re-exported from `/server`, `/shared` and `/types`, so an app imports everything from one package; both parents are pinned exactly, and a project lists this module or them, never both. diff --git a/.changeset/nuxt-declared-as-peer.md b/.changeset/nuxt-declared-as-peer.md new file mode 100644 index 0000000..98ffd73 --- /dev/null +++ b/.changeset/nuxt-declared-as-peer.md @@ -0,0 +1,6 @@ +--- +'@dphonys/nuxt-handler-errors': patch +'@dphonys/nuxt-handler-validation': patch +--- + +Declare `nuxt` as a peer dependency (`>=4.5.1 <5.0.0`), so the Nuxt range the readme already states is one the package manager checks as well. diff --git a/knip.ts b/knip.ts index cbe9792..5fb76d6 100644 --- a/knip.ts +++ b/knip.ts @@ -79,6 +79,22 @@ export default { ], }, + 'packages/nuxt-typed-handler': { + ...nuxtModuleWorkspace, + + // As in both parents: this package's suites import `@nuxt/schema`'s types. + ignoreDependencies: ['@nuxt/devtools'], + + entry: [ + ...nuxtModuleWorkspace.entry, + + // Deliberately broken sources, compiled by path by + // `test/types/compile-harness.ts` so a suite can assert on their + // diagnostics. The package tsconfig excludes them for the same reason. + 'test/types/fixtures/**/*.ts', + ], + }, + 'packages/nuxt-handler-validation/playground': { ...playgroundWorkspace, @@ -87,5 +103,14 @@ export default { // `.nuxt`. entry: ['module-options.check.ts'], }, + + 'packages/nuxt-typed-handler/playground': { + ...playgroundWorkspace, + + // As in the validation playground above: compiler-asserted by `vue-tsc`, + // never imported. It lives in an app because the route map it reads is + // only generated inside one. + entry: ['request-typing.check.ts'], + }, }, } satisfies KnipConfig diff --git a/packages/nuxt-handler-errors/README.md b/packages/nuxt-handler-errors/README.md index 4c67002..8364ee8 100644 --- a/packages/nuxt-handler-errors/README.md +++ b/packages/nuxt-handler-errors/README.md @@ -21,6 +21,8 @@ The module has one option, `channelToken` - see counterpart, and nothing about a route's failures is configured - it is declared, in the route. +**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+. + ## Declaring what a route can fail with ```ts diff --git a/packages/nuxt-handler-errors/package.json b/packages/nuxt-handler-errors/package.json index 582d57f..8de4845 100644 --- a/packages/nuxt-handler-errors/package.json +++ b/packages/nuxt-handler-errors/package.json @@ -122,6 +122,9 @@ "vue-tsc": "catalog:", "zod": "catalog:" }, + "peerDependencies": { + "nuxt": ">=4.5.1 <5.0.0" + }, "engines": { "node": "^22.19.0 || ^24.11.0 || >=26.0.0" } diff --git a/packages/nuxt-handler-errors/vitest.config.ts b/packages/nuxt-handler-errors/vitest.config.ts index 221917a..10d9a85 100644 --- a/packages/nuxt-handler-errors/vitest.config.ts +++ b/packages/nuxt-handler-errors/vitest.config.ts @@ -1,13 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' -// Three tiers: `unit` is fast, `types` suites are asserted by the compiler -// under `typecheck`, and `e2e` builds real apps. The `include` patterns also -// feed knip's entry points. - -// These specifiers only resolve inside a real build, so `unit` aliases them -// to doubles. Scoping the aliases to `unit` is deliberate: the e2e tier must -// see the real thing. const aliases = [ { find: /^#app$/, @@ -43,6 +36,9 @@ export default defineConfig({ test: { name: 'types', include: ['test/types/**/*.test.ts'], + // Each file compiles a real TypeScript program, which a loaded CI runner + // stretches well past vitest's 5s default. + testTimeout: 60_000, }, }, { @@ -50,9 +46,8 @@ export default defineConfig({ name: 'e2e', include: ['test/e2e/**/*.test.ts'], testTimeout: 120_000, - // Every file here writes into a real app's build directory - one - // prepares the playground and edits its emitted map, another builds - // and boots it. Run in parallel they race over the same `.nuxt`. + // Every file here works against a real build directory - run in + // parallel they race over the same `dist` and `.nuxt`. fileParallelism: false, }, }, diff --git a/packages/nuxt-handler-validation/package.json b/packages/nuxt-handler-validation/package.json index 333669f..d144ee8 100644 --- a/packages/nuxt-handler-validation/package.json +++ b/packages/nuxt-handler-validation/package.json @@ -98,6 +98,9 @@ "vue-tsc": "catalog:", "zod": "catalog:" }, + "peerDependencies": { + "nuxt": ">=4.5.1 <5.0.0" + }, "engines": { "node": "^22.19.0 || ^24.11.0 || >=26.0.0" } diff --git a/packages/nuxt-handler-validation/vitest.config.ts b/packages/nuxt-handler-validation/vitest.config.ts index 35f82bf..2847ad2 100644 --- a/packages/nuxt-handler-validation/vitest.config.ts +++ b/packages/nuxt-handler-validation/vitest.config.ts @@ -1,9 +1,5 @@ import { defineConfig } from 'vitest/config' -// Three tiers, mirroring the sibling: `unit` is fast, `types` suites are -// asserted by the compiler under `typecheck`, and `e2e` works against real -// built artifacts. The `include` patterns also feed knip's entry points. - export default defineConfig({ test: { projects: [ @@ -17,6 +13,9 @@ export default defineConfig({ test: { name: 'types', include: ['test/types/**/*.test.ts'], + // Each file compiles a real TypeScript program, which a loaded CI runner + // stretches well past vitest's 5s default. + testTimeout: 60_000, }, }, { diff --git a/packages/nuxt-typed-handler/LICENSE b/packages/nuxt-typed-handler/LICENSE new file mode 100644 index 0000000..2f10591 --- /dev/null +++ b/packages/nuxt-typed-handler/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniel Petr Honys + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/nuxt-typed-handler/README.md b/packages/nuxt-typed-handler/README.md new file mode 100644 index 0000000..c7145fd --- /dev/null +++ b/packages/nuxt-typed-handler/README.md @@ -0,0 +1,662 @@ +# Nuxt Typed Handler + +Declare a Nitro handler's request schemas and expected failures once, and get +both typed at every call site: the compiler knows what a route accepts as +`body` and `query`, and what it can answer with. One wrapper, +`defineTypedEventHandler`, and one flat second parameter carrying the validated +values and `fail`. + +One sentence for the whole model: **a route declares what it validates and +what it can fail with, and every caller - `useTypedFetch`, `$typedFetch`, +`event.$typedFetch` - is typed from the route path alone.** + +This module composes [`@dphonys/nuxt-handler-errors`][errors] and +[`@dphonys/nuxt-handler-validation`][validation] and is installed _instead of_ +them - never alongside. It re-exports both parents' public surface, bar their +two wrappers, so an app imports everything from one package. Each feature +below links to the parent that documents it in full. + +## Installation + +```sh +pnpm add @dphonys/nuxt-typed-handler +``` + +```ts +export default defineNuxtConfig({ + modules: ['@dphonys/nuxt-typed-handler'], +}) +``` + +The module has one option, `channelToken` - see [Channel +gating](#channel-gating). Nothing about a route's inputs or failures is +configured; both are declared, in the route. + +Bring your own schema library. Anything implementing [Standard +Schema](https://standardschema.dev) works - [zod](https://zod.dev), +[valibot](https://valibot.dev), [arktype](https://arktype.io), and others - +nothing is bundled for you. + +**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+. + +If the app already uses a parent, remove it from `modules` and uninstall it +first - registering a parent beside this module throws at startup. See +[Migrating from the parents](#migrating-from-the-parents). + +## Quick start + +```ts +// server/api/users.post.ts +import { z } from 'zod' +import { userErrors } from '~~/server/errors/users' + +const createUser = z.object({ + name: z.string().min(1), + email: z.email(), +}) + +export default defineTypedEventHandler( + { validate: { body: createUser }, errors: userErrors.pick('user-exists') }, + async (event, { body, fail }) => { + if (await taken(body.email)) + return fail('user-exists', { email: body.email }) + + return { created: body.name } + } +) +``` + +```vue + +``` + +- **`validate` alone and `errors` alone are both valid**, and the context + carries only what was declared: no `validate`, no source keys; no `errors`, + no `fail`. Declaring neither is a compile error, and a runtime one for a + JavaScript caller. +- **Your return type flows to Nitro's typed routes unchanged.** The wrapper + returns a `TypedEventHandler` - still assignable to h3's `EventHandler` - so + the response type infers exactly as it would with `defineEventHandler`. +- **Auto-imported where you use it.** `defineTypedEventHandler`, `defineError`, + `payload`, `recognizeKnownError` and `recognizeValidationError` are ambient + inside `server/`, like `defineEventHandler`; `useTypedFetch` and its siblings + are ambient in app code, like `useFetch`; `$typedFetch` is a global, like + `$fetch`. `matchError` is imported from `@dphonys/nuxt-typed-handler/shared`, + because it is used on both sides. +- **Three entries.** `@dphonys/nuxt-typed-handler/server` carries the runtime + and depends on h3 - import it where auto-imports do not reach (Nitro plugins + and tasks, tests, `imports.autoImport: false`), never from client code. + `/shared` is safe everywhere. `/types` is type-only, for app code. + +## Declaring what a route can fail with + +```ts +// server/errors/users.ts - or anywhere; the values travel, no registry exists. +// Outside server/, import from '@dphonys/nuxt-typed-handler/server'. +export const userErrors = defineError({ + 'user-not-found': { status: 404, payload: payload<{ userId: string }>() }, + 'user-exists': { status: 409, payload: payload<{ email: string }>() }, +}) + +export const forbidden = defineError('forbidden', { + status: 403, + payload: payload<{ requiredRole: 'admin' | 'owner' }>(), +}) +``` + +```ts +export default defineTypedEventHandler( + { errors: [...userErrors.pick('user-not-found'), forbidden] }, + async (event, { fail }) => { + const userId = event.context.params?.id ?? '' + const user = await lookup(userId) + + if (!user) return fail('user-not-found', { userId }) + + return user + } +) +``` + +- The unit is the **variant as a value**; a group is an array of those values, + and **spread is the only composition operator**. `.pick()` narrows a group. +- `payload()` is the no-library door; any Standard Schema works in the same + position and is read for its inferred output type - **never executed**. The + payload must survive JSON serialization or it is a compile error. +- `fail` returns `never`, so the success type still infers from the handler + body, and `fail('nope')` - a tag this route did not declare - is a compile + error. +- **`'validation-failed'` is reserved on every route**, whether or not it + validates: declaring it is a compile error and a declaration-time throw, and + `fail('validation-failed')` never typechecks. +- A duplicate tag across two declared variants is a compile error, and a + variant value produced by a _different copy_ of the module throws at + declaration. + +Rationale, and the full model: [Declaring what a route can fail with][errors-declaring]. + +## Validating the request + +```ts +export default defineTypedEventHandler( + { + validate: { + routerParams: v.object({ id: v.pipe(v.string(), v.transform(Number)) }), + query: [pagination, sorting], + body: z.object({ name: z.string() }), + }, + }, + async (event, { routerParams, query, body }) => update(routerParams.id, body) +) +``` + +- **Schemas nest under `validate`**, keyed by source. The four sources are + `routerParams`, `query`, `headers` and `body`, validated in exactly that + order, **fail-fast**, before the handler body runs. +- Values arrive typed as their schema's **output**, so coercions and transforms + land already applied. Undeclared sources are **absent** from the context. +- **Mix libraries freely**, including inside one composed tuple. Async schemas + are awaited. +- A tuple composes several schemas onto one source: every element parses the + whole raw source in order and you receive the merge. Two compile-time rules, + both reported at the offending source key: every composed output must be an + **object**, and their output keys must be **pairwise disjoint**. +- Sources arrive exactly as h3 yields them - query values are + `string | string[]`, headers are lowercased, route params are URL-decoded - + so all coercion belongs in the schema. +- A method that cannot carry a body, and an empty body, both validate + `undefined`. A body the request made unreadable becomes exactly one issue, + `{ source: 'body', message: 'Request body could not be parsed', path: [] }`. + +Rationale, the per-source detail and the composition rules in full: +[Reusing and composing schemas][validation-composing] and [What each source +receives][validation-sources]. + +## The Handler context + +The wrapper's second parameter is one flat object, built fresh per request: + +```text +(event, { routerParams, query, headers, body, fail }) => … +``` + +- **Only what was declared is there.** Each validated source appears iff + `validate` declared it; `fail` appears iff `errors` declared something. + Reading an undeclared key is a compile error naming the key. +- **It is the only door to validated values.** Calling `readBody(event)` in the + handler hands back h3's memoized _unvalidated_ parse - not what your schema + produced. +- **An `errors`-only route never reads the request.** No plan, no body read, no + extra `await`: it is the errors parent's behaviour byte for byte. +- The object is a plain object and is not frozen; nothing else is smuggled onto + it. + +## Request typing at the call site + +Every member of the [Typed fetch family](#fetching) takes +`TypedRequestOptions` - vanilla's `NitroFetchOptions` with `body` and +`query` typed from the route's declared schemas, `method` accepted in either +case, and ofetch's deprecated `params` alias removed for everyone. + +```ts +// `body` required and typed from the schema's *input* side. +await $typedFetch('/api/users', { + method: 'post', + body: { name: 'Ada', email: 'a@b.c' }, +}) + +// Excess keys rejected on a plain object literal: `nope` is an error here. +await $typedFetch('/api/users', { + method: 'post', + body: { name: 'Ada', nope: 1 }, +}) + +// `page` is `string` here: what the client sends, before `z.coerce`. +await $typedFetch('/api/search', { query: { page: '2' } }) +``` + +- **Typed per `(route, method)`.** The method defaults to `get` when the route + has one, else to the one it has - Nuxt's own rule. +- **Required iff sending nothing would fail validation.** An all-optional + schema keeps the option optional, but typed. +- **The types read the schemas' _input_ side**, not the handler's values: a + `z.coerce.number()` query is `string` at the call site and `number` in the + handler. +- **An undeclared source is untouched**, whatever else the route declares - it + types exactly as vanilla types it. +- **A route this module did not produce degrades to vanilla**, key for key. + +### What the types can and cannot see + +- **Reactive sources weaken excess-key rejection on `useTypedFetch`.** A plain + object literal is excess-key checked; the same value behind `ref()` or a + getter is not - the option is a union of reactive forms, and `ref()` infers + its own type. Values are still checked; only the extra key slips through. +- **`body` is omitted on `get` / `head` for routes that declare validation.** + On a branded route the option is gone rather than typed, so a `get` cannot + carry one. An unbranded route keeps vanilla's `body` on every method, which + is what makes the degradation key for key. + +## Handling failures + +```ts +matchError( + error, + { + 'user-not-found': (e) => notFound(e.userId), + 'validation-failed': (e) => showIssues(e.issues), + }, + (err, unrecognized) => { + if (unrecognized) return report(`unknown failure: ${unrecognized.tag}`) + showError(err) + } +) +``` + +One call absorbs the `if (error)` and the is-it-known check. **The arms are +exhaustive over what the route declared**, each arm receives the whole variant, +and the fallback is positional and required. `matchError` is imported from +`@dphonys/nuxt-typed-handler/shared` - it is used on the server too. + +### The built-in `validation-failed` variant + +Every route that declares any `validate` source implicitly declares one extra +variant, `validation-failed`, `400`, carrying the rejected source's issues. +It is always on, it cannot be raised with `fail`, and the wire is a known +error rather than the validation parent's own `400`: + +```jsonc +{ + "statusCode": 400, + "message": "validation-failed", // the tag; no `statusMessage` + "data": { + "issues": [ + { "source": "query", "message": "Expected number", "path": ["page"] }, + ], + }, +} +``` + +The known-error marker rides in `data` beside `issues` and is stripped for +callers off the channel, exactly as for any known error - `data.issues` +survives, so a plain `$fetch` client still reads +`err.data.data.issues`. Both predicates answer on the thrown error: +`recognizeKnownError` returns `{ tag: 'validation-failed', status: 400, issues }` +and `recognizeValidationError` returns `{ issues }`. + +Issues are the validation parent's projection - `{ source, message, path }` and +nothing else - and one failure's issues all share one `source`, because +validation is fail-fast. The unparseable-body case arrives as the same variant. + +### A `validate`-only route is still typed + +```ts +const { data, error } = await $typedFetch.try('/api/search', { + query: { page: 'nope' }, +}) + +if (error) { + matchError( + error, // typed as exactly `validation-failed` + { 'validation-failed': (e) => showIssues(e.issues) }, + (err) => showError(err) + ) + return null +} + +return data // narrowed to the route's response type +``` + +## Fetching + +Five composables - `useTypedFetch`, `useLazyTypedFetch`, +`useRequestTypedFetch`, `useTypedAsyncData`, `useLazyTypedAsyncData` - one +global, `$typedFetch`, and one event-bound instance, `event.$typedFetch`. Each +mirrors its vanilla counterpart and adds the route's typed request options and +error union. + +- **`$typedFetch(…)` is vanilla: it throws.** `$typedFetch.try(…)` returns + `{ data, error }` - a discriminated union, so `if (error) return` narrows + `data` with no second guard. `.raw`, `.native` and `.create(defaults)` are + ofetch's, forwarded. +- **`useTypedAsyncData`** is vanilla `useAsyncData` over a handler that returns + `.try` results instead of throwing; the union rides the handler's return + type, so no route is ever restated and forgetting `.try` is a compile error. + Request-side it adds nothing - the inner `$typedFetch.try` call types its own + options. +- **`event.$typedFetch`** is the server-to-server instance: same shape, with + `throw` as the exit, forwarding the request's cookies and headers. Always + `.try` plus translation arms - letting a callee's failure escape leaks its + status line as your route's answer. +- **`useRequestTypedFetch()`** mirrors Nuxt's `useRequestFetch()`: the + event-bound instance while rendering, the global on the client. + +Rationale and the longer worked examples: [Fetching][errors-fetching]. + +## Channel gating + +Responses to callers that are **not your app** go out with the known-error +marker stripped: third parties get an ordinary error response, while your own +calls - browser and SSR alike - get the full wire. This is **on by default**, +under the default channel token `'nuxt-typed-handler'`. + +```ts +export default defineNuxtConfig({ + modules: ['@dphonys/nuxt-typed-handler'], + typedHandler: { channelToken: 'my-app' }, +}) +``` + +- Every fetch surface of this module sends the token as the + `x-known-error-channel` request header; the match is always **by value**. +- **The token is a channel tag, not a secret.** It ships in the client bundle + by design, marks first-party intent, and authorises nothing. +- **It is build-time**: a module option baked into both bundles. No env + override, no runtime config; changing it is a rebuild. +- `channelToken: false` turns gating off entirely. `''` disables it too but + warns, because only `false` can mean it on purpose. +- **The thrown error always carries the marker** - only the serialized response + is ever stripped, so observability sees failures identically no matter who + called. + +Rationale: [Channel gating][errors-channel]. + +## Observability + +One hook, and this module suppresses nothing on its own: + +```ts +// server/plugins/observability.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('error', (error) => { + // A route's own declared failure, `validation-failed` included. + if (recognizeKnownError(error) && error.unhandled === false) return + + report(error) + }) +}) +``` + +The same predicate works in Sentry's `beforeSend` over +`hint.originalException`. The `unhandled === false` half is load-bearing: a +declared failure that **escaped** an inner handler reaches the hook carrying a +marker too, and that one is a caller bug that must keep reporting - so it must +not be added to the arm above. + +**Both predicates answer on a `validation-failed` error**, by design: +`recognizeKnownError` returns the variant `{ tag, status, issues }` and +`recognizeValidationError` returns `{ issues }`. Reach for the second one when +input rejections are routed somewhere else than declared failures; it answers +`undefined` for every other failure, including a route's own `fail`. + +## Turning the module off + +There is no `typedHandler: false`. `typedHandler` is a flat bag with exactly +one key - a stray key is a compile error. To turn the module off, remove +`'@dphonys/nuxt-typed-handler'` from `modules`. + +## Troubleshooting + +### A parent is registered beside this module + +```text +[nuxt-typed-handler] `@dphonys/nuxt-handler-errors` is also registered in `modules`. @dphonys/nuxt-typed-handler replaces it: remove `@dphonys/nuxt-handler-errors` (and uninstall it), then move any `channelToken` under `typedHandler`. +``` + +Thrown at `modules:done`, once for the first parent found, whether the parent +was listed by package name or as a module value. This module _replaces_ both +parents; running them side by side would give a route two wrappers, two channel +tokens and two generated maps. + +### A leftover parent config key + +```text +[nuxt-typed-handler] `handlerErrors` in nuxt.config is ignored: this module replaces the parent it configured. Move `channelToken` under `typedHandler` and delete `handlerErrors`. +``` + +Warned once per key, for `handlerErrors` and `handlerValidation`, whenever the +key is present at all - `handlerValidation: false` included, since there is +nothing left for it to switch off. + +### `'validation-failed'` in a route's declared errors + +```text +[nuxt-typed-handler] The error tag "validation-failed" is reserved for the built-in validation variant. Rename the declared error. +``` + +The compile guard says the same thing at the declaration +(`__reservedErrorTag__: 'validation-failed is reserved for the built-in variant'`); +this throw is the answer a JavaScript caller gets. Rename the declared variant. + +### `satisfies`, never `: ValidationSchemas` + +This is the one footgun worth memorizing. Annotating a declaration compiles, +but delivers no readable sources: + +```ts +// Wrong: `ctx.query` is a compile error, even though query is declared. +const schemas: ValidationSchemas = { query: pagination } + +// Right: the inferred literal is what the second parameter is computed from. +const schemas = { query: pagination } satisfies ValidationSchemas +``` + +The annotation throws away the very value the inference needed. Leave the +literal inline, or use `satisfies`. + +Everything else is documented where the rule lives: the parents' own +declaration diagnostics, the runtime errors for what the types cannot see and +the edges the compile-time guard does not catch are in +[Troubleshooting][validation-troubleshooting] in the validation parent, and +[When the call site does not know the tag][errors-unknown-tag] in the errors +parent. + +## Migrating from the parents + +Already on `@dphonys/nuxt-handler-errors` or +`@dphonys/nuxt-handler-validation`? Almost everything is a rename. + +| Before | After | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `modules: ['@dphonys/nuxt-handler-errors', '@dphonys/nuxt-handler-validation']` | `modules: ['@dphonys/nuxt-typed-handler']` | +| `handlerErrors: { channelToken }` / `handlerValidation: …` | `typedHandler: { channelToken }` | +| `defineCheckedEventHandler({ errors }, …)` / `defineValidatedEventHandler({ validate }, …)` | `defineTypedEventHandler({ errors \| validate }, …)` | +| `useCheckedFetch`, `useLazyCheckedFetch`, `useRequestCheckedFetch`, `useCheckedAsyncData`, `useLazyCheckedAsyncData`, `$checkedFetch`(`.try`), `event.$checkedFetch` | `useTypedFetch`, `useLazyTypedFetch`, `useRequestTypedFetch`, `useTypedAsyncData`, `useLazyTypedAsyncData`, `$typedFetch`(`.try`), `event.$typedFetch` | +| imports from `@dphonys/nuxt-handler-errors/{shared,types}` and `@dphonys/nuxt-handler-validation/types` | the same names from `@dphonys/nuxt-typed-handler/{shared,types}` | +| **Unchanged:** `defineError`, `payload`, `matchError`, `recognizeKnownError`, `recognizeValidationError`, `KnownErrorsOfRoute`, `ValidationErrorData`, `ValidationSchemas`, every other parent name | same name, new specifier only | + +Substitute **exact identifiers**, never the bare words `Checked` or +`Validated`, which would also hit kept names such as `CheckedEventHandler` +and `ValidatedContext`. With GNU `sed` and +[ripgrep](https://github.com/BurntSushi/ripgrep), from the app root: + +```sh +rg -l --glob '!node_modules' -e 'defineCheckedEventHandler|defineValidatedEventHandler|use(Lazy)?Checked(Fetch|AsyncData)|useRequestCheckedFetch|\$checkedFetch|@dphonys/nuxt-handler-(errors|validation)/' | xargs sed -i -e 's/defineCheckedEventHandler/defineTypedEventHandler/g' -e 's/defineValidatedEventHandler/defineTypedEventHandler/g' -e 's/useRequestCheckedFetch/useRequestTypedFetch/g' -e 's/useLazyCheckedFetch/useLazyTypedFetch/g' -e 's/useCheckedFetch/useTypedFetch/g' -e 's/useLazyCheckedAsyncData/useLazyTypedAsyncData/g' -e 's/useCheckedAsyncData/useTypedAsyncData/g' -e 's/\$checkedFetch/$typedFetch/g' -e 's#@dphonys/nuxt-handler-errors/\(server\|shared\|types\)#@dphonys/nuxt-typed-handler/\1#g' -e 's#@dphonys/nuxt-handler-validation/\(server\|types\)#@dphonys/nuxt-typed-handler/\1#g' +``` + +`nuxt.config` is deliberately outside that pass - its option keys need a +judgement no substitution can make. Edit it by hand: replace both `modules` +entries with `'@dphonys/nuxt-typed-handler'`, rename `handlerErrors: +{ channelToken }` to `typedHandler: { channelToken }`, and **delete** +`handlerValidation` rather than renaming it. Renaming both keys would collide +in one object, and `handlerValidation: false` would become a `typedHandler: +false` this module has no off-switch for. + +### Not a rename: a hand-nested route becomes one flat context + +Composing the two parents by hand gave a route **two** second parameters - +`{ fail }` from the outer wrapper, the validated values from the inner one. +Under the umbrella there is one wrapper and one context. + +```ts +// Before - two wrappers, two second parameters, one call forwarded by hand. +export default defineCheckedEventHandler( + { errors: userErrors.pick('user-exists') }, + (event, { fail }) => + defineValidatedEventHandler( + { validate: { body: createUser } }, + (_event, { body }) => + taken(body.email) + ? fail('user-exists', { email: body.email }) + : create(body) + )(event) +) +``` + +```ts +// After - one wrapper, one flat Handler context. +export default defineTypedEventHandler( + { validate: { body: createUser }, errors: userErrors.pick('user-exists') }, + (event, { body, fail }) => + taken(body.email) + ? fail('user-exists', { email: body.email }) + : create(body) +) +``` + +### Not a rename: the default channel token changes + +The default token moves from `'nuxt-handler-errors'` to +`'nuxt-typed-handler'`. Every first-party fetch surface follows automatically - +the composables, the globals and `event.$typedFetch` all send the new value. +Only a **non-Nuxt client that hard-coded** the old `x-known-error-channel` +value has to change. Pinning your own `channelToken` makes this a non-event. + +### Not a rename: `validate`-only routes gain a typed failure + +Under the validation parent a rejected request answered its own `400` and the +call site saw an untyped `FetchError`. Under the umbrella every validating +route implicitly declares `validation-failed`, so: + +- `.try` and `useTypedFetch` type the `error` as a union that **includes** + `validation-failed` - a new exhaustive arm your existing `matchError` calls + do not have yet, reported by the compiler; +- the wire becomes the known-error body ([Handling + failures](#handling-failures)): `message` is the tag and there is no + `statusMessage: 'Validation Error'` to branch on. + +Code that read `error.data.data.issues` off a raw `FetchError` still finds the +issues there, but move it to `matchError`'s `validation-failed` arm (client) or +`recognizeValidationError` (server) - both are typed, and neither depends on +the envelope. + +### `handlerValidation: false` has no equivalent + +See [Turning the module off](#turning-the-module-off). + +### The order to do it in + +1. Swap `modules` to `['@dphonys/nuxt-typed-handler']` and uninstall both + parents. +2. Run the one-liner above. +3. Fix the three non-renames. +4. Run `nuxt typecheck`. + +**Step 1 breaks the build until step 2, by design.** The sibling throw is the +guard against a half-migration: an app cannot sit with one foot in each model. + +## API reference + +Umbrella-owned surface, in three positions. `defineTypedEventHandler` comes +from `@dphonys/nuxt-typed-handler/server` and is auto-imported inside +`server/`. The five composables are **app-side auto-imports** and are exported +from no package entry - write them bare, as you would `useFetch`; the two +fetch handles are globals. Types come from +`@dphonys/nuxt-typed-handler/types`, which is type-only and safe to import +from components. + +| Export | Role | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `defineTypedEventHandler({ validate, errors }, fn)` | The wrapper. One signature; at least one of the two keys. Returns a `TypedEventHandler`. | +| `useTypedFetch` / `useLazyTypedFetch` | `useFetch` with the route's Request input on the options and its error union on the `error` ref. | +| `useTypedAsyncData` / `useLazyTypedAsyncData` | `useAsyncData` over a handler returning `.try` results. | +| `useRequestTypedFetch()` | The request-bound instance for SSR-safe imperative calls; the global on the client. | +| `$typedFetch` (`.try`, `.raw`, `.native`, `.create`) | The global typed fetch; `.try` returns `{ data, error }` instead of throwing. | +| `event.$typedFetch` | The event-bound instance, forwarding the request's identity. | +| `TypedRequestOptions` | The options every family member takes for a route and method. | +| `TypedFetch`, `TypedFetchTry`, `$TypedFetch`, `TypedEventFetch` | The fetch signatures behind those bindings. | +| `TypedEventHandler` | What the wrapper returns: an h3 `EventHandler` carrying both parents' brands. | +| `TypedContext` | The Handler context: the validated sources, plus `fail` iff errors were declared. | +| `TypedErrors` | A route's failure union: the declared variants plus `ValidationFailed` iff it validates. | +| `TypedHandlerFn`, `DefineTypedEventHandler` | The handler function shape and the wrapper's own call signature. | +| `AtLeastOne`, `ReservedTagGuard` | The compile-time guards behind the bare-`{}` and reserved-tag diagnostics. | +| `ValidationFailed` | `{ tag: 'validation-failed'; status: 400; issues: ValidationIssue[] }`. | +| `RequestInputOfRoute` | A route's declared Request input from its path alone; `never` means "declares no sources". | +| `KnownApiRequestInputs` | The generated map of every route's Request input - you never write to it. | +| `ModuleOptions` | From `@dphonys/nuxt-typed-handler`: `{ channelToken: string \| false }`. | + +### Re-exported from the parents + +Same names, new specifier. Roles are documented in the parent that owns them +([errors][errors], [validation][validation]). + +**`@dphonys/nuxt-typed-handler/server`** (all auto-imported inside `server/`): +`defineError`, `payload`, `recognizeKnownError`, `recognizeValidationError`. + +**`@dphonys/nuxt-typed-handler/shared`**: `matchError`, `KNOWN_ERROR_KEY`. + +**`@dphonys/nuxt-typed-handler/types`**, from `nuxt-handler-errors`: +`$CheckedFetch`, `CheckedEventHandler`, `CheckedFetch`, `Fail`, `Fallback`, +`KnownApiErrors`, `KnownError`, `KnownErrorBody`, `KnownErrorCarrier`, +`KnownErrorFor`, `KnownErrorGroup`, `KnownErrorKey`, `KnownErrorsOf`, +`KnownErrorsOfHandler`, `KnownErrorsOfRoute`, `KnownVariant`, `TryResult`, +`VariantsOf`. + +**`@dphonys/nuxt-typed-handler/types`**, from `nuxt-handler-validation`: +`InputOf`, `MergedInput`, `MergedOutput`, `OutputOf`, `RequestInput`, +`RequestInputOfHandler`, `SourceInput`, `SourceSchemas`, `SourceValue`, +`ValidatedContext`, `ValidatedEventHandler`, `ValidationDeclarationError`, +`ValidationErrorData`, `ValidationIssue`, `ValidationSchemas`, +`ValidationSchemasGuard`, `ValidationSource`. + +**One caveat on `$checkedFetch`.** Re-exporting the errors parent's types also +loads its ambient declarations, so `$checkedFetch` and `event.$checkedFetch` +still _typecheck_ under the umbrella. Nothing binds them: this module installs +`$typedFetch` only, and a call would find `undefined` at runtime. Use the +`Typed` names. + +## Repository development + +From the repository root: + +```sh +pnpm --filter @dphonys/nuxt-typed-handler dev +pnpm --filter @dphonys/nuxt-typed-handler typecheck +pnpm --filter @dphonys/nuxt-typed-handler test +pnpm --filter @dphonys/nuxt-typed-handler build +pnpm --filter @dphonys/nuxt-typed-handler publint +``` + +This package composes the parents' `internals/*` entries, which are documented +for it alone in each parent's `INTERNALS.md`. + +## License + +Licensed under the [MIT License](./LICENSE). + +[errors]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md +[errors-declaring]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#declaring-what-a-route-can-fail-with +[errors-fetching]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#fetching +[errors-channel]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#channel-gating +[errors-unknown-tag]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#when-the-call-site-does-not-know-the-tag +[validation]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md +[validation-composing]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md#reusing-and-composing-schemas +[validation-sources]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md#what-each-source-receives +[validation-troubleshooting]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md#troubleshooting diff --git a/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json new file mode 100644 index 0000000..86e6141 --- /dev/null +++ b/packages/nuxt-typed-handler/package.json @@ -0,0 +1,102 @@ +{ + "name": "@dphonys/nuxt-typed-handler", + "version": "0.1.0", + "description": "Declare a Nitro handler's request schemas and expected failures once, and get both typed at every call site.", + "keywords": [ + "nuxt", + "nuxt-module", + "nuxt-typed-handler" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/DPHonys/dph-nuxt-stuff.git", + "directory": "packages/nuxt-typed-handler" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/module.mjs", + "typesVersions": { + "*": { + ".": [ + "./dist/types.d.mts" + ], + "types": [ + "./dist/runtime/types/index.d.ts" + ], + "server": [ + "./dist/runtime/server/index.d.ts" + ], + "shared": [ + "./dist/runtime/shared/index.d.ts" + ] + } + }, + "exports": { + ".": { + "types": "./dist/types.d.mts", + "import": "./dist/module.mjs" + }, + "./types": { + "types": "./dist/runtime/types/index.d.ts", + "import": "./dist/runtime/types/index.js" + }, + "./server": { + "types": "./dist/runtime/server/index.d.ts", + "import": "./dist/runtime/server/index.js" + }, + "./shared": { + "types": "./dist/runtime/shared/index.d.ts", + "import": "./dist/runtime/shared/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "nuxt-module-build build", + "dev": "pnpm run dev:prepare && nuxt dev playground", + "dev:build": "nuxt build playground", + "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground", + "lint": "eslint .", + "prebuild": "nuxt-module-build prepare", + "prepack": "pnpm run build", + "pretest": "nuxt-module-build prepare", + "pretypecheck": "pnpm run build", + "publint": "publint", + "test": "vitest run", + "test:watch": "vitest watch", + "typecheck": "nuxt prepare playground && vue-tsc --noEmit && vue-tsc --noEmit --project playground/tsconfig.json" + }, + "dependencies": { + "@dphonys/nuxt-handler-errors": "workspace:0.4.0", + "@dphonys/nuxt-handler-validation": "workspace:0.2.0", + "@nuxt/kit": "catalog:", + "h3": "catalog:", + "nitropack": "catalog:", + "vue": "catalog:" + }, + "devDependencies": { + "@nuxt/devtools": "catalog:", + "@nuxt/module-builder": "catalog:", + "@nuxt/schema": "catalog:", + "@nuxt/test-utils": "catalog:", + "@types/node": "catalog:", + "nuxt": "catalog:", + "publint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "vue-tsc": "catalog:", + "zod": "catalog:" + }, + "peerDependencies": { + "nuxt": ">=4.5.1 <5.0.0" + }, + "engines": { + "node": "^22.19.0 || ^24.11.0 || >=26.0.0" + } +} diff --git a/packages/nuxt-typed-handler/playground/app.vue b/packages/nuxt-typed-handler/playground/app.vue new file mode 100644 index 0000000..e412629 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/app.vue @@ -0,0 +1,76 @@ + + + diff --git a/packages/nuxt-typed-handler/playground/nuxt.config.ts b/packages/nuxt-typed-handler/playground/nuxt.config.ts new file mode 100644 index 0000000..3b2cd68 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/nuxt.config.ts @@ -0,0 +1,11 @@ +export default defineNuxtConfig({ + modules: ['@dphonys/nuxt-typed-handler'], + devtools: { enabled: true }, + compatibilityDate: 'latest', + typedHandler: { + // A channel tag, not a secret: it is compiled into the client bundle by + // design and marks first-party intent. With it set, a response to a + // request that does not carry it goes out with the marker stripped. + channelToken: 'playground-channel', + }, +}) diff --git a/packages/nuxt-typed-handler/playground/package.json b/packages/nuxt-typed-handler/playground/package.json new file mode 100644 index 0000000..63ab031 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/package.json @@ -0,0 +1,13 @@ +{ + "name": "@dphonys/nuxt-typed-handler-playground", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build" + }, + "dependencies": { + "@dphonys/nuxt-typed-handler": "workspace:*", + "nuxt": "catalog:", + "zod": "catalog:" + } +} diff --git a/packages/nuxt-typed-handler/playground/request-typing.check.ts b/packages/nuxt-typed-handler/playground/request-typing.check.ts new file mode 100644 index 0000000..64a5927 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/request-typing.check.ts @@ -0,0 +1,258 @@ +import type { TypedRequestOptions } from '@dphonys/nuxt-typed-handler/types' +import type { NitroFetchOptions } from 'nitropack/types' + +/** + * The Typed fetch family's request-side contract, re-pointed off the + * hand-written map in `test/types/request-routes.ts` and onto the **real** + * one: every row below reads `.nuxt/types/nuxt-typed-handler.d.ts` as this + * app's `nuxt prepare` wrote it, from the routes in `server/api/`. + * + * It lives in an app because that map only exists inside one, and it is + * compiler-asserted: `pnpm typecheck` runs `vue-tsc` over this project. Bare + * `@ts-expect-error` throughout - a line that compiles where it must not is + * itself an error (TS2578), so a green run means every row held. + */ + +/** + * The package's own `test/types/assert.ts`, restated: the playground is a + * separate workspace, and reaching across into the package's test tree from + * an app would be a stranger dependency than these six lines. + */ +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false + +type Assert = T + +/** `/api/users` declares a body: required, typed as the wire sends it, closed. */ +export async function declaredBody(): Promise { + const _created = await $typedFetch('/api/users', { + method: 'post', + body: { name: 'Ada', email: 'ada@example.com' }, + }) + type _resp = Assert> + + // @ts-expect-error - the declared body is required + await $typedFetch('/api/users', { method: 'post' }) + + await $typedFetch('/api/users', { + method: 'POST', + // @ts-expect-error - excess key on the declared body + body: { name: 'Ada', email: 'ada@example.com', extra: 1 }, + }) + + await $typedFetch('/api/users', { + method: 'post', + // @ts-expect-error - a raw carrier is not the schema's input + body: '{"name":"Ada"}', + }) +} + +/** `/api/search` composes two query schemas: the wire satisfies both. */ +export async function tupleQuery(): Promise { + const _page = await $typedFetch('/api/search', { + query: { page: '2', sort: 'name' }, + }) + // `get` is the default method, and the route is keyed on it. + type _resp = Assert> + + // The second element is all-optional; the first is not. + await $typedFetch('/api/search', { query: { page: '2' } }) + + // @ts-expect-error - `page` comes from the first element, and is required + await $typedFetch('/api/search', { query: { sort: 'name' } }) + + // @ts-expect-error - the composed query is closed over both elements + await $typedFetch('/api/search', { query: { page: '2', nope: 1 } }) + + await $typedFetch('/api/search', { + query: { page: '2' }, + // @ts-expect-error - `body` is omitted on a branded `get` + body: { anything: 1 }, + }) + + // @ts-expect-error - the output type (`page: number`) is not what the wire takes + await $typedFetch('/api/search', { query: { page: 2 } }) +} + +/** A `default` (method-less) handler answers every verb it is not keyed for. */ +export async function defaultHandler(): Promise { + const _got = await $typedFetch('/api/items') + type _default = Assert> + + await $typedFetch('/api/items', { method: 'post', body: { qty: 1 } }) + // @ts-expect-error - post on the default handler requires the declared body + await $typedFetch('/api/items', { method: 'post' }) + // @ts-expect-error - excess key on the declared body + await $typedFetch('/api/items', { method: 'put', body: { qty: 1, x: 1 } }) +} + +/** A declared `body` is the schema's input, never a raw carrier. */ +export async function rawCarriersRejected(): Promise { + await $typedFetch('/api/users', { + method: 'post', + // @ts-expect-error - a FormData body + body: new FormData(), + }) +} + +/** A union-typed `method` distributes over the lookup, case and all. */ +export async function unionMethod(): Promise { + const method = Math.random() > 0.5 ? ('post' as const) : ('POST' as const) + + await $typedFetch('/api/users', { + method, + body: { name: 'Ada', email: 'ada@example.com' }, + }) +} + +/** `create` defaults are vanilla options: they never relax a call's requiredness. */ +export async function createdInstance(): Promise { + const api = $typedFetch.create({ headers: { 'x-any': 'thing' } }) + + // @ts-expect-error - `body` is still required per call + await api('/api/users', { method: 'post' }) + + await api('/api/users', { + method: 'post', + body: { name: 'Ada', email: 'ada@example.com' }, + }) + + const viaCreate = await api.try('/api/users', { + method: 'post', + body: { name: 'Ada', email: 'ada@example.com' }, + }) + + if (viaCreate.error) { + type _createTry = Assert< + Equal< + NonNullable< + typeof viaCreate.error.data + >['data']['__knownError__']['tag'], + 'user-exists' | 'validation-failed' + > + > + } +} + +/** `.raw` shares the call's signature, and an explicit `T` still overrides. */ +export async function rawAndExplicitResponse(): Promise { + await $typedFetch.raw('/api/users', { + method: 'post', + // @ts-expect-error - typed exactly as the call is + body: {}, + }) + + const _raw = await $typedFetch.raw('/api/users', { + method: 'post', + body: { name: 'Ada', email: 'ada@example.com' }, + }) + type _rawData = Assert< + Equal + > + + const _custom = await $typedFetch<{ custom: true }>('/api/legacy') + type _customResp = Assert> +} + +/** `params` is gone family-wide; every other vanilla key is untouched. */ +export async function optionsSurface(): Promise { + // @ts-expect-error - ofetch's deprecated alias is not a back door + await $typedFetch('/api/search', { params: { page: '2' } }) + // @ts-expect-error - nor on an unbranded route + await $typedFetch('/api/legacy', { params: { a: 1 } }) + + await $typedFetch('/api/search', { + headers: { 'x-any': 'thing' }, + query: { page: '2' }, + }) + + type Headers_ = TypedRequestOptions<'/api/search', 'get'>['headers'] + type _headersVanilla = Assert< + Equal['headers']> + > +} + +/** A route that declares nothing types exactly as vanilla, key for key. */ +export async function vanillaDegradation(): Promise { + const _legacy = await $typedFetch('/api/legacy') + type _resp = Assert> + + await $typedFetch('/api/legacy', { query: { anything: 1, goes: true } }) + // `body` on `get` is vanilla's own on an unbranded route, so it stays. + await $typedFetch('/api/legacy', { method: 'get', body: 'x' }) + + type VanillaGet = TypedRequestOptions<'/api/legacy', 'get'> + type _getKeys = Assert< + Equal< + keyof VanillaGet, + Exclude, 'params'> + > + > + type _getBody = Assert< + Equal['body']> + > +} + +/** An `errors`-only route declares no sources, so both stay vanilla. */ +export async function errorsOnlyRoute(): Promise { + await $typedFetch('/api/notes', { + method: 'post', + body: 'raw string', + query: { a: 1 }, + }) + + type ErrorsOnlyPost = TypedRequestOptions<'/api/notes', 'post'> + type _bodyVanilla = Assert< + Equal['body']> + > + type _queryVanilla = Assert< + Equal['query']> + > +} + +/** `.try` folds the failure into the result, typed from the errors map. */ +export async function tryResults(): Promise { + const both = await $typedFetch.try('/api/users', { + method: 'post', + body: { name: 'Ada', email: 'ada@example.com' }, + }) + + if (both.error) { + const variant = both.error.data!.data.__knownError__ + type _tags = Assert< + Equal + > + + if (variant.tag === 'user-exists') { + type _payload = Assert> + } + } else { + // Narrowed by the sibling guard alone - no second check and no `!`. + type _data = Assert> + } + + // A `validate`-only route can still only fail one way. + const validateOnly = await $typedFetch.try('/api/search', { + query: { page: 'nope' }, + }) + + if (validateOnly.error) { + type _onlyVariant = Assert< + Equal< + NonNullable< + typeof validateOnly.error.data + >['data']['__knownError__']['tag'], + 'validation-failed' + > + > + } + + // An unbranded route carries nothing to narrow on. + const unbranded = await $typedFetch.try('/api/legacy') + + if (unbranded.error) { + type _untyped = Assert> + } +} diff --git a/packages/nuxt-typed-handler/playground/server/api/items.ts b/packages/nuxt-typed-handler/playground/server/api/items.ts new file mode 100644 index 0000000..f5cfa59 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/api/items.ts @@ -0,0 +1,10 @@ +import { itemUpdate } from '../validation/schemas' + +/** + * A method-less (`default`) handler: it answers every verb the route is not + * keyed for, and both maps key it under `default` rather than a method. + */ +export default defineTypedEventHandler( + { validate: { body: itemUpdate } }, + (_event, { body }) => ({ qty: body.qty }) +) diff --git a/packages/nuxt-typed-handler/playground/server/api/legacy.get.ts b/packages/nuxt-typed-handler/playground/server/api/legacy.get.ts new file mode 100644 index 0000000..142d10d --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/api/legacy.get.ts @@ -0,0 +1,2 @@ +/** Unbranded, and keyed anyway: what makes both generated lookups total. */ +export default defineEventHandler(() => ({ legacy: true })) diff --git a/packages/nuxt-typed-handler/playground/server/api/notes.post.ts b/packages/nuxt-typed-handler/playground/server/api/notes.post.ts new file mode 100644 index 0000000..7fe1353 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/api/notes.post.ts @@ -0,0 +1,8 @@ +import { readRawBody } from 'h3' +import { userErrors } from '../errors/users' + +/** An `errors`-only `POST`: the body is the handler's to read, or not. */ +export default defineTypedEventHandler( + { errors: userErrors.pick('user-not-found') }, + async (event) => ({ reached: true, raw: (await readRawBody(event)) ?? null }) +) diff --git a/packages/nuxt-typed-handler/playground/server/api/search.get.ts b/packages/nuxt-typed-handler/playground/server/api/search.get.ts new file mode 100644 index 0000000..a36c054 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/api/search.get.ts @@ -0,0 +1,10 @@ +import { pagination, sorting } from '../validation/schemas' + +/** + * A `validate`-only route with a composed (tuple) query: the parent's + * context, the umbrella's failure wire. + */ +export default defineTypedEventHandler( + { validate: { query: [pagination, sorting] } }, + (_event, { query }) => ({ page: query.page, hits: [] as string[] }) +) diff --git a/packages/nuxt-typed-handler/playground/server/api/users.post.ts b/packages/nuxt-typed-handler/playground/server/api/users.post.ts new file mode 100644 index 0000000..1aa3d2b --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/api/users.post.ts @@ -0,0 +1,14 @@ +import { userErrors } from '../errors/users' +import { createUser } from '../validation/schemas' + +/** Both halves declared: a rejected body and a declared failure, one route. */ +export default defineTypedEventHandler( + { validate: { body: createUser }, errors: userErrors.pick('user-exists') }, + (_event, { body, fail }) => { + if (body.email === 'taken@example.com') { + return fail('user-exists', { email: body.email }) + } + + return { created: body.name } + } +) diff --git a/packages/nuxt-typed-handler/playground/server/api/users/[id].get.ts b/packages/nuxt-typed-handler/playground/server/api/users/[id].get.ts new file mode 100644 index 0000000..49eea89 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/api/users/[id].get.ts @@ -0,0 +1,13 @@ +import { userErrors } from '../../errors/users' + +/** The errors parent's smoke: an `errors`-only route, byte for byte the parent's. */ +export default defineTypedEventHandler( + { errors: userErrors.pick('user-not-found') }, + (event, { fail }) => { + const userId = event.context.params?.id ?? '' + + if (userId === 'missing') return fail('user-not-found', { userId }) + + return { id: userId, name: `User ${userId}` } + } +) diff --git a/packages/nuxt-typed-handler/playground/server/errors/users.ts b/packages/nuxt-typed-handler/playground/server/errors/users.ts new file mode 100644 index 0000000..8dc61e5 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/errors/users.ts @@ -0,0 +1,5 @@ +/** The failures the user routes declare, shared so both spell them once. */ +export const userErrors = defineError({ + 'user-not-found': { status: 404, payload: payload<{ userId: string }>() }, + 'user-exists': { status: 409, payload: payload<{ email: string }>() }, +}) diff --git a/packages/nuxt-typed-handler/playground/server/tsconfig.json b/packages/nuxt-typed-handler/playground/server/tsconfig.json new file mode 100644 index 0000000..b9ed69c --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/packages/nuxt-typed-handler/playground/server/validation/schemas.ts b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts new file mode 100644 index 0000000..52cd356 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +/** A page number, coerced from the string the wire always carries. */ +export const pagination = z.object({ + page: z + .string() + .regex(/^\d+$/, 'page must be a whole number') + .transform(Number), +}) + +/** What creating a user takes. */ +export const createUser = z.object({ + name: z.string().min(1, 'name is required'), + email: z.string().email('email must be an address'), +}) + +/** The second half of the search query, composed with `pagination` as a tuple. */ +export const sorting = z.object({ + sort: z.enum(['name', 'created']).optional(), +}) + +/** What updating an item takes; the `default` handler's declared body. */ +export const itemUpdate = z.object({ + qty: z.number(), +}) diff --git a/packages/nuxt-typed-handler/playground/tsconfig.json b/packages/nuxt-typed-handler/playground/tsconfig.json new file mode 100644 index 0000000..4b34df1 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/packages/nuxt-typed-handler/playground/turbo.json b/packages/nuxt-typed-handler/playground/turbo.json new file mode 100644 index 0000000..79dff1c --- /dev/null +++ b/packages/nuxt-typed-handler/playground/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "typecheck": { + "dependsOn": [] + } + } +} diff --git a/packages/nuxt-typed-handler/src/build/parent-types-paths.ts b/packages/nuxt-typed-handler/src/build/parent-types-paths.ts new file mode 100644 index 0000000..7761d4f --- /dev/null +++ b/packages/nuxt-typed-handler/src/build/parent-types-paths.ts @@ -0,0 +1,80 @@ +import { resolveTypePaths } from '@nuxt/kit' +import type { Nuxt } from '@nuxt/schema' +import type { NitroConfig } from 'nitropack/types' +import { fileURLToPath } from 'node:url' + +// The generated map augments the first and imports from the second; neither +// resolves from an app that installed the umbrella alone. +const PARENT_TYPES_SPECIFIERS = [ + '@dphonys/nuxt-handler-errors/types', + '@dphonys/nuxt-handler-validation/types', +] as const + +interface PathsCarrier { + compilerOptions?: { paths?: Record } +} + +/** + * Map both parent `/types` specifiers, on every generated tsconfig, to the + * declaration each resolves to from this module's own location (`from` is + * its `import.meta.url`), so pnpm's nested layout is honoured. + * `typescript.hoist` cannot do this: it resolves from the app's `modulesDir` + * alone and silently drops what it cannot find there. + */ +export function addParentTypesPaths(nuxt: Nuxt, from: string): void { + // A directory, not the URL: handed a `file://` URL, `resolveTypePaths` + // silently searches from the process's working directory instead. + const searchPath = fileURLToPath(new URL('.', from)) + + // Resolved once for both hooks, failure included. `resolveTypePaths` rather + // than `resolvePath`: a `paths` entry has to name the declaration + // TypeScript loads for a subpath export, not the runtime file. + let declarations: Promise> | undefined + + const resolveDeclarations = (): Promise> => { + declarations ??= resolveTypePaths( + [...PARENT_TYPES_SPECIFIERS], + [searchPath] + ).then((resolved) => { + const missing = PARENT_TYPES_SPECIFIERS.filter( + (specifier) => !resolved.some(([found]) => found === specifier) + ) + + if (missing.length > 0) { + throw new Error( + `[nuxt-typed-handler] could not resolve ${missing.join(', ')} from ${searchPath}. Both parents are exact-pinned dependencies of this package; reinstall it.` + ) + } + + return Object.fromEntries( + resolved.map(([specifier, path]) => [specifier, [path]]) + ) + }) + + return declarations + } + + const write = async (...targets: PathsCarrier[]): Promise => { + const entries = await resolveDeclarations() + + for (const target of targets) { + target.compilerOptions ??= {} + target.compilerOptions.paths ??= {} + + for (const [specifier, paths] of Object.entries(entries)) { + target.compilerOptions.paths[specifier] = paths + } + } + } + + nuxt.hook('prepare:types', ({ tsConfig, nodeTsConfig, sharedTsConfig }) => + write(tsConfig, nodeTsConfig, sharedTsConfig) + ) + + nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { + nitroConfig.typescript ??= {} + nitroConfig.typescript.tsConfig ??= {} + + return write(nitroConfig.typescript.tsConfig) + }) +} diff --git a/packages/nuxt-typed-handler/src/build/type-map.ts b/packages/nuxt-typed-handler/src/build/type-map.ts new file mode 100644 index 0000000..1b62c8c --- /dev/null +++ b/packages/nuxt-typed-handler/src/build/type-map.ts @@ -0,0 +1,60 @@ +// The generated file as a value, kept out of `module.ts` so a suite can ask +// for the real map - slots respelled in a test prove only the test. + +import { + emitMap, + emptyMap, + KNOWN_ERRORS_SLOT, +} from '@dphonys/nuxt-handler-errors/internals/build' +import type { + EmitMapSlot, + NitroPathOptions, +} from '@dphonys/nuxt-handler-errors/internals/build' +import type { NitroEventHandler } from 'nitropack/types' + +/** + * The specifier the request-inputs map augments. Exported so `setup()`'s + * `typescript.hoist.push(...)` cannot drift from the string actually emitted. + */ +export const TYPES_SPECIFIER = '@dphonys/nuxt-typed-handler/types' + +// No `Serialize`: the input *is* the wire shape by the author's intent, and +// `query` must not be serialised. +const REQUEST_INPUTS_SLOT: EmitMapSlot = { + interfaceName: 'KnownApiRequestInputs', + specifier: TYPES_SPECIFIER, + imports: [ + { names: ['Simplify'], from: 'nitropack/types' }, + { + names: ['RequestInputOfHandler'], + from: '@dphonys/nuxt-handler-validation/types', + }, + ], + extract: (handlerType) => `Simplify>`, +} + +const SLOTS: readonly EmitMapSlot[] = [KNOWN_ERRORS_SLOT, REQUEST_INPUTS_SLOT] + +export interface TypeMap { + /** + * Must live under `types/` - Nitro's `typesDir` - because every handler + * specifier the emitter computes is relative to it, and an unresolved + * `import('…')` in a `.d.ts` produces no diagnostic. + */ + readonly filename: `${string}.d.ts` + /** What a build writes before Nitro exists: one empty interface per slot. */ + readonly empty: string + readonly emit: ( + handlers: readonly NitroEventHandler[], + nitroOptions: NitroPathOptions + ) => string +} + +export function typeMap(name: string): TypeMap { + return { + filename: `types/${name}.d.ts`, + empty: emptyMap({ slots: SLOTS, generatedBy: name }), + emit: (handlers, nitroOptions) => + emitMap(handlers, { nitroOptions, slots: SLOTS, generatedBy: name }), + } +} diff --git a/packages/nuxt-typed-handler/src/module.ts b/packages/nuxt-typed-handler/src/module.ts new file mode 100644 index 0000000..1fffdbe --- /dev/null +++ b/packages/nuxt-typed-handler/src/module.ts @@ -0,0 +1,219 @@ +import { + addChannelStripErrorHandler, + addChannelToken, + normalizeChannelToken, + warnCustomErrorHandler, +} from '@dphonys/nuxt-handler-errors/internals/build' +import { + addImports, + addPlugin, + addServerImports, + addServerPlugin, + addTypeTemplate, + createResolver, + defineNuxtModule, + hasNuxtModule, + logger, + updateTemplates, +} from '@nuxt/kit' +import type { Nitro } from 'nitropack/types' +import { addParentTypesPaths } from './build/parent-types-paths' +import { typeMap, TYPES_SPECIFIER } from './build/type-map' + +export interface ModuleOptions { + /** + * The channel tag every checked call attaches, and the value the + * response-side stripper matches requests against. **A channel tag, not a + * secret**: it ships in the client bundle by design and authorises nothing. + * + * Defaults to `'nuxt-typed-handler'`, so gating is on out of the box. Set + * your own value to name your app's channel, or `false` to turn gating off + * entirely. (`false`, not `null`: the options merge treats `null` as + * "unset" and would silently restore the default.) Build-time: the value + * is baked into both bundles, so changing it is a rebuild. + */ + channelToken: string | false +} + +const NAME = 'nuxt-typed-handler' + +const TYPE_MAP = typeMap(NAME) + +const PARENTS = [ + { + packageName: '@dphonys/nuxt-handler-errors', + moduleName: 'nuxt-handler-errors', + configKey: 'handlerErrors', + }, + { + packageName: '@dphonys/nuxt-handler-validation', + moduleName: 'nuxt-handler-validation', + configKey: 'handlerValidation', + }, +] as const + +export default defineNuxtModule({ + meta: { + name: NAME, + configKey: 'typedHandler', + // The ceiling is the only guard against the h3 v2 / Nitro 3 line. + compatibility: { nuxt: '>=4.5.1 <5.0.0' }, + }, + defaults: { + channelToken: NAME, + }, + setup(options, nuxt) { + warnCustomErrorHandler(nuxt, NAME) + + // Any value, `false` included: a parent's key configures nothing here, and + // `false` has nothing left to switch off. + for (const { configKey } of PARENTS) { + if (!Object.hasOwn(nuxt.options, configKey)) continue + + logger.warn( + `[${NAME}] \`${configKey}\` in nuxt.config is ignored: this module replaces the parent it configured. Move \`channelToken\` under \`typedHandler\` and delete \`${configKey}\`.` + ) + } + + // The errors parent's app internals import `#app`, and Nuxt transpiles + // only what `modules` lists. + nuxt.options.build.transpile.push('@dphonys/nuxt-handler-errors') + + // Only this module's own specifier is hoisted: `hoist` resolves from the + // app's `modulesDir`, where an umbrella-only install has no parent. The + // parents' `/types` go through `paths` instead. + nuxt.options.typescript.hoist.push(TYPES_SPECIFIER) + addParentTypesPaths(nuxt, import.meta.url) + + // Named rather than scanned with `addServerImportsDir`: the parents' + // wrappers must not become auto-imports. + const resolver = createResolver(import.meta.url) + const serverEntry = resolver.resolve('./runtime/server/index') + + addServerImports( + [ + 'defineTypedEventHandler', + 'defineError', + 'payload', + 'recognizeKnownError', + 'recognizeValidationError', + ].map((name) => ({ name, from: serverEntry })) + ) + + // None of the composables use `addServerImports`: all five reach `#app`, + // which the Nitro build does not have. + const fetchComposables = resolver.resolve( + './runtime/app/composables/use-typed-fetch' + ) + const asyncDataComposables = resolver.resolve( + './runtime/app/composables/use-typed-async-data' + ) + const requestComposable = resolver.resolve( + './runtime/app/composables/use-request-typed-fetch' + ) + + addImports([ + { name: 'useTypedFetch', from: fetchComposables }, + { name: 'useLazyTypedFetch', from: fetchComposables }, + { name: 'useTypedAsyncData', from: asyncDataComposables }, + { name: 'useLazyTypedAsyncData', from: asyncDataComposables }, + { name: 'useRequestTypedFetch', from: requestComposable }, + ]) + + // `matchError` is deliberately not auto-imported: its callers include a + // consumer's `shared/` directory, where app-side auto-imports do not reach. + + // Without this registration duplicate calls collapse onto one + // `useAsyncData` entry. `argumentLength: 3` is vanilla's own. + nuxt.options.optimization.keyedComposables.push( + { name: 'useTypedFetch', source: fetchComposables, argumentLength: 3 }, + { + name: 'useLazyTypedFetch', + source: fetchComposables, + argumentLength: 3, + }, + { + name: 'useTypedAsyncData', + source: asyncDataComposables, + argumentLength: 3, + }, + { + name: 'useLazyTypedAsyncData', + source: asyncDataComposables, + argumentLength: 3, + } + ) + + // `$typedFetch` on both `globalThis`es. The app half is `client`-only: + // during SSR the one global is Nitro's. + addPlugin({ + src: resolver.resolve('./runtime/app/plugins/typed-fetch.client'), + mode: 'client', + }) + addServerPlugin(resolver.resolve('./runtime/server/plugins/typed-fetch')) + + addServerPlugin( + resolver.resolve('./runtime/server/plugins/event-typed-fetch') + ) + + const channelToken = normalizeChannelToken(options.channelToken, NAME) + addChannelToken(nuxt, NAME, channelToken) + addChannelStripErrorHandler( + nuxt, + channelToken, + resolver.resolve('./runtime/server/handlers/channel-strip') + ) + + // Captured here and read by `getContents` - on a dev-server restart the + // current instance and the hooked one are not the same object. + let nitro: Nitro | undefined + + // The context must name all three programs: passing a context at all opts + // out of everything it does not name. + addTypeTemplate( + { + filename: TYPE_MAP.filename, + getContents: () => + nitro === undefined + ? TYPE_MAP.empty + : TYPE_MAP.emit( + [...nitro.scannedHandlers, ...nitro.options.handlers], + // Passed whole: `resolveNitroPath` reads arbitrary + // properties off it to expand `{{ }}` path templates. + nitro.options + ), + }, + { nitro: true, nuxt: true, shared: true } + ) + + nuxt.hook('nitro:init', (instance) => { + nitro = instance + + // `types:extend` fires inside Nitro's `writeTypes` after a fresh + // `scanHandlers`, so this map lands ahead of Nitro's own route types. + instance.hooks.hook('types:extend', async () => { + await updateTemplates({ + filter: (template) => template.filename === TYPE_MAP.filename, + }) + }) + }) + + // A parent beside this module is a configuration error, not a warning. + // Both spellings: consumers list the package name, but a module passed as + // a value is known to kit by its `meta.name` alone. + nuxt.hook('modules:done', () => { + for (const { packageName, moduleName } of PARENTS) { + if ( + !hasNuxtModule(packageName, nuxt) && + !hasNuxtModule(moduleName, nuxt) + ) { + continue + } + + throw new Error( + `[${NAME}] \`${packageName}\` is also registered in \`modules\`. @dphonys/nuxt-typed-handler replaces it: remove \`${packageName}\` (and uninstall it), then move any \`channelToken\` under \`typedHandler\`.` + ) + } + }) + }, +}) diff --git a/packages/nuxt-typed-handler/src/runtime/app/composables/use-request-typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/app/composables/use-request-typed-fetch.ts new file mode 100644 index 0000000..0e26664 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/app/composables/use-request-typed-fetch.ts @@ -0,0 +1,14 @@ +import { useRequestEvent } from '#app' +import { $typedFetch } from '../../shared/typed-fetch' +import type { TypedFetch } from '../../types/fetch' + +/** + * The request-bound typed fetch for SSR-safe imperative calls in app code - + * Nuxt's `useRequestFetch()`, mirrored. A call made while rendering forwards + * the incoming request's cookies and headers. + */ +export function useRequestTypedFetch(): TypedFetch { + if (import.meta.client) return $typedFetch + + return useRequestEvent()?.$typedFetch || $typedFetch +} diff --git a/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-async-data.ts b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-async-data.ts new file mode 100644 index 0000000..464bfd9 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-async-data.ts @@ -0,0 +1,28 @@ +import { wrapVanillaAsyncData } from '@dphonys/nuxt-handler-errors/internals/app' +import { useAsyncData, useLazyAsyncData } from '#app' +import type { UseTypedAsyncData } from '../../types/composables' + +/** + * Drop-in `useAsyncData` whose handler returns `.try` results instead of + * throwing: the handler's declared union lands typed on the `error` ref, + * `data` is the unwrapped success, and `matchError(error, …)` is the one read + * path. + * + * ```ts + * const { data, error } = await useTypedAsyncData('user', () => + * $typedFetch.try(`/api/users/${id}`) + * ) + * ``` + */ +export const useTypedAsyncData = wrapVanillaAsyncData( + useAsyncData +) as UseTypedAsyncData + +/** + * The lazy twin. Delegates to Nuxt's own `useLazyAsyncData` rather than + * passing `lazy: true`, so Nuxt's dev-mode data diagnostics tag the call + * correctly. + */ +export const useLazyTypedAsyncData = wrapVanillaAsyncData( + useLazyAsyncData +) as UseTypedAsyncData diff --git a/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-fetch.ts new file mode 100644 index 0000000..657cbc9 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-fetch.ts @@ -0,0 +1,29 @@ +import { configuredChannelToken } from '#nuxt-typed-handler/channel-token' +import type { FetchWrapperOptions } from '@dphonys/nuxt-handler-errors/internals/app' +import { wrapVanillaFetch } from '@dphonys/nuxt-handler-errors/internals/app' +import { useFetch, useLazyFetch } from '#app' +import type { UseTypedFetch } from '../../types/composables' + +// A getter: the alias is a live binding the unit double sets after the +// composables are built, and the wrapper reads `token` on every call. +const bound: FetchWrapperOptions = { + get token() { + return configuredChannelToken + }, +} + +/** + * Drop-in `useFetch` with the route's Request input typed on the options and + * its declared error union typed on the `error` ref: `data` is what it always + * was, and `matchError(error, …)` is the one read path. + */ +export const useTypedFetch = wrapVanillaFetch(useFetch, bound) as UseTypedFetch + +/** + * The lazy twin. Delegates to Nuxt's own `useLazyFetch` rather than passing + * `lazy: true`, so Nuxt's dev-mode data diagnostics tag the call correctly. + */ +export const useLazyTypedFetch = wrapVanillaFetch( + useLazyFetch, + bound +) as UseTypedFetch diff --git a/packages/nuxt-typed-handler/src/runtime/app/plugins/typed-fetch.client.ts b/packages/nuxt-typed-handler/src/runtime/app/plugins/typed-fetch.client.ts new file mode 100644 index 0000000..ef9e03c --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/app/plugins/typed-fetch.client.ts @@ -0,0 +1,6 @@ +import { defineNuxtPlugin } from '#app' +import { $typedFetch } from '../../shared/typed-fetch' + +export default defineNuxtPlugin(() => { + globalThis.$typedFetch = $typedFetch +}) diff --git a/packages/nuxt-typed-handler/src/runtime/server/handlers/channel-strip.ts b/packages/nuxt-typed-handler/src/runtime/server/handlers/channel-strip.ts new file mode 100644 index 0000000..ab4c7f6 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/handlers/channel-strip.ts @@ -0,0 +1,4 @@ +import { configuredChannelToken } from '#nuxt-typed-handler/channel-token' +import { createChannelStripHandler } from '@dphonys/nuxt-handler-errors/internals/server' + +export default createChannelStripHandler(() => configuredChannelToken) diff --git a/packages/nuxt-typed-handler/src/runtime/server/index.ts b/packages/nuxt-typed-handler/src/runtime/server/index.ts new file mode 100644 index 0000000..1f8b4af --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/index.ts @@ -0,0 +1,7 @@ +export { + defineError, + payload, + recognizeKnownError, +} from '@dphonys/nuxt-handler-errors/server' +export { recognizeValidationError } from '@dphonys/nuxt-handler-validation/server' +export { defineTypedEventHandler } from './lib/typed-handler' diff --git a/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts b/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts new file mode 100644 index 0000000..a4091bb --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts @@ -0,0 +1,21 @@ +import { createKnownError } from '@dphonys/nuxt-handler-errors/internals/server' +import type { OnInvalid } from '@dphonys/nuxt-handler-validation/internals/server' +import { markValidationError } from '@dphonys/nuxt-handler-validation/internals/shared' +import { RESERVED_TAG } from './reserved-tag' + +/** + * Every client-input rejection becomes the one built-in known error, + * `validation-failed` `400`, carrying both parents' markers so both + * recognizers answer. + */ +export const onInvalid: OnInvalid = (_source, issues) => { + const error = createKnownError(RESERVED_TAG, 400, { issues: [...issues] }) + + // Beside the marker too: what a client reads once the marker is stripped, + // at the path the validation parent documents. A second copy, so neither + // place shares an array with the other or the hook's input. + ;(error.data as Record).issues = [...issues] + markValidationError(error, issues) + + throw error +} diff --git a/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts b/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts new file mode 100644 index 0000000..9fb365f --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts @@ -0,0 +1,16 @@ +import type { DeclaredError } from '@dphonys/nuxt-handler-errors/internals/server' + +/** The tag of the built-in variant; no umbrella route may declare it. */ +export const RESERVED_TAG = 'validation-failed' + +// The compile guard's answer for a JavaScript caller: thrown at declaration, +// so the route never becomes servable. +export function assertNoReservedTag( + declared: readonly DeclaredError[] | undefined +): void { + if (declared?.some((entry) => entry.tag === RESERVED_TAG) !== true) return + + throw new Error( + `[nuxt-typed-handler] The error tag "${RESERVED_TAG}" is reserved for the built-in validation variant. Rename the declared error.` + ) +} diff --git a/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts new file mode 100644 index 0000000..3366f13 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts @@ -0,0 +1,68 @@ +import { + createFail, + resolveDeclared, +} from '@dphonys/nuxt-handler-errors/internals/server' +import { + sourcePlan, + validatedContext, +} from '@dphonys/nuxt-handler-validation/internals/server' +import type { ValidatedContextOptions } from '@dphonys/nuxt-handler-validation/internals/server' +import { defineEventHandler } from 'h3' +import type { DefineTypedEventHandler } from '../../types/handler' +import { onInvalid } from './on-invalid' +import { assertNoReservedTag } from './reserved-tag' + +const VALIDATION_OPTIONS: ValidatedContextOptions = { onInvalid } + +/** + * Declare what a route validates and what it can fail with, and get both in + * the handler's second parameter: the validated sources, flat, plus `fail` + * scoped to the declared errors. Either half alone is valid. + * + * ```ts + * export default defineTypedEventHandler( + * { validate: { body: createUser }, errors: [...userErrors] }, + * async (event, { body, fail }) => { + * if (await exists(body.email)) return fail('user-exists') + * return create(body) + * } + * ) + * ``` + * + * A rejected request answers the built-in `validation-failed` variant rather + * than the validation parent's own `400`; everything else about each half is + * the parent's, unchanged. Reading the body again with `readBody` yields h3's + * memoized unvalidated parse. + */ +export const defineTypedEventHandler: DefineTypedEventHandler = ( + options, + handler +) => { + // In this order, so each declaration fault reports with its owner's message. + const declared = options.errors ? resolveDeclared(options.errors) : undefined + assertNoReservedTag(declared) + const plan = options.validate ? sourcePlan(options.validate) : undefined + const fail = declared === undefined ? undefined : createFail(declared) + + // The compile guard's answer for a JavaScript caller - `validate: {}` plans + // nothing, so it counts for nothing here either. + if ((plan === undefined || plan.length === 0) && fail === undefined) { + throw new Error( + '[nuxt-typed-handler] defineTypedEventHandler needs validate, errors, or both.' + ) + } + + // Cast because the loose record is typed at this seam and nowhere else. + const contextFor = (validated: Record): never => + (fail === undefined ? validated : { ...validated, fail }) as never + + // No validation call at all on a route that declares none: no body read, + // no await. + return defineEventHandler((event) => + plan === undefined + ? handler(event, contextFor({})) + : validatedContext(event, plan, VALIDATION_OPTIONS).then((validated) => + handler(event, contextFor(validated)) + ) + ) as never +} diff --git a/packages/nuxt-typed-handler/src/runtime/server/plugins/event-typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/server/plugins/event-typed-fetch.ts new file mode 100644 index 0000000..ab6b92c --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/plugins/event-typed-fetch.ts @@ -0,0 +1,14 @@ +import { configuredChannelToken } from '#nuxt-typed-handler/channel-token' +import type { RawEventFetch } from '@dphonys/nuxt-handler-errors/internals/server' +import { createCheckedEventFetch } from '@dphonys/nuxt-handler-errors/internals/server' +import { defineNitroPlugin } from 'nitropack/runtime' +import type { TypedEventFetch } from '../../types/fetch' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', (event) => { + event.$typedFetch = createCheckedEventFetch( + () => event.$fetch as RawEventFetch | undefined, + configuredChannelToken + ) as TypedEventFetch + }) +}) diff --git a/packages/nuxt-typed-handler/src/runtime/server/plugins/typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/server/plugins/typed-fetch.ts new file mode 100644 index 0000000..36337f2 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/plugins/typed-fetch.ts @@ -0,0 +1,6 @@ +import { defineNitroPlugin } from 'nitropack/runtime' +import { $typedFetch } from '../../shared/typed-fetch' + +export default defineNitroPlugin(() => { + globalThis.$typedFetch = $typedFetch +}) diff --git a/packages/nuxt-typed-handler/src/runtime/server/tsconfig.json b/packages/nuxt-typed-handler/src/runtime/server/tsconfig.json new file mode 100644 index 0000000..0e35e64 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../.nuxt/tsconfig.server.json" +} diff --git a/packages/nuxt-typed-handler/src/runtime/shared/index.ts b/packages/nuxt-typed-handler/src/runtime/shared/index.ts new file mode 100644 index 0000000..a1ab6db --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/shared/index.ts @@ -0,0 +1 @@ +export * from '@dphonys/nuxt-handler-errors/shared' diff --git a/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts new file mode 100644 index 0000000..945b8c7 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts @@ -0,0 +1,21 @@ +import { configuredChannelToken } from '#nuxt-typed-handler/channel-token' +import type { CheckedFetchFactoryOptions } from '@dphonys/nuxt-handler-errors/internals/shared' +import { + createCheckedFetch, + lazyGlobalFetch, +} from '@dphonys/nuxt-handler-errors/internals/shared' +import type { $TypedFetch } from '../types/fetch' + +// A getter, never spread: the alias is a live binding the unit double sets +// after the global is built, and the factory reads `token` on every call. +const bound: CheckedFetchFactoryOptions = { + get token() { + return configuredChannelToken + }, +} + +/** The value the module installs on `globalThis` - one object on every side. */ +export const $typedFetch = createCheckedFetch( + lazyGlobalFetch, + bound +) as $TypedFetch diff --git a/packages/nuxt-typed-handler/src/runtime/types/composables.ts b/packages/nuxt-typed-handler/src/runtime/types/composables.ts new file mode 100644 index 0000000..5e9923c --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/composables.ts @@ -0,0 +1,67 @@ +import type { UseCheckedAsyncData } from '@dphonys/nuxt-handler-errors/internals/app' +import type { NitroFetchRequest } from 'nitropack/types' +import type { AsyncData, UseFetchOptions } from 'nuxt/app' +import type { MaybeRefOrGetter, Ref } from 'vue' +import type { + DefaultMethod, + MethodArg, + Resp, + TypedErrorFor, + TypedSources, +} from './fetch' + +// Nuxt does not export `ComputedOptions`; this is its definition verbatim - +// the bare `Function` included, because narrowing it would stop matching what +// vanilla's own option types accept. +type ComputedOptions> = { + // eslint-disable-next-line ts/no-unsafe-function-type + [K in keyof T]: T[K] extends Function + ? T[K] + : ComputedOptions | MaybeRefOrGetter +} + +type Reactive = + T extends Record + ? ComputedOptions | MaybeRefOrGetter + : MaybeRefOrGetter + +// Each typed source re-added the way vanilla types its own. A plain literal +// is still excess-key checked; through `ref()` or a getter it is not - a +// union target, and `ref()` infers its own type. +type ReactiveSources = { [K in keyof O]: Reactive } + +/** + * Vanilla `useFetch`'s options for a route and method, with `body` and + * `query` typed from the route's declared schemas and `params` gone. + */ +export type UseTypedFetchOptions< + ResT, + ReqT extends NitroFetchRequest, + Method extends MethodArg, +> = Omit< + UseFetchOptions, + 'body' | 'query' | 'params' +> & + ReactiveSources> + +/** + * `useFetch` with the route's Request input typed on the options and its + * declared error union typed on the `error` ref. + */ +export interface UseTypedFetch { + < + ReqT extends NitroFetchRequest, + const Method extends MethodArg = DefaultMethod, + ResT = Resp, + >( + request: Ref | ReqT | (() => ReqT), + opts?: UseTypedFetchOptions + ): AsyncData | undefined> +} + +/** + * `useAsyncData` whose handler returns `.try` results instead of throwing. + * The parent's signature exactly: the inner `$typedFetch.try` call types its + * own options, and its error union is what lands on the `error` ref. + */ +export type UseTypedAsyncData = UseCheckedAsyncData diff --git a/packages/nuxt-typed-handler/src/runtime/types/fetch.ts b/packages/nuxt-typed-handler/src/runtime/types/fetch.ts new file mode 100644 index 0000000..e1b71a8 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/fetch.ts @@ -0,0 +1,166 @@ +import type { + KnownErrorFor, + TryResult, +} from '@dphonys/nuxt-handler-errors/types' +import type { RouterMethod } from 'h3' +import type { + $Fetch, + AvailableRouterMethod, + NitroFetchOptions, + NitroFetchRequest, + TypedInternalResponse, +} from 'nitropack/types' +import type { RequestInputOfRoute } from './index' + +// Stack-depth rules, each one a `TS2321 Excessive stack depth` per +// `InternalApi` key if broken: `M`'s default references only `R`; a method +// derived from the options lives in an alias default, never in a signature +// parameter's constraint; no type parameter appears in its own constraint. + +/** The methods a call may name for a route: Nitro's, in either case. */ +export type MethodArg = + | AvailableRouterMethod + | Uppercase> + +/** `get` when the route has one, else whatever it has - Nuxt's own rule. */ +export type DefaultMethod = + 'get' extends MethodArg ? 'get' : MethodArg + +// `R extends string` because `NitroFetchRequest` also admits a `Request` +// object no route path can be read out of. +type InputFor = R extends string + ? RequestInputOfRoute, RouterMethod>> + : never + +// Required only when the input cannot be omitted: an all-optional, `unknown` +// or `any` input stays optional but typed. +type Declared = + // eslint-disable-next-line ts/no-empty-object-type + {} extends I[K] ? { [P in K]?: I[K] } : { [P in K]-?: I[K] } + +/** ofetch's own typing of one key, for a source nobody declared. */ +type Vanilla = Pick, K> + +type QueryOption = [I] extends [never] + ? Vanilla<'query'> + : 'query' extends keyof I + ? Declared + : Vanilla<'query'> + +// A route declaring nothing keeps vanilla's `body` on every method; a +// declaring route has none at all on `get` and `head`. +type BodyOption = [I] extends [never] + ? Vanilla<'body'> + : Lowercase extends 'get' | 'head' + ? // eslint-disable-next-line ts/no-empty-object-type + {} + : 'body' extends keyof I + ? Declared + : Vanilla<'body'> + +/** The typed sources alone - what the composables re-add reactive. */ +export type TypedSources< + R extends NitroFetchRequest, + M extends MethodArg, +> = QueryOption> & BodyOption, M> + +/** + * The options every member of the Typed fetch family takes for a route and + * method: vanilla's, with `body` and `query` typed from the route's declared + * schemas and ofetch's deprecated `params` alias gone for everyone. + */ +export type TypedRequestOptions< + R extends NitroFetchRequest, + M extends MethodArg, +> = Omit< + NitroFetchOptions, AvailableRouterMethod>>, + 'method' | 'body' | 'query' | 'params' +> & { method?: M } & TypedSources + +export type Resp = TypedInternalResponse< + R, + T, + Extract, RouterMethod> +> + +/** + * The error one call can produce. The known-errors map already carries + * `validation-failed` for every validating route. + */ +export type TypedErrorFor = KnownErrorFor< + R, + Extract, RouterMethod> +> + +/** The `.try` call: returns a {@link TryResult} instead of throwing. */ +export interface TypedFetchTry< + DefaultT = unknown, + DefaultR extends NitroFetchRequest = NitroFetchRequest, +> { + < + T = DefaultT, + R extends NitroFetchRequest = DefaultR, + const M extends MethodArg = DefaultMethod, + >( + request: R, + opts?: TypedRequestOptions + ): Promise, TypedErrorFor>> +} + +/** + * The minimal typed instance: the call plus `.try`. Every instance satisfies + * it - the global, a created instance, and the event-bound one. + */ +export interface TypedFetch< + DefaultT = unknown, + DefaultR extends NitroFetchRequest = NitroFetchRequest, +> { + < + T = DefaultT, + R extends NitroFetchRequest = DefaultR, + const M extends MethodArg = DefaultMethod, + >( + request: R, + opts?: TypedRequestOptions + ): Promise> + try: TypedFetchTry +} + +/** What `event.$typedFetch` is typed as: no `.raw`, `.create` or `.native`. */ +export type TypedEventFetch = TypedFetch + +// ofetch's `FetchOptions` and `FetchResponse`, indexed out of Nitro's own +// signatures rather than imported from a package this module does not +// depend on. +type FetchDefaults = Parameters<$Fetch['create']>[0] +type RawResponse = Omit>, '_data'> & { + _data?: T +} + +/** The `$typedFetch` global: a full mirror of vanilla's namespace plus `.try`. */ +export interface $TypedFetch< + DefaultT = unknown, + DefaultR extends NitroFetchRequest = NitroFetchRequest, +> extends TypedFetch { + /** Shares the call's signature; no `.try`, it already returns without throwing. */ + raw: < + T = DefaultT, + R extends NitroFetchRequest = DefaultR, + const M extends MethodArg = DefaultMethod, + >( + request: R, + opts?: TypedRequestOptions + ) => Promise>> + + /** ofetch's bare `fetch`, passed through untouched. */ + native: typeof globalThis.fetch + + /** + * Like `$fetch.create`: a derived instance with defaults, keeping `.try`. + * A default `query` never relaxes a call's own requiredness. + */ + // Must return the *typed* interface, or `.try` vanishes one level down. + create: ( + defaults: FetchDefaults + ) => $TypedFetch +} diff --git a/packages/nuxt-typed-handler/src/runtime/types/handler.ts b/packages/nuxt-typed-handler/src/runtime/types/handler.ts new file mode 100644 index 0000000..82016a3 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/handler.ts @@ -0,0 +1,135 @@ +import type { defineCheckedEventHandler } from '@dphonys/nuxt-handler-errors/server' +import type { + CheckedEventHandler, + Fail, + KnownError, + KnownErrorsOf, + KnownVariant, +} from '@dphonys/nuxt-handler-errors/types' +import type { + RequestInput, + ValidatedContext, + ValidatedEventHandler, + ValidationIssue, + ValidationSchemas, + ValidationSchemasGuard, +} from '@dphonys/nuxt-handler-validation/types' +import type { EventHandlerRequest, EventHandlerResponse, H3Event } from 'h3' + +/** The built-in variant every validating route can fail with. */ +export interface ValidationFailed { + tag: 'validation-failed' + status: 400 + issues: ValidationIssue[] +} + +export type AnyKnownError = KnownError + +type HasValidate = [keyof S] extends [never] + ? false + : true + +type HasErrors> = [A[number]] extends [ + never, +] + ? false + : true + +/** + * The handler `defineTypedEventHandler` returns: an ordinary h3 + * `EventHandler` carrying both parents' phantom slots, so each parent's + * extractor reads its own. + */ +// Extends rather than restates: the validation slot is keyed on a private +// symbol. +export interface TypedEventHandler< + Request extends EventHandlerRequest = EventHandlerRequest, + Response extends EventHandlerResponse = EventHandlerResponse, + Errors = never, + Input = never, +> + extends + CheckedEventHandler, + ValidatedEventHandler {} + +/** The validated sources, flat, plus `fail` exactly when `errors` is declared. */ +export type TypedContext< + S extends ValidationSchemas, + A extends ReadonlyArray, +> = ValidatedContext & + (HasErrors extends true + ? { fail: Fail> } + : // eslint-disable-next-line ts/no-empty-object-type + {}) + +/** The declared union, plus the built-in variant when the route validates. */ +export type TypedErrors< + S extends ValidationSchemas, + A extends ReadonlyArray, +> = KnownErrorsOf | (HasValidate extends true ? ValidationFailed : never) + +export type TypedHandlerFn< + S extends ValidationSchemas, + A extends ReadonlyArray, + Request extends EventHandlerRequest, + Response, +> = (event: H3Event, ctx: TypedContext) => Response + +// Every guard below is a missing-property guard: an unsatisfiable property +// naming the mistake, surfaced by the compiler at the options argument. + +/** Bare `{}` is a compile error: a route must declare something. */ +export type AtLeastOne< + S extends ValidationSchemas, + A extends ReadonlyArray, +> = + HasValidate extends true + ? // eslint-disable-next-line ts/no-empty-object-type + {} + : HasErrors extends true + ? // eslint-disable-next-line ts/no-empty-object-type + {} + : { __declareSomething__: 'declare validate, errors, or both' } + +/** `validation-failed` belongs to the built-in variant on every umbrella route. */ +export type ReservedTagGuard> = + 'validation-failed' extends KnownErrorsOf['tag'] + ? { + __reservedErrorTag__: 'validation-failed is reserved for the built-in variant' + } + : // eslint-disable-next-line ts/no-empty-object-type + {} + +// The errors parent's `ConflictGuard`, read off its wrapper's options type +// because the parent's `/types` entry does not export the guard by name. +type ConflictGuard> = Omit< + Parameters>[0], + 'errors' +> + +export type TypedHandlerOptions< + S extends ValidationSchemas, + A extends ReadonlyArray, +> = AtLeastOne & + ReservedTagGuard & + ConflictGuard & { + validate?: S & ValidationSchemasGuard + errors?: A + } + +// `Response` has no default type parameter on purpose: an explicit type +// argument becomes an arity error instead of collapsing the success type. +export interface DefineTypedEventHandler { + < + // `{}` is the "declared nothing" default: no key, so no source and no + // built-in variant. + // eslint-disable-next-line ts/no-empty-object-type + const S extends ValidationSchemas = {}, + const A extends ReadonlyArray = [], + Response extends EventHandlerResponse = EventHandlerResponse, + Request extends EventHandlerRequest = EventHandlerRequest, + >( + options: TypedHandlerOptions, + handler: TypedHandlerFn + ): TypedEventHandler, RequestInput> +} diff --git a/packages/nuxt-typed-handler/src/runtime/types/index.ts b/packages/nuxt-typed-handler/src/runtime/types/index.ts new file mode 100644 index 0000000..206f40e --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/index.ts @@ -0,0 +1,74 @@ +import type { RouterMethod } from 'h3' +import type { MatchedRoutes } from 'nitropack/types' +import type { $TypedFetch, TypedEventFetch } from './fetch' + +export type * from '@dphonys/nuxt-handler-errors/types' +export type * from '@dphonys/nuxt-handler-validation/types' + +export type { + $TypedFetch, + TypedEventFetch, + TypedFetch, + TypedFetchTry, + TypedRequestOptions, +} from './fetch' + +/** + * The generated map of every route's Request input - you never write to + * this. Keyed exactly like Nitro's `InternalApi`; the build-time emitter + * reopens it with `declare module`, and empty means no handler has declared + * anything yet. + */ +// Declared here, not re-exported: the emitted template augments this module +// by its package specifier, and a `declare module` on a barrel that merely +// re-exports an interface opens a second, unrelated one. +export interface KnownApiRequestInputs {} + +/** + * A route's declared Request input from its path alone; `never` means + * "declares no sources" - the call site then types exactly as vanilla. + */ +// The `default` fallback is by presence rather than Nitro's on-`never` rule: +// `never` is a legitimate value here. +export type RequestInputOfRoute< + R extends string, + M extends RouterMethod | Uppercase = 'get', +> = + MatchedRoutes extends infer Key + ? // Distributes over multiple matched keys; a route this map lacks answers + // `never` rather than `TS2536`. + Key extends keyof KnownApiRequestInputs + ? Lowercase extends keyof KnownApiRequestInputs[Key] + ? KnownApiRequestInputs[Key][Lowercase] + : 'default' extends keyof KnownApiRequestInputs[Key] + ? KnownApiRequestInputs[Key]['default'] + : never + : never + : never + +export type { + AtLeastOne, + DefineTypedEventHandler, + ReservedTagGuard, + TypedContext, + TypedErrors, + TypedEventHandler, + TypedHandlerFn, + ValidationFailed, +} from './handler' + +declare module 'h3' { + interface H3Event { + /** The event-bound typed fetch, forwarding the request's headers and cookies. */ + $typedFetch: TypedEventFetch + } +} + +declare global { + /** + * The typed fetch global - callable in a Nitro handler, in `