From bef96d3b99fcbda721ab66f5362abc1f6f8f22b7 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 17:08:39 +0200 Subject: [PATCH 01/28] feat(nuxt-typed-handler): scaffold the umbrella module over both parents' internals One module installed instead of `nuxt-handler-errors` and `nuxt-handler-validation`: the `typedHandler.channelToken` key, the five server auto-imports (neither parent wrapper), the umbrella's own channel-token alias and strip handler, the errors parent on `build.transpile`, a throw at `modules:done` when either parent is also registered (package name and module name both tried), and a once-per-key warning for a leftover `handlerErrors` / `handlerValidation`. Both parents are pinned exactly through `workspace:`, which links locally and publishes as the literal version. The package exposes `.`, `/types`, `/server` and `/shared` only. Co-Authored-By: Claude Fable 5 --- knip.ts | 16 + packages/nuxt-typed-handler/LICENSE | 21 ++ packages/nuxt-typed-handler/README.md | 23 ++ packages/nuxt-typed-handler/package.json | 97 ++++++ .../nuxt-typed-handler/playground/app.vue | 3 + .../playground/nuxt.config.ts | 11 + .../playground/package.json | 13 + .../playground/server/tsconfig.json | 3 + .../playground/tsconfig.json | 3 + .../nuxt-typed-handler/playground/turbo.json | 9 + packages/nuxt-typed-handler/src/module.ts | 121 +++++++ .../runtime/server/handlers/channel-strip.ts | 4 + .../src/runtime/server/tsconfig.json | 3 + .../src/runtime/shared/index.ts | 1 + .../src/runtime/virtual.d.ts | 9 + .../test/doubles/channel-token.ts | 15 + .../test/fixtures/basic/app.vue | 3 + .../test/fixtures/basic/nuxt.config.ts | 5 + .../test/fixtures/basic/package.json | 5 + .../test/unit/module-setup.test.ts | 297 ++++++++++++++++++ packages/nuxt-typed-handler/tsconfig.json | 7 + packages/nuxt-typed-handler/vitest.config.ts | 48 +++ pnpm-lock.yaml | 61 ++++ 23 files changed, 778 insertions(+) create mode 100644 packages/nuxt-typed-handler/LICENSE create mode 100644 packages/nuxt-typed-handler/README.md create mode 100644 packages/nuxt-typed-handler/package.json create mode 100644 packages/nuxt-typed-handler/playground/app.vue create mode 100644 packages/nuxt-typed-handler/playground/nuxt.config.ts create mode 100644 packages/nuxt-typed-handler/playground/package.json create mode 100644 packages/nuxt-typed-handler/playground/server/tsconfig.json create mode 100644 packages/nuxt-typed-handler/playground/tsconfig.json create mode 100644 packages/nuxt-typed-handler/playground/turbo.json create mode 100644 packages/nuxt-typed-handler/src/module.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/server/handlers/channel-strip.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/server/tsconfig.json create mode 100644 packages/nuxt-typed-handler/src/runtime/shared/index.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/virtual.d.ts create mode 100644 packages/nuxt-typed-handler/test/doubles/channel-token.ts create mode 100644 packages/nuxt-typed-handler/test/fixtures/basic/app.vue create mode 100644 packages/nuxt-typed-handler/test/fixtures/basic/nuxt.config.ts create mode 100644 packages/nuxt-typed-handler/test/fixtures/basic/package.json create mode 100644 packages/nuxt-typed-handler/test/unit/module-setup.test.ts create mode 100644 packages/nuxt-typed-handler/tsconfig.json create mode 100644 packages/nuxt-typed-handler/vitest.config.ts diff --git a/knip.ts b/knip.ts index cbe9792..dd58f05 100644 --- a/knip.ts +++ b/knip.ts @@ -79,6 +79,22 @@ export default { ], }, + 'packages/nuxt-typed-handler': { + ...nuxtModuleWorkspace, + + // As above: 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, 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..eaa336e --- /dev/null +++ b/packages/nuxt-typed-handler/README.md @@ -0,0 +1,23 @@ +# @dphonys/nuxt-typed-handler + +Declare a Nitro handler's request schemas and expected failures once, and get +both typed at every call site. One module installed _instead of_ +`@dphonys/nuxt-handler-errors` and `@dphonys/nuxt-handler-validation`. + +Documentation lands with the package's first release. + +## 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 +``` + +## License + +Licensed under the [MIT License](./LICENSE). diff --git a/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json new file mode 100644 index 0000000..c34a6f3 --- /dev/null +++ b/packages/nuxt-typed-handler/package.json @@ -0,0 +1,97 @@ +{ + "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.3.1", + "@dphonys/nuxt-handler-validation": "workspace:0.1.1", + "@nuxt/kit": "catalog:", + "h3": "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:" + }, + "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..dce885b --- /dev/null +++ b/packages/nuxt-typed-handler/playground/app.vue @@ -0,0 +1,3 @@ + 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/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/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/module.ts b/packages/nuxt-typed-handler/src/module.ts new file mode 100644 index 0000000..99616dc --- /dev/null +++ b/packages/nuxt-typed-handler/src/module.ts @@ -0,0 +1,121 @@ +import { + addChannelStripErrorHandler, + addChannelToken, + normalizeChannelToken, + warnCustomErrorHandler, +} from '@dphonys/nuxt-handler-errors/internals/build' +import { + addServerImports, + createResolver, + defineNuxtModule, + hasNuxtModule, + logger, +} from '@nuxt/kit' + +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' + +/** The package this module replaces, and the key it used to be configured under. */ +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) + + // A parent's key left behind configures nothing now: this module owns the + // one option, under its own key. Own keys only, any value - `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}\`.` + ) + } + + // Required by the errors parent's internals contract: its app internals + // import `#app`, and Nuxt transpiles only what `modules` lists. The + // validation parent's contract says push nothing. + nuxt.options.build.transpile.push('@dphonys/nuxt-handler-errors') + + nuxt.options.typescript.hoist.push('@dphonys/nuxt-typed-handler/types') + + // Named explicitly rather than through `addServerImportsDir`, whose scan + // would auto-import whatever the runtime tree happens to export. Neither + // parent wrapper is among them: the umbrella's wrapper is the one door. + const resolver = createResolver(import.meta.url) + const serverEntry = resolver.resolve('./runtime/server/index') + + addServerImports( + [ + 'defineTypedEventHandler', + 'defineError', + 'payload', + 'recognizeKnownError', + 'recognizeValidationError', + ].map((name) => ({ name, from: serverEntry })) + ) + + const channelToken = normalizeChannelToken(options.channelToken, NAME) + addChannelToken(nuxt, NAME, channelToken) + addChannelStripErrorHandler( + nuxt, + channelToken, + resolver.resolve('./runtime/server/handlers/channel-strip') + ) + + // Exclusive by construction: a project lists this module *or* the + // parents. After every module has registered, a parent beside this one + // is a configuration error, not a warning. Consumers list the package + // name in `modules`; a module listed as a value is known to kit by its + // `meta.name` alone, so both spellings are tried. + 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/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/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/virtual.d.ts b/packages/nuxt-typed-handler/src/runtime/virtual.d.ts new file mode 100644 index 0000000..ffb65de --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/virtual.d.ts @@ -0,0 +1,9 @@ +// The build-only specifier `src/module.ts` aliases in both builds. This +// declaration is what lets the runtime sources typecheck outside a build. +declare module '#nuxt-typed-handler/channel-token' { + /** + * The `channelToken` module option, normalised: `undefined` when the + * consumer opted out with `false`, the literal token otherwise. + */ + export const configuredChannelToken: string | undefined +} diff --git a/packages/nuxt-typed-handler/test/doubles/channel-token.ts b/packages/nuxt-typed-handler/test/doubles/channel-token.ts new file mode 100644 index 0000000..d0e0898 --- /dev/null +++ b/packages/nuxt-typed-handler/test/doubles/channel-token.ts @@ -0,0 +1,15 @@ +/** + * The `#nuxt-typed-handler/channel-token` double, wired in by + * `vitest.config.ts`'s alias. The real specifier resolves to a template the + * module writes at build time; this stands in with a settable live binding so + * suites can vary the token. The default is no token. + */ + +// The mutable export is the double's entire mechanism: importers must see the +// value a suite sets, through the same named binding the template exports. +// eslint-disable-next-line import/no-mutable-exports +export let configuredChannelToken: string | undefined + +export function setConfiguredChannelToken(next: string | undefined): void { + configuredChannelToken = next +} diff --git a/packages/nuxt-typed-handler/test/fixtures/basic/app.vue b/packages/nuxt-typed-handler/test/fixtures/basic/app.vue new file mode 100644 index 0000000..3afe395 --- /dev/null +++ b/packages/nuxt-typed-handler/test/fixtures/basic/app.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt-typed-handler/test/fixtures/basic/nuxt.config.ts b/packages/nuxt-typed-handler/test/fixtures/basic/nuxt.config.ts new file mode 100644 index 0000000..e6bcb3e --- /dev/null +++ b/packages/nuxt-typed-handler/test/fixtures/basic/nuxt.config.ts @@ -0,0 +1,5 @@ +import NuxtModule from '../../../src/module' + +export default defineNuxtConfig({ + modules: [NuxtModule], +}) diff --git a/packages/nuxt-typed-handler/test/fixtures/basic/package.json b/packages/nuxt-typed-handler/test/fixtures/basic/package.json new file mode 100644 index 0000000..9c5baf0 --- /dev/null +++ b/packages/nuxt-typed-handler/test/fixtures/basic/package.json @@ -0,0 +1,5 @@ +{ + "name": "@dphonys/nuxt-typed-handler-test-fixture", + "private": true, + "type": "module" +} diff --git a/packages/nuxt-typed-handler/test/unit/module-setup.test.ts b/packages/nuxt-typed-handler/test/unit/module-setup.test.ts new file mode 100644 index 0000000..c480de3 --- /dev/null +++ b/packages/nuxt-typed-handler/test/unit/module-setup.test.ts @@ -0,0 +1,297 @@ +import errorsModule from '@dphonys/nuxt-handler-errors' +import validationModule from '@dphonys/nuxt-handler-validation' +import { loadNuxt, logger } from '@nuxt/kit' +import type { Nuxt, NuxtConfig, NuxtHooks } from '@nuxt/schema' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const FIXTURE = fileURLToPath(new URL('../fixtures/basic', import.meta.url)) + +/** Separates this module's registrations from Nuxt's, Nitro's and the parents'. */ +const FROM_THIS_PACKAGE = /nuxt-typed-handler\/src\/runtime\// + +/** Nitro's own instance type, without a dependency on `nitropack` for it. */ +type NitroInstance = Parameters[0] + +interface Booted { + nuxt: Nuxt + nitro: NitroInstance | undefined + warnings: string[] +} + +/** + * Everything the kit logger warns while `run` executes. The reporter seam + * rather than a console spy: consola's default reporter writes to stdout + * directly, so a `console.warn` spy never sees it. + */ +async function warningsDuring(run: () => Promise): Promise { + const captured: string[] = [] + const reporter = { + log: (entry: { type: string; args: unknown[] }) => { + if (entry.type === 'warn') captured.push(entry.args.map(String).join(' ')) + }, + } + + logger.addReporter(reporter) + + try { + await run() + } finally { + logger.removeReporter(reporter) + } + + return captured +} + +/** + * A real `loadNuxt` boot of the fixture app, in two steps rather than + * `ready: true`: `nitro:init` fires *during* `ready()`, and the instance it + * hands over is the only public route to the resolved Nitro options. + */ +async function boot(overrides?: NuxtConfig): Promise { + let nuxt: Nuxt | undefined + let nitro: NitroInstance | undefined + + const warnings = await warningsDuring(async () => { + nuxt = await loadNuxt({ cwd: FIXTURE, ready: false, overrides }) + + nuxt.hook('nitro:init', (instance) => { + nitro = instance + }) + + await nuxt.ready() + }) + + return { nuxt: nuxt as unknown as Nuxt, nitro, warnings } +} + +/** The §4.2 text, verbatim. */ +function leftoverKeyWarning(key: string): string { + return `[nuxt-typed-handler] \`${key}\` in nuxt.config is ignored: this module replaces the parent it configured. Move \`channelToken\` under \`typedHandler\` and delete \`${key}\`.` +} + +/** The §4.6 text, verbatim. */ +function siblingMessage(parent: string): string { + return `[nuxt-typed-handler] \`${parent}\` is also registered in \`modules\`. @dphonys/nuxt-typed-handler replaces it: remove \`${parent}\` (and uninstall it), then move any \`channelToken\` under \`typedHandler\`.` +} + +describe('module setup wiring', () => { + let booted: Booted + + beforeAll(async () => { + booted = await boot() + }, 120_000) + + afterAll(async () => { + await booted?.nuxt.close() + }) + + it('auto-imports the five server helpers and neither parent wrapper', () => { + // Read off the *resolved* Nitro options - `addServerImports` only queues + // onto `nitro:config`. `toEqual` over the whole filtered list, so a + // duplicate or a leaked parent wrapper fails too. + const imports = booted.nitro?.options.imports + const entries = (imports === false ? [] : (imports?.imports ?? [])).filter( + (entry) => FROM_THIS_PACKAGE.test(entry.from) + ) + + expect(entries).toEqual( + [ + 'defineTypedEventHandler', + 'defineError', + 'payload', + 'recognizeKnownError', + 'recognizeValidationError', + ].map((name) => ({ + name, + as: name, + from: expect.stringMatching(/\/runtime\/server\/index$/), + })) + ) + }) + + it('transpiles the errors parent, whose app internals import `#app`', () => { + expect(booted.nuxt.options.build.transpile).toContain( + '@dphonys/nuxt-handler-errors' + ) + // The validation parent's contract says push nothing. + expect(booted.nuxt.options.build.transpile).not.toContain( + '@dphonys/nuxt-handler-validation' + ) + }) + + it('hoists its own types specifier onto the generated tsconfigs', () => { + expect(booted.nuxt.options.typescript.hoist).toContain( + '@dphonys/nuxt-typed-handler/types' + ) + }) + + it('writes the channel token under its own alias, defaulting to its name', () => { + const dst = booted.nuxt.options.alias['#nuxt-typed-handler/channel-token'] + + expect(dst).toMatch(/\/nuxt-typed-handler\/channel-token\.mjs$/) + expect( + booted.nitro?.options.alias?.['#nuxt-typed-handler/channel-token'] + ).toBe(dst) + + const template = booted.nuxt.options.build.templates.find( + (entry) => entry.filename === 'nuxt-typed-handler/channel-token.mjs' + ) + + expect(template?.write).toBe(true) + expect( + (template as { getContents?: () => string } | undefined)?.getContents?.() + ).toBe('export const configuredChannelToken = "nuxt-typed-handler"\n') + }) + + it('prepends its own stripper to the errorHandler chain, keeping Nuxt’s own', () => { + const chain = booted.nitro?.options.errorHandler + + expect(Array.isArray(chain)).toBe(true) + expect(chain?.[0]).toMatch( + /nuxt-typed-handler\/src\/runtime\/server\/handlers\/channel-strip$/ + ) + expect(chain?.length).toBeGreaterThan(1) + }) + + it('warns about nothing on a clean boot', () => { + expect(booted.warnings).toEqual([]) + }) +}) + +describe('the channel token', () => { + it.each<[string, false | '', unknown[]]>([ + ['false', false, []], + [ + 'an empty string', + '', + [expect.stringMatching(/channelToken.*empty string/)], + ], + ])( + 'disables gating for %s, and registers no stripper', + async (_label, channelToken, expectedWarnings) => { + let disabled: Booted | undefined + + try { + disabled = await boot({ typedHandler: { channelToken } }) + + const chain = disabled.nitro?.options.errorHandler + const entries = Array.isArray(chain) ? chain : [chain ?? ''] + + expect( + entries.filter((entry) => /channel-strip/.test(String(entry))) + ).toEqual([]) + + const template = disabled.nuxt.options.build.templates.find( + (entry) => entry.filename === 'nuxt-typed-handler/channel-token.mjs' + ) + + expect( + ( + template as { getContents?: () => string } | undefined + )?.getContents?.() + ).toBe('export const configuredChannelToken = undefined\n') + + expect(disabled.warnings).toEqual(expectedWarnings) + } finally { + await disabled?.nuxt.close() + } + }, + 120_000 + ) +}) + +describe('the leftover parent config keys', () => { + it('warns once per key, with the documented text, whatever the value', async () => { + let booted: Booted | undefined + + try { + // `false` is a value too: the parent's off-switch has nothing to switch. + // Cast because neither key exists on this app's config any more - which + // is the point. + booted = await boot({ + handlerErrors: { channelToken: 'moved-me' }, + handlerValidation: false, + } as NuxtConfig) + + expect(booted.warnings).toEqual([ + leftoverKeyWarning('handlerErrors'), + leftoverKeyWarning('handlerValidation'), + ]) + } finally { + await booted?.nuxt.close() + } + }, 120_000) +}) + +describe('the sibling guard', () => { + /** The boot that must not complete, with whatever it rejected with. */ + async function rejectionOf(overrides: NuxtConfig): Promise { + let nuxt: Nuxt | undefined + + try { + nuxt = await loadNuxt({ cwd: FIXTURE, ready: false, overrides }) + await nuxt.ready() + } catch (error) { + return error + } finally { + await nuxt?.close() + } + + return undefined + } + + it.each([ + ['the errors parent, by package name', '@dphonys/nuxt-handler-errors'], + [ + 'the validation parent, by package name', + '@dphonys/nuxt-handler-validation', + ], + ])( + 'throws at modules:done for %s', + async (_label, parent) => { + const error = await rejectionOf({ modules: [parent] }) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe(siblingMessage(parent)) + }, + 120_000 + ) + + it.each([ + [ + 'the errors parent, as a module instance', + errorsModule, + '@dphonys/nuxt-handler-errors', + ], + [ + 'the validation parent, as a module instance', + validationModule, + '@dphonys/nuxt-handler-validation', + ], + ])( + 'throws for %s, which only its meta name can identify', + async (_label, module, parent) => { + // Listed as a value rather than a string, the parent is known to kit by + // its `meta.name` alone - the second spelling the guard tries. + const error = await rejectionOf({ modules: [module] }) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe(siblingMessage(parent)) + }, + 120_000 + ) + + it('names the errors parent first when both are present', async () => { + const error = await rejectionOf({ + modules: [ + '@dphonys/nuxt-handler-errors', + '@dphonys/nuxt-handler-validation', + ], + }) + + expect((error as Error).message).toBe( + siblingMessage('@dphonys/nuxt-handler-errors') + ) + }, 120_000) +}) diff --git a/packages/nuxt-typed-handler/tsconfig.json b/packages/nuxt-typed-handler/tsconfig.json new file mode 100644 index 0000000..c2f56a0 --- /dev/null +++ b/packages/nuxt-typed-handler/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": ["./.nuxt/tsconfig.json", "../../tsconfig.json"], + // `test/types/fixtures` holds deliberately broken sources: they are compiled + // by `test/types/compile-harness.ts`, which asserts on the diagnostics they + // produce, so this project must not report them as its own failures. + "exclude": ["dist", "node_modules", "playground", "test/types/fixtures"] +} diff --git a/packages/nuxt-typed-handler/vitest.config.ts b/packages/nuxt-typed-handler/vitest.config.ts new file mode 100644 index 0000000..4c0edbf --- /dev/null +++ b/packages/nuxt-typed-handler/vitest.config.ts @@ -0,0 +1,48 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +// Three tiers, as in both parents: `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. + +// The channel-token alias only resolves inside a real build, so `unit` +// aliases it to a double. Scoped to `unit` on purpose: the e2e tier must see +// the real thing. The parents' internals are imported for real. +const aliases = [ + { + find: /^#nuxt-typed-handler\/channel-token$/, + replacement: fileURLToPath( + new URL('./test/doubles/channel-token.ts', import.meta.url) + ), + }, +] + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: ['test/unit/**/*.test.ts'], + }, + resolve: { alias: aliases }, + }, + { + test: { + name: 'types', + include: ['test/types/**/*.test.ts'], + }, + }, + { + test: { + name: 'e2e', + include: ['test/e2e/**/*.test.ts'], + testTimeout: 120_000, + // 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/pnpm-lock.yaml b/pnpm-lock.yaml index 8ea4860..e6f0870 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,67 @@ importers: specifier: 'catalog:' version: 4.4.3 + packages/nuxt-typed-handler: + dependencies: + '@dphonys/nuxt-handler-errors': + specifier: workspace:0.3.1 + version: link:../nuxt-handler-errors + '@dphonys/nuxt-handler-validation': + specifier: workspace:0.1.1 + version: link:../nuxt-handler-validation + '@nuxt/kit': + specifier: 'catalog:' + version: 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))) + h3: + specifier: 'catalog:' + version: 1.15.11 + devDependencies: + '@nuxt/devtools': + specifier: 'catalog:' + version: 3.4.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.1)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0)))(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + '@nuxt/module-builder': + specifier: 'catalog:' + version: 1.0.3(@nuxt/cli@3.37.0(@nuxt/schema@4.5.2)(magicast@0.5.4)(supports-color@10.2.2))(@volar/typescript@2.4.28)(@vue/compiler-core@3.5.41)(@vue/language-core@3.3.9)(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.62.4)(typescript@5.9.3)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(vue@3.5.41(typescript@5.9.3)) + '@nuxt/schema': + specifier: 'catalog:' + version: 4.5.2 + '@nuxt/test-utils': + specifier: 'catalog:' + version: 4.1.0(esbuild@0.28.2)(magicast@0.5.4)(rolldown@1.2.1)(rollup@4.62.4)(typescript@5.9.3)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.2.0)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))) + '@types/node': + specifier: 'catalog:' + version: 26.2.0 + nuxt: + specifier: 'catalog:' + version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.142.0)(@types/node@26.2.0)(@vue/compiler-sfc@3.5.41)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.142.0)(oxlint@1.76.0)(rolldown@1.2.1)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(supports-color@10.2.2)(terser@5.49.2)(typescript@5.9.3)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0) + publint: + specifier: 'catalog:' + version: 0.3.23 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.2.0)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0)) + vue-tsc: + specifier: 'catalog:' + version: 3.3.9(typescript@5.9.3) + zod: + specifier: 'catalog:' + version: 4.4.3 + + packages/nuxt-typed-handler/playground: + dependencies: + '@dphonys/nuxt-typed-handler': + specifier: workspace:* + version: link:.. + nuxt: + specifier: 'catalog:' + version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.142.0)(@types/node@26.2.0)(@vue/compiler-sfc@3.5.41)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.142.0)(oxlint@1.76.0)(rolldown@1.2.1)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(supports-color@10.2.2)(terser@5.49.2)(typescript@5.9.3)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(yaml@2.9.0) + zod: + specifier: 'catalog:' + version: 4.4.3 + release/publishing-contract: devDependencies: vitest: From 777c396411e5b0f44eed6266a2be91c13b5273c3 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 17:08:40 +0200 Subject: [PATCH 02/28] feat(nuxt-typed-handler): land defineTypedEventHandler with the built-in variant One `defineEventHandler` composing both parents' internals: the flat Handler context carries the validated sources plus `fail` exactly when `errors` is declared, an `errors`-only route never calls the validation seam, and a rejected request answers the known error `validation-failed` (400, issues both inside the marker and at `data.issues`, both recognizers answering). Declaration-time throws fire foreign copy, reserved tag, not-a-schema, in that order; the compile guards refuse the reserved tag, a bare `{}` and `fail('validation-failed')` with verbatim sentences at the offending line. The returned handler extends both parents' branded handler types, because the validation parent keys its request-input slot on a private symbol rather than a structural property. Co-Authored-By: Claude Fable 5 --- .../src/runtime/server/index.ts | 7 + .../src/runtime/server/lib/on-invalid.ts | 22 ++ .../src/runtime/server/lib/reserved-tag.ts | 17 + .../src/runtime/server/lib/typed-handler.ts | 70 +++++ .../src/runtime/types/handler.ts | 143 +++++++++ .../src/runtime/types/index.ts | 15 + packages/nuxt-typed-handler/test/h3-app.ts | 56 ++++ .../test/types/compile-harness.ts | 138 ++++++++ .../test/types/fixtures/misuse-declaration.ts | 57 ++++ .../test/types/misuse-diagnostics.test.ts | 92 ++++++ .../test/types/tsconfig.fixtures.json | 14 + .../test/unit/on-invalid.test.ts | 108 +++++++ .../test/unit/typed-handler.test.ts | 297 ++++++++++++++++++ 13 files changed, 1036 insertions(+) create mode 100644 packages/nuxt-typed-handler/src/runtime/server/index.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/types/handler.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/types/index.ts create mode 100644 packages/nuxt-typed-handler/test/h3-app.ts create mode 100644 packages/nuxt-typed-handler/test/types/compile-harness.ts create mode 100644 packages/nuxt-typed-handler/test/types/fixtures/misuse-declaration.ts create mode 100644 packages/nuxt-typed-handler/test/types/misuse-diagnostics.test.ts create mode 100644 packages/nuxt-typed-handler/test/types/tsconfig.fixtures.json create mode 100644 packages/nuxt-typed-handler/test/unit/on-invalid.test.ts create mode 100644 packages/nuxt-typed-handler/test/unit/typed-handler.test.ts 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..d11408c --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts @@ -0,0 +1,22 @@ +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' + +/** + * The built-in variant's raiser: every client-input rejection the validation + * parent reports - a rejecting schema and an unparseable body alike - becomes + * one known error, `validation-failed`, `400`, with the issues both inside the + * known-error marker and at `data.issues`, plus the validation marker. Both + * parents' recognizers answer, and channel stripping leaves `data.issues`. + */ +export const onInvalid: OnInvalid = (_source, issues) => { + const error = createKnownError(RESERVED_TAG, 400, { issues: [...issues] }) + + // Beside the marker, not inside it: what a client reads once the marker is + // stripped, and the path the validation parent documents. + ;(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..96e349b --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts @@ -0,0 +1,17 @@ +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' + +// At declaration, like the parent's foreign-copy guard: the route never +// becomes servable. The compile guard says the same thing; this is the +// answer a JavaScript caller gets. +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 "validation-failed" 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..8f78177 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts @@ -0,0 +1,70 @@ +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 +) => { + // Declaration time, in this order: the foreign-copy guard first, then the + // reserved tag, then the not-a-schema check - each with its owner's message. + const declared = + options.errors === undefined ? undefined : resolveDeclared(options.errors) + assertNoReservedTag(declared) + const plan = + options.validate === undefined ? undefined : sourcePlan(options.validate) + const fail = declared === undefined ? undefined : createFail(declared) + + // The compile guard's answer for a JavaScript caller. + if (plan === undefined && fail === undefined) { + throw new Error( + '[nuxt-typed-handler] defineTypedEventHandler needs validate, errors, or both.' + ) + } + + // A fresh plain object per request; `fail` present exactly when declared. + const context = (validated: Record): never => + (fail === undefined ? validated : { ...validated, fail }) as never + + // One `defineEventHandler`, and no validation call at all on a route that + // declares none: no body read, no await. + return defineEventHandler((event) => + plan === undefined + ? handler(event, context({})) + : validatedContext(event, plan, VALIDATION_OPTIONS).then((validated) => + handler(event, context(validated)) + ) + ) as never +} 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..a8bea18 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/handler.ts @@ -0,0 +1,143 @@ +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[] +} + +/** The element type of an `errors` slot - what the errors parent composes. */ +export type AnyKnownError = KnownError + +/** Whether `validate` declared at least one source. */ +type HasValidate = [keyof S] extends [never] + ? false + : true + +/** Whether `errors` was declared at all. */ +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. + */ +// The validation parent keys its slot on a private symbol, so the only way +// to carry it is to extend the parent's own branded handler type. +export interface TypedEventHandler< + Request extends EventHandlerRequest = EventHandlerRequest, + Response extends EventHandlerResponse = EventHandlerResponse, + Errors = never, + Input = never, +> + extends + CheckedEventHandler, + ValidatedEventHandler {} + +/** + * The Handler context: 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 + {}) + +/** What the route can fail with: the declared union, plus the built-in variant when it validates. */ +export type TypedErrors< + S extends ValidationSchemas, + A extends ReadonlyArray, +> = KnownErrorsOf | (HasValidate extends true ? ValidationFailed : never) + +/** A typed handler body. The success type infers from it with no annotation. */ +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' +> + +/** The options argument, with every guard intersected ahead of the slots. */ +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..67ae6bb --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/index.ts @@ -0,0 +1,15 @@ +export type * from '@dphonys/nuxt-handler-errors/types' +export type * from '@dphonys/nuxt-handler-validation/types' + +export type { + AnyKnownError, + AtLeastOne, + DefineTypedEventHandler, + ReservedTagGuard, + TypedContext, + TypedErrors, + TypedEventHandler, + TypedHandlerFn, + TypedHandlerOptions, + ValidationFailed, +} from './handler' diff --git a/packages/nuxt-typed-handler/test/h3-app.ts b/packages/nuxt-typed-handler/test/h3-app.ts new file mode 100644 index 0000000..606d693 --- /dev/null +++ b/packages/nuxt-typed-handler/test/h3-app.ts @@ -0,0 +1,56 @@ +/** + * Shared mounting tools for the handler suites. Not a Vitest test file, so it + * is not matched as a suite; knip reaches it through the suites importing it. + */ + +import type { EventHandler } from 'h3' +import { createApp, createRouter, toWebHandler } from 'h3' + +/** + * Mount one handler and send it a request. + * + * `debug` is h3's verbose-errors switch, the knob a Nitro dev build turns on. + * `route` mounts on h3's own router rather than the plain prefix, which is the + * only way to get real route params. `onError` is h3's error hook, the only + * place a suite sees the thrown error rather than its serialized body. + */ +export function request( + handler: EventHandler, + path: string, + options: { + init?: RequestInit + debug?: boolean + route?: string + onError?: (error: unknown) => void + } = {} +): Promise { + const app = createApp({ + debug: options.debug ?? false, + ...(options.onError === undefined ? {} : { onError: options.onError }), + }) + + if (options.route === undefined) { + app.use('/api/test', handler) + } else { + app.use(createRouter().use(options.route, handler)) + } + + return toWebHandler(app)( + new Request(`http://test.local${path}`, options.init) + ) +} + +/** + * A POST carrying an already-serialized JSON payload, so a malformed one is as + * easy to send as a valid one. The caller's headers land last. + */ +export function postJson( + payload: string, + headers: Record = {} +): RequestInit { + return { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: payload, + } +} diff --git a/packages/nuxt-typed-handler/test/types/compile-harness.ts b/packages/nuxt-typed-handler/test/types/compile-harness.ts new file mode 100644 index 0000000..b417fdf --- /dev/null +++ b/packages/nuxt-typed-handler/test/types/compile-harness.ts @@ -0,0 +1,138 @@ +/** + * Compile one fixture and read the diagnostics it produced. The + * `Assert>` suites next door prove a type resolved, never that the + * author is shown the sentence a guard carries; only a compiler run answers + * that. + * + * Not a Vitest test file, so it is not matched by vitest's `include`; knip + * reaches it through the suite importing it. + */ + +import { readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +/** TS18003 - "No inputs were found in config file". */ +const NO_INPUTS_FOUND = 18003 + +/** + * TS2375 - the `exactOptionalPropertyTypes` flavour of TS2322. Every key of + * `ValidationSchemas` is optional, so a stray key lands here rather than on the + * plain assignability code. + */ +export const NOT_ASSIGNABLE_EXACT_OPTIONAL = 2375 + +export interface Diagnostic { + readonly code: number + readonly message: string + readonly line: number | undefined +} + +/** The options every fixture compiles under - a consumer's own, extended. */ +export const FIXTURE_TSCONFIG = fileURLToPath( + new URL('./tsconfig.fixtures.json', import.meta.url) +) + +/** One of the deliberately broken sources next door, by file name. */ +export function fixturePath(name: string): string { + return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)) +} + +/** The diagnostics carrying a sentence, in the order the compiler reported them. */ +export function saying( + diagnostics: readonly Diagnostic[], + sentence: string +): readonly Diagnostic[] { + return diagnostics.filter((diagnostic) => + diagnostic.message.includes(sentence) + ) +} + +/** + * The 1-based line a fixture's offending key sits on, found by its text - a + * hard-coded line turns any edit above it into a false failure. + */ +export function lineContaining(fixture: string, needle: string): number { + const lines = readFileSync(fixture, 'utf8').split('\n') + const index = lines.findIndex((line) => line.includes(needle)) + + if (index === -1) { + throw new Error(`${fixture} has no line containing ${needle}`) + } + + return index + 1 +} + +/** + * Compile one fixture against a tsconfig's own options, returning only the + * diagnostics that fixture itself produced. + */ +export function compileFixture( + tsconfigPath: string, + fixture: string +): readonly Diagnostic[] { + const read = ts.readConfigFile(tsconfigPath, ts.sys.readFile) + + if (read.error !== undefined) { + throw new Error(`Could not read ${tsconfigPath}`) + } + + const parsed = ts.parseJsonConfigFileContent( + read.config, + ts.sys, + dirname(tsconfigPath), + undefined, + tsconfigPath + ) + + // A broken `extends` or an invalid option lands here rather than on + // `readConfigFile`, and ignoring it would compile the fixture against + // defaults. TS18003 is the exception: this harness supplies the file list. + const fatal = parsed.errors.filter((error) => error.code !== NO_INPUTS_FOUND) + + if (fatal.length > 0) { + throw new Error( + [ + `${tsconfigPath} did not parse clean:`, + ...fatal.map( + (diagnostic) => + ` TS${diagnostic.code} - ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}` + ), + ].join('\n') + ) + } + + const program = ts.createProgram([fixture], { + ...parsed.options, + noEmit: true, + }) + + return ts + .getPreEmitDiagnostics(program) + .filter( + (diagnostic) => + normalize(diagnostic.file?.fileName) === normalize(fixture) + ) + .map((diagnostic) => ({ + code: diagnostic.code, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + line: lineOf(diagnostic), + })) +} + +// TypeScript reports file names with forward slashes; a path off `node:url` or +// `node:path` uses backslashes on Windows. +function normalize(path: string | undefined): string | undefined { + return path?.replaceAll('\\', '/') +} + +function lineOf(diagnostic: ts.Diagnostic): number | undefined { + if (diagnostic.file === undefined || diagnostic.start === undefined) { + return undefined + } + + return ( + diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1 + ) +} diff --git a/packages/nuxt-typed-handler/test/types/fixtures/misuse-declaration.ts b/packages/nuxt-typed-handler/test/types/fixtures/misuse-declaration.ts new file mode 100644 index 0000000..aa2153a --- /dev/null +++ b/packages/nuxt-typed-handler/test/types/fixtures/misuse-declaration.ts @@ -0,0 +1,57 @@ +/** + * A DELIBERATELY BROKEN fixture, compiled by `misuse-diagnostics.test.ts` + * through the harness and never by `pnpm typecheck` (the package tsconfig + * excludes this directory). No `@ts-expect-error` anywhere: the point is to let + * each diagnostic through so the suite can read it. + */ + +import { defineError } from '@dphonys/nuxt-handler-errors/server' +import { z } from 'zod' +import { defineTypedEventHandler } from '../../../src/runtime/server' + +const userErrors = defineError({ + 'user-not-found': { status: 404 }, + forbidden: { status: 403 }, +}) + +// --- The built-in variant's tag cannot be declared ------------------------ + +const reserved = defineError('validation-failed', { status: 400 }) + +export const reservedTag = defineTypedEventHandler( + { errors: [...userErrors, reserved] }, + (_event, { fail }) => fail('forbidden') +) + +// --- Bare `{}` declares nothing ------------------------------------------- + +export const bare = defineTypedEventHandler({}, () => null) + +// --- `fail` can never raise the built-in variant -------------------------- + +export const failReserved = defineTypedEventHandler( + { + validate: { query: z.object({ page: z.coerce.number() }) }, + errors: [...userErrors], + }, + (_event, { fail }) => fail('validation-failed') +) + +// --- The parents' guards still fire at the key they own ------------------- + +export const strayKey = defineTypedEventHandler( + { + validate: { + query: z.object({ page: z.coerce.number() }), + boyd: z.object({ name: z.string() }), + }, + }, + () => null +) + +const conflicting = defineError({ 'user-not-found': { status: 410 } }) + +export const divergentTag = defineTypedEventHandler( + { errors: [...userErrors, ...conflicting] }, + () => null +) diff --git a/packages/nuxt-typed-handler/test/types/misuse-diagnostics.test.ts b/packages/nuxt-typed-handler/test/types/misuse-diagnostics.test.ts new file mode 100644 index 0000000..36b57f6 --- /dev/null +++ b/packages/nuxt-typed-handler/test/types/misuse-diagnostics.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + compileFixture, + fixturePath, + FIXTURE_TSCONFIG, + lineContaining, + NOT_ASSIGNABLE_EXACT_OPTIONAL, + saying, +} from './compile-harness' + +// What an author reads when they misuse a declaration. The umbrella's two +// sentences are public surface, asserted verbatim and at the offending line; +// the parents' guards are asserted to still fire through the umbrella. + +const FIXTURE = fixturePath('misuse-declaration.ts') + +const diagnostics = compileFixture(FIXTURE_TSCONFIG, FIXTURE) + +/** TS2345 - an argument that does not fit its parameter. */ +const ARGUMENT_NOT_ASSIGNABLE = 2345 + +/** The reserved-tag sentence, verbatim. */ +const RESERVED_TAG = 'validation-failed is reserved for the built-in variant' + +/** The bare-`{}` sentence, verbatim. */ +const DECLARE_SOMETHING = 'declare validate, errors, or both' + +describe('the declaration guards’ diagnostics', () => { + it('is the same run every time, and nothing more than this run', () => { + // The length is pinned as well as the sentences, so a diagnostic that + // appears, moves or vanishes fails here rather than passing quietly. + expect(diagnostics).toHaveLength(5) + expect(compileFixture(FIXTURE_TSCONFIG, FIXTURE)).toEqual(diagnostics) + }) + + it('never collapses into an overload paragraph', () => { + for (const diagnostic of diagnostics) { + expect(diagnostic.message).not.toContain('Overload') + } + }) + + it('refuses the reserved tag at the declaration that carries it', () => { + const [reserved] = saying(diagnostics, RESERVED_TAG) + + expect(reserved?.code).toBe(ARGUMENT_NOT_ASSIGNABLE) + expect(reserved?.message).toContain('__reservedErrorTag__') + expect(reserved?.line).toBe( + lineContaining(FIXTURE, 'errors: [...userErrors, reserved]') + ) + }) + + it('refuses a bare `{}` at the argument itself', () => { + const [bare] = saying(diagnostics, DECLARE_SOMETHING) + + expect(bare?.code).toBe(ARGUMENT_NOT_ASSIGNABLE) + expect(bare?.message).toContain('__declareSomething__') + expect(bare?.line).toBe(lineContaining(FIXTURE, '({}, () => null)')) + }) + + it('refuses `fail("validation-failed")` against the declared tags alone', () => { + // The built-in variant is never in `fail`'s union, so the compiler's own + // sentence names exactly the declared tags - no umbrella wording needed. + const [reservedFail] = saying( + diagnostics, + `Argument of type '"validation-failed"' is not assignable to parameter of type '"user-not-found" | "forbidden"'` + ) + + expect(reservedFail?.code).toBe(ARGUMENT_NOT_ASSIGNABLE) + expect(reservedFail?.line).toBe( + lineContaining(FIXTURE, "fail('validation-failed')") + ) + }) + + it('still fires the validation parent’s stray-key sentence at the key', () => { + const [stray] = saying( + diagnostics, + "'boyd' is not a validation source - the sources are routerParams, query, headers and body" + ) + + expect(stray?.code).toBe(NOT_ASSIGNABLE_EXACT_OPTIONAL) + expect(stray?.line).toBe(lineContaining(FIXTURE, 'boyd:')) + }) + + it('still fires the errors parent’s divergent-tag guard at the declaration', () => { + const [divergent] = saying(diagnostics, '__divergentErrorTag__') + + expect(divergent?.code).toBe(ARGUMENT_NOT_ASSIGNABLE) + expect(divergent?.line).toBe( + lineContaining(FIXTURE, 'errors: [...userErrors, ...conflicting]') + ) + }) +}) diff --git a/packages/nuxt-typed-handler/test/types/tsconfig.fixtures.json b/packages/nuxt-typed-handler/test/types/tsconfig.fixtures.json new file mode 100644 index 0000000..a5e521a --- /dev/null +++ b/packages/nuxt-typed-handler/test/types/tsconfig.fixtures.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + + // The options the diagnostic fixtures compile under, read by + // `compile-harness.ts` rather than built as a project. It extends the repo + // base so a fixture is read under a consumer's settings, and stops there: + // the package's `./.nuxt/tsconfig.json` only adds Nuxt globals and aliases, + // which a fixture importing by relative path never uses. + "extends": "../../../../tsconfig.json", + + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/nuxt-typed-handler/test/unit/on-invalid.test.ts b/packages/nuxt-typed-handler/test/unit/on-invalid.test.ts new file mode 100644 index 0000000..3d06f7a --- /dev/null +++ b/packages/nuxt-typed-handler/test/unit/on-invalid.test.ts @@ -0,0 +1,108 @@ +import { readFloor } from '@dphonys/nuxt-handler-errors/internals/shared' +import { KNOWN_ERROR_KEY } from '@dphonys/nuxt-handler-errors/shared' +import { readValidationMarker } from '@dphonys/nuxt-handler-validation/internals/shared' +import type { ValidationIssue } from '@dphonys/nuxt-handler-validation/types' +import type { H3Error } from 'h3' +import { describe, expect, it } from 'vitest' +import { + recognizeKnownError, + recognizeValidationError, +} from '../../src/runtime/server' +import { onInvalid } from '../../src/runtime/server/lib/on-invalid' + +// The built-in variant's shape, off the live error the hook throws. + +const issues: ValidationIssue[] = [ + { source: 'query', message: 'page must be a whole number', path: ['page'] }, + { source: 'query', message: 'sort must be asc or desc', path: ['sort'] }, +] + +function thrownBy(run: () => never): H3Error { + try { + run() + } catch (error) { + return error as H3Error + } + + throw new Error('expected a throw') +} + +describe('the built-in validation-failed variant', () => { + const error = thrownBy(() => onInvalid('query', issues)) + + it('is a known error: 400, message === tag, no reason phrase', () => { + expect(error).toBeInstanceOf(Error) + expect(error.statusCode).toBe(400) + expect(error.message).toBe('validation-failed') + // The reason phrase survives an escaped throw untouched, so the tag must + // never ride it. + expect(error.statusMessage).toBeUndefined() + // Left alone, so the production serializer keeps `data`. + expect(error).toMatchObject({ fatal: false, unhandled: false }) + }) + + it('carries the issues at data.issues and inside the known-error marker', () => { + // `toEqual`, so an extra key in either place is a failure: `data.issues` + // is what a stripped response keeps, the marker is the first-party wire. + expect(error.data).toEqual({ + issues, + [KNOWN_ERROR_KEY]: { tag: 'validation-failed', status: 400, issues }, + }) + }) + + it('shares no issue object between the two copies and the hook’s input', () => { + const data = error.data as { + issues: ValidationIssue[] + [KNOWN_ERROR_KEY]: { issues: ValidationIssue[] } + } + + expect(data.issues).not.toBe(issues) + expect(data[KNOWN_ERROR_KEY].issues).not.toBe(issues) + expect(data.issues).not.toBe(data[KNOWN_ERROR_KEY].issues) + }) + + it('answers both parents’ recognizers and both markers', () => { + expect(readFloor(error)).toEqual({ + tag: 'validation-failed', + status: 400, + issues, + }) + expect(readValidationMarker(error)).toEqual({ issues }) + + expect(recognizeKnownError(error)).toEqual({ + tag: 'validation-failed', + status: 400, + issues, + }) + expect(recognizeValidationError(error)).toEqual({ issues }) + }) + + it('keeps the validation marker off the enumerable surface', () => { + // Non-enumerable and symbol-keyed, so it survives none of the copies the + // error takes on its way out - the parent's rule, inherited. + expect(Object.keys(error)).not.toContain( + Symbol.for('@dphonys/nuxt-handler-validation:error').toString() + ) + expect(JSON.stringify(error)).not.toContain('nuxt-handler-validation:error') + }) + + it('raises the unparseable-body case as the same variant with the parent’s issue', () => { + const unparseable: ValidationIssue[] = [ + { source: 'body', message: 'Request body could not be parsed', path: [] }, + ] + + const bodyError = thrownBy(() => onInvalid('body', unparseable)) + + expect(bodyError.statusCode).toBe(400) + expect(bodyError.message).toBe('validation-failed') + expect(bodyError.data).toEqual({ + issues: unparseable, + [KNOWN_ERROR_KEY]: { + tag: 'validation-failed', + status: 400, + issues: unparseable, + }, + }) + expect(recognizeValidationError(bodyError)).toEqual({ issues: unparseable }) + }) +}) diff --git a/packages/nuxt-typed-handler/test/unit/typed-handler.test.ts b/packages/nuxt-typed-handler/test/unit/typed-handler.test.ts new file mode 100644 index 0000000..59222ed --- /dev/null +++ b/packages/nuxt-typed-handler/test/unit/typed-handler.test.ts @@ -0,0 +1,297 @@ +import { defineError } from '@dphonys/nuxt-handler-errors/server' +import { KNOWN_ERROR_KEY } from '@dphonys/nuxt-handler-errors/shared' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { + defineTypedEventHandler, + recognizeKnownError, + recognizeValidationError, +} from '../../src/runtime/server' +import { postJson, request } from '../h3-app' + +// The real internals, with the one seam the errors-only case asserts on +// observed through a spy: `validatedContext` is the only door to a body read. +const { validatedContextSpy } = vi.hoisted(() => ({ + validatedContextSpy: vi.fn(), +})) + +vi.mock( + '@dphonys/nuxt-handler-validation/internals/server', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@dphonys/nuxt-handler-validation/internals/server') + >() + + return { + ...actual, + validatedContext: validatedContextSpy.mockImplementation( + actual.validatedContext + ), + } + } +) + +// Handlers built by `defineTypedEventHandler`, driven by real requests through +// a real h3 app, with both parents' internals imported for real. + +const userErrors = defineError({ + 'user-not-found': { status: 404 }, + forbidden: { status: 403 }, +}) + +describe('a route declaring only errors', () => { + it('raises a declared tag through `fail` as the errors parent does', async () => { + const handler = defineTypedEventHandler( + { errors: [...userErrors] }, + (_event, { fail }) => fail('user-not-found') + ) + + const response = await request(handler, '/api/test') + + expect(response.status).toBe(404) + // The marker is the observation: plain h3 withholds `message` off a + // non-debug app, and the wire suite covers Nitro's envelope. + await expect(response.json()).resolves.toMatchObject({ + data: { [KNOWN_ERROR_KEY]: { tag: 'user-not-found', status: 404 } }, + }) + }) + + it('never calls the validation seam, so the body is never read', async () => { + // Observed at the seam the internals expose rather than by a body spy: + // `validatedContext` is the one door to a body read, and it is never + // opened for a route that declares nothing to validate. + const handler = defineTypedEventHandler( + { errors: [...userErrors] }, + () => ({ reached: true }) + ) + + const response = await request(handler, '/api/test', { + init: postJson('{ not json at all'), + }) + + expect(validatedContextSpy).not.toHaveBeenCalled() + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ reached: true }) + }) +}) + +describe('a route declaring only validation', () => { + it('hands the handler exactly the validation parent’s context - no `fail`', async () => { + const handler = defineTypedEventHandler( + { validate: { query: z.object({ page: z.coerce.number() }) } }, + (_event, context) => ({ + keys: Object.keys(context), + page: context.query.page, + }) + ) + + const response = await request(handler, '/api/test?page=2') + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + keys: ['query'], + page: 2, + }) + expect(validatedContextSpy).toHaveBeenCalled() + }) + + it('answers a rejected source with the built-in variant, both markers on', async () => { + const handler = defineTypedEventHandler( + { validate: { query: z.object({ page: z.coerce.number() }) } }, + () => 'the body never runs' + ) + + const seen: unknown[] = [] + const response = await request(handler, '/api/test?page=nope', { + onError: (error) => seen.push(error), + }) + + expect(response.status).toBe(400) + + const issues = [ + { source: 'query', message: expect.any(String), path: ['page'] }, + ] + + // Inside the known-error marker and beside it: the stripped wire keeps + // `data.issues`, the first-party wire keeps the variant. + await expect(response.json()).resolves.toMatchObject({ + statusCode: 400, + data: { + issues, + [KNOWN_ERROR_KEY]: { tag: 'validation-failed', status: 400, issues }, + }, + }) + + // The live error is what an observability hook sees: both recognizers. + expect(recognizeKnownError(seen[0])).toMatchObject({ + tag: 'validation-failed', + status: 400, + }) + expect(recognizeValidationError(seen[0])).toMatchObject({ issues }) + }) + + it('answers an unparseable body with the same variant and the parent’s issue', async () => { + const handler = defineTypedEventHandler( + { validate: { body: z.object({ name: z.string() }) } }, + () => 'the body never runs' + ) + + const response = await request(handler, '/api/test', { + init: postJson('{ not json at all'), + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + data: { + issues: [ + { + source: 'body', + message: 'Request body could not be parsed', + path: [], + }, + ], + [KNOWN_ERROR_KEY]: { tag: 'validation-failed', status: 400 }, + }, + }) + }) +}) + +describe('a route declaring both', () => { + const both = defineTypedEventHandler( + { + validate: { query: z.object({ page: z.coerce.number() }) }, + errors: [...userErrors], + }, + (_event, context) => { + if (context.query.page > 1) return context.fail('forbidden') + + return { keys: Object.keys(context).toSorted(), page: context.query.page } + } + ) + + it('delivers the validated sources and `fail` in one flat context', async () => { + const response = await request(both, '/api/test?page=1') + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + keys: ['fail', 'query'], + page: 1, + }) + }) + + it('raises a declared failure through `fail`', async () => { + const response = await request(both, '/api/test?page=2') + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + data: { [KNOWN_ERROR_KEY]: { tag: 'forbidden', status: 403 } }, + }) + }) + + it('raises the built-in variant when validation rejects', async () => { + const response = await request(both, '/api/test?page=nope') + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + data: { [KNOWN_ERROR_KEY]: { tag: 'validation-failed', status: 400 } }, + }) + }) + + it('builds a fresh, unfrozen context for every request', async () => { + const contexts: object[] = [] + const arrivedTouched: boolean[] = [] + + const handler = defineTypedEventHandler( + { errors: [...userErrors] }, + (_event, context) => { + contexts.push(context) + arrivedTouched.push('touched' in context) + // Not frozen: a handler may decorate its own context. + Object.assign(context, { touched: true }) + + return { ok: true } + } + ) + + await request(handler, '/api/test') + await request(handler, '/api/test') + + expect(contexts).toHaveLength(2) + expect(contexts[0]).not.toBe(contexts[1]) + expect(arrivedTouched).toEqual([false, false]) + }) +}) + +describe('declaration-time misuse', () => { + const foreign = {} as (typeof userErrors)[number] + const reserved = defineError('validation-failed', { status: 400 }) + + it('throws the parents’ and its own messages in the order foreign copy, reserved tag, not a schema', () => { + const notASchema = { query: 42 as never } + + // All three mistakes at once: the foreign copy wins. + expect(() => + defineTypedEventHandler( + { validate: notASchema, errors: [foreign, reserved] as never }, + () => null + ) + ).toThrow( + '[nuxt-handler-errors] errors[0] is not an error created by this copy of the module. ' + + 'Either it did not come from defineError(), or there are two copies of ' + + '@dphonys/nuxt-handler-errors in the dependency tree - a version duplicate, or a Nuxt ' + + 'layer or package that resolved its own. Deduplicate it so every error and every ' + + 'handler come from one copy.' + ) + + // Without the foreign copy: the reserved tag, ahead of the schema check. + expect(() => + defineTypedEventHandler( + { validate: notASchema, errors: [reserved] as never }, + () => null + ) + ).toThrow( + '[nuxt-typed-handler] The error tag "validation-failed" is reserved for the built-in validation variant. Rename the declared error.' + ) + + // With a clean declaration: the validation parent's own message. + expect(() => + defineTypedEventHandler( + { validate: notASchema, errors: [...userErrors] }, + () => null + ) + ).toThrow( + '[nuxt-handler-validation] cannot validate query: the value at index 0 is not a Standard Schema. ' + + "A source slot holds a schema or a non-empty tuple of them - every element must carry a '~standard' property." + ) + }) + + it('throws on a bare `{}` - the compile guard’s answer for a JavaScript caller', () => { + expect(() => defineTypedEventHandler({} as never, () => null)).toThrow( + '[nuxt-typed-handler] defineTypedEventHandler needs validate, errors, or both.' + ) + }) + + it('lets `fail("validation-failed")` hit the parent’s undeclared-tag Error', async () => { + // `declared` can never carry the tag, so the parent's plain `Error` is + // the whole answer - no umbrella wording, no marker. + const handler = defineTypedEventHandler( + { errors: [...userErrors] }, + // Cast because the compile guard already refuses the tag. + (_event, { fail }) => + (fail as (tag: string) => never)('validation-failed') + ) + + const seen: unknown[] = [] + const response = await request(handler, '/api/test', { + onError: (error) => seen.push(error), + }) + + expect(response.status).toBe(500) + expect(seen[0]).toBeInstanceOf(Error) + expect((seen[0] as Error).message).toBe( + '[nuxt-handler-errors] undeclared error tag: validation-failed' + ) + expect(recognizeKnownError(seen[0])).toBeUndefined() + }) +}) From 26939b2e77ecf8c329f29190f765a7ea688c3160 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 17:08:42 +0200 Subject: [PATCH 03/28] test(nuxt-typed-handler): prove the four entries and the wire from a consumer's seat The playground consumes the umbrella through its published specifiers: the four entries resolve for runtime and types, neither parent wrapper nor any parent internal leaks through an umbrella door, and no `/internals/*` specifier resolves on the umbrella. One wire contract runs against a production build and a dev server: the built-in variant with and without the channel header, a route declaring both halves raising each, malformed JSON on an `errors`-only `POST` versus a validating one, and one smoke per parent. Co-Authored-By: Claude Fable 5 --- .../playground/server/api/notes.post.ts | 8 + .../playground/server/api/search.get.ts | 7 + .../playground/server/api/users.post.ts | 14 + .../playground/server/api/users/[id].get.ts | 13 + .../playground/server/errors/users.ts | 5 + .../playground/server/validation/schemas.ts | 15 ++ .../test/e2e/package-entries.test.ts | 245 ++++++++++++++++++ .../nuxt-typed-handler/test/e2e/typed-wire.ts | 242 +++++++++++++++++ .../test/e2e/wire-dev.test.ts | 22 ++ .../nuxt-typed-handler/test/e2e/wire.test.ts | 20 ++ 10 files changed, 591 insertions(+) create mode 100644 packages/nuxt-typed-handler/playground/server/api/notes.post.ts create mode 100644 packages/nuxt-typed-handler/playground/server/api/search.get.ts create mode 100644 packages/nuxt-typed-handler/playground/server/api/users.post.ts create mode 100644 packages/nuxt-typed-handler/playground/server/api/users/[id].get.ts create mode 100644 packages/nuxt-typed-handler/playground/server/errors/users.ts create mode 100644 packages/nuxt-typed-handler/playground/server/validation/schemas.ts create mode 100644 packages/nuxt-typed-handler/test/e2e/package-entries.test.ts create mode 100644 packages/nuxt-typed-handler/test/e2e/typed-wire.ts create mode 100644 packages/nuxt-typed-handler/test/e2e/wire-dev.test.ts create mode 100644 packages/nuxt-typed-handler/test/e2e/wire.test.ts 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..0a973c9 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/api/search.get.ts @@ -0,0 +1,7 @@ +import { pagination } from '../validation/schemas' + +/** A `validate`-only route: the parent's context, the umbrella's failure wire. */ +export default defineTypedEventHandler( + { validate: { query: pagination } }, + (_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/validation/schemas.ts b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts new file mode 100644 index 0000000..6bf66e4 --- /dev/null +++ b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts @@ -0,0 +1,15 @@ +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'), +}) diff --git a/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts b/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts new file mode 100644 index 0000000..01723a2 --- /dev/null +++ b/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts @@ -0,0 +1,245 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import ts from 'typescript' +import { beforeAll, describe, expect, it } from 'vitest' + +// Resolved the way a consumer resolves them: through the package name, from +// the playground - the one workspace directory with the package in +// `node_modules`. The build is a task dependency - see `turbo.json`. + +const run = promisify(execFile) + +const PLAYGROUND = fileURLToPath(new URL('../../playground', import.meta.url)) +const BUILT_MODULE = fileURLToPath( + new URL('../../dist/module.mjs', import.meta.url) +) + +const MODULE_ENTRY = '@dphonys/nuxt-typed-handler' +const TYPES_ENTRY = '@dphonys/nuxt-typed-handler/types' +const SERVER_ENTRY = '@dphonys/nuxt-typed-handler/server' +const SHARED_ENTRY = '@dphonys/nuxt-typed-handler/shared' + +const PUBLIC_ENTRIES = [ + MODULE_ENTRY, + TYPES_ENTRY, + SERVER_ENTRY, + SHARED_ENTRY, +] as const + +/** The umbrella is the leaf: none of these may resolve on it. */ +const INTERNALS_SUBPATHS = ['build', 'server', 'shared', 'app'] as const + +/** + * Every name the parents publish only through their internals. None may be + * reachable through an umbrella door, runtime or type. + */ +const PARENT_INTERNALS = { + runtime: [ + // errors `/internals/build` + 'addChannelStripErrorHandler', + 'addChannelToken', + 'emitMap', + 'EMPTY_MAP', + 'emptyMap', + 'KNOWN_ERRORS_SLOT', + 'normalizeChannelToken', + 'TYPES_SPECIFIER', + 'warnCustomErrorHandler', + // errors `/internals/server` + 'createCheckedEventFetch', + 'createChannelStripHandler', + 'createFail', + 'createKnownError', + 'EventFetchUnavailableError', + 'raiseKnown', + 'resolveDeclared', + // errors `/internals/shared` + 'CHANNEL_HEADER', + 'createCheckedFetch', + 'knownErrorMarker', + 'lazyGlobalFetch', + 'readFloor', + 'toNuxtError', + 'toTryResult', + // errors `/internals/app` + 'wrapVanillaAsyncData', + 'wrapVanillaFetch', + // validation `/internals/server` + 'raiseValidationError', + 'sourcePlan', + 'validatedContext', + // validation `/internals/shared` + 'markValidationError', + 'readValidationMarker', + 'VALIDATION_ERROR_KEY', + ], + types: [ + 'EmitMapOptions', + 'EmitMapSlot', + 'NitroPathOptions', + 'SlotImport', + 'DeclaredError', + 'RawEventFetch', + 'CheckedFetchFactoryOptions', + 'RawFetch', + 'RawOptions', + 'RawTryResult', + 'FailureOf', + 'FetchWrapperOptions', + 'KnownErrorRef', + 'RawUseAsyncData', + 'RawUseFetch', + 'SuccessOf', + 'TrySource', + 'UseCheckedAsyncData', + 'UseCheckedFetch', + 'OnInvalid', + 'SourcePlan', + 'ValidatedContextOptions', + ], +} as const + +beforeAll(() => { + if (existsSync(BUILT_MODULE)) return + + throw new Error( + 'No `dist` to resolve against. Run `pnpm run build` in this package, or ' + + 'run the suite through `turbo run test`, which builds it first.' + ) +}) + +// Plain bundler options rather than the playground's generated tsconfig: its +// `paths` map this package's subpaths straight at `dist`, short-circuiting +// the very `exports` block under test. +function diagnosticsFor(source: string): string[] { + const probe = `${PLAYGROUND}/__entry-resolution.probe.ts` + + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.Preserve, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + noEmit: true, + types: [], + } + + const host = ts.createCompilerHost(options, true) + const readFile = host.readFile.bind(host) + const fileExists = host.fileExists.bind(host) + const getSourceFile = host.getSourceFile.bind(host) + + host.readFile = (name) => (name === probe ? source : readFile(name)) + host.fileExists = (name) => name === probe || fileExists(name) + host.getSourceFile = (name, ...rest) => + name === probe + ? ts.createSourceFile(probe, source, ts.ScriptTarget.ESNext, true) + : getSourceFile(name, ...rest) + + const program = ts.createProgram([probe], options, host) + + return ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file?.fileName === probe) + .map( + (diagnostic) => + `TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}` + ) +} + +/** TS2305 or, when a near-miss exists on the door, TS2724's "named". */ +function notExported(name: string): RegExp { + return new RegExp(`has no exported member (?:named )?'${name}'`) +} + +/** A real Node process from the consumer's seat - only Node applies `exports`. */ +function importFromPlayground(specifier: string) { + return run( + process.execPath, + [ + '--input-type=module', + '--eval', + `await import(${JSON.stringify(specifier)})`, + ], + { cwd: PLAYGROUND } + ) +} + +describe('the published entries', () => { + it('all import at runtime from a consumer’s node_modules', async () => { + for (const entry of PUBLIC_ENTRIES) { + await expect(importFromPlayground(entry)).resolves.toBeDefined() + } + }) + + it('all resolve for types, each through its own door', () => { + // Each name is imported from the entry that owns it: every umbrella-owned + // name, plus re-exported parent names - at least one per parent per door. + const probes = [ + `import type { ModuleOptions } from '${MODULE_ENTRY}'`, + `import type { AnyKnownError, AtLeastOne, DefineTypedEventHandler, ReservedTagGuard, TypedContext, TypedErrors, TypedEventHandler, TypedHandlerFn, TypedHandlerOptions, ValidationFailed } from '${TYPES_ENTRY}'`, + `import type { CheckedEventHandler, Fail, KnownApiErrors, KnownErrorsOf, KnownErrorsOfHandler, KnownErrorsOfRoute, KnownVariant, TryResult } from '${TYPES_ENTRY}'`, + `import type { RequestInput, RequestInputOfHandler, ValidatedContext, ValidatedEventHandler, ValidationIssue, ValidationSchemas, ValidationSchemasGuard } from '${TYPES_ENTRY}'`, + `import { defineTypedEventHandler, defineError, payload, recognizeKnownError, recognizeValidationError } from '${SERVER_ENTRY}'`, + `import { KNOWN_ERROR_KEY, matchError } from '${SHARED_ENTRY}'`, + `export type Probe = [ModuleOptions, AnyKnownError, AtLeastOne<{}, []>, DefineTypedEventHandler, ReservedTagGuard<[]>, TypedContext<{}, []>, TypedErrors<{}, []>, TypedEventHandler, TypedHandlerFn<{}, [], never, unknown>, TypedHandlerOptions<{}, []>, ValidationFailed, CheckedEventHandler, Fail, KnownApiErrors, KnownErrorsOf<[]>, KnownErrorsOfHandler, KnownErrorsOfRoute<'/api/users/:id'>, KnownVariant, TryResult, RequestInput<{}>, RequestInputOfHandler, ValidatedContext<{}>, ValidatedEventHandler, ValidationIssue, ValidationSchemas, ValidationSchemasGuard<{}>, typeof defineTypedEventHandler, typeof defineError, typeof payload, typeof recognizeKnownError, typeof recognizeValidationError, typeof KNOWN_ERROR_KEY, typeof matchError]`, + ] + + expect(diagnosticsFor(probes.join('\n'))).toEqual([]) + }) + + it('keep the parents’ wrappers off every door', () => { + for (const door of PUBLIC_ENTRIES) { + const failures = diagnosticsFor( + `import { defineCheckedEventHandler, defineValidatedEventHandler } from '${door}'` + ).join('\n') + + for (const name of [ + 'defineCheckedEventHandler', + 'defineValidatedEventHandler', + ]) { + expect(failures).toMatch(notExported(name)) + } + } + }) + + it('keep every parent internal off every door', () => { + // The whole parents' internals contract against every umbrella door, types + // included, so a seam cannot leak through the door nobody listed it for. + for (const door of PUBLIC_ENTRIES) { + const failures = diagnosticsFor( + [ + `import { ${PARENT_INTERNALS.runtime.join(', ')} } from '${door}'`, + `import type { ${PARENT_INTERNALS.types.join(', ')} } from '${door}'`, + ].join('\n') + ).join('\n') + + for (const name of [ + ...PARENT_INTERNALS.runtime, + ...PARENT_INTERNALS.types, + ]) { + expect(failures).toMatch(notExported(name)) + } + } + }) + + it('expose no `/internals/*` specifier of its own, for runtime or types', async () => { + for (const subpath of INTERNALS_SUBPATHS) { + const specifier = `${MODULE_ENTRY}/internals/${subpath}` + + await expect(importFromPlayground(specifier)).rejects.toThrow( + 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ) + + // A namespace import, not a bare one: an unresolved side-effect import + // is no diagnostic at all under the compiler's defaults. + expect( + diagnosticsFor( + `import * as probe from '${specifier}'\nexport const p = probe` + ).join('\n') + ).toContain(`Cannot find module '${specifier}'`) + } + }) +}) diff --git a/packages/nuxt-typed-handler/test/e2e/typed-wire.ts b/packages/nuxt-typed-handler/test/e2e/typed-wire.ts new file mode 100644 index 0000000..47cbeaf --- /dev/null +++ b/packages/nuxt-typed-handler/test/e2e/typed-wire.ts @@ -0,0 +1,242 @@ +import { CHANNEL_HEADER } from '@dphonys/nuxt-handler-errors/internals/shared' +import { VALIDATION_ERROR_KEY } from '@dphonys/nuxt-handler-validation/internals/shared' +import { $fetch, fetch } from '@nuxt/test-utils/e2e' +import { expect, it } from 'vitest' +import { KNOWN_ERROR_KEY } from '../../src/runtime/shared' + +/** + * The umbrella's wire, in one copy, run by `wire.test.ts` against a + * production build and `wire-dev.test.ts` against a dev server: the delta + * the umbrella adds over its parents, plus one smoke per parent asserted + * against that parent's own expectations. + */ + +/** What `playground/nuxt.config.ts` configures. Gating is on for this app. */ +const TOKEN = 'playground-channel' + +/** A first-party call: the header every checked surface attaches for itself. */ +const firstParty = { accept: 'application/json', [CHANNEL_HEADER]: TOKEN } + +/** A caller that is not the app. */ +const thirdParty = { accept: 'application/json' } + +/** + * The validation marker's key as it would read if it ever reached a client, + * derived rather than restated so a rename cannot leave this passing. + */ +const VALIDATION_MARKER: string = VALIDATION_ERROR_KEY.description ?? '' + +/** What `/api/search?page=nope` produces. */ +const BAD_PAGE = { + source: 'query', + message: 'page must be a whole number', + path: ['page'], +} + +/** The validation parent's own wording for a body the request made unreadable. */ +const UNPARSEABLE_BODY = { + source: 'body', + message: 'Request body could not be parsed', + path: [], +} + +/** Keys Nitro adds to an error body that this package does not own. */ +type NitroExtras = Record + +export function theTypedWire(nitroExtras: NitroExtras): void { + /** Nitro's envelope around the built-in variant, as a first party sees it. */ + const variantBody = (issues: unknown[]): Record => ({ + ...nitroExtras, + error: true, + url: expect.any(String), + statusCode: 400, + statusMessage: expect.any(String), + message: 'validation-failed', + data: { + issues, + [KNOWN_ERROR_KEY]: { tag: 'validation-failed', status: 400, issues }, + }, + }) + + it('answers a rejected source with the built-in variant on the first-party channel', async () => { + const response = await fetch('/api/search?page=nope', { + headers: firstParty, + }) + + expect(response.status).toBe(400) + + const body = await response.json() + + // The whole body, so an extra key fails too. The tag must never ride the + // reason phrase. + expect(body).toEqual(variantBody([BAD_PAGE])) + expect(body.statusMessage).not.toBe('validation-failed') + }) + + it('strips the marker for a third party and leaves data.issues', async () => { + const response = await fetch('/api/search?page=nope', { + headers: thirdParty, + }) + + expect(response.status).toBe(400) + + const body = await response.json() + + expect(JSON.stringify(body)).not.toContain(KNOWN_ERROR_KEY) + expect(body.data).toEqual({ issues: [BAD_PAGE] }) + }) + + it('serves a `validate`-only route the parent’s context', async () => { + expect(await $fetch('/api/search?page=3')).toEqual({ page: 3, hits: [] }) + }) + + it('raises each half of a route declaring both', async () => { + const rejected = await fetch( + '/api/users', + jsonPost(JSON.stringify({ name: '', email: 'nope' }), firstParty) + ) + + expect(rejected.status).toBe(400) + expect(await rejected.json()).toEqual( + variantBody([ + { source: 'body', message: 'name is required', path: ['name'] }, + { + source: 'body', + message: 'email must be an address', + path: ['email'], + }, + ]) + ) + + const declared = await fetch( + '/api/users', + jsonPost( + JSON.stringify({ name: 'Ada', email: 'taken@example.com' }), + firstParty + ) + ) + + expect(declared.status).toBe(409) + expect(await declared.json()).toMatchObject({ + statusCode: 409, + message: 'user-exists', + data: { + [KNOWN_ERROR_KEY]: { + tag: 'user-exists', + status: 409, + email: 'taken@example.com', + }, + }, + }) + + expect( + await $fetch( + '/api/users', + jsonPost(JSON.stringify({ name: 'Ada', email: 'ada@example.com' })) + ) + ).toEqual({ created: 'Ada' }) + }) + + it('lets malformed JSON reach an `errors`-only POST untouched', async () => { + // No validation declared, so nothing reads the body before the handler. + expect(await $fetch('/api/notes', jsonPost('{"name":'))).toEqual({ + reached: true, + raw: '{"name":', + }) + }) + + it('answers malformed JSON on a validating POST with the body issue', async () => { + const response = await fetch('/api/users', jsonPost('{"name":', firstParty)) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual(variantBody([UNPARSEABLE_BODY])) + }) + + it('puts the validation marker nowhere in a serialized response', async () => { + const responses = await Promise.all([ + fetch('/api/search?page=nope', { headers: firstParty }), + fetch('/api/search?page=nope', { headers: thirdParty }), + fetch('/api/users', jsonPost('{"name":', firstParty)), + ]) + + for (const response of responses) { + expect(await response.text()).not.toContain(VALIDATION_MARKER) + } + }) + + // --- One smoke per parent, against the parent's own expectations -------- + + it('carries a declared failure exactly as the errors parent does', async () => { + const response = await fetch('/api/users/missing', { headers: firstParty }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + ...nitroExtras, + error: true, + url: expect.stringMatching(/\/api\/users\/missing$/), + statusCode: 404, + statusMessage: expect.any(String), + message: 'user-not-found', + data: { + [KNOWN_ERROR_KEY]: { + tag: 'user-not-found', + status: 404, + userId: 'missing', + }, + }, + }) + + // The third-party view: an ordinary error, marker gone, `data` with it. + const stripped = await fetch('/api/users/missing', { headers: thirdParty }) + const body = await stripped.json() + + expect(stripped.status).toBe(404) + expect(JSON.stringify(body)).not.toContain(KNOWN_ERROR_KEY) + expect(body.data).toBeUndefined() + + expect(await $fetch('/api/users/42')).toEqual({ id: '42', name: 'User 42' }) + }) + + it('rejects a request exactly as the validation parent does, but for the wire', async () => { + // The parent's promise, minus its own failure shape: every issue in one + // source arrives together, and the success path is untouched. + const response = await fetch( + '/api/users', + jsonPost(JSON.stringify({ name: '', email: 'nope' }), thirdParty) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + data: { + issues: [ + { source: 'body', message: 'name is required', path: ['name'] }, + { + source: 'body', + message: 'email must be an address', + path: ['email'], + }, + ], + }, + }) + }) +} + +/** + * A POST carrying an already-serialized payload. Typed structurally rather + * than as `RequestInit`, because it is handed to both `fetch` and ofetch's + * `$fetch`. The caller's headers land last. + */ +function jsonPost( + body: string, + headers: Record = {} +): { + method: 'POST' + headers: Record + body: string +} { + return { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + } +} diff --git a/packages/nuxt-typed-handler/test/e2e/wire-dev.test.ts b/packages/nuxt-typed-handler/test/e2e/wire-dev.test.ts new file mode 100644 index 0000000..04fcdd9 --- /dev/null +++ b/packages/nuxt-typed-handler/test/e2e/wire-dev.test.ts @@ -0,0 +1,22 @@ +import { setup } from '@nuxt/test-utils/e2e' +import { fileURLToPath } from 'node:url' +import { describe, expect } from 'vitest' +import { theTypedWire } from './typed-wire' + +// The same wire against a dev server. A separate file rather than a second +// `describe` because `@nuxt/test-utils` keeps one global test context: two +// `setup()` calls in one file leave both suites pointing at whichever server +// was created last. +describe('the typed wire, against a dev server', async () => { + await setup({ + rootDir: fileURLToPath(new URL('../../playground', import.meta.url)), + server: true, + browser: false, + dev: true, + }) + + // Nitro's dev error handler adds a `stack` of its own to the JSON body. The + // framework's key, not this package's, so it is declared rather than + // relaxed away. + theTypedWire({ stack: expect.any(Array) }) +}) diff --git a/packages/nuxt-typed-handler/test/e2e/wire.test.ts b/packages/nuxt-typed-handler/test/e2e/wire.test.ts new file mode 100644 index 0000000..7befb31 --- /dev/null +++ b/packages/nuxt-typed-handler/test/e2e/wire.test.ts @@ -0,0 +1,20 @@ +import { setup } from '@nuxt/test-utils/e2e' +import { fileURLToPath } from 'node:url' +import { describe } from 'vitest' +import { theTypedWire } from './typed-wire' + +// The wire against a real production build; the contract itself lives in +// `typed-wire.ts`. The playground is the fixture because it consumes the +// module through its real published specifiers - which is why `test` depends +// on this package's own `build`. +describe('the typed wire, against a production build', async () => { + await setup({ + rootDir: fileURLToPath(new URL('../../playground', import.meta.url)), + server: true, + browser: false, + }) + + // Nothing extra: the production error body is exactly what this package and + // Nitro's own envelope put there. + theTypedWire({}) +}) From 62162a452375536939f25b77cc21038360ac7fae Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 17:12:35 +0200 Subject: [PATCH 04/28] chore(nuxt-typed-handler): keep the package private until its admission The first-release runbook keeps a generated package at `0.0.1` with `private: true` until its docs replace the template's and the debut intent is recorded; that admission is a later step of the same branch, not this scaffold. Co-Authored-By: Claude Fable 5 --- packages/nuxt-typed-handler/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json index c34a6f3..9fec684 100644 --- a/packages/nuxt-typed-handler/package.json +++ b/packages/nuxt-typed-handler/package.json @@ -1,6 +1,7 @@ { "name": "@dphonys/nuxt-typed-handler", - "version": "0.1.0", + "version": "0.0.1", + "private": true, "description": "Declare a Nitro handler's request schemas and expected failures once, and get both typed at every call site.", "keywords": [ "nuxt", From 3dfe609f11d8ffa1b90024bc934a6b95f49e49af Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 17:12:36 +0200 Subject: [PATCH 05/28] refactor(nuxt-typed-handler): trim the types door to what the wrapper needs `AnyKnownError` and `TypedHandlerOptions` stay internal to the handler types; the declaration checks read `validate` and `errors` by truthiness as the spec does; the reserved-tag message is built from the one constant; review-noted comments tightened. Co-Authored-By: Claude Fable 5 --- packages/nuxt-typed-handler/src/module.ts | 8 +++++--- .../src/runtime/server/lib/on-invalid.ts | 4 +++- .../src/runtime/server/lib/reserved-tag.ts | 2 +- .../src/runtime/server/lib/typed-handler.ts | 13 ++++++------- .../nuxt-typed-handler/src/runtime/types/index.ts | 2 -- .../test/e2e/package-entries.test.ts | 4 ++-- .../test/types/compile-harness.ts | 7 +++---- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/nuxt-typed-handler/src/module.ts b/packages/nuxt-typed-handler/src/module.ts index 99616dc..35dc7ad 100644 --- a/packages/nuxt-typed-handler/src/module.ts +++ b/packages/nuxt-typed-handler/src/module.ts @@ -29,7 +29,7 @@ export interface ModuleOptions { const NAME = 'nuxt-typed-handler' -/** The package this module replaces, and the key it used to be configured under. */ +/** The two packages this module replaces, and the keys they were configured under. */ const PARENTS = [ { packageName: '@dphonys/nuxt-handler-errors', @@ -68,8 +68,10 @@ export default defineNuxtModule({ } // Required by the errors parent's internals contract: its app internals - // import `#app`, and Nuxt transpiles only what `modules` lists. The - // validation parent's contract says push nothing. + // import `#app`, and Nuxt transpiles only what `modules` lists. Pushed + // ahead of the typed fetch family that binds those internals, so the + // contract holds from the first build. The validation parent's contract + // says push nothing. nuxt.options.build.transpile.push('@dphonys/nuxt-handler-errors') nuxt.options.typescript.hoist.push('@dphonys/nuxt-typed-handler/types') 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 index d11408c..cc5a6ed 100644 --- a/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts @@ -14,7 +14,9 @@ export const onInvalid: OnInvalid = (_source, issues) => { const error = createKnownError(RESERVED_TAG, 400, { issues: [...issues] }) // Beside the marker, not inside it: what a client reads once the marker is - // stripped, and the path the validation parent documents. + // stripped, and the path the validation parent documents. A second copy on + // purpose, so neither place shares an array with the other or the hook's + // input. ;(error.data as Record).issues = [...issues] markValidationError(error, issues) 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 index 96e349b..91e8b2e 100644 --- a/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts @@ -12,6 +12,6 @@ export function assertNoReservedTag( if (declared?.some((entry) => entry.tag === RESERVED_TAG) !== true) return throw new Error( - '[nuxt-typed-handler] The error tag "validation-failed" is reserved for the built-in validation variant. Rename the declared 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 index 8f78177..c7067f3 100644 --- a/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts @@ -40,11 +40,9 @@ export const defineTypedEventHandler: DefineTypedEventHandler = ( ) => { // Declaration time, in this order: the foreign-copy guard first, then the // reserved tag, then the not-a-schema check - each with its owner's message. - const declared = - options.errors === undefined ? undefined : resolveDeclared(options.errors) + const declared = options.errors ? resolveDeclared(options.errors) : undefined assertNoReservedTag(declared) - const plan = - options.validate === undefined ? undefined : sourcePlan(options.validate) + const plan = options.validate ? sourcePlan(options.validate) : undefined const fail = declared === undefined ? undefined : createFail(declared) // The compile guard's answer for a JavaScript caller. @@ -55,16 +53,17 @@ export const defineTypedEventHandler: DefineTypedEventHandler = ( } // A fresh plain object per request; `fail` present exactly when declared. - const context = (validated: Record): never => + // 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 // One `defineEventHandler`, and no validation call at all on a route that // declares none: no body read, no await. return defineEventHandler((event) => plan === undefined - ? handler(event, context({})) + ? handler(event, contextFor({})) : validatedContext(event, plan, VALIDATION_OPTIONS).then((validated) => - handler(event, context(validated)) + handler(event, contextFor(validated)) ) ) as never } diff --git a/packages/nuxt-typed-handler/src/runtime/types/index.ts b/packages/nuxt-typed-handler/src/runtime/types/index.ts index 67ae6bb..2cd07b8 100644 --- a/packages/nuxt-typed-handler/src/runtime/types/index.ts +++ b/packages/nuxt-typed-handler/src/runtime/types/index.ts @@ -2,7 +2,6 @@ export type * from '@dphonys/nuxt-handler-errors/types' export type * from '@dphonys/nuxt-handler-validation/types' export type { - AnyKnownError, AtLeastOne, DefineTypedEventHandler, ReservedTagGuard, @@ -10,6 +9,5 @@ export type { TypedErrors, TypedEventHandler, TypedHandlerFn, - TypedHandlerOptions, ValidationFailed, } from './handler' diff --git a/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts b/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts index 01723a2..e705fac 100644 --- a/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts +++ b/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts @@ -179,12 +179,12 @@ describe('the published entries', () => { // name, plus re-exported parent names - at least one per parent per door. const probes = [ `import type { ModuleOptions } from '${MODULE_ENTRY}'`, - `import type { AnyKnownError, AtLeastOne, DefineTypedEventHandler, ReservedTagGuard, TypedContext, TypedErrors, TypedEventHandler, TypedHandlerFn, TypedHandlerOptions, ValidationFailed } from '${TYPES_ENTRY}'`, + `import type { AtLeastOne, DefineTypedEventHandler, ReservedTagGuard, TypedContext, TypedErrors, TypedEventHandler, TypedHandlerFn, ValidationFailed } from '${TYPES_ENTRY}'`, `import type { CheckedEventHandler, Fail, KnownApiErrors, KnownErrorsOf, KnownErrorsOfHandler, KnownErrorsOfRoute, KnownVariant, TryResult } from '${TYPES_ENTRY}'`, `import type { RequestInput, RequestInputOfHandler, ValidatedContext, ValidatedEventHandler, ValidationIssue, ValidationSchemas, ValidationSchemasGuard } from '${TYPES_ENTRY}'`, `import { defineTypedEventHandler, defineError, payload, recognizeKnownError, recognizeValidationError } from '${SERVER_ENTRY}'`, `import { KNOWN_ERROR_KEY, matchError } from '${SHARED_ENTRY}'`, - `export type Probe = [ModuleOptions, AnyKnownError, AtLeastOne<{}, []>, DefineTypedEventHandler, ReservedTagGuard<[]>, TypedContext<{}, []>, TypedErrors<{}, []>, TypedEventHandler, TypedHandlerFn<{}, [], never, unknown>, TypedHandlerOptions<{}, []>, ValidationFailed, CheckedEventHandler, Fail, KnownApiErrors, KnownErrorsOf<[]>, KnownErrorsOfHandler, KnownErrorsOfRoute<'/api/users/:id'>, KnownVariant, TryResult, RequestInput<{}>, RequestInputOfHandler, ValidatedContext<{}>, ValidatedEventHandler, ValidationIssue, ValidationSchemas, ValidationSchemasGuard<{}>, typeof defineTypedEventHandler, typeof defineError, typeof payload, typeof recognizeKnownError, typeof recognizeValidationError, typeof KNOWN_ERROR_KEY, typeof matchError]`, + `export type Probe = [ModuleOptions, AtLeastOne<{}, []>, DefineTypedEventHandler, ReservedTagGuard<[]>, TypedContext<{}, []>, TypedErrors<{}, []>, TypedEventHandler, TypedHandlerFn<{}, [], never, unknown>, ValidationFailed, CheckedEventHandler, Fail, KnownApiErrors, KnownErrorsOf<[]>, KnownErrorsOfHandler, KnownErrorsOfRoute<'/api/users/:id'>, KnownVariant, TryResult, RequestInput<{}>, RequestInputOfHandler, ValidatedContext<{}>, ValidatedEventHandler, ValidationIssue, ValidationSchemas, ValidationSchemasGuard<{}>, typeof defineTypedEventHandler, typeof defineError, typeof payload, typeof recognizeKnownError, typeof recognizeValidationError, typeof KNOWN_ERROR_KEY, typeof matchError]`, ] expect(diagnosticsFor(probes.join('\n'))).toEqual([]) diff --git a/packages/nuxt-typed-handler/test/types/compile-harness.ts b/packages/nuxt-typed-handler/test/types/compile-harness.ts index b417fdf..85dc989 100644 --- a/packages/nuxt-typed-handler/test/types/compile-harness.ts +++ b/packages/nuxt-typed-handler/test/types/compile-harness.ts @@ -1,8 +1,7 @@ /** - * Compile one fixture and read the diagnostics it produced. The - * `Assert>` suites next door prove a type resolved, never that the - * author is shown the sentence a guard carries; only a compiler run answers - * that. + * Compile one fixture and read the diagnostics it produced. A type resolving + * proves nothing about what the author is shown - the sentence a guard + * carries is only a compiler run's to answer. * * Not a Vitest test file, so it is not matched by vitest's `include`; knip * reaches it through the suite importing it. From 452ea6dfcc40082ae99395f0b06d4e3b5e204a61 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 20:17:57 +0200 Subject: [PATCH 06/28] feat(nuxt-typed-handler): type the typed fetch call sites per route and method A call site should read what its own route declared, not what the family happens to accept. `/types` gains the `KnownApiRequestInputs` map, the `RequestInputOfRoute` lookup and the `Typed*` / `UseTyped*` family, so a declared `body` or `query` is required exactly when `{} extends Input` is false, excess keys are rejected, `params` is gone family-wide, and `get` is the default method. `body` is omitted on `get`/`head` for branded routes only: an unbranded route stays `NitroFetchOptions` minus `params`, key for key, as ticket 10 resolved. The five stack-depth rules are requirements on `fetch.ts` rather than style, and the ported ticket 10 fixtures assert them over a hand-written 51-route map - no `TS2321`, no `TS2589`. The map and its lookup sit in the `/types` barrel, as the errors parent's own pair does, rather than in the `request-inputs.ts` the spec sketches: 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. Co-Authored-By: Claude Opus 5 --- packages/nuxt-typed-handler/package.json | 4 +- .../src/runtime/types/composables.ts | 69 ++ .../src/runtime/types/fetch.ts | 171 +++++ .../src/runtime/types/index.ts | 64 ++ .../test/e2e/package-entries.test.ts | 36 +- .../test/types/request-routes.ts | 693 ++++++++++++++++++ .../test/types/request-typing.test.ts | 337 +++++++++ pnpm-lock.yaml | 8 +- 8 files changed, 1379 insertions(+), 3 deletions(-) create mode 100644 packages/nuxt-typed-handler/src/runtime/types/composables.ts create mode 100644 packages/nuxt-typed-handler/src/runtime/types/fetch.ts create mode 100644 packages/nuxt-typed-handler/test/types/request-routes.ts create mode 100644 packages/nuxt-typed-handler/test/types/request-typing.test.ts diff --git a/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json index 9fec684..c86eb8a 100644 --- a/packages/nuxt-typed-handler/package.json +++ b/packages/nuxt-typed-handler/package.json @@ -77,7 +77,9 @@ "@dphonys/nuxt-handler-errors": "workspace:0.3.1", "@dphonys/nuxt-handler-validation": "workspace:0.1.1", "@nuxt/kit": "catalog:", - "h3": "catalog:" + "h3": "catalog:", + "nitropack": "catalog:", + "vue": "catalog:" }, "devDependencies": { "@nuxt/devtools": "catalog:", 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..1425c23 --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/composables.ts @@ -0,0 +1,69 @@ +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 value, a +// ref, a getter, or an object whose leaves are any of those. A plain literal +// is still excess-key checked; through `ref()` or a getter the check does +// not fire - 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. + * Request-side it adds nothing: the inner `$typedFetch.try` call types its own + * options, and its error union is what lands on the `error` ref - so the + * signature is the parent's exactly. + */ +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..b08101b --- /dev/null +++ b/packages/nuxt-typed-handler/src/runtime/types/fetch.ts @@ -0,0 +1,171 @@ +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 a requirement on this file rather than a style: +// `M`'s default references only `R`; anything deriving a method from the +// options lives in an alias default, never in a signature parameter's +// constraint; `MatchedRoutes` is evaluated once per lookup (inside +// `RequestInputOfRoute`); 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 - it degrades to no declared +// inputs. +type InputFor = R extends string + ? RequestInputOfRoute, RouterMethod>> + : never + +/** Required iff `{} extends Input` is false: 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'> + +// An unbranded route keeps vanilla's `body` on every method, so the +// degradation stays key for key; a branded route omits it - neither typed nor +// vanilla - when the resolved method is `get` or `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 + +/** Nitro's typed response for the route, method and explicit `T`. */ +export type Resp = TypedInternalResponse< + R, + T, + Extract, RouterMethod> +> + +/** + * The error one call can produce - the errors parent's reading of the + * known-errors map, which 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: the seam exactly, 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`. + * Defaults are vanilla ofetch options, not route-scoped: 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/index.ts b/packages/nuxt-typed-handler/src/runtime/types/index.ts index 2cd07b8..b86c067 100644 --- a/packages/nuxt-typed-handler/src/runtime/types/index.ts +++ b/packages/nuxt-typed-handler/src/runtime/types/index.ts @@ -1,6 +1,54 @@ +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. + */ +// The map and its lookup live in this file, as the errors parent's own pair +// does, because the emitted template augments this module by its package +// specifier: 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. + */ +// Mirrors the errors parent's `KnownErrorsOfRoute`: `MatchedRoutes` once per +// lookup, every method read through `Lowercase`, and the `default` fallback +// 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, and doubles as the totality + // guard: 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, @@ -11,3 +59,19 @@ export type { 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 ` + 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/search.get.ts b/packages/nuxt-typed-handler/playground/server/api/search.get.ts index 0a973c9..a36c054 100644 --- a/packages/nuxt-typed-handler/playground/server/api/search.get.ts +++ b/packages/nuxt-typed-handler/playground/server/api/search.get.ts @@ -1,7 +1,10 @@ -import { pagination } from '../validation/schemas' +import { pagination, sorting } from '../validation/schemas' -/** A `validate`-only route: the parent's context, the umbrella's failure wire. */ +/** + * A `validate`-only route with a composed (tuple) query: the parent's + * context, the umbrella's failure wire. + */ export default defineTypedEventHandler( - { validate: { query: pagination } }, + { validate: { query: [pagination, sorting] } }, (_event, { query }) => ({ page: query.page, hits: [] as string[] }) ) diff --git a/packages/nuxt-typed-handler/playground/server/validation/schemas.ts b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts index 6bf66e4..52cd356 100644 --- a/packages/nuxt-typed-handler/playground/server/validation/schemas.ts +++ b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts @@ -13,3 +13,13 @@ 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/test/types/request-typing.test.ts b/packages/nuxt-typed-handler/test/types/request-typing.test.ts index c03e1d5..fd8302b 100644 --- a/packages/nuxt-typed-handler/test/types/request-typing.test.ts +++ b/packages/nuxt-typed-handler/test/types/request-typing.test.ts @@ -4,6 +4,7 @@ import type { TypedRequestOptions, ValidationFailed, } from '../../src/runtime/types' +import type { Assert, Equal } from './assert' import type { Forbidden, Item, @@ -21,25 +22,23 @@ import type { * Ported from the ticket 10 prototype with its one divergence flipped: `body` * is omitted on `get`/`head` for **branded** routes only, so an unbranded * route stays `NitroFetchOptions` minus `params`, key for key. + * + * Hand-written because fifty-one routes is the size the stack-depth rules + * have to hold at, and no playground is that. The same rows re-pointed onto + * the map the emitter really writes live in + * `playground/request-typing.check.ts`, over that app's own routes. */ -type Equal = - (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 - ? true - : false - -type Expect = T - // The global is ambient - `/types` declares it, and nothing binds it here. declare const $typedFetch: typeof globalThis.$typedFetch // The fixture really merged into both map interfaces - an augmentation that // silently opened a second, unrelated interface would leave every row below // asserting the degraded reading and still pass. -type _ErrorsMapAugmented = Expect< +type _ErrorsMapAugmented = Assert< Equal > -type _InputsMapAugmented = Expect< +type _InputsMapAugmented = Assert< Equal > @@ -50,7 +49,7 @@ export async function declaredSources(): Promise { body: { name: 'a', age: '30' }, query: { team: 't', page: 2 }, }) - type _resp = Expect> + type _resp = Assert> // @ts-expect-error - body is required on the declared route await $typedFetch('/api/users', { method: 'post', query: { team: 't' } }) @@ -88,17 +87,17 @@ export async function declaredSources(): Promise { /** The method: `get` by default, either case, and it keys the lookup. */ export async function methodSelection(): Promise { const _listed = await $typedFetch('/api/users') - type _get = Expect> + type _get = Assert> const _searched = await $typedFetch('/api/users', { query: { search: 'x' } }) - type _get2 = Expect> + type _get2 = Assert> const _upper = await $typedFetch('/api/users', { method: 'POST', body: { name: 'a', age: '1' }, query: { team: 't' }, }) - type _upperResp = Expect> + type _upperResp = Assert> // @ts-expect-error - excess query key on the get route: an all-optional schema still closes it await $typedFetch('/api/users', { query: { search: 'x', zzz: 1 } }) @@ -129,7 +128,7 @@ export async function rawCarriersRejected(): Promise { /** A `default` (method-less) handler answers every verb it is not keyed for. */ export async function defaultHandler(): Promise { const _got = await $typedFetch('/api/items/42') - type _default = Expect> + type _default = Assert> await $typedFetch('/api/items/42', { method: 'post', body: { qty: 1 } }) // @ts-expect-error - post on the default handler requires the declared body @@ -155,7 +154,7 @@ export async function optionsSurface(): Promise { }) type Headers_ = TypedRequestOptions<'/api/users', 'get'>['headers'] - type _headersVanilla = Expect< + type _headersVanilla = Assert< Equal['headers']> > } @@ -184,27 +183,27 @@ export async function vanillaDegradation(): Promise { await $typedFetch('/api/plain', { method: 'get', body: 'x' }) type VanillaGet = TypedRequestOptions<'/api/plain', 'get'> - type _getKeys = Expect< + type _getKeys = Assert< Equal< keyof VanillaGet, Exclude, 'params'> > > - type _getBody = Expect< + type _getBody = Assert< Equal['body']> > type VanillaPost = TypedRequestOptions<'/api/errors-only', 'post'> - type _postKeys = Expect< + type _postKeys = Assert< Equal< keyof VanillaPost, Exclude, 'params'> > > - type _bodyVanilla = Expect< + type _bodyVanilla = Assert< Equal['body']> > - type _queryVanilla = Expect< + type _queryVanilla = Assert< Equal['query']> > @@ -252,16 +251,16 @@ export async function tryResults(): Promise { if (result.error) { const variant = result.error.data!.data.__knownError__ // The built-in variant rides the errors map: the wrapper's slot carries it. - type _union = Expect> + type _union = Assert> if (variant.tag === 'validation-failed') { - type _issues = Expect< + type _issues = Assert< Equal > } } else { // Narrowed by the sibling guard alone - no second check and no `!`. - type _data = Expect> + type _data = Assert> } } @@ -270,7 +269,7 @@ export async function tryUnionEdges(): Promise { const validateOnly = await $typedFetch.try('/api/users') if (validateOnly.error) { - type _validateOnly = Expect< + type _validateOnly = Assert< Equal< NonNullable['data']['__knownError__'], ValidationFailed @@ -281,7 +280,7 @@ export async function tryUnionEdges(): Promise { const unbranded = await $typedFetch.try('/api/plain') if (unbranded.error) { - type _untyped = Expect> + type _untyped = Assert> } } @@ -305,7 +304,7 @@ export async function createdInstance(): Promise { }) if (viaCreate.error) { - type _createTry = Expect< + type _createTry = Assert< Equal< NonNullable['data']['__knownError__'], Forbidden | ValidationFailed @@ -328,10 +327,10 @@ export async function rawAndExplicitResponse(): Promise { body: { name: 'a', age: '1' }, query: { team: 't' }, }) - type _rawData = Expect> + type _rawData = Assert> const _custom = await $typedFetch<{ custom: true }>('/api/plain') - type _customResp = Expect> + type _customResp = Assert> } it('is asserted by the compiler', () => {}) From 63fd71b6ebd0ecdc091f2db61248f3d02553aec4 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:12:45 +0200 Subject: [PATCH 11/28] test(nuxt-typed-handler): render both maps in a real app's two programs `emitted-map` compiles the emitted text in a temporary tree; this reads it where a consumer does - off the file `nuxt prepare` wrote into the playground, through specifiers the app resolves on its own, in both the app program and the server program. Every claim is a rendering one: an unresolved `import("...")` inside a `.d.ts` produces no diagnostic under `skipLibCheck` and silently becomes `any`, which satisfies any structural assertion. The last block induces exactly that failure by redirecting the one directory every handler specifier traverses, and asserts the harness refuses to answer - and that the playground's own compiler-asserted rows go red with it, which is what makes them load-bearing. Packed-tarball consumer proof (spec 03 section 8.2, deviation D5), run 2026-08-22 from a throwaway app outside the workspace: pnpm --filter ./packages/nuxt-handler-errors pack --pack-destination /tarballs pnpm --filter ./packages/nuxt-handler-validation pack --pack-destination /tarballs pnpm --filter ./packages/nuxt-typed-handler pack --pack-destination /tarballs # consumer package.json: # "@dphonys/nuxt-typed-handler": "file:../tarballs/dphonys-nuxt-typed-handler-0.0.1.tgz" # consumer pnpm-workspace.yaml overrides both parents to their packed tarballs pnpm install --prefer-offline pnpm exec nuxt prepare pnpm exec vue-tsc --noEmit pnpm exec nuxt build PORT=3199 node .output/server/index.mjs Outcome, all green: - the packed umbrella manifest rewrites `workspace:0.3.1` / `workspace:0.1.1` to the literal exact `"0.3.1"` / `"0.1.1"`, as the manifest section requires - `nuxt prepare` wrote `.nuxt/types/nuxt-typed-handler.d.ts` with both `declare module` blocks - all five generated tsconfigs (root, app, node, shared, server) map `@dphonys/nuxt-handler-errors/types`, `@dphonys/nuxt-handler-validation/types` and `@dphonys/nuxt-typed-handler/types` to declaration files that exist - `vue-tsc --noEmit` exit 0 - `nuxt build` complete, and the errors parent is inlined in both outputs: `__knownError__` appears in `.output/public/_nuxt/*.js` and no `from '@dphonys/nuxt-handler-errors...'` survives in `.output/server/**`, so `/internals/app` was transpiled rather than externalised - booting `.output/server/index.mjs`: `POST /api/users` answers 409 `user-exists`; an invalid body answers 400 `validation-failed` carrying the body issue; a valid body answers `{"created":"Ada"}`; SSR of `/` rendered `

user-exists: taken

`, so `useTypedFetch` plus `matchError` read the marker in the browser build too The proof also found the merge gate biting: the registry copies of the pinned parents (`@dphonys/nuxt-handler-errors@0.3.1`, `@dphonys/nuxt-handler-validation@0.1.1`) do not export `./internals/build`, so a consumer resolving them from npm fails at module load with ERR_PACKAGE_PATH_NOT_EXPORTED. The pins must move to the published `0.4.0` / `0.2.0` before this branch merges. Co-Authored-By: Claude Opus 5 --- .../test/e2e/app-program.test.ts | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 packages/nuxt-typed-handler/test/e2e/app-program.test.ts diff --git a/packages/nuxt-typed-handler/test/e2e/app-program.test.ts b/packages/nuxt-typed-handler/test/e2e/app-program.test.ts new file mode 100644 index 0000000..225122f --- /dev/null +++ b/packages/nuxt-typed-handler/test/e2e/app-program.test.ts @@ -0,0 +1,265 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { assertNoDiagnostics, compileForHover } from '../types/compile-harness' +import type { Compilation, Diagnostic } from '../types/compile-harness' + +// Both maps rendered through a real app's programs - the half `emitted-map` +// defers, and the only place the two slots are read the way a consumer reads +// them: off the file `nuxt prepare` wrote, through specifiers the app resolves +// on its own. The claims are *rendering* ones on purpose: an unresolved +// `import("…")` inside a `.d.ts` produces no diagnostic under `skipLibCheck` +// (which every generated tsconfig sets) and silently becomes `any`, satisfying +// every structural assertion. The broken-specifier block at the bottom is that +// failure, induced. + +const PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const PLAYGROUND = join(PACKAGE_ROOT, 'playground') + +/** Where the map lands - restated, not imported, for `generated-map`'s reason. */ +const MAP_PATH = join(PLAYGROUND, '.nuxt/types/nuxt-typed-handler.d.ts') + +// The probes live inside `.nuxt/`, a dot-directory TypeScript's wildcard +// expansion skips - invisible to `vue-tsc`, eslint and knip, reaching a +// program only as this harness's explicit root file. +const APP_PROBE = join(PLAYGROUND, '.nuxt/typed-app-probe.ts') +const SERVER_PROBE = join(PLAYGROUND, '.nuxt/typed-server-probe.ts') + +const APP_TSCONFIG = join(PLAYGROUND, '.nuxt/tsconfig.json') +const SERVER_TSCONFIG = join(PLAYGROUND, '.nuxt/tsconfig.server.json') + +/** The app program's call sites, as a consumer writes them. */ +const APP_PROBE_SOURCE = [ + `import type { KnownApiErrors } from '@dphonys/nuxt-handler-errors/types'`, + `import type { KnownApiRequestInputs } from '@dphonys/nuxt-typed-handler/types'`, + `import { useTypedFetch } from '#imports'`, + ``, + // The route declaring both halves: one key in each map, read one at a time. + `export type BothErrors = KnownApiErrors['/api/users']['post']`, + `export type BothInput = KnownApiRequestInputs['/api/users']['post']`, + ``, + // The `validate`-only route, whose query is composed from two schemas. + `export type TupleQueryInput = KnownApiRequestInputs['/api/search']['get']`, + `export type ValidateOnlyErrors = KnownApiErrors['/api/search']['get']`, + ``, + // Unbranded, and keyed anyway - what makes both lookups total. + `export type UnbrandedErrors = KnownApiErrors['/api/legacy']['get']`, + `export type UnbrandedInput = KnownApiRequestInputs['/api/legacy']['get']`, + ``, + `const fetched = await useTypedFetch('/api/users', {`, + ` method: 'post',`, + ` body: { name: 'Ada', email: 'ada@example.com' },`, + `})`, + `export type ComposableTags =`, + ` NonNullable['data']>['data']['__knownError__']['tag']`, + ``, + `const tried = await $typedFetch.try('/api/users', {`, + ` method: 'post',`, + ` body: { name: 'Ada', email: 'ada@example.com' },`, + `})`, + `export type TryResult = typeof tried`, + ``, + // The tag union, extracted: `TryResult` renders the two arms but stops at + // the emitter's own `Simplify>` alias, which says nothing + // about what is inside it. + `export type TryTags =`, + ` NonNullable['data']>['data']['__knownError__']['tag']`, + ``, +].join('\n') + +// The server program's call site. The app probe cannot stand in for it: the +// app program types `H3Event` too, so a render there would say nothing about +// the program a handler author writes in. +const SERVER_PROBE_SOURCE = [ + `import { defineEventHandler } from 'h3'`, + ``, + `const probe = defineEventHandler(async (event) =>`, + ` event.$typedFetch.try('/api/users', {`, + ` method: 'post',`, + ` body: { name: 'Ada', email: 'ada@example.com' },`, + ` }))`, + ``, + `const tried = await probe({} as never)`, + `export type EventTryTags =`, + ` NonNullable['data']>['data']['__knownError__']['tag']`, + ``, +].join('\n') + +/** The playground's own compiler-asserted call sites, over the same map. */ +const REQUEST_TYPING_CHECK = join(PLAYGROUND, 'request-typing.check.ts') + +/** TS2578 - "Unused '@ts-expect-error' directive". */ +const UNUSED_TS_EXPECT_ERROR = 2578 + +/** What `/api/users` on `post` really declares, in the playground. */ +const DECLARED_TAGS = ['user-exists', 'validation-failed'] + +/** + * One file's own diagnostics out of the app program. The harness's + * `assertNoDiagnostics` speaks for the whole program, which is the right + * reading while the map is intact and the wrong one once it is broken on + * purpose: the breakage is meant to reach the app's call sites. + */ +function diagnosticsOf(file: string): readonly Diagnostic[] { + return appProgram().diagnostics.filter( + (one) => one.fileName === file.replaceAll('\\', '/') + ) +} + +/** The app program, rooted at the probe. Recompiled per call: the last block + * rewrites the map underneath it, so a cached one would answer for the wrong + * state of the file. */ +function appProgram(): Compilation { + return compileForHover(APP_TSCONFIG, APP_PROBE) +} + +beforeAll(() => { + execFileSync( + join(PACKAGE_ROOT, 'node_modules/.bin/nuxt'), + ['prepare', PLAYGROUND], + { cwd: PACKAGE_ROOT, stdio: 'pipe' } + ) + + writeFileSync(APP_PROBE, APP_PROBE_SOURCE) + writeFileSync(SERVER_PROBE, SERVER_PROBE_SOURCE) +}, 300_000) + +afterAll(() => { + rmSync(APP_PROBE, { force: true }) + rmSync(SERVER_PROBE, { force: true }) +}) + +describe('both maps, rendered in the app program', () => { + // `vue-tsc` covers the app's `.vue` files under `pnpm typecheck`; a + // TypeScript program does not read them, so this speaks for the `.ts` half + // of the app plus the probe. + it('typechecks the app program the probe is rooted in', () => { + assertNoDiagnostics(appProgram()) + }) + + it('reads the errors slot serialised, with this route’s real tags', () => { + const rendered = appProgram().renderHover('BothErrors') + + for (const tag of DECLARED_TAGS) expect(rendered).toContain(`"${tag}"`) + expect(rendered).toContain('email: string') + // The built-in variant's own shape, carried by the declaring wrapper. + expect(rendered).toContain('status: 400') + }) + + it('reads the request-inputs slot with no Serialize at all', () => { + const compilation = appProgram() + + expect(compilation.renderHover('BothInput')).toContain('body:') + expect(compilation.renderHover('BothInput')).toContain('name: string') + + // The input side of a composed query: `page` before its transform to + // `number`, intersected with the second element's optional key. + const tupled = compilation.renderHover('TupleQueryInput') + + expect(tupled).toContain('query:') + expect(tupled).toContain('page: string') + expect(tupled).toContain('sort?:') + }) + + it('keeps each map to its own slot, on the route that declares both', () => { + const compilation = appProgram() + + // The failure this catches is one slot's extractor emitted under the + // other's interface - which type-checks, and is silently wrong. + expect(compilation.renderHover('BothErrors')).not.toContain('body:') + expect(compilation.renderHover('BothInput')).not.toContain('tag:') + }) + + it('answers a `validate`-only route with the built-in variant alone', () => { + const rendered = appProgram().renderHover('ValidateOnlyErrors') + + expect(rendered).toContain('"validation-failed"') + expect(rendered).not.toContain('"user-exists"') + }) + + it('extracts nothing from an unbranded route in either map', () => { + const compilation = appProgram() + + expect(compilation.renderHover('UnbrandedErrors')).toBe('never') + expect(compilation.renderHover('UnbrandedInput')).toBe('never') + }) + + it('carries the union into the composable’s error ref', () => { + const rendered = appProgram().renderHover('ComposableTags') + + for (const tag of DECLARED_TAGS) expect(rendered).toContain(`"${tag}"`) + }) + + it('gives `.try` two arms, with the union on the failing one', () => { + const rendered = appProgram().renderHover('TryResult') + + // The discriminated union: `if (error) return` narrows the sibling. + expect(rendered).toContain('error: undefined') + // The success arm is Nitro's own response type - plain, no `| undefined`. + expect(rendered).toContain('created: string') + }) + + it('types the failing arm from this route’s two declared tags', () => { + const rendered = appProgram().renderHover('TryTags') + + for (const tag of DECLARED_TAGS) expect(rendered).toContain(`"${tag}"`) + }) +}) + +describe('both maps, rendered in the server program', () => { + it('typechecks the server program, the probe included', () => { + assertNoDiagnostics(compileForHover(SERVER_TSCONFIG, SERVER_PROBE)) + }) + + it('carries the callee’s real tags into `event.$typedFetch.try`', () => { + const rendered = compileForHover(SERVER_TSCONFIG, SERVER_PROBE).renderHover( + 'EventTryTags' + ) + + for (const tag of DECLARED_TAGS) expect(rendered).toContain(`"${tag}"`) + }) +}) + +describe('the lookup, over a map whose specifiers resolve to nothing', () => { + let original: string + + beforeAll(() => { + original = readFileSync(MAP_PATH, 'utf8') + + // Every handler specifier in the map is relative to `.nuxt/types`, so + // redirecting the one directory they all traverse points every one of + // them at nothing - exactly what emitting the file one level too high + // would do. + writeFileSync( + MAP_PATH, + original.replaceAll(`'../../server/`, `'../../nowhere/`) + ) + }) + + afterAll(() => { + writeFileSync(MAP_PATH, original) + }) + + it('is swallowed whole: the probe still compiles clean', () => { + // The probe's own diagnostics, not the program's: the app's compiler- + // asserted call sites do notice, and the next case is that claim. + expect(diagnosticsOf(APP_PROBE)).toEqual([]) + }) + + it('is caught by the rendering assertion, which is why one is used', () => { + const compilation = appProgram() + + expect(() => compilation.renderHover('BothErrors')).toThrow(/`any`/) + expect(() => compilation.renderHover('BothInput')).toThrow(/`any`/) + }) + + it('takes the playground’s re-pointed request-typing rows down with it', () => { + // Those rows read this same file, so a map that resolves to nothing turns + // every `@ts-expect-error` in them into an unused directive (TS2578). + expect( + diagnosticsOf(REQUEST_TYPING_CHECK).map((one) => one.code) + ).toContain(UNUSED_TS_EXPECT_ERROR) + }) +}) From 3b9690ec21666ba0763a218da89e9b8429c62725 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:12:56 +0200 Subject: [PATCH 12/28] test(nuxt-typed-handler): prove one handler carries both parents' brands The generated map hands the *same* handler type to `KnownErrorsOfHandler` and to `RequestInputOfHandler` and expects two different answers, so the slots have to intersect without leaking into one another. Asserted both ways: each parent's own wrapper answers `never` to the other parent's extractor, a `validate`-only route answers exactly the built-in variant to the errors extractor, and an `errors`-only route answers the empty record to the request extractor. That last row is where the spec and the built code part: section 8.3 calls for `never`, but the wrapper computes `RequestInput<{}>`, which is `{}` - and the emitted map already renders it that way. `never` is what a handler neither parent branded answers, which the sibling row covers. The assertion follows the code. `assert.ts` is the validation parent's, copied as section 8 asks; the two suites that had spelled the pair inline now read it from there. Co-Authored-By: Claude Opus 5 --- .../nuxt-typed-handler/test/types/assert.ts | 15 ++ .../test/types/brand-intersection.test.ts | 184 ++++++++++++++++++ .../test/types/composables.test.ts | 20 +- 3 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 packages/nuxt-typed-handler/test/types/assert.ts create mode 100644 packages/nuxt-typed-handler/test/types/brand-intersection.test.ts diff --git a/packages/nuxt-typed-handler/test/types/assert.ts b/packages/nuxt-typed-handler/test/types/assert.ts new file mode 100644 index 0000000..e0ba81d --- /dev/null +++ b/packages/nuxt-typed-handler/test/types/assert.ts @@ -0,0 +1,15 @@ +/** + * The type-assertion helpers the compiler-asserted suites share. `Equal` is the + * invariant-position trick, so it distinguishes widenings a bare `extends` pair + * would call equal. + * + * Not a Vitest test file, so it is not matched by vitest's `include`; knip + * reaches it through the suites importing it. + */ + +export type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false + +export type Assert = T diff --git a/packages/nuxt-typed-handler/test/types/brand-intersection.test.ts b/packages/nuxt-typed-handler/test/types/brand-intersection.test.ts new file mode 100644 index 0000000..40876bd --- /dev/null +++ b/packages/nuxt-typed-handler/test/types/brand-intersection.test.ts @@ -0,0 +1,184 @@ +import type { + CheckedEventHandler, + KnownErrorsOfHandler, +} from '@dphonys/nuxt-handler-errors/types' +import type { + RequestInputOfHandler, + ValidatedEventHandler, +} from '@dphonys/nuxt-handler-validation/types' +import type { EventHandlerRequest, EventHandlerResponse } from 'h3' +import { it } from 'vitest' +import { z } from 'zod' +import type { ValidationFailed } from '../../src/runtime/types' +import type { Assert, Equal } from './assert' + +/** + * Where the two parents meet: one typed handler carrying both phantom slots, + * and each parent's extractor reading only the slot it owns. This is the + * claim the generated map rests on - the emitter hands the *same* handler + * type to `KnownErrorsOfHandler` and to `RequestInputOfHandler`, and the two + * must answer different questions about it. + * + * Asserted by the compiler under `pnpm typecheck`. Every wrapper is declared + * rather than imported: the runtime modules reach for the parents' internals, + * which only resolve inside a build. `typeof import(…)` is a type query - it + * asserts the real declaration and emits no import. + */ + +declare const defineTypedEventHandler: typeof import('../../src/runtime/server/lib/typed-handler').defineTypedEventHandler +declare const defineCheckedEventHandler: typeof import('@dphonys/nuxt-handler-errors/server').defineCheckedEventHandler +declare const defineValidatedEventHandler: typeof import('@dphonys/nuxt-handler-validation/server').defineValidatedEventHandler +declare const defineError: typeof import('@dphonys/nuxt-handler-errors/server').defineError +declare const payload: typeof import('@dphonys/nuxt-handler-errors/server').payload + +/** `[A] extends [B]`, so a union on the left is answered whole. */ +type Extends = [A] extends [B] ? true : false + +/** + * Each member's own properties, flattened. The errors parent composes a + * declared variant as an intersection, and `Equal` - invariant on purpose - + * reads an intersection and its flat twin as different types. + */ +type Flatten = T extends unknown ? { [K in keyof T]: T[K] } : never + +const createUser = z.object({ name: z.string() }) +const pagination = z.object({ page: z.string().transform(Number) }) + +// Never called: `declare const` binds no value. The handler *types* are what +// this suite is about, and `ReturnType` is how they are named without one. + +export function bothDeclared() { + const userErrors = defineError({ + 'user-exists': { status: 409, payload: payload<{ email: string }>() }, + }) + + return defineTypedEventHandler( + { + validate: { body: createUser, query: pagination }, + errors: userErrors.pick('user-exists'), + }, + (_event, { body, fail }) => + body.name === '' ? fail('user-exists', { email: '' }) : { ok: true } + ) +} + +export function validateOnly() { + return defineTypedEventHandler( + { validate: { query: pagination } }, + (_event, { query }) => ({ page: query.page }) + ) +} + +export function errorsOnly() { + const userErrors = defineError({ 'user-not-found': { status: 404 } }) + + return defineTypedEventHandler( + { errors: userErrors.pick('user-not-found') }, + (_event, { fail }) => fail('user-not-found') + ) +} + +/** The errors parent's own wrapper: one slot, and only one. */ +export function parentChecked() { + const userErrors = defineError({ gone: { status: 410 } }) + + return defineCheckedEventHandler( + { errors: userErrors.pick('gone') }, + (_event, { fail }) => fail('gone') + ) +} + +/** The validation parent's own wrapper: the other slot, and only that one. */ +export function parentValidated() { + return defineValidatedEventHandler( + { validate: { body: createUser } }, + (_event, { body }) => ({ name: body.name }) + ) +} + +type BothHandler = ReturnType +type ValidateOnlyHandler = ReturnType +type ErrorsOnlyHandler = ReturnType +type ParentCheckedHandler = ReturnType +type ParentValidatedHandler = ReturnType + +/** What a route that declared no source computes as its Request input. */ +interface EmptyInput {} + +/** The declared failure, as the errors parent computes it from the tuple. */ +interface UserExists { + tag: 'user-exists' + status: 409 + email: string +} + +// --- one handler, both brands ---------------------------------------------- + +type _isCheckedHandler = Assert< + Extends< + BothHandler, + CheckedEventHandler< + EventHandlerRequest, + EventHandlerResponse, + UserExists | ValidationFailed + > + > +> + +type _isValidatedHandler = Assert< + Extends< + BothHandler, + ValidatedEventHandler< + EventHandlerRequest, + EventHandlerResponse, + { body: { name: string }; query: { page: string } } + > + > +> + +// --- each extractor reads its own slot, and nothing else -------------------- + +type _errorsSlot = Assert< + Equal< + Flatten>, + Flatten + > +> + +type _inputSlot = Assert< + Equal< + RequestInputOfHandler, + { body: { name: string }; query: { page: string } } + > +> + +// The failure this catches is one parent's extractor keying on the other's +// slot: it would answer here, where it must not. +type _checkedCarriesNoInput = Assert< + Equal, never> +> + +type _validatedCarriesNoErrors = Assert< + Equal, never> +> + +// --- one negative per slot ------------------------------------------------- + +/** A `validate`-only route can only fail the one way, and says so. */ +type _validateOnlyErrors = Assert< + Equal, ValidationFailed> +> + +/** + * An `errors`-only route declares no source, so its input is the empty + * record - not `never`, which is what a handler this parent never branded + * answers (`_checkedCarriesNoInput` above). The emitted map settles which: + * `emitted-map.test.ts` renders this slot as `{}`. Spec 03 section 8.3 says + * `never` for this row; the built code says `{}`, and the assertion follows + * the code. + */ +type _errorsOnlyInput = Assert< + Equal, EmptyInput> +> + +it('is asserted by the compiler', () => {}) diff --git a/packages/nuxt-typed-handler/test/types/composables.test.ts b/packages/nuxt-typed-handler/test/types/composables.test.ts index a17bb88..a3f6a93 100644 --- a/packages/nuxt-typed-handler/test/types/composables.test.ts +++ b/packages/nuxt-typed-handler/test/types/composables.test.ts @@ -1,6 +1,7 @@ import { it } from 'vitest' import { ref } from 'vue' import type { ValidationFailed } from '../../src/runtime/types' +import type { Assert, Equal } from './assert' import type { Forbidden, UserCreated, UserList } from './request-routes' /** @@ -10,13 +11,6 @@ import type { Forbidden, UserCreated, UserList } from './request-routes' * reactive; what is asserted here is the reactive re-adding and the two refs. */ -type Equal = - (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 - ? true - : false - -type Expect = T - // Declared rather than imported: these live behind `#app`, which does not // resolve under a plain `vitest run`. `typeof import(…)` is a type query - it // asserts the real declaration and emits no import. @@ -32,12 +26,12 @@ export function refTypes(): void { body: { name: 'a', age: '1' }, query: { team: 't' }, }) - type _data = Expect< + type _data = Assert< Equal > if (_created.error.value) { - type _error = Expect< + type _error = Assert< Equal< NonNullable['data']['__knownError__'], Forbidden | ValidationFailed @@ -46,11 +40,11 @@ export function refTypes(): void { } const _listed = useTypedFetch('/api/users') - type _get = Expect> + type _get = Assert> // The lazy twin carries the same signature; only `lazy` is pre-set. const _lazy = useLazyTypedFetch('/api/users') - type _lazyData = Expect> + type _lazyData = Assert> } /** Each typed source is accepted plain, through a `ref`, or through a getter. */ @@ -114,10 +108,10 @@ export async function asyncDataUnion(): Promise { }) ) - type _asyncData = Expect> + type _asyncData = Assert> if (error.value) { - type _error = Expect< + type _error = Assert< Equal< NonNullable['data']['__knownError__'], Forbidden | ValidationFailed From c5e21cbdfccff94af1f47f5292fc6cb086dfd148 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:41:46 +0200 Subject: [PATCH 13/28] docs(nuxt-typed-handler): make the README the one door a consumer adopts from Fifteen sections in the shape ticket 14 settled: the model taught in this package's own words, one example per parent concept and no parent sample restated, so a reader never has to translate `defineCheckedEventHandler` in their head. Every rule this module enforces is stated here; every rationale stays in the parent that owns it and is linked. The migration section is the whole of ticket 13 - the rename table, an identifier-exact one-liner (bare `Checked` / `Validated` would hit the kept names), the three things that are not renames, and the order to do them in. Its "Unchanged" row names `KnownErrorsOfRoute` where the ticket wrote `CheckedHeaders`: no such export exists in either parent as built. Troubleshooting quotes the three diagnostics verbatim - they are public surface, and `module-setup` and `typed-handler` assert the same strings. The API reference lists only what a door actually exports, which is why the five composables are called out as app-side auto-imports belonging to no entry, and why the parent's `$checkedFetch` ambient types get their one-line note. Co-Authored-By: Claude Opus 5 (1M context) --- packages/nuxt-typed-handler/README.md | 620 +++++++++++++++++++++++++- 1 file changed, 616 insertions(+), 4 deletions(-) diff --git a/packages/nuxt-typed-handler/README.md b/packages/nuxt-typed-handler/README.md index eaa336e..25ba6f9 100644 --- a/packages/nuxt-typed-handler/README.md +++ b/packages/nuxt-typed-handler/README.md @@ -1,10 +1,609 @@ -# @dphonys/nuxt-typed-handler +# Nuxt Typed Handler Declare a Nitro handler's request schemas and expected failures once, and get -both typed at every call site. One module installed _instead of_ -`@dphonys/nuxt-handler-errors` and `@dphonys/nuxt-handler-validation`. +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`. -Documentation lands with the package's first release. +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, from its own entries, so an app imports everything from one +package. + +## Installation + +```sh +pnpm add @dphonys/nuxt-typed-handler +``` + +```ts +export default defineNuxtConfig({ + modules: ['@dphonys/nuxt-typed-handler'], + typedHandler: { + channelToken: 'my-app', + }, +}) +``` + +**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+. Bring your +own schema library - anything implementing [Standard +Schema](https://standardschema.dev) works, and nothing is bundled for you. + +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. + +**There is no off-switch.** `typedHandler` is a flat bag with exactly one key - +a stray key is a compile error, and there is no `typedHandler: false`. To turn +the module off, remove `'@dphonys/nuxt-typed-handler'` from `modules`. + +**Never list a parent beside it.** A project registers this module _or_ the two +parents. Registering both throws at startup - see +[Troubleshooting](#troubleshooting). + +## Coming from `nuxt-handler-errors` / `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)|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' -e 's/\bhandlerErrors\b/typedHandler/g' -e 's/\bhandlerValidation\b/typedHandler/g' +``` + +### 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 + +There is no `typedHandler: false`. To turn the module off, remove it from +`modules`. + +### 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. + +## 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. + +## 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 + +`useTypedFetch`, `useLazyTypedFetch`, `useRequestTypedFetch`, +`useTypedAsyncData` and `useLazyTypedAsyncData` are auto-imported in app code, +and `defineTypedEventHandler`, `defineError`, `payload`, `recognizeKnownError` +and `recognizeValidationError` inside `server/`. `$typedFetch` is a global, +like `$fetch`. `matchError` is imported, because it is used in `shared/` too. + +- **`$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`. + +## 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. + +## 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 @@ -18,6 +617,19 @@ 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 From fbc5f980de5db178ab49fa86875f1ab4274fd343 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:41:57 +0200 Subject: [PATCH 14/28] test(nuxt-typed-handler): keep each runtime door out of the other's build The parents' walker, ported to this package's table: the server door must not reach `@nuxt/kit` or `#app`, the shared door must not reach Nitro's runtime either, nothing under `app/` may reach `@nuxt/kit` or `nitropack/runtime`, and no runtime file at all may reach the errors parent's `/internals/build` - the one graph rollup bundles into `module.ts` and mkdist must never copy. Two rows are directories rather than single entries, so a break is keyed by the file that pulled the graph in. Asserted on `src/`, not on `dist`, so it needs no build and the failure lands on the import that caused it. Unlike the parents nothing forbids `#nuxt-typed-handler/channel-token`: this package is the binding layer, and the internals only ever receive the token as a value. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/unit/layering.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 packages/nuxt-typed-handler/test/unit/layering.test.ts diff --git a/packages/nuxt-typed-handler/test/unit/layering.test.ts b/packages/nuxt-typed-handler/test/unit/layering.test.ts new file mode 100644 index 0000000..a249ce6 --- /dev/null +++ b/packages/nuxt-typed-handler/test/unit/layering.test.ts @@ -0,0 +1,123 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +// Asserted on the source rather than on `dist`, so the failure lands on the +// import that broke it and needs no build to run. + +const PACKAGE = fileURLToPath(new URL('../../', import.meta.url)) + +interface Row { + /** A file, or a directory every `.ts` under which is walked. */ + readonly entry: string + readonly forbidden: readonly string[] +} + +// Unlike the parents, no row forbids `#nuxt-typed-handler/channel-token`: this +// package *is* the binding layer, so every runtime file that needs the token +// reads it from its own alias, and the parents' internals only ever receive it +// as a value. +const ROWS: readonly Row[] = [ + { + entry: 'src/runtime/server/index.ts', + forbidden: ['@nuxt/kit', '#app'], + }, + { + entry: 'src/runtime/shared/index.ts', + forbidden: ['@nuxt/kit', 'nitropack/runtime', '#app'], + }, + { + entry: 'src/runtime/app', + forbidden: ['@nuxt/kit', 'nitropack/runtime'], + }, + // The build helpers are bundled by rollup from `src/module.ts` and reach + // `@nuxt/kit` freely; no runtime file may pull that graph in behind them. + { + entry: 'src/runtime', + forbidden: ['@dphonys/nuxt-handler-errors/internals/build'], + }, +] + +// Extensionless first, so a specifier that already carries `.ts` wins over a +// same-named directory. +const CANDIDATE_SUFFIXES = ['', '.ts', '/index.ts'] + +/** The row's own files: one named entry, or every `.ts` under a directory. */ +function filesOf(entry: string): string[] { + const target = resolve(PACKAGE, entry) + + if (statSync(target).isFile()) return [target] + + return readdirSync(target, { recursive: true, withFileTypes: true }) + .filter((item) => item.isFile() && item.name.endsWith('.ts')) + .map((item) => resolve(item.parentPath, item.name)) + .toSorted() +} + +/** Every bare (non-relative) specifier the entry reaches, transitively. */ +function bareSpecifiersReachableFrom(entry: string): string[] { + const visited = new Set() + const bare = new Set() + const queue = [entry] + + while (queue.length > 0) { + const file = queue.pop() + if (file === undefined || visited.has(file)) continue + visited.add(file) + + // `preProcessFile` reads the import graph without building a program, and + // reports `import type` alongside value imports - a type import is one edit + // away from a value import, so it counts here. + const scanned = ts.preProcessFile(readFileSync(file, 'utf8'), true, true) + + for (const { fileName: specifier } of scanned.importedFiles) { + if (!specifier.startsWith('.')) { + bare.add(specifier) + continue + } + + const target = resolveRelative(dirname(file), specifier) + if (target === undefined) { + throw new Error(`Could not resolve "${specifier}" from ${file}.`) + } + + queue.push(target) + } + } + + return [...bare].toSorted() +} + +function resolveRelative(from: string, specifier: string): string | undefined { + const base = resolve(from, specifier) + return CANDIDATE_SUFFIXES.map((suffix) => `${base}${suffix}`).find( + (candidate) => statSync(candidate, { throwIfNoEntry: false })?.isFile() + ) +} + +/** `@nuxt/kit` and `@nuxt/kit/…` alike. */ +function matches(specifier: string, forbidden: string): boolean { + return specifier === forbidden || specifier.startsWith(`${forbidden}/`) +} + +describe.each(ROWS)('the $entry layer', ({ entry, forbidden }) => { + it(`reaches none of ${forbidden.join(', ')}, as a value or as a type`, () => { + // Keyed by file, so a break names the file that pulled the graph in + // rather than only the specifier it reached. + const violations = filesOf(entry) + .map( + (file) => + [ + relative(PACKAGE, file), + bareSpecifiersReachableFrom(file).filter((specifier) => + forbidden.some((banned) => matches(specifier, banned)) + ), + ] as const + ) + .filter(([, reached]) => reached.length > 0) + + expect(Object.fromEntries(violations)).toEqual({}) + }) +}) From f364cf46b5244c99f49e9e5136eac311e587ebe5 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:42:05 +0200 Subject: [PATCH 15/28] chore(nuxt-typed-handler): seed the debut version at 0.1.0 The registry-absent package debuts at its manifest version - pnpm consumes the first intent without an extra bump - and the initial consumer contract should read as 0.1.0 rather than 0.0.1, as both parents' debuts did. Co-Authored-By: Claude Opus 5 (1M context) --- packages/nuxt-typed-handler/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json index c86eb8a..ef69288 100644 --- a/packages/nuxt-typed-handler/package.json +++ b/packages/nuxt-typed-handler/package.json @@ -1,6 +1,6 @@ { "name": "@dphonys/nuxt-typed-handler", - "version": "0.0.1", + "version": "0.1.0", "private": true, "description": "Declare a Nitro handler's request schemas and expected failures once, and get both typed at every call site.", "keywords": [ From af2983fa5aa20ca4531dd5a20ccb3ed05113b83f Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:42:16 +0200 Subject: [PATCH 16/28] chore(nuxt-typed-handler): admit the package with its first release intent Removing `private` is the whole admission transition, and the bootstrap runbook wants it in the same commit as the first intent. The prospective tarball was inspected while the package was still private: `dist/**` plus LICENSE, package.json and README.md, a runtime and a type entry for each of the four exported subpaths, no source, tests or playground, and publint clean. The name is absent from the registry. `pnpm change status` reads `0.1.0 -> 0.1.0 (minor, via dependencies+intent)`: the seeded version is what publishes. The pins on both parents stay at the current workspace versions until their Release commit lands - `pnpm version -r` rewrites them, and pack turns `workspace:` into a literal exact version in the published manifest. That bump is this branch's merge gate. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/dull-pens-love.md | 5 +++++ packages/nuxt-typed-handler/package.json | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/dull-pens-love.md 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/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json index ef69288..01c096e 100644 --- a/packages/nuxt-typed-handler/package.json +++ b/packages/nuxt-typed-handler/package.json @@ -1,7 +1,6 @@ { "name": "@dphonys/nuxt-typed-handler", "version": "0.1.0", - "private": true, "description": "Declare a Nitro handler's request schemas and expected failures once, and get both typed at every call site.", "keywords": [ "nuxt", From 2cf5ae556cb3b4048e054dc03109e0aa3b4a3ea1 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:42:27 +0200 Subject: [PATCH 17/28] docs(nuxt-handler-errors): send a two-package install to the umbrella One sentence under Installation. Anyone reaching for this package *and* nuxt-handler-validation wants @dphonys/nuxt-typed-handler instead, and its migration section is where that story is told. Nothing else changes: hand composition stays first-class and no prose here becomes false. Co-Authored-By: Claude Opus 5 (1M context) --- packages/nuxt-handler-errors/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/nuxt-handler-errors/README.md b/packages/nuxt-handler-errors/README.md index 4c67002..05d246e 100644 --- a/packages/nuxt-handler-errors/README.md +++ b/packages/nuxt-handler-errors/README.md @@ -21,6 +21,10 @@ The module has one option, `channelToken` - see counterpart, and nothing about a route's failures is configured - it is declared, in the route. +Using both this package and `nuxt-handler-validation`? Install +`@dphonys/nuxt-typed-handler` instead - see its +[_Coming from…_ section](https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-typed-handler/README.md#coming-from-nuxt-handler-errors--nuxt-handler-validation). + ## Declaring what a route can fail with ```ts From fe3aede64cafa83d197823f73c33269f358e360a Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 21:42:28 +0200 Subject: [PATCH 18/28] docs(nuxt-handler-validation): send a two-package install to the umbrella One sentence under Installation, mirroring the sibling package's. Anyone reaching for this package *and* nuxt-handler-errors wants @dphonys/nuxt-typed-handler instead, and its migration section is where that story is told. Nothing else changes - including the note that this package pairs with the sibling's recognizeKnownError in one hook, which stays true. Co-Authored-By: Claude Opus 5 (1M context) --- packages/nuxt-handler-validation/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/nuxt-handler-validation/README.md b/packages/nuxt-handler-validation/README.md index 03964e1..29d7d44 100644 --- a/packages/nuxt-handler-validation/README.md +++ b/packages/nuxt-handler-validation/README.md @@ -30,6 +30,10 @@ Bring your own schema library. Anything implementing Standard Schema works - **Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+. +Using both this package and `nuxt-handler-errors`? Install +`@dphonys/nuxt-typed-handler` instead - see its +[_Coming from…_ section](https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-typed-handler/README.md#coming-from-nuxt-handler-errors--nuxt-handler-validation). + ## Quick start ```ts From 57d6dc2fac5c0e80115f41262d7bd9bdac74d35e Mon Sep 17 00:00:00 2001 From: dphonys Date: Sat, 22 Aug 2026 22:02:19 +0200 Subject: [PATCH 19/28] build(nuxt-typed-handler): keep a sibling's rebuild out of the umbrella's typecheck `typecheck` only depended on the package's own `build`, so the three packages' typecheck tasks ran concurrently. Each one re-runs `nuxt-module-build build` through its `pretypecheck`, which clears and rewrites `dist/`. That left the errors parent's `dist/internals/build.d.mts` missing for the seconds between its `.mjs` bundles and its declarations being written. The umbrella reads that file. When it is absent, TypeScript falls back to the `import` condition and infers the module from `build.mjs` under `allowJs`: the value exports resolve, the type-only ones do not, and `type-map.ts` fails with TS2305 on `EmitMapSlot` and `NitroPathOptions` plus a consequent TS7006. The parent's contract was never at fault - `src/internals/build.ts` and `INTERNALS.md` both carry those exports, and so does the finished `.d.mts`. It surfaced now because the two README commits invalidated both parents' `typecheck` cache entries, so their rebuilds ran for real beside the umbrella's typecheck instead of being restored from cache. Ordering `typecheck` behind `^typecheck` lets a dependency finish rewriting its `dist/` before a dependent reads it. The two independent parents still typecheck in parallel. Co-Authored-By: Claude Opus 5 --- turbo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index 3d183fa..8a181f6 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,7 @@ "cache": false }, "typecheck": { - "dependsOn": ["build"] + "dependsOn": ["build", "^typecheck"] }, "lint": { "outputs": [] From 91d2e2fa92cd09a2dd77dd546b56c3516121a883 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 12:17:51 +0200 Subject: [PATCH 20/28] build(nuxt-typed-handler): pin both parents at their published 0.4.0 and 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge gate from spec 03 §10 is satisfied: PRs #10–#14 are merged and @dphonys/nuxt-handler-errors@0.4.0 and @dphonys/nuxt-handler-validation@0.2.0 are on npm, so the exact workspace pins move from 0.3.1/0.1.1 to the published versions. pnpm pack still rewrites them to literal exact versions. Co-Authored-By: Claude Fable 5 --- packages/nuxt-typed-handler/package.json | 4 ++-- pnpm-lock.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json index 01c096e..a271cb2 100644 --- a/packages/nuxt-typed-handler/package.json +++ b/packages/nuxt-typed-handler/package.json @@ -73,8 +73,8 @@ "typecheck": "nuxt prepare playground && vue-tsc --noEmit && vue-tsc --noEmit --project playground/tsconfig.json" }, "dependencies": { - "@dphonys/nuxt-handler-errors": "workspace:0.3.1", - "@dphonys/nuxt-handler-validation": "workspace:0.1.1", + "@dphonys/nuxt-handler-errors": "workspace:0.4.0", + "@dphonys/nuxt-handler-validation": "workspace:0.2.0", "@nuxt/kit": "catalog:", "h3": "catalog:", "nitropack": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66911ad..8bddf65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,10 +269,10 @@ importers: packages/nuxt-typed-handler: dependencies: '@dphonys/nuxt-handler-errors': - specifier: workspace:0.3.1 + specifier: workspace:0.4.0 version: link:../nuxt-handler-errors '@dphonys/nuxt-handler-validation': - specifier: workspace:0.1.1 + specifier: workspace:0.2.0 version: link:../nuxt-handler-validation '@nuxt/kit': specifier: 'catalog:' From 6c424ac86e04d85ddc26ce3a12fbff30ef612e5d Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 13:26:03 +0200 Subject: [PATCH 21/28] test: keep every package's vitest config in one shared shape The three per-package tier comments had drifted into three different descriptions of the same setup, so a reader could not tell whether the configs actually differed. Drop the header comments and unify the one remaining `fileParallelism` note, leaving the alias list as the only real difference between the files. Co-Authored-By: Claude Opus 5 (1M context) --- packages/nuxt-handler-errors/vitest.config.ts | 12 ++---------- packages/nuxt-handler-validation/vitest.config.ts | 4 ---- packages/nuxt-typed-handler/vitest.config.ts | 7 ------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/nuxt-handler-errors/vitest.config.ts b/packages/nuxt-handler-errors/vitest.config.ts index 221917a..77a9ab8 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$/, @@ -50,9 +43,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/vitest.config.ts b/packages/nuxt-handler-validation/vitest.config.ts index 35f82bf..36a3d55 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: [ diff --git a/packages/nuxt-typed-handler/vitest.config.ts b/packages/nuxt-typed-handler/vitest.config.ts index 3b4d661..540da64 100644 --- a/packages/nuxt-typed-handler/vitest.config.ts +++ b/packages/nuxt-typed-handler/vitest.config.ts @@ -1,13 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' -// Three tiers, as in both parents: `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. Scoped to `unit` on purpose: the e2e tier must see the real -// thing. The parents' internals are imported for real. const aliases = [ { find: /^#app$/, From 0920b53301ba113e8852ba37b75abed56db53d5e Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 13:28:53 +0200 Subject: [PATCH 22/28] docs: keep each parent readme about its own package The migration pointer to @dphonys/nuxt-typed-handler belongs in that package's own readme, not in the two parents it supersedes. Co-Authored-By: Claude Opus 5 (1M context) --- packages/nuxt-handler-errors/README.md | 4 ---- packages/nuxt-handler-validation/README.md | 4 ---- 2 files changed, 8 deletions(-) diff --git a/packages/nuxt-handler-errors/README.md b/packages/nuxt-handler-errors/README.md index 05d246e..4c67002 100644 --- a/packages/nuxt-handler-errors/README.md +++ b/packages/nuxt-handler-errors/README.md @@ -21,10 +21,6 @@ The module has one option, `channelToken` - see counterpart, and nothing about a route's failures is configured - it is declared, in the route. -Using both this package and `nuxt-handler-validation`? Install -`@dphonys/nuxt-typed-handler` instead - see its -[_Coming from…_ section](https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-typed-handler/README.md#coming-from-nuxt-handler-errors--nuxt-handler-validation). - ## Declaring what a route can fail with ```ts diff --git a/packages/nuxt-handler-validation/README.md b/packages/nuxt-handler-validation/README.md index 29d7d44..03964e1 100644 --- a/packages/nuxt-handler-validation/README.md +++ b/packages/nuxt-handler-validation/README.md @@ -30,10 +30,6 @@ Bring your own schema library. Anything implementing Standard Schema works - **Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+. -Using both this package and `nuxt-handler-errors`? Install -`@dphonys/nuxt-typed-handler` instead - see its -[_Coming from…_ section](https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-typed-handler/README.md#coming-from-nuxt-handler-errors--nuxt-handler-validation). - ## Quick start ```ts From f064dd6dc5653873f17610cce6193671ac5d459c Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 13:33:02 +0200 Subject: [PATCH 23/28] refactor(nuxt-typed-handler): trim comments to the sibling packages' standard Drop docblocks that restate the identifier, narration of what the parent packages do, spec references and the repeated "umbrella owns only the type" note; keep the consumer-facing JSDoc and every internal note that names a real trap. Co-Authored-By: Claude Fable 5 --- .../src/build/parent-types-paths.ts | 39 ++++++------------- .../nuxt-typed-handler/src/build/type-map.ts | 34 +++++----------- packages/nuxt-typed-handler/src/module.ts | 36 +++++++---------- .../composables/use-request-typed-fetch.ts | 3 -- .../app/composables/use-typed-fetch.ts | 5 +-- .../src/runtime/server/lib/on-invalid.ts | 15 +++---- .../src/runtime/server/lib/reserved-tag.ts | 5 +-- .../src/runtime/server/lib/typed-handler.ts | 8 ++-- .../server/plugins/event-typed-fetch.ts | 2 - .../src/runtime/shared/typed-fetch.ts | 7 +--- .../src/runtime/types/composables.ts | 12 +++--- .../src/runtime/types/fetch.ts | 31 +++++++-------- .../src/runtime/types/handler.ts | 16 ++------ .../src/runtime/types/index.ts | 17 ++++---- 14 files changed, 76 insertions(+), 154 deletions(-) diff --git a/packages/nuxt-typed-handler/src/build/parent-types-paths.ts b/packages/nuxt-typed-handler/src/build/parent-types-paths.ts index 806a762..7761d4f 100644 --- a/packages/nuxt-typed-handler/src/build/parent-types-paths.ts +++ b/packages/nuxt-typed-handler/src/build/parent-types-paths.ts @@ -3,49 +3,32 @@ import type { Nuxt } from '@nuxt/schema' import type { NitroConfig } from 'nitropack/types' import { fileURLToPath } from 'node:url' -/** - * The parents' `/types` specifiers, which the generated map reaches for: the - * errors map *augments* the first, the request-inputs map *imports* from the - * second. Neither resolves from an app that installed the umbrella alone. - */ +// 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 -/** The slice of a tsconfig object the entry is written on. */ interface PathsCarrier { compilerOptions?: { paths?: Record } } /** - * Map both parent `/types` specifiers on every generated tsconfig - app, - * node, shared and Nitro - to the declaration each resolves to *from the - * umbrella's own location*, so pnpm's nested layout is honoured. - * + * 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. Each entry joins the - * `paths` map Nuxt and Nitro wrote; only its own specifier's entry, if some - * other layer already claimed one, is replaced. - * - * @param nuxt - The instance whose generated tsconfigs carry the entries. - * @param from - This module's own URL (`import.meta.url`). + * alone and silently drops what it cannot find there. */ export function addParentTypesPaths(nuxt: Nuxt, from: string): void { - // The *directory* this module was loaded from. `resolveTypePaths` searches - // paths, not module URLs: handed a `file://` URL it resolves from the - // process's working directory instead - an app root, where an - // umbrella-only install has no parent to find. + // 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, lazily, failure included: `prepare:types` and - // `nitro:config` both need it, and the parents are exact-pinned - // dependencies, so one answer holds for the whole build. - // - // `resolveTypePaths` rather than `resolvePath`: it answers with the - // declaration TypeScript loads for a subpath export, which is what a - // `paths` entry has to name. Its answers are extensionless, as Nuxt's own - // entries are - TypeScript retries `.d.ts` itself. + // 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> => { diff --git a/packages/nuxt-typed-handler/src/build/type-map.ts b/packages/nuxt-typed-handler/src/build/type-map.ts index 5029ef0..1b62c8c 100644 --- a/packages/nuxt-typed-handler/src/build/type-map.ts +++ b/packages/nuxt-typed-handler/src/build/type-map.ts @@ -1,8 +1,5 @@ -// The one generated file, as a thing rather than a procedure: the errors -// parent's slot reused verbatim beside the umbrella's own request-inputs -// slot, the seed a cold build writes before Nitro exists, and the render of a -// scanned handler set. Kept out of `module.ts` so a suite can ask for the -// real map - slots respelled in a test prove only the test. +// 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, @@ -16,19 +13,13 @@ import type { import type { NitroEventHandler } from 'nitropack/types' /** - * The specifier the request-inputs map augments: this module's own `/types`. - * Exported so `setup()`'s `typescript.hoist.push(...)` cannot drift from the - * string actually emitted. + * 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' -/** - * The request-inputs map, beside the errors parent's slot in the one file. - * - * No `Serialize`: the input *is* the wire shape by the author's intent, and - * `query` must not be serialised. `Simplify` comes from where the parent's - * slot sources it, so the two slots share one import line. - */ +// 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, @@ -42,30 +33,23 @@ const REQUEST_INPUTS_SLOT: EmitMapSlot = { extract: (handlerType) => `Simplify>`, } -/** Both maps, in emit order: the parent's errors slot first. */ const SLOTS: readonly EmitMapSlot[] = [KNOWN_ERRORS_SLOT, REQUEST_INPUTS_SLOT] -/** The generated file this module owns, in the three states a build reads. */ export interface TypeMap { /** - * Where the file lands, under the build directory. `types/` is Nitro's - * `typesDir`: every handler specifier the emitter computes is relative to - * it, and an unresolved `import('…')` in a `.d.ts` produces no diagnostic. - * - * Typed as kit types a declaration template's filename, so the two cannot - * drift. + * 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 - /** Both maps over one scanned handler set, in one file. */ readonly emit: ( handlers: readonly NitroEventHandler[], nitroOptions: NitroPathOptions ) => string } -/** The generated map of the module named `name`, banner and filename included. */ export function typeMap(name: string): TypeMap { return { filename: `types/${name}.d.ts`, diff --git a/packages/nuxt-typed-handler/src/module.ts b/packages/nuxt-typed-handler/src/module.ts index af41bc6..1fffdbe 100644 --- a/packages/nuxt-typed-handler/src/module.ts +++ b/packages/nuxt-typed-handler/src/module.ts @@ -37,10 +37,8 @@ export interface ModuleOptions { const NAME = 'nuxt-typed-handler' -/** The one generated file: both maps, its cold-start seed, and where it lands. */ const TYPE_MAP = typeMap(NAME) -/** The two packages this module replaces, and the keys they were configured under. */ const PARENTS = [ { packageName: '@dphonys/nuxt-handler-errors', @@ -67,9 +65,8 @@ export default defineNuxtModule({ setup(options, nuxt) { warnCustomErrorHandler(nuxt, NAME) - // A parent's key left behind configures nothing now: this module owns the - // one option, under its own key. Own keys only, any value - `false` has - // nothing left to switch off. + // 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 @@ -78,23 +75,18 @@ export default defineNuxtModule({ ) } - // Required by the errors parent's internals contract: its app internals - // import `#app`, and Nuxt transpiles only what `modules` lists. Pushed - // ahead of the typed fetch family that binds those internals, so the - // contract holds from the first build. The validation parent's contract - // says push nothing. + // 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` specifiers, which the generated map augments and - // imports from, are mapped through `paths` from this module's location. + // parents' `/types` go through `paths` instead. nuxt.options.typescript.hoist.push(TYPES_SPECIFIER) addParentTypesPaths(nuxt, import.meta.url) - // Named explicitly rather than through `addServerImportsDir`, whose scan - // would auto-import whatever the runtime tree happens to export. Neither - // parent wrapper is among them: the umbrella's wrapper is the one door. + // 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') @@ -108,7 +100,7 @@ export default defineNuxtModule({ ].map((name) => ({ name, from: serverEntry })) ) - // None of the five uses `addServerImports`: every one reaches `#app`, + // 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' @@ -176,8 +168,8 @@ export default defineNuxtModule({ // current instance and the hooked one are not the same object. let nitro: Nitro | undefined - // One file carrying both maps. The context must name all three programs: - // passing a context at all opts out of everything it does not name. + // 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, @@ -206,11 +198,9 @@ export default defineNuxtModule({ }) }) - // Exclusive by construction: a project lists this module *or* the - // parents. After every module has registered, a parent beside this one - // is a configuration error, not a warning. Consumers list the package - // name in `modules`; a module listed as a value is known to kit by its - // `meta.name` alone, so both spellings are tried. + // 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 ( 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 index 29f516b..0e26664 100644 --- 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 @@ -7,9 +7,6 @@ import type { TypedFetch } from '../../types/fetch' * Nuxt's `useRequestFetch()`, mirrored. A call made while rendering forwards * the incoming request's cookies and headers. */ -// The errors parent's `/internals/app` exposes no request-fetch wrapper, so -// these few lines are its `useRequestCheckedFetch` reimplemented over the -// umbrella's own global and `event.$typedFetch` (spec §11 D3). export function useRequestTypedFetch(): TypedFetch { if (import.meta.client) return $typedFetch 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 index 79f4843..657cbc9 100644 --- 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 @@ -4,9 +4,8 @@ import { wrapVanillaFetch } from '@dphonys/nuxt-handler-errors/internals/app' import { useFetch, useLazyFetch } from '#app' import type { UseTypedFetch } from '../../types/composables' -// A getter, never spread: the alias is a live binding the unit double sets -// after the composables are built, and the wrapper reads `token` on every -// call. The one getter-backed options object the internals contract asks for. +// 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 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 index cc5a6ed..a4091bb 100644 --- a/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts @@ -4,19 +4,16 @@ import { markValidationError } from '@dphonys/nuxt-handler-validation/internals/ import { RESERVED_TAG } from './reserved-tag' /** - * The built-in variant's raiser: every client-input rejection the validation - * parent reports - a rejecting schema and an unparseable body alike - becomes - * one known error, `validation-failed`, `400`, with the issues both inside the - * known-error marker and at `data.issues`, plus the validation marker. Both - * parents' recognizers answer, and channel stripping leaves `data.issues`. + * 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, not inside it: what a client reads once the marker is - // stripped, and the path the validation parent documents. A second copy on - // purpose, so neither place shares an array with the other or the hook's - // input. + // 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) 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 index 91e8b2e..9fb365f 100644 --- a/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts @@ -3,9 +3,8 @@ import type { DeclaredError } from '@dphonys/nuxt-handler-errors/internals/serve /** The tag of the built-in variant; no umbrella route may declare it. */ export const RESERVED_TAG = 'validation-failed' -// At declaration, like the parent's foreign-copy guard: the route never -// becomes servable. The compile guard says the same thing; this is the -// answer a JavaScript caller gets. +// 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 { 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 index c7067f3..8522a16 100644 --- a/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts @@ -38,8 +38,7 @@ export const defineTypedEventHandler: DefineTypedEventHandler = ( options, handler ) => { - // Declaration time, in this order: the foreign-copy guard first, then the - // reserved tag, then the not-a-schema check - each with its owner's message. + // 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 @@ -52,13 +51,12 @@ export const defineTypedEventHandler: DefineTypedEventHandler = ( ) } - // A fresh plain object per request; `fail` present exactly when declared. // 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 - // One `defineEventHandler`, and no validation call at all on a route that - // declares none: no body read, no await. + // No validation call at all on a route that declares none: no body read, + // no await. return defineEventHandler((event) => plan === undefined ? handler(event, contextFor({})) 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 index f97ce97..ab6b92c 100644 --- 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 @@ -6,8 +6,6 @@ import type { TypedEventFetch } from '../../types/fetch' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('request', (event) => { - // The parent's event-bound factory, handed the umbrella's token; the - // umbrella owns only the type, applied with this one cast. event.$typedFetch = createCheckedEventFetch( () => event.$fetch as RawEventFetch | undefined, configuredChannelToken diff --git a/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts index 5bde1ce..945b8c7 100644 --- a/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts +++ b/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts @@ -8,18 +8,13 @@ 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. -// The one getter-backed options object the internals contract asks for. const bound: CheckedFetchFactoryOptions = { get token() { return configuredChannelToken }, } -/** - * The value the module installs on `globalThis` - one object on every side. - * The parent's factory over the lazily read `$fetch`; the umbrella owns only - * the type, applied with this one cast. - */ +/** The value the module installs on `globalThis` - one object on every side. */ export const $typedFetch = createCheckedFetch( lazyGlobalFetch, bound diff --git a/packages/nuxt-typed-handler/src/runtime/types/composables.ts b/packages/nuxt-typed-handler/src/runtime/types/composables.ts index 1425c23..5e9923c 100644 --- a/packages/nuxt-typed-handler/src/runtime/types/composables.ts +++ b/packages/nuxt-typed-handler/src/runtime/types/composables.ts @@ -25,10 +25,9 @@ type Reactive = ? ComputedOptions | MaybeRefOrGetter : MaybeRefOrGetter -// Each typed source re-added the way vanilla types its own: a plain value, a -// ref, a getter, or an object whose leaves are any of those. A plain literal -// is still excess-key checked; through `ref()` or a getter the check does -// not fire - a union target, and `ref()` infers its own type. +// 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 } /** @@ -62,8 +61,7 @@ export interface UseTypedFetch { /** * `useAsyncData` whose handler returns `.try` results instead of throwing. - * Request-side it adds nothing: the inner `$typedFetch.try` call types its own - * options, and its error union is what lands on the `error` ref - so the - * signature is the parent's exactly. + * 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 index b08101b..e1b71a8 100644 --- a/packages/nuxt-typed-handler/src/runtime/types/fetch.ts +++ b/packages/nuxt-typed-handler/src/runtime/types/fetch.ts @@ -12,11 +12,10 @@ import type { } from 'nitropack/types' import type { RequestInputOfRoute } from './index' -// Stack-depth rules, each a requirement on this file rather than a style: -// `M`'s default references only `R`; anything deriving a method from the -// options lives in an alias default, never in a signature parameter's -// constraint; `MatchedRoutes` is evaluated once per lookup (inside -// `RequestInputOfRoute`); no type parameter appears in its own constraint. +// 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 = @@ -28,13 +27,13 @@ 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 - it degrades to no declared -// inputs. +// object no route path can be read out of. type InputFor = R extends string ? RequestInputOfRoute, RouterMethod>> : never -/** Required iff `{} extends Input` is false: an all-optional, `unknown` or `any` input stays optional but typed. */ +// 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] } @@ -48,9 +47,8 @@ type QueryOption = [I] extends [never] ? Declared : Vanilla<'query'> -// An unbranded route keeps vanilla's `body` on every method, so the -// degradation stays key for key; a branded route omits it - neither typed nor -// vanilla - when the resolved method is `get` or `head`. +// 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' @@ -79,7 +77,6 @@ export type TypedRequestOptions< 'method' | 'body' | 'query' | 'params' > & { method?: M } & TypedSources -/** Nitro's typed response for the route, method and explicit `T`. */ export type Resp = TypedInternalResponse< R, T, @@ -87,9 +84,8 @@ export type Resp = TypedInternalResponse< > /** - * The error one call can produce - the errors parent's reading of the - * known-errors map, which already carries `validation-failed` for every - * validating route. + * The error one call can produce. The known-errors map already carries + * `validation-failed` for every validating route. */ export type TypedErrorFor = KnownErrorFor< R, @@ -130,7 +126,7 @@ export interface TypedFetch< try: TypedFetchTry } -/** What `event.$typedFetch` is typed as: the seam exactly, no `.raw`, `.create` or `.native`. */ +/** 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 @@ -161,8 +157,7 @@ export interface $TypedFetch< /** * Like `$fetch.create`: a derived instance with defaults, keeping `.try`. - * Defaults are vanilla ofetch options, not route-scoped: a default `query` - * never relaxes a call's own requiredness. + * A default `query` never relaxes a call's own requiredness. */ // Must return the *typed* interface, or `.try` vanishes one level down. create: ( diff --git a/packages/nuxt-typed-handler/src/runtime/types/handler.ts b/packages/nuxt-typed-handler/src/runtime/types/handler.ts index a8bea18..82016a3 100644 --- a/packages/nuxt-typed-handler/src/runtime/types/handler.ts +++ b/packages/nuxt-typed-handler/src/runtime/types/handler.ts @@ -23,15 +23,12 @@ export interface ValidationFailed { issues: ValidationIssue[] } -/** The element type of an `errors` slot - what the errors parent composes. */ export type AnyKnownError = KnownError -/** Whether `validate` declared at least one source. */ type HasValidate = [keyof S] extends [never] ? false : true -/** Whether `errors` was declared at all. */ type HasErrors> = [A[number]] extends [ never, ] @@ -43,8 +40,8 @@ type HasErrors> = [A[number]] extends [ * `EventHandler` carrying both parents' phantom slots, so each parent's * extractor reads its own. */ -// The validation parent keys its slot on a private symbol, so the only way -// to carry it is to extend the parent's own branded handler type. +// Extends rather than restates: the validation slot is keyed on a private +// symbol. export interface TypedEventHandler< Request extends EventHandlerRequest = EventHandlerRequest, Response extends EventHandlerResponse = EventHandlerResponse, @@ -55,10 +52,7 @@ export interface TypedEventHandler< CheckedEventHandler, ValidatedEventHandler {} -/** - * The Handler context: the validated sources, flat, plus `fail` exactly when - * `errors` is declared. - */ +/** The validated sources, flat, plus `fail` exactly when `errors` is declared. */ export type TypedContext< S extends ValidationSchemas, A extends ReadonlyArray, @@ -68,13 +62,12 @@ export type TypedContext< : // eslint-disable-next-line ts/no-empty-object-type {}) -/** What the route can fail with: the declared union, plus the built-in variant when it validates. */ +/** 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) -/** A typed handler body. The success type infers from it with no annotation. */ export type TypedHandlerFn< S extends ValidationSchemas, A extends ReadonlyArray, @@ -114,7 +107,6 @@ type ConflictGuard> = Omit< 'errors' > -/** The options argument, with every guard intersected ahead of the slots. */ export type TypedHandlerOptions< S extends ValidationSchemas, A extends ReadonlyArray, diff --git a/packages/nuxt-typed-handler/src/runtime/types/index.ts b/packages/nuxt-typed-handler/src/runtime/types/index.ts index b86c067..206f40e 100644 --- a/packages/nuxt-typed-handler/src/runtime/types/index.ts +++ b/packages/nuxt-typed-handler/src/runtime/types/index.ts @@ -19,27 +19,24 @@ export type { * reopens it with `declare module`, and empty means no handler has declared * anything yet. */ -// The map and its lookup live in this file, as the errors parent's own pair -// does, because the emitted template augments this module by its package -// specifier: a `declare module` on a barrel that merely re-exports an -// interface opens a second, unrelated one. +// 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. */ -// Mirrors the errors parent's `KnownErrorsOfRoute`: `MatchedRoutes` once per -// lookup, every method read through `Lowercase`, and the `default` fallback -// by presence rather than Nitro's on-`never` rule - `never` is a legitimate -// value here. +// 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, and doubles as the totality - // guard: a route this map lacks answers `never` rather than `TS2536`. + ? // 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] From 2e0e81a4d2e77c3c75cca9e5c19cba0841523a81 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 14:07:02 +0200 Subject: [PATCH 24/28] docs(nuxt-typed-handler): lead the readme with quick start, not migration A new reader met a parent-migration table before any code. Move that guide after Troubleshooting, add the siblings' orientation bullets to Quick start, and give the off-switch its own section, matching the other packages' readme shape. Co-Authored-By: Claude Fable 5 --- packages/nuxt-typed-handler/README.md | 259 ++++++++++++++------------ 1 file changed, 139 insertions(+), 120 deletions(-) diff --git a/packages/nuxt-typed-handler/README.md b/packages/nuxt-typed-handler/README.md index 25ba6f9..b4589e1 100644 --- a/packages/nuxt-typed-handler/README.md +++ b/packages/nuxt-typed-handler/README.md @@ -6,11 +6,15 @@ both typed at every call site: the compiler knows what a route accepts as `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, from its own entries, so an app imports everything from one -package. +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 @@ -21,123 +25,23 @@ pnpm add @dphonys/nuxt-typed-handler ```ts export default defineNuxtConfig({ modules: ['@dphonys/nuxt-typed-handler'], - typedHandler: { - channelToken: 'my-app', - }, }) ``` -**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+. Bring your -own schema library - anything implementing [Standard -Schema](https://standardschema.dev) works, and nothing is bundled for you. - 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. -**There is no off-switch.** `typedHandler` is a flat bag with exactly one key - -a stray key is a compile error, and there is no `typedHandler: false`. To turn -the module off, remove `'@dphonys/nuxt-typed-handler'` from `modules`. - -**Never list a parent beside it.** A project registers this module _or_ the two -parents. Registering both throws at startup - see -[Troubleshooting](#troubleshooting). - -## Coming from `nuxt-handler-errors` / `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)|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' -e 's/\bhandlerErrors\b/typedHandler/g' -e 's/\bhandlerValidation\b/typedHandler/g' -``` - -### 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 +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. -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: +**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+. -- `.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 - -There is no `typedHandler: false`. To turn the module off, remove it from -`modules`. - -### 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. +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 @@ -183,10 +87,23 @@ matchError( ``` -**`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. +- **`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 @@ -409,11 +326,11 @@ return data // narrowed to the route's response type ## Fetching -`useTypedFetch`, `useLazyTypedFetch`, `useRequestTypedFetch`, -`useTypedAsyncData` and `useLazyTypedAsyncData` are auto-imported in app code, -and `defineTypedEventHandler`, `defineError`, `payload`, `recognizeKnownError` -and `recognizeValidationError` inside `server/`. `$typedFetch` is a global, -like `$fetch`. `matchError` is imported, because it is used in `shared/` too. +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 @@ -489,6 +406,12 @@ not be added to the arm above. 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 @@ -545,6 +468,102 @@ the edges the compile-time guard does not catch are in [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)|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' -e 's/\bhandlerErrors\b/typedHandler/g' -e 's/\bhandlerValidation\b/typedHandler/g' +``` + +### 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 121b964c5f9047d50db0674b59c432aa82fb7dff Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 14:11:11 +0200 Subject: [PATCH 25/28] test: give the types tier a timeout that survives a loaded CI runner Each types file compiles a real TypeScript program, and the repeat compile in the misuse-diagnostics test runs 7-10s on CI once three packages' e2e builds share the runner - past vitest's 5s default. Give the tier its own timeout, as e2e already has, in all three configs. Co-Authored-By: Claude Fable 5 --- packages/nuxt-handler-errors/vitest.config.ts | 3 +++ packages/nuxt-handler-validation/vitest.config.ts | 3 +++ packages/nuxt-typed-handler/vitest.config.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/nuxt-handler-errors/vitest.config.ts b/packages/nuxt-handler-errors/vitest.config.ts index 77a9ab8..10d9a85 100644 --- a/packages/nuxt-handler-errors/vitest.config.ts +++ b/packages/nuxt-handler-errors/vitest.config.ts @@ -36,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, }, }, { diff --git a/packages/nuxt-handler-validation/vitest.config.ts b/packages/nuxt-handler-validation/vitest.config.ts index 36a3d55..2847ad2 100644 --- a/packages/nuxt-handler-validation/vitest.config.ts +++ b/packages/nuxt-handler-validation/vitest.config.ts @@ -13,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/vitest.config.ts b/packages/nuxt-typed-handler/vitest.config.ts index 540da64..bc4ddaa 100644 --- a/packages/nuxt-typed-handler/vitest.config.ts +++ b/packages/nuxt-typed-handler/vitest.config.ts @@ -36,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, }, }, { From 420b3dd6cd940cdfdf3951898431e6c8ed2a71be Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 14:23:48 +0200 Subject: [PATCH 26/28] build: declare nuxt as a peer dependency of all three modules The readmes state Nuxt `>=4.5.1 <5.0.0` as a requirement; declaring it as a peer lets the package manager check that range too. Give the errors readme the same requirements line the other two carry. Co-Authored-By: Claude Fable 5 --- .changeset/nuxt-declared-as-peer.md | 6 ++++++ packages/nuxt-handler-errors/README.md | 2 ++ packages/nuxt-handler-errors/package.json | 3 +++ packages/nuxt-handler-validation/package.json | 3 +++ packages/nuxt-typed-handler/package.json | 3 +++ 5 files changed, 17 insertions(+) create mode 100644 .changeset/nuxt-declared-as-peer.md 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/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-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-typed-handler/package.json b/packages/nuxt-typed-handler/package.json index a271cb2..86e6141 100644 --- a/packages/nuxt-typed-handler/package.json +++ b/packages/nuxt-typed-handler/package.json @@ -93,6 +93,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" } From 3f3f9054bce40d3ead51a6e560fd46744cd3e9a6 Mon Sep 17 00:00:00 2001 From: dphonys Date: Sun, 23 Aug 2026 14:23:51 +0200 Subject: [PATCH 27/28] fix(nuxt-typed-handler): refuse an empty validate, and compare the probe path normalized `validate: {}` planned nothing yet slipped past the JavaScript-caller guard; an empty plan now counts as no plan. The entry-resolution probe compares its path against TypeScript's forward-slash file names, as app-program already does, so the leak check cannot pass vacuously on Windows. Co-Authored-By: Claude Fable 5 --- .../src/runtime/server/lib/typed-handler.ts | 5 +++-- .../nuxt-typed-handler/test/e2e/package-entries.test.ts | 6 +++++- .../nuxt-typed-handler/test/unit/typed-handler.test.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) 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 index 8522a16..3366f13 100644 --- a/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts +++ b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts @@ -44,8 +44,9 @@ export const defineTypedEventHandler: DefineTypedEventHandler = ( const plan = options.validate ? sourcePlan(options.validate) : undefined const fail = declared === undefined ? undefined : createFail(declared) - // The compile guard's answer for a JavaScript caller. - if (plan === undefined && fail === undefined) { + // 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.' ) diff --git a/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts b/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts index 5173eba..c9e1e9b 100644 --- a/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts +++ b/packages/nuxt-typed-handler/test/e2e/package-entries.test.ts @@ -134,7 +134,11 @@ beforeAll(() => { // `paths` map this package's subpaths straight at `dist`, short-circuiting // the very `exports` block under test. function diagnosticsFor(source: string): string[] { - const probe = `${PLAYGROUND}/__entry-resolution.probe.ts` + // Forward slashes, since TypeScript normalizes the names it hands back. + const probe = `${PLAYGROUND}/__entry-resolution.probe.ts`.replaceAll( + '\\', + '/' + ) const options: ts.CompilerOptions = { target: ts.ScriptTarget.ESNext, diff --git a/packages/nuxt-typed-handler/test/unit/typed-handler.test.ts b/packages/nuxt-typed-handler/test/unit/typed-handler.test.ts index 59222ed..6cabf84 100644 --- a/packages/nuxt-typed-handler/test/unit/typed-handler.test.ts +++ b/packages/nuxt-typed-handler/test/unit/typed-handler.test.ts @@ -272,6 +272,14 @@ describe('declaration-time misuse', () => { ) }) + it('throws on an empty `validate` just the same - it plans nothing', () => { + expect(() => + defineTypedEventHandler({ validate: {} } as never, () => null) + ).toThrow( + '[nuxt-typed-handler] defineTypedEventHandler needs validate, errors, or both.' + ) + }) + it('lets `fail("validation-failed")` hit the parent’s undeclared-tag Error', async () => { // `declared` can never carry the tag, so the parent's plain `Error` is // the whole answer - no umbrella wording, no marker. From 8678fd1baa8d7059ab6c5b466a3467e3830818bf Mon Sep 17 00:00:00 2001 From: dphonys Date: Mon, 24 Aug 2026 11:16:41 +0200 Subject: [PATCH 28/28] docs(nuxt-typed-handler): keep the migration sweep out of nuxt.config Substituting the parent option keys produced configs the module cannot accept: `handlerValidation: false` became a `typedHandler: false` that is not an off-switch this module has, and a config carrying both parent keys ended up with two `typedHandler` keys in one object. Drop both clauses, narrow the file filter to subpath specifiers, and spell out the nuxt.config edit by hand. Co-Authored-By: Claude Opus 5 --- packages/nuxt-typed-handler/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-typed-handler/README.md b/packages/nuxt-typed-handler/README.md index b4589e1..c7145fd 100644 --- a/packages/nuxt-typed-handler/README.md +++ b/packages/nuxt-typed-handler/README.md @@ -488,9 +488,17 @@ 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)|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' -e 's/\bhandlerErrors\b/typedHandler/g' -e 's/\bhandlerValidation\b/typedHandler/g' +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 -