From ff4b5994ef132e47db59773ebca37d620ceba184 Mon Sep 17 00:00:00 2001 From: Guillaume Date: Fri, 10 Jul 2026 16:33:40 +0200 Subject: [PATCH] Ready for release --- .changeset/brave-bubbles-report.md | 13 + .changeset/config.json | 2 +- .github/workflows/ci.yml | 23 ++ .github/workflows/release.yml | 36 ++ README.md | 161 ++++++++- apps/benchmarks/package.json | 4 + apps/benchmarks/src/hono.ts | 10 + apps/benchmarks/src/index.ts | 5 +- apps/benchmarks/src/koa.ts | 15 + apps/benchmarks/src/nodehttp.ts | 15 + apps/examples/broadcast.ts | 32 -- apps/examples/contextExtension.ts | 21 -- apps/examples/gracefulShutdown.ts | 13 - apps/examples/package.json | 5 +- apps/examples/run.ts | 30 ++ apps/examples/{ => src}/asyncFunction.ts | 24 +- apps/examples/src/contextExtension.ts | 25 ++ apps/examples/src/docgen.ts | 42 +++ apps/examples/src/gracefulShutdown.ts | 12 + apps/examples/src/pubsub.ts | 27 ++ apps/examples/src/routers.ts | 30 ++ apps/examples/src/schemas.ts | 37 ++ apps/examples/src/sse.ts | 25 ++ apps/examples/tests/context.test.ts | 99 ++++++ apps/examples/tests/docgen.test.ts | 110 ++++++ apps/examples/tests/examples.test.ts | 58 ++++ apps/examples/tests/routing.test.ts | 106 ++++++ apps/examples/tests/schemas.test.ts | 111 ++++++ apps/examples/tests/sse.test.ts | 55 +++ apps/examples/tests/websocket.test.ts | 105 ++++++ apps/examples/tsconfig.json | 6 +- docs/context.md | 9 +- docs/docgen.md | 102 +++--- docs/getting-started.md | 75 ---- docs/modules.md | 17 +- docs/routing.md | 7 + docs/schemas.md | 23 +- docs/server-sent-events.md | 7 + docs/testing.md | 29 +- docs/typescript.md | 9 +- docs/websockets.md | 33 +- package.json | 5 +- packages/mousse/LICENSE | 21 ++ packages/mousse/README.md | 27 ++ packages/mousse/package.json | 34 +- packages/mousse/src/context.ts | 13 +- .../mousse/src/docgen/docSchemaTranslator.ts | 57 +++ packages/mousse/src/docgen/docgen.ts | 13 + packages/mousse/src/docgen/htmlDocGen.ts | 25 +- packages/mousse/src/docgen/jsonschema.ts | 2 +- packages/mousse/src/docgen/openapi.ts | 43 ++- packages/mousse/src/handler.ts | 3 +- packages/mousse/src/index.ts | 12 +- packages/mousse/src/module/bodyparser.ts | 10 +- packages/mousse/src/module/docgen.ts | 12 - packages/mousse/src/module/errorhandler.ts | 3 +- packages/mousse/src/module/schemaparser.ts | 58 ---- packages/mousse/src/mousse.ts | 2 +- packages/mousse/src/route.ts | 2 +- packages/mousse/src/router.ts | 57 ++- packages/mousse/src/{module => }/schema.ts | 0 packages/mousse/src/{module => }/tester.ts | 6 +- packages/mousse/src/utils.ts | 5 +- .../mousse/tests/docSchemaTranslator.test.ts | 49 +++ packages/mousse/tests/jsonschema.test.ts | 48 +++ packages/mousse/tests/schema.test.ts | 57 +++ packages/mousse/tests/utils.test.ts | 67 ++++ pnpm-lock.yaml | 327 ++++++++++++++++++ turbo.json | 8 + 69 files changed, 2146 insertions(+), 388 deletions(-) create mode 100644 .changeset/brave-bubbles-report.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 apps/benchmarks/src/hono.ts create mode 100644 apps/benchmarks/src/koa.ts create mode 100644 apps/benchmarks/src/nodehttp.ts delete mode 100644 apps/examples/broadcast.ts delete mode 100644 apps/examples/contextExtension.ts delete mode 100644 apps/examples/gracefulShutdown.ts create mode 100644 apps/examples/run.ts rename apps/examples/{ => src}/asyncFunction.ts (59%) create mode 100644 apps/examples/src/contextExtension.ts create mode 100644 apps/examples/src/docgen.ts create mode 100644 apps/examples/src/gracefulShutdown.ts create mode 100644 apps/examples/src/pubsub.ts create mode 100644 apps/examples/src/routers.ts create mode 100644 apps/examples/src/schemas.ts create mode 100644 apps/examples/src/sse.ts create mode 100644 apps/examples/tests/context.test.ts create mode 100644 apps/examples/tests/docgen.test.ts create mode 100644 apps/examples/tests/examples.test.ts create mode 100644 apps/examples/tests/routing.test.ts create mode 100644 apps/examples/tests/schemas.test.ts create mode 100644 apps/examples/tests/sse.test.ts create mode 100644 apps/examples/tests/websocket.test.ts delete mode 100644 docs/getting-started.md create mode 100644 packages/mousse/LICENSE create mode 100644 packages/mousse/README.md create mode 100644 packages/mousse/src/docgen/docSchemaTranslator.ts create mode 100644 packages/mousse/src/docgen/docgen.ts delete mode 100644 packages/mousse/src/module/docgen.ts delete mode 100644 packages/mousse/src/module/schemaparser.ts rename packages/mousse/src/{module => }/schema.ts (100%) rename packages/mousse/src/{module => }/tester.ts (96%) create mode 100644 packages/mousse/tests/docSchemaTranslator.test.ts create mode 100644 packages/mousse/tests/jsonschema.test.ts create mode 100644 packages/mousse/tests/schema.test.ts create mode 100644 packages/mousse/tests/utils.test.ts diff --git a/.changeset/brave-bubbles-report.md b/.changeset/brave-bubbles-report.md new file mode 100644 index 0000000..189c828 --- /dev/null +++ b/.changeset/brave-bubbles-report.md @@ -0,0 +1,13 @@ +--- +"mousse": minor +--- + +Documentation generation redesign and robustness fixes. + +- `document()` becomes `generateDoc(docgen)`, with new `generateHTMLDoc(options)` and `generateOpenAPIDoc(options)` helpers on every Router +- `SchemaParser` classes replaced by the `DocSchemaTranslator` function type, registered per Standard Schema vendor with `addDocSchemaTranslator(vendor, translate)`; registries merge by union when mounting routers +- Doc generation is now strict by default : a schema vendor without translator throws (opt out with `lenient: true`) +- `DefaultBodyParser` now matches content types carrying parameters (`application/json; charset=utf-8`) +- Routes mounted at a router root (`GET /users` for a `/` route mounted on `/users`) now match correctly +- `c.param()` returns `string` instead of `string | undefined` when the param is declared through schemas or generics +- Middlewares and HTTP error handlers can return the context (`return c.status(401).respond()`) diff --git a/.changeset/config.json b/.changeset/config.json index ad6f18a..b821332 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,7 @@ "commit": false, "fixed": [], "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d664aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm turbo run build check-types test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d29f603 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Opens/updates a "Version Packages" PR aggregating pending changesets. + # When that PR is merged, builds and publishes to npm. + # version defaults to 'changeset version' : bumps packages and changelogs + - uses: changesets/action@v1 + with: + publish: pnpm release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index f345500..df5e548 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,18 @@ Mousse is a Node.js web server framework developed in TypeScript and based on [ - πŸ“„ Documentation generation from route schemas : self-styled HTML site or standard OpenAPI 3.1 output - πŸ§ͺ One-call route testing : `router.test()` fires real requests through a single ephemeral instance, no port management +## Installation + +> **WARNING**: Mousse is a work in progress, no package is published yet. For now, use it from the monorepo workspace: + +```json +{ + "dependencies": { + "mousse": "workspace:*" + } +} +``` + ## Quick Start ```ts @@ -49,33 +61,156 @@ new Mousse() `c` is a `Context` instance : it wraps the request and the response and carries every utility you need. A handler may respond explicitly (`c.respond()`, `c.json()`...) or simply return a value. +Every registration method returns the app itself, so the whole application can be chained β€” and stopped with `app.stop()`, which closes the listen socket while letting in-flight work finish. + +## TLS + +Pass the Β΅WebSockets.js SSL options to the constructor. When both `key_file_name` and `cert_file_name` are provided, Mousse automatically creates an SSL application: + +```ts +const app = new Mousse({ + key_file_name: 'misc/key.pem', + cert_file_name: 'misc/cert.pem', + passphrase: '1234' +}); +``` + +## WebSockets and Server-Sent Events + +Real-time channels use the same routing and `Context` API as HTTP. + +A `ws` route separates the **upgrade phase** β€” plain HTTP middlewares that can reject the connection by responding β€” from the **connected phase**, where the handler receives an already-open `WSContext` with events, pub/sub and automatic backpressure handling. + +```ts +app.ws('/chat/:room', + (c) => { + if (!c.getHeader('authorization')) return c.status(401).respond(); // upgrade rejected + }, + (c) => { + c.subscribe(c.param('room')); + c.onMessage((ws, message) => c.publish(c.param('room'), message)); + }); +``` + +An `sse` route hands you an already-sustained context: `c.send()` pushes events through a backpressured queue, and any regular route can open a channel itself with `c.sustain()`. + + +See [WebSockets](docs/websockets.md) and [Server-Sent Events](docs/server-sent-events.md). + +## Validation and typing with schemas + +Attach [Standard Schema](https://standardschema.dev) schemas (Zod, ArkType, Valibot...) to a route: requests are validated at runtime and the handler is fully typed from them β€” no generics to write. + +```ts +import { z } from 'zod'; + +app.post('/orgs/:org/users', { + schemas: { + Body: z.object({ name: z.string(), email: z.string().email() }), + Response: z.object({ id: z.number() }) + } +}, async (c) => { + const body = await c.body(); // { name: string; email: string } β€” validated AND typed + return { id: 1 }; // must match the Response schema +}); +``` + +See [Schemas](docs/schemas.md) for what is validated and when, failure handling and inference details. The same schemas also feed the [documentation generators](docs/docgen.md). + ## Documentation -- [Getting started](docs/getting-started.md) β€” installation, hello world, TLS -- [Routing](docs/routing.md) β€” routes, patterns, middlewares, routers, error handling -- [Context](docs/context.md) β€” request and response API -- [Schemas](docs/schemas.md) β€” validation and type inference -- [Documentation generation](docs/docgen.md) β€” HTML docs and OpenAPI output -- [Testing routes](docs/testing.md) β€” firing requests against a router with no real port -- [WebSockets](docs/websockets.md) β€” upgrades, events, backpressure -- [Server-Sent Events](docs/server-sent-events.md) β€” sustained requests -- [Modules](docs/modules.md) β€” body parsers, serializers, loggers, error handlers -- [Working with types](docs/typescript.md) β€” context types and extensions +1. [Routing](docs/routing.md) β€” routes, patterns, middlewares, routers, error handling +2. [Context](docs/context.md) β€” request and response API +3. [Server-Sent Events](docs/server-sent-events.md) β€” sustained requests +4. [WebSockets](docs/websockets.md) β€” upgrades, events, backpressure +5. [Modules](docs/modules.md) β€” body parsers, serializers, loggers, error handlers +6. [Typescript](docs/typescript.md) β€” context types and extensions +7. [Schemas](docs/schemas.md) β€” validation and type inference +8. [Testing routes](docs/testing.md) β€” firing requests against a router with no real port +9. [Documentation generation](docs/docgen.md) β€” HTML docs and OpenAPI output ## Monorepo | Path | Content | |---|---| -| [`packages/mousse`](packages/mousse) | The framework | -| [`apps/examples`](apps/examples) | Runnable examples | +| [`packages/mousse`](packages/mousse) | The framework, with its unit tests | +| [`apps/examples`](apps/examples) | Runnable examples and the framework integration tests | | [`apps/benchmarks`](apps/benchmarks) | Benchmarks against express, fastify and raw Β΅WebSockets.js | +```sh +pnpm example # list runnable examples +pnpm example schemas # run one on http://localhost:8080 +``` + +## Tests + +```sh +pnpm test # turbo run test : builds mousse first, then every suite +``` + +Two suites, run together by turbo : + +- **Unit** β€” [`packages/mousse/tests`](packages/mousse/tests) : pure logic of the package (uri joining, query parsing, Standard Schema validation, JSON Schema rendering, doc translators). +- **Integration & examples** β€” [`apps/examples/tests`](apps/examples/tests) : real requests fired through `router.test()` covering routing, context, schemas, docgen, SSE β€” plus a real `listen(0)` + native `WebSocket` client for upgrades, and a smoke test of every example app ([executable documentation](docs/testing.md#dogfooding)). + +Each one also runs alone with `pnpm --filter mousse test` or `pnpm --filter @mousse/examples test`. Tests are plain `node --test` : no test framework dependency. + +## Benchmarks + +`pnpm bench` spawns each server and fires [autocannon](https://github.com/mcollina/autocannon) at a plain-text route β€” 100 connections, 10 seconds, Node 24, same machine for all: + +| Framework | req/s | avg latency | p99 latency | +|---|---:|---:|---:| +| **Mousse** | **41 085** | **2.16 ms** | **5 ms** | +| Β΅WebSockets.js (raw) | 39 277 | 2.22 ms | 5 ms | +| node:http (raw) | 36 576 | 2.23 ms | 5 ms | +| koa | 31 352 | 2.68 ms | 5 ms | +| fastify | 30 286 | 2.72 ms | 7 ms | +| hono | 26 095 | 3.35 ms | 8 ms | +| express | 19 884 | 4.50 ms | 9 ms | + +Mousse runs at raw Β΅WebSockets.js speed β€” the two are within measurement noise of each other β€” while adding routing, middlewares, validation and the whole `Context` API. Every framework built on `node:http` starts below the second baseline; Mousse doesn't. Single 10-second runs, numbers vary with hardware and differences under ~5% aren't significant : run `pnpm bench` to reproduce on yours. + ## Contributing > Disclaimer : even though I'm proud of this version, it represents one of my first public, ambitious and TypeScript based modules. Many improvements need to be done and will be, I hope. -I haven't yet decided a clear procedure to contribute to this package. I guess the main contribution you can make for now is to test it and open issues to help me chase the bugs. I will try to fix them as fast as I can while thinking of better implementations for performance gains and new handy features. If this package begins to get big attention, I might invite the most interested people to participate in its design and development. +The simplest contribution is to test Mousse and [open issues](https://github.com/Tyrenn/mousse/issues) to help chase the bugs. For code contributions : + +1. **Fork** the repository and create a branch from `main` : + + ```sh + git clone https://github.com//mousse + cd mousse && pnpm install + git checkout -b my-change + ``` + +2. **Make your change**, with tests. New feature β†’ integration test in [`apps/examples/tests`](apps/examples/tests) (or a unit test in [`packages/mousse/tests`](packages/mousse/tests) for pure logic). Check everything locally : + + ```sh + pnpm build && pnpm check-types && pnpm test + ``` + +3. **Record your change** with a changeset β€” it describes the change and the version bump it deserves (patch/minor/major), and ends up in the changelog : + + ```sh + pnpm changeset + ``` + + Answer the prompts, then commit the generated `.changeset/*.md` file along with your change. + +4. **Open a Pull Request** against `main`. CI runs build, types and the full test suites. + +### Releasing (maintainer) + +Merged changesets accumulate on `main`; the [release workflow](.github/workflows/release.yml) maintains a "Version Packages" PR aggregating them. Merging that PR bumps versions, updates changelogs and publishes `mousse` to npm (requires the `NPM_TOKEN` repository secret). Manual alternative : `pnpm changeset version && pnpm release`. ## License [MIT](LICENSE) + + + + +> Revoir la fonctionnalitΓ© testing (pas vraiment un module ?) +> Mecanique de room pour les sse et ws ? \ No newline at end of file diff --git a/apps/benchmarks/package.json b/apps/benchmarks/package.json index 05d81b1..a2c6cf0 100644 --- a/apps/benchmarks/package.json +++ b/apps/benchmarks/package.json @@ -12,12 +12,16 @@ "autocannon": "^8.0.0", "express": "^5.1.0", "fastify": "^5.4.0", + "hono": "^4.6.0", + "@hono/node-server": "^1.13.0", + "koa": "^2.15.0", "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.67.0" }, "devDependencies": { "@core/tsconfig" : "workspace:*", "@types/express": "^5.0.3", + "@types/koa": "^2.15.0", "@types/autocannon": "^7.12.7", "@types/node": "^26.1.1", "typescript": "5.9.2" diff --git a/apps/benchmarks/src/hono.ts b/apps/benchmarks/src/hono.ts new file mode 100644 index 0000000..06a682a --- /dev/null +++ b/apps/benchmarks/src/hono.ts @@ -0,0 +1,10 @@ +import { Hono } from 'hono' +import { serve } from '@hono/node-server' + +const app = new Hono() + +app.get('/hello', (c) => c.text('Hello from Hono')) + +serve({ fetch: app.fetch, port: 3015 }, () => { + console.log('Hono listening on http://localhost:3015') +}) diff --git a/apps/benchmarks/src/index.ts b/apps/benchmarks/src/index.ts index 5057a7f..704bbc8 100644 --- a/apps/benchmarks/src/index.ts +++ b/apps/benchmarks/src/index.ts @@ -7,7 +7,10 @@ const servers = [ { name: 'uWebSockets.js', host: "localhost", port: 3010, file: './dist/uwebsocket.js' }, { name: 'mousse', host: "localhost", port: 3011, file: './dist/mousse.js' }, { name: 'express', host: "localhost", port: 3012, file: './dist/express.js' }, - { name: 'fastify', host: "127.0.0.1", port: 3013, file: './dist/fastify.js' } + { name: 'fastify', host: "127.0.0.1", port: 3013, file: './dist/fastify.js' }, + { name: 'hono', host: "127.0.0.1", port: 3015, file: './dist/hono.js' }, + { name: 'node:http', host: "localhost", port: 3016, file: './dist/nodehttp.js' }, + { name: 'koa', host: "localhost", port: 3017, file: './dist/koa.js' } ] const waitForPort = (host : string, port : number, timeoutMs = 5000) : Promise => new Promise((resolve, reject) => { diff --git a/apps/benchmarks/src/koa.ts b/apps/benchmarks/src/koa.ts new file mode 100644 index 0000000..07f382a --- /dev/null +++ b/apps/benchmarks/src/koa.ts @@ -0,0 +1,15 @@ +import Koa from 'koa' + +const app = new Koa() + +// Koa is middleware-only : minimal routing by hand, no @koa/router overhead +app.use((ctx) => { + if (ctx.path === '/hello' && ctx.method === 'GET') + ctx.body = 'Hello from Koa' + else + ctx.status = 404 +}) + +app.listen(3017, () => { + console.log('Koa listening on http://localhost:3017') +}) diff --git a/apps/benchmarks/src/nodehttp.ts b/apps/benchmarks/src/nodehttp.ts new file mode 100644 index 0000000..2ed3978 --- /dev/null +++ b/apps/benchmarks/src/nodehttp.ts @@ -0,0 +1,15 @@ +import { createServer } from 'node:http' + +// Raw node:http baseline : no routing, no framework +createServer((req, res) => { + if (req.url === '/hello' && req.method === 'GET') { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.end('Hello from node:http') + } + else { + res.writeHead(404) + res.end() + } +}).listen(3016, () => { + console.log('node:http listening on http://localhost:3016') +}) diff --git a/apps/examples/broadcast.ts b/apps/examples/broadcast.ts deleted file mode 100644 index 2f2783f..0000000 --- a/apps/examples/broadcast.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {Mousse} from 'mousse'; - -const app = new Mousse({ - // Options - // key_file_name: 'misc/key.pem', - // cert_file_name: 'misc/cert.pem', - // passphrase: '1234' -}) -// The ws handler receives an already connected WSContext -.ws('/subscriber', {maxBackPressure : 16 * 1024}, (c) => { - // Let this client listen to topic "broadcast" - c.subscribe('broadcast'); - - c.onMessage((ws, message) => { - // Print the message - console.log(message); - }); -}) -.ws('/broadcaster', {maxBackPressure : 16 * 1024}, (c) => { - c.onMessage((ws, message, isBinary) => { - // Broadcast the message to every subscriber, across all ws routes - c.mousse.publish('broadcast', message, isBinary); - }); -}) -.setDefaultHandler({handle : (c) => c.respond('Nothing to see here !')}) -.listen(8080, (token, port) => { - if (token) { - console.log('Listening to port ' + port); - } else { - console.log('Failed to listen to port ' + port); - } -}); diff --git a/apps/examples/contextExtension.ts b/apps/examples/contextExtension.ts deleted file mode 100644 index d9891d0..0000000 --- a/apps/examples/contextExtension.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {Mousse} from 'mousse'; - -type ContextExtension = { - myAdditionalProp : boolean; - myAdditionalFunction : () => void; -} - -const app = new Mousse(); - -app.post<{Body : "test"}, ContextExtension>('/', - // A middleware can now extends context - (c) => { - - c.myAdditionalProp = true; - c.myAdditionalFunction = () => console.log('Hello'); - }, - (c) => { - // No type errors - console.log(c.myAdditionalProp); - c.myAdditionalFunction(); -}); \ No newline at end of file diff --git a/apps/examples/gracefulShutdown.ts b/apps/examples/gracefulShutdown.ts deleted file mode 100644 index ec0a1c8..0000000 --- a/apps/examples/gracefulShutdown.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {Mousse} from 'mousse'; - -const app = new Mousse({ - // Options - // key_file_name: 'misc/key.pem', - // cert_file_name: 'misc/cert.pem', - // passphrase: '1234' -}) -.get('/shutdown', (c) => { - c.respond('Shutting down know !'); - c.mousse.stop(); -}) -.listen(8080); \ No newline at end of file diff --git a/apps/examples/package.json b/apps/examples/package.json index efa7700..413bf78 100644 --- a/apps/examples/package.json +++ b/apps/examples/package.json @@ -3,6 +3,8 @@ "type": "module", "private": true, "scripts": { + "start": "node run.ts", + "test": "node --test \"tests/*.test.ts\"", "check-types": "tsc --noEmit" }, "dependencies": { @@ -12,6 +14,7 @@ "@core/tsconfig": "workspace:*", "@types/node": "^26.1.1", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "zod": "^4.1.0" } } diff --git a/apps/examples/run.ts b/apps/examples/run.ts new file mode 100644 index 0000000..e31e9b1 --- /dev/null +++ b/apps/examples/run.ts @@ -0,0 +1,30 @@ +import { readdir } from 'node:fs/promises'; + +/** + * Runs one example : pnpm start + * Every example exports its app (and optionally a port, or a run() taking over the launch). + */ +const name = process.argv[2]; + +if(!name){ + const files = await readdir(new URL('./src', import.meta.url)); + console.log('Usage : pnpm start \n\nAvailable examples :'); + for(const file of files.filter((f) => f.endsWith('.ts'))) + console.log(` - ${file.replace(/\.ts$/, '')}`); + process.exit(1); +} + +const example = await import(`./src/${name}.ts`).catch(() => { + console.error(`Unknown example '${name}'. Run without argument to list them.`); + process.exit(1); +}); + +if(example.run){ + await example.run(); +} +else{ + const port = example.port ?? 3000; + example.app.listen(port, (listenSocket : unknown, boundPort : number) => { + console.log(listenSocket ? `[${name}] listening on http://localhost:${boundPort}` : `[${name}] failed to listen on port ${port}`); + }); +} diff --git a/apps/examples/asyncFunction.ts b/apps/examples/src/asyncFunction.ts similarity index 59% rename from apps/examples/asyncFunction.ts rename to apps/examples/src/asyncFunction.ts index eac086a..2e0ce27 100644 --- a/apps/examples/asyncFunction.ts +++ b/apps/examples/src/asyncFunction.ts @@ -1,18 +1,19 @@ -import {Mousse, Context} from 'mousse'; +import { Mousse, Context } from 'mousse'; + +export const port = 8080; function delay(t : number, val : string) : Promise{ - return new Promise(function(resolve) { - setTimeout(function() { - resolve(val); - }, t); - }); + return new Promise((resolve) => setTimeout(() => resolve(val), t)); } async function someAsyncTask() { return delay(500, 'Hey wait for me!'); } -const app = new Mousse({ +/** + * Handlers can be async : the context stays fully usable after any await. + */ +export const app = new Mousse({ // SSL options : providing key + cert switches to an SSLApp // key_file_name: 'misc/key.pem', // cert_file_name: 'misc/cert.pem', @@ -25,11 +26,4 @@ const app = new Mousse({ // context.respond(r); return r; - -}).listen(8080, (token, port) => { - if (token) { - console.log('Listening to port ' + port); - } else { - console.log('Failed to listen to port ' + port); - } -}); \ No newline at end of file +}); diff --git a/apps/examples/src/contextExtension.ts b/apps/examples/src/contextExtension.ts new file mode 100644 index 0000000..7607e88 --- /dev/null +++ b/apps/examples/src/contextExtension.ts @@ -0,0 +1,25 @@ +import { Mousse } from 'mousse'; + +export const port = 8080; + +type ContextExtension = { + myAdditionalProp : boolean; + myAdditionalFunction : () => string; +} + +/** + * Middlewares often attach data to the context (a user, a session...). + * Declare the extension in the second generic slot : every function of the route sees it, no cast needed. + */ +export const app = new Mousse(); + +app.post<{Body : 'test'}, ContextExtension>('/', + // A middleware can now extend the context + (c) => { + c.myAdditionalProp = true; + c.myAdditionalFunction = () => 'Hello'; + }, + (c) => { + // No type errors + return {prop : c.myAdditionalProp, greeting : c.myAdditionalFunction()}; + }); diff --git a/apps/examples/src/docgen.ts b/apps/examples/src/docgen.ts new file mode 100644 index 0000000..6536b51 --- /dev/null +++ b/apps/examples/src/docgen.ts @@ -0,0 +1,42 @@ +import { Mousse } from 'mousse'; +import { z } from 'zod'; + +export const port = 8080; + +/** + * Route schemas + doc metadata feed the documentation generators. + * Register one DocSchemaTranslator per schema vendor : generation is strict by + * default and throws if a vendor used by the routes has no translator. + * + * Running this example generates ./docs-output then serves it statically. + */ +export const app = new Mousse(); + +app.addDocSchemaTranslator('zod', (schema) => z.toJSONSchema(schema as any)); + +app.get('/users/:id', { + schemas : { + Params : z.object({id : z.string()}), + Response : z.object({id : z.string(), name : z.string()}) + }, + doc : {summary : 'Get a user', description : 'Returns a single user by id.', tags : ['Users']} +}, (c) => ({id : c.param('id'), name : 'Guillaume'})); + +app.post('/users', { + schemas : { + Body : z.object({name : z.string(), email : z.string().email()}), + Response : z.object({id : z.string()}) + }, + doc : {summary : 'Create a user', tags : ['Users']} +}, async (c) => ({id : '1'})); + +export async function run(){ + const outputDir = './docs-output'; + + await app.generateHTMLDoc({outputDir, title : 'Example API'}); + await app.generateOpenAPIDoc({outputDir, info : {title : 'Example API', version : '1.0.0'}}); + console.log(`Documentation generated in ${outputDir}`); + + app.get('/*', (c) => c.file(`${outputDir}${c.url === '/' ? '/index.html' : c.url}`)); + app.listen(port, (ls, p) => console.log(ls ? `Browse it on http://localhost:${p}` : 'Failed to listen')); +} diff --git a/apps/examples/src/gracefulShutdown.ts b/apps/examples/src/gracefulShutdown.ts new file mode 100644 index 0000000..033358e --- /dev/null +++ b/apps/examples/src/gracefulShutdown.ts @@ -0,0 +1,12 @@ +import { Mousse } from 'mousse'; + +export const port = 8080; + +/** + * stop() closes the listen socket : in-flight work finishes, no new connection is accepted. + */ +export const app = new Mousse() + .get('/shutdown', (c) => { + c.respond('Shutting down now !'); + c.mousse.stop(); + }); diff --git a/apps/examples/src/pubsub.ts b/apps/examples/src/pubsub.ts new file mode 100644 index 0000000..4cb906e --- /dev/null +++ b/apps/examples/src/pubsub.ts @@ -0,0 +1,27 @@ +import { Mousse } from 'mousse'; + +export const port = 8080; + +/** + * uWS pub/sub : subscribers listen on a topic, a broadcaster publishes app-wide. + * c.mousse.publish reaches every ws route (and the sender) ; c.publish stays + * scoped to the route and skips the sender. + */ +export const app = new Mousse() + // The ws handler receives an already connected WSContext + .ws('/subscriber', {maxBackPressure : 16 * 1024}, (c) => { + // Let this client listen to topic "broadcast" + c.subscribe('broadcast'); + + c.onMessage((ws, message) => { + // Print the message + console.log(message); + }); + }) + .ws('/broadcaster', {maxBackPressure : 16 * 1024}, (c) => { + c.onMessage((ws, message, isBinary) => { + // Broadcast the message to every subscriber, across all ws routes + c.mousse.publish('broadcast', message, isBinary); + }); + }) + .setDefaultHandler({handle : (c) => c.respond('Nothing to see here !')}); diff --git a/apps/examples/src/routers.ts b/apps/examples/src/routers.ts new file mode 100644 index 0000000..ce6d8b5 --- /dev/null +++ b/apps/examples/src/routers.ts @@ -0,0 +1,30 @@ +import { Mousse, Router } from 'mousse'; + +export const port = 8080; + +/** + * Routers are mountable collections of routes : mounting copies them with the + * pattern prefix, so a definition can be reused at several paths. + */ +const users = new Router({ + // Router defaults apply to every route it contains ; route options win over them + logger : {log : (data) => console.log('[users]', data)} +}) + .get('/', (c) => { + c.log('listing users'); + return [{id : 1}, {id : 2}]; + }) + .get('/:id', (c) => ({id : c.param('id')})); + +const admin = new Router() + .use((c) => { + // Scoped middleware : runs for every route of this router + if(!c.getHeader('authorization')) + return c.status(401).respond('Who are you ?'); + }) + .del('/users/:id', (c) => ({deleted : c.param('id')})); + +export const app = new Mousse() + .use('/users', users) + .use('/admin', admin) + .setDefaultHandler({handle : (c) => c.status(404).respond('Nothing here')}); diff --git a/apps/examples/src/schemas.ts b/apps/examples/src/schemas.ts new file mode 100644 index 0000000..06ba92d --- /dev/null +++ b/apps/examples/src/schemas.ts @@ -0,0 +1,37 @@ +import { Mousse, SchemaValidationError } from 'mousse'; +import { z } from 'zod'; + +export const port = 8080; + +/** + * Any Standard Schema library works as-is (Zod, ArkType, Valibot...) : + * runtime validation + full typing inferred from the schemas, no generics to write. + */ +export const app = new Mousse(); + +app.post('/orgs/:org/users', { + schemas : { + Body : z.object({name : z.string().trim(), email : z.string().email()}), + Query : z.object({dryrun : z.coerce.boolean().optional()}), + Params : z.object({org : z.string()}), + Response : z.object({id : z.number(), org : z.string(), name : z.string()}) + } +}, async (c) => { + const body = await c.body(); // { name: string; email: string } β€” validated AND typed + c.query.dryrun; // boolean | undefined β€” coerced by the schema + + return {id : 1, org : c.param('org'), name : body.name}; // must match the Response schema +}); + +// Customize validation failures with an httpErrorHandler +app.post('/strict', { + schemas : {Body : z.object({value : z.number()})}, + httpErrorHandler : { + handle : (error, c) => { + if(error instanceof SchemaValidationError) + return c.status(422).json({part : error.part, issues : error.issues}); + + c.status(500).respond(); + } + } +}, async (c) => await c.body()); diff --git a/apps/examples/src/sse.ts b/apps/examples/src/sse.ts new file mode 100644 index 0000000..c0475c6 --- /dev/null +++ b/apps/examples/src/sse.ts @@ -0,0 +1,25 @@ +import { Mousse } from 'mousse'; + +export const port = 8080; + +/** + * Server-Sent Events : the sse handler receives an already sustained context. + * Try it : curl -N http://localhost:8080/clock + */ +export const app = new Mousse() + .sse('/clock', (c) => { + c.send('welcome'); + + const interval = setInterval(() => { + // c.ended becomes true when the client disconnects ; send() would throw + if(c.ended) + clearInterval(interval); + else + c.send({time : new Date().toISOString()}); + }, 1000); + }) + // Any get route can open a channel itself with sustain() + .get('/manual', (c) => { + c.sustain(); + c.send('sustained by hand'); + }); diff --git a/apps/examples/tests/context.test.ts b/apps/examples/tests/context.test.ts new file mode 100644 index 0000000..c041c37 --- /dev/null +++ b/apps/examples/tests/context.test.ts @@ -0,0 +1,99 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { Router, testRouter } from 'mousse'; + +describe('request', () => { + test('headers are exposed with lowercased keys', () => testRouter( + new Router().get('/headers', (c) => ({auth : c.getHeader('authorization'), missing : c.getHeader('x-missing')})), + async (client) => { + const res = await client.get('/headers', {headers : {Authorization : 'Bearer token'}}); + assert.deepEqual(await res.json(), {auth : 'Bearer token', missing : null}); + } + )); + + test('query string parsing : arrays, +, [] stripped', () => testRouter( + new Router().get('/q', (c) => c.query), + async (client) => { + const res = await client.get('/q?a=1&tags[]=x&tags[]=y&msg=hello+world'); + assert.deepEqual(await res.json(), {a : '1', tags : ['x', 'y'], msg : 'hello world'}); + } + )); + + test('json body is parsed and cached', () => testRouter( + new Router().post('/echo', async (c) => { + const first = await c.body(); + const second = await c.body(); + return {same : first === second, body : first}; + }), + async (client) => { + const res = await client.post('/echo', {name : 'mousse'}); + assert.deepEqual(await res.json(), {same : true, body : {name : 'mousse'}}); + } + )); + + test('body stays readable after other async work', () => testRouter( + new Router().post('/late', async (c) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return await c.body(); + }), + async (client) => { + assert.deepEqual(await (await client.post('/late', {late : true})).json(), {late : true}); + } + )); + + test('urlencoded body parsed to a record', () => testRouter( + new Router().post('/form', async (c) => await c.body()), + async (client) => { + const res = await client.post('/form', new URLSearchParams({a : '1', b : 'two'})); + assert.deepEqual(await res.json(), {a : '1', b : 'two'}); + } + )); + + test('method and route pattern are exposed', () => testRouter( + new Router().get('/users/:id', (c) => ({method : c.method(), route : c.route, url : c.url})), + async (client) => { + assert.deepEqual(await (await client.get('/users/5')).json(), {method : 'get', route : '/users/:id', url : '/users/5'}); + } + )); +}); + +describe('response', () => { + test('status, headers and json chain', () => testRouter( + new Router().get('/chained', (c) => c.status(201).header('x-request-id', 'abc').json({ok : true})), + async (client) => { + const res = await client.get('/chained'); + assert.equal(res.status, 201); + assert.equal(res.headers.get('x-request-id'), 'abc'); + assert.deepEqual(await res.json(), {ok : true}); + } + )); + + test('html responds with text/html', () => testRouter( + new Router().get('/page', (c) => c.html('

Hi

')), + async (client) => { + const res = await client.get('/page'); + assert.ok(res.headers.get('content-type')?.includes('text/html')); + assert.equal(await res.text(), '

Hi

'); + } + )); + + test('redirect responds 302 with location', () => testRouter( + new Router().get('/old', (c) => c.redirect('/new')), + async (client) => { + const res = await client.get('/old', {redirect : 'manual'}); + assert.equal(res.status, 302); + assert.equal(res.headers.get('location'), '/new'); + } + )); + + test('responding twice is a no-op, never an error', () => testRouter( + new Router().get('/twice', (c) => { + c.respond('first'); + c.respond('second'); + return 'third'; + }), + async (client) => { + assert.equal(await (await client.get('/twice')).text(), 'first'); + } + )); +}); diff --git a/apps/examples/tests/docgen.test.ts b/apps/examples/tests/docgen.test.ts new file mode 100644 index 0000000..f58fb3d --- /dev/null +++ b/apps/examples/tests/docgen.test.ts @@ -0,0 +1,110 @@ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Router, Mousse, toOpenAPI } from 'mousse'; +import { z } from 'zod'; + +const outputDir = await mkdtemp(join(tmpdir(), 'mousse-docgen-')); +after(() => rm(outputDir, {recursive : true, force : true})); + +const zodTranslator = (schema : any) => z.toJSONSchema(schema); + +function usersRouter(){ + return new Router() + .get('/:id', { + schemas : { + Params : z.object({id : z.string()}), + Response : z.object({id : z.string()}) + }, + doc : {summary : 'Get user', tags : ['Users']} + }, (c) => ({id : c.param('id')})) + .post('/', { + schemas : {Body : z.object({name : z.string()}), Query : z.object({dryrun : z.string().optional()})} + }, async (c) => await c.body()); +} + +describe('DocSchemaTranslator registry', () => { + test('strict by default : missing vendor throws, listing it', async () => { + await assert.rejects( + usersRouter().generateOpenAPIDoc({outputDir}), + (error : any) => error.message.includes(`'zod'`) && error.message.includes('DocSchemaTranslator') + ); + }); + + test('lenient documents unknown schemas as empty instead', async () => { + await usersRouter().generateOpenAPIDoc({outputDir, fileName : 'lenient.json', lenient : true}); + const doc = JSON.parse(await readFile(join(outputDir, 'lenient.json'), 'utf8')); + assert.ok(doc.paths['/{id}']); + }); + + test('registry merges by union on mount, target vendors kept', async () => { + const users = usersRouter().addDocSchemaTranslator('zod', zodTranslator); + + const app = new Mousse(); + app.addDocSchemaTranslator('zod', () => ({type : 'string', 'x-from' : 'target'})); + app.use('/users', users); // must NOT override the app's zod translator + + await app.generateOpenAPIDoc({outputDir, fileName : 'merged.json'}); + const doc = JSON.parse(await readFile(join(outputDir, 'merged.json'), 'utf8')); + const schema = doc.paths['/users/{id}'].get.responses['200'].content['application/json'].schema; + assert.equal(schema['x-from'], 'target'); + }); + + test('per-call translators option wins over the registry', async () => { + const router = usersRouter().addDocSchemaTranslator('zod', () => ({'x-from' : 'registry'})); + + await router.generateOpenAPIDoc({ + outputDir, fileName : 'override.json', + translators : {zod : () => ({'x-from' : 'option'})} + }); + const doc = JSON.parse(await readFile(join(outputDir, 'override.json'), 'utf8')); + assert.equal(doc.paths['/{id}'].get.responses['200'].content['application/json'].schema['x-from'], 'option'); + }); +}); + +describe('generateOpenAPIDoc', () => { + test('produces a valid OpenAPI 3.1 structure from route schemas', async () => { + const router = usersRouter().addDocSchemaTranslator('zod', zodTranslator); + await router.generateOpenAPIDoc({outputDir, fileName : 'api.json', info : {title : 'Users API', version : '2.0.0'}}); + + const doc = JSON.parse(await readFile(join(outputDir, 'api.json'), 'utf8')); + assert.equal(doc.openapi, '3.1.0'); + assert.equal(doc.info.title, 'Users API'); + + const get = doc.paths['/{id}'].get; + assert.equal(get.summary, 'Get user'); + assert.deepEqual(get.tags, ['Users']); + assert.equal(get.parameters[0].in, 'path'); + + const post = doc.paths['/'].post; + assert.equal(post.requestBody.required, true); + assert.equal(post.parameters.find((p : any) => p.in === 'query').name, 'dryrun'); + }); + + test('toOpenAPI stays usable as a pure function', () => { + const doc = toOpenAPI([], {info : {title : 'Empty'}}); + assert.equal(doc.info.title, 'Empty'); + assert.deepEqual(doc.paths, {}); + }); +}); + +describe('generateHTMLDoc', () => { + test('produces index, group pages and translated schema views', async () => { + const app = new Mousse().use('/users', usersRouter()); + app.addDocSchemaTranslator('zod', zodTranslator); + await app.generateHTMLDoc({outputDir : join(outputDir, 'html'), title : 'Test API'}); + + const index = await readFile(join(outputDir, 'html', 'index.html'), 'utf8'); + assert.ok(index.includes('Test API')); + + const users = await readFile(join(outputDir, 'html', 'router', 'users.html'), 'utf8'); + assert.ok(users.includes('/users/:id')); + assert.ok(users.includes('string')); + + // Untagged routes fall into the API group + const api = await readFile(join(outputDir, 'html', 'router', 'api.html'), 'utf8'); + assert.ok(api.includes('/users')); + }); +}); diff --git a/apps/examples/tests/examples.test.ts b/apps/examples/tests/examples.test.ts new file mode 100644 index 0000000..04db9f2 --- /dev/null +++ b/apps/examples/tests/examples.test.ts @@ -0,0 +1,58 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { testRouter } from 'mousse'; + +import { app as asyncFunction } from '../src/asyncFunction.ts'; +import { app as contextExtension } from '../src/contextExtension.ts'; +import { app as schemas } from '../src/schemas.ts'; +import { app as routers } from '../src/routers.ts'; +import { app as sse } from '../src/sse.ts'; +import { app as docgen } from '../src/docgen.ts'; + +/** + * Examples are executable documentation : each one exports its app, + * so RouteTester can verify they keep working against the current package. + */ +describe('examples smoke tests', () => { + test('asyncFunction responds after awaiting', () => testRouter(asyncFunction, async (client) => { + assert.equal(await (await client.get('/anything')).text(), 'Hey wait for me!'); + })); + + test('contextExtension middleware extends the context', () => testRouter(contextExtension, async (client) => { + assert.deepEqual(await (await client.post('/', 'test')).json(), {prop : true, greeting : 'Hello'}); + })); + + test('schemas validates and types the request', () => testRouter(schemas, async (client) => { + const created = await client.post('/orgs/acme/users', {name : ' Ada ', email : 'ada@lovelace.dev'}); + assert.deepEqual(await created.json(), {id : 1, org : 'acme', name : 'Ada'}); + + assert.equal((await client.post('/orgs/acme/users', {name : 42})).status, 400); + + const custom = await client.post('/strict', {value : 'not-a-number'}); + assert.equal(custom.status, 422); + assert.equal((await custom.json()).part, 'body'); + })); + + test('routers composition, defaults and scoped middleware', () => testRouter(routers, async (client) => { + assert.deepEqual(await (await client.get('/users')).json(), [{id : 1}, {id : 2}]); + assert.deepEqual(await (await client.get('/users/7')).json(), {id : '7'}); + + assert.equal((await client.del('/admin/users/7')).status, 401); + const authorized = await client.del('/admin/users/7', undefined, {headers : {authorization : 'yes'}}); + assert.deepEqual(await authorized.json(), {deleted : '7'}); + + assert.equal((await client.get('/nowhere')).status, 404); + })); + + test('sse streams its welcome frame', () => testRouter(sse, async (client) => { + const res = await client.get('/clock'); + const reader = res.body!.getReader(); + const {value} = await reader.read(); + assert.ok(new TextDecoder().decode(value).includes('welcome')); + await reader.cancel(); + })); + + test('docgen routes respond, registry is set up', () => testRouter(docgen, async (client) => { + assert.deepEqual(await (await client.get('/users/1')).json(), {id : '1', name : 'Guillaume'}); + })); +}); diff --git a/apps/examples/tests/routing.test.ts b/apps/examples/tests/routing.test.ts new file mode 100644 index 0000000..3604fc2 --- /dev/null +++ b/apps/examples/tests/routing.test.ts @@ -0,0 +1,106 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { Router, Mousse, testRouter } from 'mousse'; + +describe('routing', () => { + test('route params, lowercased names', () => testRouter( + new Router().get('/users/:id/books/:bookID', (c) => ({user : c.param('id'), book : c.param('bookid')})), + async (client) => { + const res = await client.get('/users/7/books/42'); + assert.deepEqual(await res.json(), {user : '7', book : '42'}); + } + )); + + test('wildcard routes', () => testRouter( + new Router().get('/files/*', (c) => c.url), + async (client) => { + assert.equal(await (await client.get('/files/a/b.txt')).text(), '/files/a/b.txt'); + } + )); + + test('handler return values : string, object, undefined', () => testRouter( + new Router() + .get('/text', () => 'plain') + .get('/json', () => ({ok : true})) + .get('/empty', (c) => { c.status(204); }), + async (client) => { + assert.equal(await (await client.get('/text')).text(), 'plain'); + assert.deepEqual(await (await client.get('/json')).json(), {ok : true}); + assert.equal((await client.get('/empty')).status, 204); + } + )); + + test('middlewares run in order before the handler', () => testRouter( + new Router() + .get('/traced', + (c) => { c.trace = ['first']; }, + (c) => { c.trace.push('second'); }, + (c) => [...c.trace, 'handler']), + async (client) => { + assert.deepEqual(await (await client.get('/traced')).json(), ['first', 'second', 'handler']); + } + )); + + test('scoped middleware matches segment boundaries only', async () => { + const router = new Router(); + router.use('/user', (c) => { c.seen = true; }); + router.get('/user/me', (c) => ({seen : c.seen ?? false})); + router.get('/users', (c) => ({seen : c.seen ?? false})); + + await testRouter(router, async (client) => { + assert.deepEqual(await (await client.get('/user/me')).json(), {seen : true}); + assert.deepEqual(await (await client.get('/users')).json(), {seen : false}); + }); + }); + + test('mounted router routes are prefixed', async () => { + const users = new Router().get('/:id', (c) => ({id : c.param('id')})); + const app = new Router().use('/users', users); + + await testRouter(app, async (client) => { + assert.deepEqual(await (await client.get('/users/1')).json(), {id : '1'}); + }); + }); + + test('default handler catches unmatched routes', async () => { + const app = new Mousse().get('/known', () => 'ok'); + app.setDefaultHandler({handle : (c) => c.status(404).respond('Nothing here')}); + + await testRouter(app, async (client) => { + const res = await client.get('/unknown'); + assert.equal(res.status, 404); + assert.equal(await res.text(), 'Nothing here'); + }); + }); +}); + +describe('error handling', () => { + test('a throwing handler responds 500, never an implicit 200', () => testRouter( + new Router().get('/boom', () => { throw new Error('boom'); }, ), + async (client) => { + assert.equal((await client.get('/boom')).status, 500); + } + )); + + test('httpErrorHandler may respond itself', () => testRouter( + new Router().get('/teapot', { + httpErrorHandler : {handle : (error, c) => c.status(418).respond(error.message)} + }, () => { throw new Error('short and stout'); }), + async (client) => { + const res = await client.get('/teapot'); + assert.equal(res.status, 418); + assert.equal(await res.text(), 'short and stout'); + } + )); + + test('router-level httpErrorHandler applies to its routes, route option wins', async () => { + const router = new Router({httpErrorHandler : {handle : (_e, c) => c.status(502).respond()}}); + router.get('/inherit', () => { throw new Error(); }); + router.get('/own', {httpErrorHandler : {handle : (_e, c) => c.status(503).respond()}}, () => { throw new Error(); }); + + await testRouter(router, async (client) => { + assert.equal((await client.get('/inherit')).status, 502); + assert.equal((await client.get('/own')).status, 503); + }); + }); +}); diff --git a/apps/examples/tests/schemas.test.ts b/apps/examples/tests/schemas.test.ts new file mode 100644 index 0000000..27e62ae --- /dev/null +++ b/apps/examples/tests/schemas.test.ts @@ -0,0 +1,111 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { Router, testRouter, SchemaValidationError } from 'mousse'; +import { z } from 'zod'; + +describe('schema validation', () => { + test('valid request passes, schema output transformations applied', () => testRouter( + new Router().post('/users', { + schemas : { + Body : z.object({name : z.string().trim(), age : z.number()}), + Response : z.object({created : z.boolean()}) + } + }, async (c) => { + const body = await c.body(); + assert.equal(body.name, 'Guillaume'); // trimmed by the schema + return {created : true}; + }), + async (client) => { + const res = await client.post('/users', {name : ' Guillaume ', age : 30}); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), {created : true}); + } + )); + + test('invalid body responds 400 with issues', () => testRouter( + new Router().post('/users', { + schemas : {Body : z.object({name : z.string()})} + }, async (c) => await c.body()), + async (client) => { + const res = await client.post('/users', {name : 42}); + assert.equal(res.status, 400); + const payload = await res.json(); + assert.ok(Array.isArray(payload.issues)); + } + )); + + test('invalid query responds 400 before the handler runs', async () => { + let handlerRan = false; + const router = new Router().get('/search', { + schemas : {Query : z.object({limit : z.coerce.number().max(10)})} + }, (c) => { handlerRan = true; return c.query; }); + + await testRouter(router, async (client) => { + assert.equal((await client.get('/search?limit=100')).status, 400); + assert.equal(handlerRan, false); + }); + }); + + test('query coercion feeds the handler with schema output', () => testRouter( + new Router().get('/search', { + schemas : {Query : z.object({limit : z.coerce.number()})} + }, (c) => ({limit : c.query.limit, type : typeof c.query.limit})), + async (client) => { + assert.deepEqual(await (await client.get('/search?limit=5')).json(), {limit : 5, type : 'number'}); + } + )); + + test('invalid params respond 400', () => testRouter( + new Router().get('/orgs/:org', { + schemas : {Params : z.object({org : z.string().regex(/^[a-z]+$/)})} + }, (c) => c.param('org')), + async (client) => { + assert.equal((await client.get('/orgs/valid')).status, 200); + assert.equal((await client.get('/orgs/INVALID42')).status, 400); + } + )); + + test('invalid response is a 500 server bug', () => testRouter( + new Router().get('/broken', { + schemas : {Response : z.object({id : z.number()})} + }, (c) => { c.json({id : 'not-a-number'} as any); }), + async (client) => { + assert.equal((await client.get('/broken')).status, 500); + } + )); + + test('body validation is lazy : handler never reading the body never fails', () => testRouter( + new Router().post('/lazy', { + schemas : {Body : z.object({strict : z.string()})} + }, () => 'never read the body'), + async (client) => { + const res = await client.post('/lazy', {strict : 42}); + assert.equal(res.status, 200); + } + )); + + test('custom httpErrorHandler receives SchemaValidationError with part', () => testRouter( + new Router().post('/custom', { + schemas : {Body : z.object({name : z.string()})}, + httpErrorHandler : { + handle : (error, c) => { + if(error instanceof SchemaValidationError) + return c.status(422).json({part : error.part}); + c.status(500).respond(); + } + } + }, async (c) => await c.body()), + async (client) => { + const res = await client.post('/custom', {name : 1}); + assert.equal(res.status, 422); + assert.deepEqual(await res.json(), {part : 'body'}); + } + )); + + test('route without schemas validates nothing', () => testRouter( + new Router().post('/free', async (c) => await c.body()), + async (client) => { + assert.deepEqual(await (await client.post('/free', {anything : ['goes']})).json(), {anything : ['goes']}); + } + )); +}); diff --git a/apps/examples/tests/sse.test.ts b/apps/examples/tests/sse.test.ts new file mode 100644 index 0000000..38aad00 --- /dev/null +++ b/apps/examples/tests/sse.test.ts @@ -0,0 +1,55 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { Router, testRouter } from 'mousse'; + +async function readFrames(res : Response, count : number) : Promise { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let received = ''; + + while((received.match(/data:/g) ?? []).length < count){ + const {value, done} = await reader.read(); + if(done) + break; + received += decoder.decode(value, {stream : true}); + } + + await reader.cancel(); + return received; +} + +describe('server-sent events', () => { + test('sse handler receives an already sustained context', () => testRouter( + new Router().sse('/events', (c) => { + c.send('welcome'); + c.send({time : 123}); + }), + async (client) => { + const res = await client.get('/events'); + const frames = await readFrames(res, 2); + assert.ok(frames.includes('data: welcome') || frames.includes('data:welcome')); + assert.ok(frames.includes('{"time":123}')); + } + )); + + test('manual sustain on a regular get route', () => testRouter( + new Router().get('/manual', (c) => { + c.sustain(); + assert.equal(c.sustained, true); + c.send('hello'); + }), + async (client) => { + const frames = await readFrames(await client.get('/manual'), 1); + assert.ok(frames.includes('hello')); + } + )); + + test('send before sustain throws inside the handler, request gets a 500', () => testRouter( + new Router().get('/unsustained', (c) => { + c.send('too early'); + }), + async (client) => { + assert.equal((await client.get('/unsustained')).status, 500); + } + )); +}); diff --git a/apps/examples/tests/websocket.test.ts b/apps/examples/tests/websocket.test.ts new file mode 100644 index 0000000..e977c57 --- /dev/null +++ b/apps/examples/tests/websocket.test.ts @@ -0,0 +1,105 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { Mousse } from 'mousse'; + +/** + * WebSocket upgrades are not covered by RouteTester : these tests bind a real + * ephemeral port (listen(0)) and drive it with the native WebSocket client. + */ +function listen(app : Mousse) : Promise { + return new Promise((resolve, reject) => { + app.listen(0, (listenSocket, port) => listenSocket ? resolve(port) : reject(new Error('failed to listen'))); + }); +} + +function connect(url : string) : Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + socket.onopen = () => resolve(socket); + socket.onerror = () => reject(new Error(`connection failed on ${url}`)); + }); +} + +function nextMessage(socket : WebSocket) : Promise { + return new Promise((resolve) => { + socket.onmessage = (event) => resolve(typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data)); + }); +} + +describe('websockets', () => { + test('echo : handler runs connected, onMessage replies', async () => { + const app = new Mousse().ws('/echo', (c) => { + c.onMessage((_ws, message) => { c.send(message); }); + }); + const port = await listen(app); + + try{ + const socket = await connect(`ws://localhost:${port}/echo`); + const reply = nextMessage(socket); + socket.send('bubble'); + assert.equal(await reply, 'bubble'); + socket.close(); + } + finally{ + app.stop(); + } + }); + + test('upgrade middleware rejects by responding', async () => { + const app = new Mousse().ws('/private', + (c) => { + if(!c.getHeader('authorization')) + return c.status(401).respond(); + }, + (c) => { + c.send('connected'); + }); + const port = await listen(app); + + try{ + await assert.rejects(connect(`ws://localhost:${port}/private`)); + } + finally{ + app.stop(); + } + }); + + test('params and immediate send on connection', async () => { + const app = new Mousse().ws('/rooms/:room', (c) => { + c.send(`joined ${c.param('room')}`); + }); + const port = await listen(app); + + try{ + const socket = await connect(`ws://localhost:${port}/rooms/lobby`); + assert.equal(await nextMessage(socket), 'joined lobby'); + socket.close(); + } + finally{ + app.stop(); + } + }); + + test('publish reaches route subscribers, not the sender', async () => { + const app = new Mousse().ws<{Params : 'room'}>('/chat/:room', (c) => { + c.subscribe(c.param('room')); + c.onMessage((_ws, message) => { c.publish(c.param('room'), message); }); + }); + const port = await listen(app); + + try{ + const alice = await connect(`ws://localhost:${port}/chat/general`); + const bob = await connect(`ws://localhost:${port}/chat/general`); + + const bobReceives = nextMessage(bob); + alice.send('hi from alice'); + assert.equal(await bobReceives, 'hi from alice'); + + alice.close(); + bob.close(); + } + finally{ + app.stop(); + } + }); +}); diff --git a/apps/examples/tsconfig.json b/apps/examples/tsconfig.json index 68a61ae..5ff9e04 100644 --- a/apps/examples/tsconfig.json +++ b/apps/examples/tsconfig.json @@ -2,7 +2,9 @@ "extends": "@core/tsconfig/base", "compilerOptions": { "rootDir": ".", - "noEmit": true + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] }, - "include": ["*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "run.ts"] } diff --git a/docs/context.md b/docs/context.md index 2c205af..e83dff0 100644 --- a/docs/context.md +++ b/docs/context.md @@ -1,3 +1,5 @@ +[Β« Documentation index](../README.md#documentation) + # Context Every request is wrapped in a `Context` object (`c` in the examples) passed to each middleware and handler. It exposes the request, the response and utility methods for HTTP, SSE and WebSocket upgrades. @@ -69,4 +71,9 @@ Calling any respond method after the response ended is a no-op β€” never an erro | `c.mousse` | The Mousse instance that created the context | | `c.response` | The underlying Β΅WS response, for advanced use | -SSE members (`sustain`, `send`) are documented in [Server-Sent Events](server-sent-events.md). WebSocket route **handlers** receive a different, already-connected `WSContext` β€” the HTTP `Context` described here is what ws **middlewares** get during the upgrade request ; see [WebSockets](websockets.md). +SSE members (`sustain`, `send`) are documented in [Server-Sent Events](server-sent-events.md). WebSocket route **handlers** receive a different, already-connected `WSContext` β€” the HTTP `Context` described here is what ws **middlewares** get during the upgrade request; see [WebSockets](websockets.md). + +--- + +| [Β« Routing](routing.md) | [Documentation index](../README.md#documentation) | [Server-Sent Events Β»](server-sent-events.md) | +|:---|:---:|---:| diff --git a/docs/docgen.md b/docs/docgen.md index 3165062..6cafff9 100644 --- a/docs/docgen.md +++ b/docs/docgen.md @@ -1,6 +1,8 @@ +[Β« Documentation index](../README.md#documentation) + # Documentation generation -Mousse turns route [schemas](schemas.md) into documentation. Two independent outputs are available: a self-styled HTML site, and a standard OpenAPI 3.1 document for external tooling. Both read the same route metadata β€” nothing to duplicate. +Mousse turns route [schemas](schemas.md) into documentation. Two built-in outputs are available: a self-styled HTML site (`generateHTMLDoc`), and a standard OpenAPI 3.1 document (`generateOpenAPIDoc`) for external tooling. Both read the same route metadata β€” nothing to duplicate. Both exist on any `Router`, so a sub-router can be documented on its own, and on the app itself. ## Route metadata @@ -19,85 +21,70 @@ app.post('/orgs/:org/users', { `doc` is entirely optional β€” routes without it are still documented, just without summary/description/grouping. -## SchemaParser : schema β†’ JSON Schema +## DocSchemaTranslator: schema β†’ JSON Schema -Validation goes through [Standard Schema](schemas.md) directly and needs nothing extra. Documentation needs the actual **shape** of a schema, which Standard Schema does not expose β€” that is what `SchemaParser` is for : one small adapter per schema library, translating a schema to JSON Schema. +Validation goes through [Standard Schema](schemas.md) directly and needs nothing extra. Documentation needs the actual **shape** of a schema, which Standard Schema does not expose β€” that is what a `DocSchemaTranslator` is for: one small function per schema library, translating a schema to JSON Schema. ```ts -interface SchemaParser { - vendor: string; // matched against schema['~standard'].vendor - toJsonSchema(schema: StandardSchemaV1): JsonSchema; -} +type DocSchemaTranslator = (schema: StandardSchemaV1) => JsonSchema; ``` -Both generators take a `schemaParsers` array and pick the matching parser by vendor. - -### ArkType - -ArkType types expose `toJsonSchema()` natively β€” `ArkTypeSchemaParser` needs nothing else: +Translators are registered on the router (or the app) under their library's **vendor** string, one translator per vendor: ```ts -import { ArkTypeSchemaParser } from 'mousse'; +import { z } from 'zod'; +import { arkTypeDocSchemaTranslator } from 'mousse'; -const parsers = [new ArkTypeSchemaParser()]; +app.addDocSchemaTranslator('zod', z.toJSONSchema); // Zod >= 3.24 exposes this natively +app.addDocSchemaTranslator('arktype', arkTypeDocSchemaTranslator); // ready-made helper ``` -### Zod +Mousse stays dependency-free: it never imports a schema library itself, you hand it the translate function. -Zod (>= 3.24) exposes `z.toJSONSchema()` as a standalone function. Wrap it with `CustomSchemaParser`, mousse stays dependency-free either way: +> **Vendors**: the vendor is not a Mousse convention β€” it is a **required field of the Standard Schema spec** (`schema['~standard'].vendor`), self-declared by every conforming library: `'zod'`, `'arktype'`, `'valibot'`... The key you register a translator under must match that exact string; a translator registered under the wrong vendor is never picked. Mousse reads the vendor from each route schema automatically, and the strict-mode error (below) reports the precise missing string, so a typo surfaces immediately. -```ts -import { z } from 'zod'; -import { CustomSchemaParser } from 'mousse'; +When a router is mounted with `use`, its translators are merged into the target by union β€” a vendor already registered on the target is kept. A feature router can therefore ship its own translators and be documented from the app without extra setup. -const parsers = [new CustomSchemaParser('zod', (schema) => z.toJSONSchema(schema as any))]; -``` +## Generating -### Any other library +```ts +await app.generateHTMLDoc({ outputDir: './docs-output', title: 'My API' }); +// -> ./docs-output/index.html + router/ + model/ pages -Same pattern : `CustomSchemaParser(vendor, translate)`, `vendor` matching the string reported by that library's Standard Schema implementation. +await app.generateOpenAPIDoc({ outputDir: './docs-output', info: { title: 'My API', version: '1.0.0' } }); +// -> ./docs-output/openapi.json +``` -A route whose schema has no matching parser is simply documented as `any` β€” nothing throws. +Both accept a `translators` option (`Record`) taking precedence over the router registry for that call. -## OpenAPI output +### Strict by default -`toOpenAPI(httproutes, options)` assembles a plain OpenAPI 3.1 document from an array of `HTTPRoute` β€” pure data, no file I/O, usable with any renderer (or none). `OpenAPIDocGen` is the file-writing wrapper built on it, run through `app.document()` : +If a route schema uses a vendor with **no registered translator**, generation **throws**, listing every missing vendor at once: -```ts -import { OpenAPIDocGen } from 'mousse'; - -await app.document(new OpenAPIDocGen({ - outputDir: './docs-output', - schemaParsers: parsers, - info: { title: 'My API', version: '1.0.0' } -})); -// -> ./docs-output/openapi.json +``` +No DocSchemaTranslator registered for vendor(s) 'zod'. Register one with +addDocSchemaTranslator(vendor, translator) or through the 'translators' option, +or set 'lenient : true' to document these schemas as 'any'. ``` -`:param` patterns are converted to `{param}`, `Body` becomes `requestBody`, `Query`/`Params` become `parameters`, `Response` becomes the `200` response. WebSocket routes have no OpenAPI equivalent and are skipped. +This prevents silently shipping a documentation full of `any`. Pass `lenient: true` to fall back to `any` (HTML) or an empty schema (OpenAPI) instead. -## HTML output +## OpenAPI output -`HTMLDocGen` renders Mousse's own documentation site β€” colored route cards per verb, collapsible responses, highlighted TypeScript-style model views. No Swagger UI, no external renderer : +`generateOpenAPIDoc` writes an OpenAPI 3.1 json document. `:param` patterns are converted to `{param}`, `Body` becomes `requestBody`, `Query`/`Params` become `parameters`, `Response` becomes the `200` response. WebSocket routes have no OpenAPI equivalent and are skipped. -```ts -import { HTMLDocGen } from 'mousse'; +`toOpenAPI(httproutes, options)` remains available as a pure function β€” it assembles the document as plain data, no file I/O, usable with any renderer (or none). -await app.document(new HTMLDocGen({ - outputDir: './docs-output', - schemaParsers: parsers, - title: 'My API' -})); -``` +## HTML output -This produces: +`generateHTMLDoc` renders Mousse's own documentation site β€” colored route cards per verb, collapsible responses, highlighted TypeScript-style model views. No Swagger UI, no external renderer. It produces: ``` docs-output/ index.html router/ - users.html # one page per doc.tags[0] group ("Users") - system.html # routes without a tag fall into "API" + users.html # one page per doc.tags[0] group ("Users") + api.html # routes without a tag fall into "API" model/ models.html # named $defs / definitions referenced by a route, one page ``` @@ -106,14 +93,23 @@ A schema property using `$ref` (a named `$defs`/`definitions` entry) links to it ## Writing your own generator -Both outputs are just implementations of the same `DocGen` interface β€” swap or extend freely: +Both outputs are just implementations of the same `DocGen` interface, run through `generateDoc` β€” swap or extend freely: ```ts interface DocGen { - toDocumentation(httproutes: HTTPRoute[], wsroutes: WSRoute[]): void | Promise; + toDocumentation( + httproutes: HTTPRoute[], + wsroutes: WSRoute[], + translators?: Map // the calling router's registry + ): void | Promise; } -app.document(myCustomDocGen); +await app.generateDoc(myCustomDocGen); ``` -`src/docgen/jsonschema.ts` (`jsonSchemaToView`, `extractModels`) is reusable on its own if you only need the JSON Schema β†’ TypeScript-view rendering, without the rest of `HTMLDocGen`. +`translateSchema`, `missingDocSchemaVendors` and `assertDocSchemaTranslators` are exported for custom generators wanting the same translation and strictness behavior. `src/docgen/jsonschema.ts` (`jsonSchemaToView`, `extractModels`) is reusable on its own if you only need the JSON Schema β†’ TypeScript-view rendering, without the rest of `HTMLDocGen`. + +--- + +| [Β« Testing routes](testing.md) | [Documentation index](../README.md#documentation) | [README Β»](../README.md) | +|:---|:---:|---:| diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index d4b8ad8..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,75 +0,0 @@ -# Getting Started - -Mousse is an opinionated, fully typed Node.js web framework built on top of [Β΅WebSockets.js](https://github.com/uNetworking/uWebSockets.js). It handles HTTP routing, WebSockets and Server-Sent Events with a single, consistent `Context` API. - -## Installation - -> **WARNING**: Mousse is a work in progress, no package is published yet. For now, use it from the monorepo workspace: - -```json -{ - "dependencies": { - "mousse": "workspace:*" - } -} -``` - -## Hello World - -```ts -import { Mousse } from 'mousse'; - -const app = new Mousse(); - -app.get('/hello', (c) => { - return 'Hello from Mousse'; -}); - -app.listen(3000, (listenSocket, port) => { - if (listenSocket) - console.log(`Listening on port ${port}`); - else - console.log('Failed to listen'); -}); -``` - -Every registration method returns the app itself, so the whole application can be chained: - -```ts -new Mousse() - .get('/hello', (c) => 'world') - .post('/echo', async (c) => await c.body()) - .listen(3000); -``` - -## TLS - -Pass the Β΅WebSockets.js SSL options to the constructor. When both `key_file_name` and `cert_file_name` are provided, Mousse automatically creates an SSL application: - -```ts -const app = new Mousse({ - key_file_name: 'misc/key.pem', - cert_file_name: 'misc/cert.pem', - passphrase: '1234' -}); -``` - -## Stopping the server - -```ts -app.stop(); -``` - -Closes the listen socket. Active connections finish their in-flight work; no new connection is accepted. - -## Next steps - -- [Routing](routing.md) β€” routes, routers, middlewares -- [Context](context.md) β€” the request/response object handed to every handler -- [Schemas](schemas.md) β€” validation and type inference -- [Documentation generation](docgen.md) β€” HTML docs and OpenAPI output -- [Testing routes](testing.md) β€” firing requests against a router with no real port -- [WebSockets](websockets.md) -- [Server-Sent Events](server-sent-events.md) -- [Modules](modules.md) β€” body parsers, serializers, loggers, error handlers -- [Working with types](typescript.md) diff --git a/docs/modules.md b/docs/modules.md index a3740f9..5203af5 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -1,3 +1,5 @@ +[Β« Documentation index](../README.md#documentation) + # Modules Modules are small, swappable pieces of behavior attached to routes. Each one is a plain interface: bring your own implementation or use the provided defaults. @@ -87,12 +89,13 @@ interface DefaultHandler { } ``` -## DocGen +Unlike the other modules, a `DefaultHandler` is **not assignable to a single route** β€” "no route matched" is by nature a router-level concern. Set it on the app or a router only, through `setDefaultHandler()` or the `defaultHandler` constructor option. -```ts -interface DocGen { - toDocumentation(httproutes: HTTPRoute[], wsroutes: WSRoute[]): void | Promise; -} -``` +## What is not a module + +Documentation generation (`DocGen`, `DocSchemaTranslator`) is **not** a module: it is an offline pass over the whole route tree, not a per-request behavior, and follows its own registration rules β€” see [Documentation generation](docgen.md). + +--- -Turns registered routes and their schemas into documentation, run with `app.document(docgen)`. Two implementations are provided β€” `HTMLDocGen` (Mousse's own styled HTML site) and `OpenAPIDocGen` (a standard OpenAPI 3.1 document) β€” see [Documentation generation](docgen.md). +| [Β« WebSockets](websockets.md) | [Documentation index](../README.md#documentation) | [Typescript Β»](typescript.md) | +|:---|:---:|---:| diff --git a/docs/routing.md b/docs/routing.md index 3d6c17f..1f859d1 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -1,3 +1,5 @@ +[Β« Documentation index](../README.md#documentation) + # Routing ## Registering routes @@ -134,3 +136,8 @@ When a middleware or handler throws: 1. The route's `httpErrorHandler` is called if defined β€” it may respond itself. 2. Otherwise the error is logged (route `logger` if any, `console.error` as fallback). 3. If the response was still not sent, Mousse responds with a `500`. Errors never end up as an implicit `200`. + +--- + +| [Β« README](../README.md) | [Documentation index](../README.md#documentation) | [Context Β»](context.md) | +|:---|:---:|---:| diff --git a/docs/schemas.md b/docs/schemas.md index eac30c7..7769e42 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -1,10 +1,12 @@ -# Schemas : validation, typing & documentation +[Β« Documentation index](../README.md#documentation) + +# Schemas: validation, typing and documentation Routes accept a `schemas` option describing the request and response data. Mousse uses it for three things: 1. **Runtime validation** β€” invalid requests are rejected before your handler runs 2. **Static typing** β€” handler types are inferred from the schemas, no generics to write -3. **Documentation** β€” the same schemas feed the documentation generator (work in progress) +3. **Documentation** β€” the same schemas feed the [documentation generators](docgen.md) ## Standard Schema @@ -50,8 +52,8 @@ Handler and middleware types are **inferred from the schemas**: no explicit gene | `Body` | At the first `c.body()` call | `400` | | `Response` | Inside `c.json()`, before serialization | `500` | -- A route without schemas validates nothing : zero overhead. -- Body validation is lazy by design : a handler that never reads the body never pays for it. +- A route without schemas validates nothing: zero overhead. +- Body validation is lazy by design: a handler that never reads the body never pays for it. - `Response` schemas must validate **synchronously** (serialization cannot await). Async refinements are fine everywhere else. ## Validation failures @@ -67,7 +69,7 @@ Without a custom error handler, an invalid request gets a `400` with the issues } ``` -An invalid **response** is a server bug : the client gets an empty `500` and the error is logged. +An invalid **response** is a server bug: the client gets an empty `500` and the error is logged. To customize this behavior, set an `httpErrorHandler` (per route or as a router default) and check for `SchemaValidationError`: @@ -99,14 +101,19 @@ The defaults ignore it, and validation is handled by Mousse either way β€” a cus ## Typing without schemas -Schemas are optional. Routes without them fall back to the explicit generic form described in [Working with types](typescript.md): +Schemas are optional. Routes without them fall back to the explicit generic form described in [Typescript](typescript.md): ```ts app.post('/legacy', handler); ``` -Note : when `schemas` is provided, inference always wins over explicit generics on the helper methods. +Note: when `schemas` is provided, inference always wins over explicit generics on the helper methods. ## Documentation generation -The second purpose of route schemas is documentation β€” see [Documentation generation](docgen.md) for the full pipeline : `SchemaParser` per library, OpenAPI 3.1 output, and Mousse's own HTML renderer. +The second purpose of route schemas is documentation β€” see [Documentation generation](docgen.md) for the full pipeline: one `DocSchemaTranslator` per schema library, OpenAPI 3.1 output, and Mousse's own HTML renderer. + +--- + +| [Β« Typescript](typescript.md) | [Documentation index](../README.md#documentation) | [Testing routes Β»](testing.md) | +|:---|:---:|---:| diff --git a/docs/server-sent-events.md b/docs/server-sent-events.md index 27f935a..e8a8a4a 100644 --- a/docs/server-sent-events.md +++ b/docs/server-sent-events.md @@ -1,3 +1,5 @@ +[Β« Documentation index](../README.md#documentation) + # Server-Sent Events A Server-Sent Events channel is a **sustained** request: instead of responding once, the connection is kept open and events are pushed through it. @@ -36,3 +38,8 @@ app.get('/events', (c) => { - `c.sustained` tells whether the channel is open. Once a context is sustained, Mousse does not auto-respond after the handler returns: the connection stays open for future `send()` calls. + +--- + +| [Β« Context](context.md) | [Documentation index](../README.md#documentation) | [WebSockets Β»](websockets.md) | +|:---|:---:|---:| diff --git a/docs/testing.md b/docs/testing.md index 5139f0e..160151d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,6 +1,8 @@ +[Β« Documentation index](../README.md#documentation) + # Testing routes -Mousse can fire real HTTP requests against a router or app without binding a real port : `RouteTester` spins up a single ephemeral instance (`listen(0)`, OS-assigned port) and lets you send requests through it, then closes it. +Mousse can fire real HTTP requests against a router or app without binding a real port: `RouteTester` spins up a single ephemeral instance (`listen(0)`, OS-assigned port) and lets you send requests through it, then closes it. ```ts import { Router } from 'mousse'; @@ -24,7 +26,7 @@ await client.close(); ## One instance per test, not per route -Testing a whole router means firing several requests through the **same** `RouteTester` β€” calling `router.test()` once and reusing the returned client is what keeps it to a single ephemeral instance, no matter how many routes you exercise : +Testing a whole router means firing several requests through the **same** `RouteTester` β€” calling `router.test()` once and reusing the returned client is what keeps it to a single ephemeral instance, no matter how many routes you exercise: ```ts const client = router.test(); @@ -39,9 +41,9 @@ await client.close(); // one instance, closed once Calling `router.test()` again creates a **new** independent ephemeral instance on a different port β€” useful for isolating unrelated test files, but avoid calling it more than once per test suite/file if you're testing many routes of the same router. -## `testRouter` : auto-closing helper +## `testRouter`: auto-closing helper -`testRouter(router, callback)` creates the client, runs `callback`, and closes the instance afterwards β€” even if the callback throws. Convenient inside a single test case : +`testRouter(router, callback)` creates the client, runs `callback`, and closes the instance afterwards β€” even if the callback throws. Convenient inside a single test case: ```ts import { testRouter } from 'mousse'; @@ -54,19 +56,19 @@ test('users routes', () => testRouter(users, async (client) => { ## Request bodies -Passing a plain object as the body auto-serializes it as JSON and sets `content-type` : +Passing a plain object as the body auto-serializes it as JSON and sets `content-type`: ```ts await client.post('/users', { name: 'Guillaume' }); ``` -Pass a `string`, `Buffer`, `FormData` or `URLSearchParams` to send it as-is (no JSON encoding) : +Pass a `string`, `Buffer`, `FormData` or `URLSearchParams` to send it as-is (no JSON encoding): ```ts await client.post('/upload', new FormData()); ``` -The last argument of any verb method is a standard [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) for full control (custom headers, signal...) : +The last argument of any verb method is a standard [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) for full control (custom headers, signal...): ```ts await client.get('/users/42', { headers: { authorization: 'Bearer token' } }); @@ -74,7 +76,7 @@ await client.get('/users/42', { headers: { authorization: 'Bearer token' } }); ## Testing a Mousse app directly -`test()` and `testRouter()` work the same way on a full `Mousse` instance β€” its routes, middlewares and default handler are all mounted into the ephemeral instance, the real app is never touched (never listens on a real port during the test) : +`test()` and `testRouter()` work the same way on a full `Mousse` instance β€” its routes, middlewares and default handler are all mounted into the ephemeral instance, the real app is never touched (never listens on a real port during the test): ```ts const client = app.test(); @@ -82,4 +84,13 @@ const client = app.test(); ## What isn't covered -`RouteTester` drives plain HTTP requests β€” this already covers [Server-Sent Events](server-sent-events.md) naturally, since a `get()` response is a regular streamable `Response`. WebSocket upgrades are not covered yet : testing a `ws()` route today still requires a real listening instance and a `WebSocket` client, as in [WebSockets](websockets.md). +`RouteTester` drives plain HTTP requests β€” this already covers [Server-Sent Events](server-sent-events.md) naturally, since a `get()` response is a regular streamable `Response`. WebSocket upgrades are not covered yet: testing a `ws()` route today still requires a real listening instance and a `WebSocket` client, as in [WebSockets](websockets.md). + +## Dogfooding + +Mousse's own test suites are built on `RouteTester`: the integration tests in [`apps/examples/tests`](../apps/examples/tests) exercise routing, schemas, docgen and SSE through it, and every example in [`apps/examples/src`](../apps/examples/src) exports its app so [`examples.test.ts`](../apps/examples/tests/examples.test.ts) smoke-tests it the same way β€” executable documentation. Everything runs with `pnpm test` at the repo root. + +--- + +| [Β« Schemas](schemas.md) | [Documentation index](../README.md#documentation) | [Documentation generation Β»](docgen.md) | +|:---|:---:|---:| diff --git a/docs/typescript.md b/docs/typescript.md index 88cac3a..bd195a7 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -1,4 +1,6 @@ -# Working with Types +[Β« Documentation index](../README.md#documentation) + +# Typescript Mousse is fully typed. Two generic slots drive the typing of a route: the **context types** (what flows through the request/response) and the **context extension** (extra members middlewares attach to the context). @@ -65,3 +67,8 @@ router.get('/profile', (c) => c.user); ``` > Note: `use()` currently returns the router unchanged (`this`) for chaining; it does not propagate a new extension type to the returned value. Declare extensions on the router generics or per-route. + +--- + +| [Β« Modules](modules.md) | [Documentation index](../README.md#documentation) | [Schemas Β»](schemas.md) | +|:---|:---:|---:| diff --git a/docs/websockets.md b/docs/websockets.md index 47f5d3f..64d3f80 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -1,13 +1,15 @@ +[Β« Documentation index](../README.md#documentation) + # WebSockets A `ws` route separates two phases, each with its own context: 1. **Upgrade phase** β€” the *middlewares* run on the HTTP upgrade request, with a regular HTTP [`Context`](context.md). Respond through it (`c.status(401).respond()`...) to **reject** the connection. If no middleware responds, Mousse upgrades automatically. -2. **Connected phase** β€” the *handler* runs once the connection is established, with a `WSContext` : the socket is already open, `c.send()` works immediately. Like `sse` handlers receive an already-sustained context. +2. **Connected phase** β€” the *handler* runs once the connection is established, with a `WSContext`: the socket is already open, `c.send()` works immediately β€” just as `sse` handlers receive an already-sustained context. ```ts app.ws('/chat/:room', (c) => { - // c is a WSContext : already connected + // c is a WSContext: already connected c.subscribe(c.param('room')); c.send(`welcome to ${c.param('room')}`); @@ -19,7 +21,7 @@ app.ws('/chat/:room', (c) => { ## Rejecting an upgrade -Anything you would do in a `get` handler (read headers, query, check auth, respond) belongs in a middleware of the ws route : +Anything you would do in a `get` handler (read headers, query, check auth, respond) belongs in a middleware of the ws route: ```ts app.ws('/private', @@ -29,34 +31,34 @@ app.ws('/private', return c.status(401).respond(); // upgrade rejected }, (c) => { - // WSContext : only reached when no middleware responded + // WSContext: only reached when no middleware responded c.send('authenticated and connected'); }); ``` -> uWebSockets.js only accepts upgrades on patterns registered through its ws router β€” a plain `get` route cannot upgrade. Upgrade middlewares are the supported way to run HTTP logic before a connection. +> Β΅WebSockets.js only accepts upgrades on patterns registered through its ws router β€” a plain `get` route cannot upgrade. Upgrade middlewares are the supported way to run HTTP logic before a connection. ## WSContext -The connected context exposes the socket, the original request data (snapshotted at upgrade time), and event registration : +The connected context exposes the socket, the original request data (snapshotted at upgrade time), and event registration: | Member | Description | |---|---| | `c.send(message, isBinary?, compress?)` | Send to this socket, backpressure handled (see below) | -| `c.subscribe(topic)` / `c.unsubscribe(topic)` | uWS pub/sub topics | +| `c.subscribe(topic)` / `c.unsubscribe(topic)` | Β΅WS pub/sub topics | | `c.publish(topic, message, ...)` | Publish to this **route's** subscribers, excluding the sender | | `c.mousse.publish(topic, message, ...)` | Publish **app-wide** (all ws routes), including the sender | | `c.end(code?, message?)` | Gracefully close the connection | | `c.params`, `c.param()`, `c.query`, `c.headers`, `c.getHeader()`, `c.url`, `c.route` | Data of the original upgrade request | | `c.on(event, listener)` / `c.off(event)` + shortcuts below | Event registration | -| `c.socket` | The native uWS socket, for advanced use | +| `c.socket` | The native Β΅WS socket, for advanced use | | `c.log(data)` | Route logger, no-op when none is set | -> **Topic scoping** : uWebSockets.js keeps one topic tree per ws route. `c.publish` therefore only reaches subscribers of the same route pattern (and skips the sender) β€” the classic chat-room case. For broadcasting across routes, or including the sender, use `c.mousse.publish`. +> **Topic scoping**: Β΅WebSockets.js keeps one topic tree per ws route. `c.publish` therefore only reaches subscribers of the same route pattern (and skips the sender) β€” the classic chat-room case. For broadcasting across routes, or including the sender, use `c.mousse.publish`. ## Events -Register listeners synchronously inside the handler β€” a listener registered after an `await` can miss early events : +Register listeners synchronously inside the handler β€” a listener registered after an `await` can miss early events: | Shortcut | Signature | Fired | |---|---|---| @@ -67,7 +69,7 @@ Register listeners synchronously inside the handler β€” a listener registered af | `onDropped` | `(ws, message, isBinary?)` | Message dropped because of backpressure | | `onSubscription` | `(ws, topic, newCount, oldCount)` | Pub/sub subscription change | -One listener per event : registering twice replaces the first. The `ws` argument is the native socket ; in practice you rarely need it since the listener closes over `c` : +One listener per event: registering twice replaces the first. The `ws` argument is the native socket; in practice you rarely need it since the listener closes over `c`: ```ts c.onMessage((ws, message) => c.send(message)); // echo @@ -75,7 +77,7 @@ c.onMessage((ws, message) => c.send(message)); // echo ## Backpressure -`c.send` never overflows a slow client : when the socket buffer exceeds the route's `maxBackPressure` (default 16 KiB), messages are queued and flushed automatically, in order, on drain. +`c.send` never overflows a slow client: when the socket buffer exceeds the route's `maxBackPressure` (default 16 KiB), messages are queued and flushed automatically, in order, on drain. ```ts app.ws('/feed', { maxBackPressure: 64 * 1024 }, (c) => { /* ... */ }); @@ -84,8 +86,13 @@ app.ws('/feed', { maxBackPressure: 64 * 1024 }, (c) => { /* ... */ }); ## Error handling - Errors thrown by upgrade **middlewares** go through the route's `httpErrorHandler`, then a `500` fallback β€” like any HTTP route. -- Errors thrown by the **handler** or inside event listeners go through the route's `wsErrorHandler`, or `console.error` when none is set : +- Errors thrown by the **handler** or inside event listeners go through the route's `wsErrorHandler`, or `console.error` when none is set: ```ts app.ws('/chat', { wsErrorHandler: { handle: (error) => report(error) } }, (c) => { /* ... */ }); ``` + +--- + +| [Β« Server-Sent Events](server-sent-events.md) | [Documentation index](../README.md#documentation) | [Modules Β»](modules.md) | +|:---|:---:|---:| diff --git a/package.json b/package.json index 1686524..d141ae2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "dev": "turbo run dev", "lint": "turbo run lint", "check-types": "turbo run check-types", - "bench": "turbo run bench" + "bench": "turbo run bench", + "test": "turbo run test", + "example": "pnpm --filter @mousse/examples start", + "release": "turbo run build check-types test && changeset publish" }, "devDependencies": { "@changesets/cli": "^2.31.0", diff --git a/packages/mousse/LICENSE b/packages/mousse/LICENSE new file mode 100644 index 0000000..b1c57a0 --- /dev/null +++ b/packages/mousse/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Guillaume Vailland + +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/mousse/README.md b/packages/mousse/README.md new file mode 100644 index 0000000..21fddeb --- /dev/null +++ b/packages/mousse/README.md @@ -0,0 +1,27 @@ +# Mousse : Let us play with bubbles + +Mousse is a Node.js web server framework developed in TypeScript and based on [Β΅WebSockets.js](https://github.com/uNetworking/uWebSockets.js). It provides simple ways to route HTTP requests and handle WebSockets or Server-Sent Events through a single, consistent `Context` API. + +- ⚑ Built on Β΅WebSockets.js, one of the fastest HTTP/WS servers available for Node.js +- 🌐 HTTP routing with parameters, wildcards, route options and composable routers +- πŸ”Œ WebSockets and πŸ“‘ Server-Sent Events with backpressure handling +- πŸ›‘οΈ Fully typed, βœ… [Standard Schema](https://standardschema.dev) validation (Zod, ArkType, Valibot...) +- πŸ“„ Documentation generation : self-styled HTML site or OpenAPI 3.1 +- πŸ§ͺ One-call route testing through a single ephemeral instance + +```ts +import { Mousse } from 'mousse'; + +new Mousse() + .get('/hello', (c) => 'Hello World !') + .get('/users/:id', (c) => ({ id: c.param('id') })) + .ws('/chat', (c) => c.onMessage((ws, message) => { c.send(message); })) + .sse('/events', (c) => c.send('welcome')) + .listen(3000); +``` + +Full documentation, examples and benchmarks : [github.com/Tyrenn/mousse](https://github.com/Tyrenn/mousse#documentation) + +## License + +[MIT](LICENSE) diff --git a/packages/mousse/package.json b/packages/mousse/package.json index 82fab39..884752e 100644 --- a/packages/mousse/package.json +++ b/packages/mousse/package.json @@ -1,10 +1,31 @@ { "name": "mousse", "version": "0.0.0", + "description": "Fully typed Node.js web framework built on Β΅WebSockets.js : HTTP routing, WebSockets and Server-Sent Events through a single Context API, Standard Schema validation and documentation generation.", "author": "Guillaume Vailland", "type": "module", "license": "MIT", - + "keywords": [ + "web", + "framework", + "server", + "http", + "websocket", + "sse", + "uwebsockets", + "router", + "typescript", + "standard-schema", + "openapi" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Tyrenn/mousse.git", + "directory": "packages/mousse" + }, + "homepage": "https://github.com/Tyrenn/mousse#readme", + "bugs": "https://github.com/Tyrenn/mousse/issues", + "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -13,10 +34,19 @@ "default": "./dist/index.js" } }, + "files": [ + "dist" + ], + "sideEffects": false, + "engines": { + "node": ">=18" + }, "scripts": { "build": "tsc", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "node --test \"tests/*.test.ts\"", + "prepublishOnly": "npm run build" }, "dependencies": { diff --git a/packages/mousse/src/context.ts b/packages/mousse/src/context.ts index 2fb01bc..391bce3 100644 --- a/packages/mousse/src/context.ts +++ b/packages/mousse/src/context.ts @@ -8,7 +8,7 @@ import { BodyParser } from './module/bodyparser.js'; import { ResponseSerializer } from './module/responseserializer.js'; import { Logger } from './module/logger.js'; import { HTTPRouteMethod } from './route.js'; -import { Schemas, validateSchema, validateSchemaSync } from './module/schema.js'; +import { Schemas, validateSchema, validateSchemaSync } from './schema.js'; /** * Data carried by each websocket connection @@ -372,12 +372,13 @@ export class Context implement } /** - * + * A declared param (Params schema or generics) is guaranteed by the route match : + * only untyped routes get string | undefined. * @param key * @returns */ - param(key: Types["Params"] extends string ? Types["Params"] : string) : string | undefined { - return this._params[key.toLowerCase()]; + param(key: Types["Params"] extends string ? Types["Params"] : string) : string extends Types["Params"] ? string | undefined : string { + return this._params[key.toLowerCase()] as any; } @@ -763,8 +764,8 @@ export class WSContext{ return this._userData.snapshot.params; } - param(key: Types["Params"] extends string ? Types["Params"] : string) : string | undefined { - return this._userData.snapshot.params[key.toLowerCase()]; + param(key: Types["Params"] extends string ? Types["Params"] : string) : string extends Types["Params"] ? string | undefined : string { + return this._userData.snapshot.params[key.toLowerCase()] as any; } get query() : Types["Query"] extends object ? Types["Query"] : { [key: string]: any } { diff --git a/packages/mousse/src/docgen/docSchemaTranslator.ts b/packages/mousse/src/docgen/docSchemaTranslator.ts new file mode 100644 index 0000000..26150e5 --- /dev/null +++ b/packages/mousse/src/docgen/docSchemaTranslator.ts @@ -0,0 +1,57 @@ +import { StandardSchemaV1 } from '../schema.js'; +import { HTTPRoute } from '../route.js'; + +export type JsonSchema = Record; + +/** + * Translates a schema into JSON Schema for documentation generation. + * Validation does not need this: it goes through Standard Schema directly. + * One translator per schema library, registered under the vendor string the library + * reports through Standard Schema (schema['~standard'].vendor : 'zod', 'arktype'...). + */ +export type DocSchemaTranslator = (schema : StandardSchemaV1) => JsonSchema; + +/** + * Ready-made translator for ArkType, whose types expose toJsonSchema() natively. + * For other libraries, register their own translate function : + * addDocSchemaTranslator('zod', z.toJSONSchema) + */ +export const arkTypeDocSchemaTranslator : DocSchemaTranslator = (schema) => (schema as any).toJsonSchema(); + +/** + * Translate a schema using the translator registered for its vendor. + * Returns undefined when no translator handles the schema vendor. + */ +export function translateSchema(schema : StandardSchemaV1, translators : Map) : JsonSchema | undefined { + return translators.get(schema['~standard'].vendor)?.(schema); +} + +/** + * Vendors used by the routes schemas that have no registered translator. + */ +export function missingDocSchemaVendors(httproutes : HTTPRoute[], translators : Map) : string[] { + const missing = new Set(); + + for(const route of httproutes) + for(const schema of Object.values(route.schemas ?? {})){ + const vendor = (schema as StandardSchemaV1)['~standard'].vendor; + if(!translators.has(vendor)) + missing.add(vendor); + } + + return [...missing]; +} + +/** + * Throw when a route schema uses a vendor with no registered translator, listing + * every missing vendor at once. Doc generators call this to fail fast instead of + * silently documenting schemas as 'any'. + */ +export function assertDocSchemaTranslators(httproutes : HTTPRoute[], translators : Map) : void { + const missing = missingDocSchemaVendors(httproutes, translators); + + if(missing.length > 0) + throw new Error(`No DocSchemaTranslator registered for vendor(s) ${missing.map(vendor => `'${vendor}'`).join(', ')}. ` + + `Register one with addDocSchemaTranslator(vendor, translator) or through the 'translators' option, ` + + `or set 'lenient : true' to document these schemas as 'any'.`); +} diff --git a/packages/mousse/src/docgen/docgen.ts b/packages/mousse/src/docgen/docgen.ts new file mode 100644 index 0000000..cdd821f --- /dev/null +++ b/packages/mousse/src/docgen/docgen.ts @@ -0,0 +1,13 @@ +import { HTTPRoute, WSRoute } from '../route.js'; +import { DocSchemaTranslator } from './docSchemaTranslator.js'; + +export interface DocGen{ + + /** + * Translate routes to a documentation output (html files, openapi json...) + * @param httproutes + * @param wsroutes + * @param translators DocSchemaTranslator registry of the router generateDoc was called on + */ + toDocumentation(httproutes : HTTPRoute[], wsroutes : WSRoute[], translators? : Map) : void | Promise; +} diff --git a/packages/mousse/src/docgen/htmlDocGen.ts b/packages/mousse/src/docgen/htmlDocGen.ts index 828968a..cdd1625 100644 --- a/packages/mousse/src/docgen/htmlDocGen.ts +++ b/packages/mousse/src/docgen/htmlDocGen.ts @@ -1,9 +1,9 @@ import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; -import { DocGen } from '../module/docgen.js'; +import { DocGen } from './docgen.js'; import { HTTPRoute, WSRoute } from '../route.js'; -import { SchemaParser, translateSchema } from '../module/schemaparser.js'; -import { StandardSchemaV1 } from '../module/schema.js'; +import { DocSchemaTranslator, translateSchema, assertDocSchemaTranslators } from './docSchemaTranslator.js'; +import { StandardSchemaV1 } from '../schema.js'; import { jsonSchemaToView, extractModels } from './jsonschema.js'; import { HTMLRouterRenderer } from './htmlRouteRenderer.js'; import { HTMLModelRenderer } from './htmlModelRenderer.js'; @@ -14,7 +14,10 @@ import { Model, Route as DocRoute, Router as DocRouter } from './types.js'; export type HTMLDocGenOptions = { outputDir : string; title? : string; - schemaParsers? : SchemaParser[]; + // Per-call translators, taking precedence over the router registry + translators? : Record; + // Document schemas with no matching translator as 'any' instead of throwing + lenient? : boolean; }; const METHOD_DISPLAY : Record = { @@ -31,6 +34,8 @@ export class HTMLDocGen implements DocGen { private _models : Map = new Map(); + private _translators : Map = new Map(); + constructor(options : HTMLDocGenOptions){ this._options = options; } @@ -39,7 +44,7 @@ export class HTMLDocGen implements DocGen { if(!schema) return ''; - const jsonSchema = translateSchema(schema, this._options.schemaParsers ?? []); + const jsonSchema = translateSchema(schema, this._translators); // Named definitions become linkable documentation models if(jsonSchema) @@ -81,7 +86,15 @@ ${content} `; } - async toDocumentation(httproutes : HTTPRoute[], wsroutes : WSRoute[]) : Promise { + async toDocumentation(httproutes : HTTPRoute[], wsroutes : WSRoute[], translators? : Map) : Promise { + // Router registry first, per-call translators option wins on conflict + this._translators = new Map(translators); + for(const [vendor, translator] of Object.entries(this._options.translators ?? {})) + this._translators.set(vendor, translator); + + if(!this._options.lenient) + assertDocSchemaTranslators(httproutes, this._translators); + const title = this._options.title ?? 'API Documentation'; // Group routes by their first tag diff --git a/packages/mousse/src/docgen/jsonschema.ts b/packages/mousse/src/docgen/jsonschema.ts index f78e7c2..444cd70 100644 --- a/packages/mousse/src/docgen/jsonschema.ts +++ b/packages/mousse/src/docgen/jsonschema.ts @@ -1,4 +1,4 @@ -import { JsonSchema } from '../module/schemaparser.js'; +import { JsonSchema } from './docSchemaTranslator.js'; /** * Render a JSON Schema as a typescript-looking code view for documentation pages. diff --git a/packages/mousse/src/docgen/openapi.ts b/packages/mousse/src/docgen/openapi.ts index 4b7caea..87be2ba 100644 --- a/packages/mousse/src/docgen/openapi.ts +++ b/packages/mousse/src/docgen/openapi.ts @@ -1,8 +1,8 @@ import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; import { HTTPRoute, WSRoute } from '../route.js'; -import { DocGen } from '../module/docgen.js'; -import { JsonSchema, SchemaParser, translateSchema } from '../module/schemaparser.js'; +import { DocGen } from './docgen.js'; +import { JsonSchema, DocSchemaTranslator, translateSchema, assertDocSchemaTranslators } from './docSchemaTranslator.js'; export type OpenAPIInfo = { title? : string; @@ -12,7 +12,14 @@ export type OpenAPIInfo = { export type OpenAPIOptions = { info? : OpenAPIInfo; - schemaParsers? : SchemaParser[]; + translators? : Record; +}; + +export type OpenAPIDocGenOptions = OpenAPIOptions & { + outputDir : string; + fileName? : string; + // Document schemas with no matching translator as empty instead of throwing + lenient? : boolean; }; const METHODS : Record = { @@ -24,7 +31,7 @@ const METHODS : Record = { * 'any' routes are skipped : OpenAPI has no catch-all verb. */ export function toOpenAPI(httproutes : HTTPRoute[], options? : OpenAPIOptions) : JsonSchema { - const parsers = options?.schemaParsers ?? []; + const translators = new Map(Object.entries(options?.translators ?? {})); const paths : Record = {}; for(const route of httproutes){ @@ -43,19 +50,19 @@ export function toOpenAPI(httproutes : HTTPRoute[], options? : OpenAPI if(route.doc?.tags) operation.tags = route.doc.tags; - operation.parameters = buildParameters(route, parsers); + operation.parameters = buildParameters(route, translators); if(operation.parameters.length === 0) delete operation.parameters; if(route.schemas?.Body && ['post', 'put', 'patch', 'delete'].includes(method)){ operation.requestBody = { required : true, - content : {'application/json' : {schema : translateSchema(route.schemas.Body, parsers) ?? {}}} + content : {'application/json' : {schema : translateSchema(route.schemas.Body, translators) ?? {}}} }; } operation.responses = route.schemas?.Response - ? {200 : {description : 'Success', content : {'application/json' : {schema : translateSchema(route.schemas.Response, parsers) ?? {}}}}} + ? {200 : {description : 'Success', content : {'application/json' : {schema : translateSchema(route.schemas.Response, translators) ?? {}}}}} : {200 : {description : 'Success'}}; paths[path] = {...paths[path], [method] : operation}; @@ -78,25 +85,33 @@ export function toOpenAPI(httproutes : HTTPRoute[], options? : OpenAPI */ export class OpenAPIDocGen implements DocGen { - private _options : OpenAPIOptions & {outputDir : string, fileName? : string}; + private _options : OpenAPIDocGenOptions; - constructor(options : OpenAPIOptions & {outputDir : string, fileName? : string}){ + constructor(options : OpenAPIDocGenOptions){ this._options = options; } - async toDocumentation(httproutes : HTTPRoute[], _wsroutes : WSRoute[]) : Promise { - const document = toOpenAPI(httproutes, this._options); + async toDocumentation(httproutes : HTTPRoute[], _wsroutes : WSRoute[], translators? : Map) : Promise { + // Router registry first, per-call translators option wins on conflict + const effective = new Map(translators); + for(const [vendor, translator] of Object.entries(this._options.translators ?? {})) + effective.set(vendor, translator); + + if(!this._options.lenient) + assertDocSchemaTranslators(httproutes, effective); + + const document = toOpenAPI(httproutes, {info : this._options.info, translators : Object.fromEntries(effective)}); await mkdir(this._options.outputDir, {recursive : true}); await writeFile(join(this._options.outputDir, this._options.fileName ?? 'openapi.json'), JSON.stringify(document, null, '\t')); } } -function buildParameters(route : HTTPRoute, parsers : SchemaParser[]) : any[] { +function buildParameters(route : HTTPRoute, translators : Map) : any[] { const parameters : any[] = []; // Path parameters : schema properties when provided, plain strings otherwise - const paramsSchema = route.schemas?.Params ? translateSchema(route.schemas.Params, parsers) : undefined; + const paramsSchema = route.schemas?.Params ? translateSchema(route.schemas.Params, translators) : undefined; for(const name of route.pattern.match(/:[\w]+/g) ?? []){ const key = name.slice(1); parameters.push({ @@ -108,7 +123,7 @@ function buildParameters(route : HTTPRoute, parsers : SchemaParser[]) } // Query parameters : one per property of the Query object schema - const querySchema = route.schemas?.Query ? translateSchema(route.schemas.Query, parsers) : undefined; + const querySchema = route.schemas?.Query ? translateSchema(route.schemas.Query, translators) : undefined; if(querySchema?.properties){ const required : string[] = querySchema.required ?? []; for(const [key, schema] of Object.entries(querySchema.properties)){ diff --git a/packages/mousse/src/handler.ts b/packages/mousse/src/handler.ts index 4503d4c..6a969fa 100644 --- a/packages/mousse/src/handler.ts +++ b/packages/mousse/src/handler.ts @@ -6,7 +6,8 @@ type GenericHandler< Passive extends boolean = false > = ( context: C -) => Passive extends true ? (void | Promise) : (void | T["Response"] | Promise); + // Middleware returns are ignored, but 'return c.status(401).respond()' is the documented early-exit idiom +) => Passive extends true ? (void | Context | Promise>) : (void | T["Response"] | Promise); // ────────────────────────── diff --git a/packages/mousse/src/index.ts b/packages/mousse/src/index.ts index 149809e..09f48c0 100644 --- a/packages/mousse/src/index.ts +++ b/packages/mousse/src/index.ts @@ -9,11 +9,13 @@ export { DefaultResponseSerializer, type ResponseSerializer } from './module/res export type { Logger } from './module/logger.js'; export type { HTTPErrorHandler, WSErrorHandler } from './module/errorhandler.js'; export type { DefaultHandler } from './module/defaulthandler.js'; -export type { DocGen } from './module/docgen.js'; -export { SchemaValidationError, validateSchema, validateSchemaSync, type StandardSchemaV1, type Schemas, type SchemaPart, type InferContextTypes } from './module/schema.js'; -export { ArkTypeSchemaParser, CustomSchemaParser, translateSchema, type SchemaParser, type JsonSchema } from './module/schemaparser.js'; +export { SchemaValidationError, validateSchema, validateSchemaSync, type StandardSchemaV1, type Schemas, type SchemaPart, type InferContextTypes } from './schema.js'; + +export type { DocGen } from './docgen/docgen.js'; +export { arkTypeDocSchemaTranslator, translateSchema, missingDocSchemaVendors, assertDocSchemaTranslators, type DocSchemaTranslator, type JsonSchema } from './docgen/docSchemaTranslator.js'; +export { jsonSchemaToView, extractModels } from './docgen/jsonschema.js'; export { HTMLDocGen, type HTMLDocGenOptions } from './docgen/htmlDocGen.js'; -export { toOpenAPI, OpenAPIDocGen, type OpenAPIOptions, type OpenAPIInfo } from './docgen/openapi.js'; -export { RouteTester, testRouter } from './module/tester.js'; +export { toOpenAPI, OpenAPIDocGen, type OpenAPIOptions, type OpenAPIDocGenOptions, type OpenAPIInfo } from './docgen/openapi.js'; +export { RouteTester, testRouter } from './tester.js'; export { joinUri, parseQueryString } from './utils.js'; diff --git a/packages/mousse/src/module/bodyparser.ts b/packages/mousse/src/module/bodyparser.ts index 10a8831..7340984 100644 --- a/packages/mousse/src/module/bodyparser.ts +++ b/packages/mousse/src/module/bodyparser.ts @@ -19,17 +19,21 @@ export class DefaultBodyParser implements BodyParser{ if(!contentType || !raw?.length) return {} as Body; - if(contentType === 'application/json'){ + // 'application/json; charset=utf-8' must match like plain 'application/json' + const mediaType = contentType.split(';')[0].trim().toLowerCase(); + + if(mediaType === 'application/json'){ const bodyStr = raw.toString(); return bodyStr ? JSON.parse(bodyStr) as Body : {} as Body; } - if(contentType === 'application/x-www-form-urlencoded'){ + if(mediaType === 'application/x-www-form-urlencoded'){ const bodyStr = raw.toString(); return bodyStr ? parseQueryString(bodyStr) as Body : {} as Body; } - if (contentType.startsWith('multipart/form-data')) { + // getParts still needs the full header : the boundary lives in the parameters + if (mediaType === 'multipart/form-data') { const multiparts = getParts(raw, contentType); if(!multiparts) return {} as Body; diff --git a/packages/mousse/src/module/docgen.ts b/packages/mousse/src/module/docgen.ts deleted file mode 100644 index 95b69b8..0000000 --- a/packages/mousse/src/module/docgen.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { HTTPRoute, WSRoute } from "../route.js"; - - -export interface DocGen{ - - /** - * Translate routes to a documentation output (html files, openapi json...) - * @param httproutes - * @param wsroutes - */ - toDocumentation(httproutes : HTTPRoute[], wsroutes : WSRoute[]) : void | Promise; -} diff --git a/packages/mousse/src/module/errorhandler.ts b/packages/mousse/src/module/errorhandler.ts index 0e5363a..e935b9e 100644 --- a/packages/mousse/src/module/errorhandler.ts +++ b/packages/mousse/src/module/errorhandler.ts @@ -1,7 +1,8 @@ import { Context } from "../context.js"; export interface HTTPErrorHandler{ - handle(error : any, c : Context) : void | Promise; + // The return is ignored : 'return c.status(422).json(...)' is the documented early-exit idiom + handle(error : any, c : Context) : void | Context | Promise>; } export interface WSErrorHandler{ diff --git a/packages/mousse/src/module/schemaparser.ts b/packages/mousse/src/module/schemaparser.ts deleted file mode 100644 index ca1c4c1..0000000 --- a/packages/mousse/src/module/schemaparser.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { StandardSchemaV1 } from './schema.js'; - -export type JsonSchema = Record; - -/** - * Translates a schema into JSON Schema for documentation generation. - * Validation does not need this : it goes through Standard Schema directly. - * One parser per schema library, matched by the schema's vendor string. - */ -export interface SchemaParser { - - // Matched against schema['~standard'].vendor ('zod', 'arktype', 'valibot'...) - vendor : string; - - toJsonSchema(schema : StandardSchemaV1) : JsonSchema; -} - - -/** - * ArkType exposes toJsonSchema() directly on its types : no dependency needed. - */ -export class ArkTypeSchemaParser implements SchemaParser { - vendor = 'arktype'; - - toJsonSchema(schema : StandardSchemaV1) : JsonSchema { - return (schema as any).toJsonSchema(); - } -} - -/** - * Generic parser built from a translate function, keeping mousse dependency free. - * For Zod : new CustomSchemaParser('zod', z.toJSONSchema) - */ -export class CustomSchemaParser implements SchemaParser { - vendor : string; - - private _translate : (schema : StandardSchemaV1) => JsonSchema; - - constructor(vendor : string, translate : (schema : StandardSchemaV1) => JsonSchema){ - this.vendor = vendor; - this._translate = translate; - } - - toJsonSchema(schema : StandardSchemaV1) : JsonSchema { - return this._translate(schema); - } -} - - -/** - * Translate a schema using the parser matching its vendor. - * Returns undefined when no parser handles the schema vendor. - */ -export function translateSchema(schema : StandardSchemaV1, parsers : SchemaParser[]) : JsonSchema | undefined { - const vendor = schema['~standard'].vendor; - - return parsers.find(parser => parser.vendor === vendor)?.toJsonSchema(schema); -} diff --git a/packages/mousse/src/mousse.ts b/packages/mousse/src/mousse.ts index f0b23e6..c3b89fe 100644 --- a/packages/mousse/src/mousse.ts +++ b/packages/mousse/src/mousse.ts @@ -5,7 +5,7 @@ import { HTTPRoute, WSRoute } from './route.js'; import { DefaultBodyParser } from './module/bodyparser.js'; import { DefaultResponseSerializer } from './module/responseserializer.js'; import { matchPattern } from './utils.js'; -import { SchemaValidationError } from './module/schema.js'; +import { SchemaValidationError } from './schema.js'; export type MousseOptions = uWSAppOptions; diff --git a/packages/mousse/src/route.ts b/packages/mousse/src/route.ts index 65c79d3..74110a8 100644 --- a/packages/mousse/src/route.ts +++ b/packages/mousse/src/route.ts @@ -6,7 +6,7 @@ import { BodyParser } from "./module/bodyparser.js"; import { HTTPErrorHandler, WSErrorHandler } from "./module/errorhandler.js"; import { Logger } from "./module/logger.js"; import { ResponseSerializer } from "./module/responseserializer.js"; -import { Schemas } from "./module/schema.js"; +import { Schemas } from "./schema.js"; /** * Route documentation metadata, consumed by DocGen modules diff --git a/packages/mousse/src/router.ts b/packages/mousse/src/router.ts index 8bccaf3..024d2ae 100644 --- a/packages/mousse/src/router.ts +++ b/packages/mousse/src/router.ts @@ -7,9 +7,12 @@ import { MiddlewareHandler, WSHandler, PATCHHandlers, POSTHandlers, PUTHandlers, import { BodyParser } from './module/bodyparser.js'; import { ResponseSerializer } from './module/responseserializer.js'; import { DefaultHandler } from './module/defaulthandler.js'; -import { Schemas, InferContextTypes } from './module/schema.js'; -import { DocGen } from './module/docgen.js'; -import { RouteTester } from './module/tester.js'; +import { Schemas, InferContextTypes } from './schema.js'; +import { DocGen } from './docgen/docgen.js'; +import { DocSchemaTranslator } from './docgen/docSchemaTranslator.js'; +import { HTMLDocGen, HTMLDocGenOptions } from './docgen/htmlDocGen.js'; +import { OpenAPIDocGen, OpenAPIDocGenOptions } from './docgen/openapi.js'; +import { RouteTester } from './tester.js'; export interface Middleware{ pattern : string; @@ -34,6 +37,9 @@ export class Router = new Map(); + constructor(options? : RouterOptions){ this._defaultHandler = options?.defaultHandler; @@ -100,6 +106,11 @@ export class Router}); @@ -215,12 +226,46 @@ export class Router { + await docgen.toDocumentation(this._httproutes, this._wsroutes, new Map(this._docSchemaTranslators)); + } + + /** + * Generate Mousse's own HTML documentation site from the registered routes. + * Throws when a route schema vendor has no registered translator, unless lenient. See docs/docgen.md. + * @param options + */ + generateHTMLDoc(options : HTMLDocGenOptions){ + return this.generateDoc(new HTMLDocGen(options)); + } + + /** + * Generate a standard OpenAPI 3.1 json document from the registered routes. + * Throws when a route schema vendor has no registered translator, unless lenient. See docs/docgen.md. + * @param options + */ + generateOpenAPIDoc(options : OpenAPIDocGenOptions){ + return this.generateDoc(new OpenAPIDocGen(options)); } /** diff --git a/packages/mousse/src/module/schema.ts b/packages/mousse/src/schema.ts similarity index 100% rename from packages/mousse/src/module/schema.ts rename to packages/mousse/src/schema.ts diff --git a/packages/mousse/src/module/tester.ts b/packages/mousse/src/tester.ts similarity index 96% rename from packages/mousse/src/module/tester.ts rename to packages/mousse/src/tester.ts index 865c48c..885d70c 100644 --- a/packages/mousse/src/module/tester.ts +++ b/packages/mousse/src/tester.ts @@ -1,5 +1,5 @@ -import type { Router } from '../router.js'; -import type { Mousse } from '../mousse.js'; +import type { Router } from './router.js'; +import type { Mousse } from './mousse.js'; type JsonBody = unknown; @@ -44,7 +44,7 @@ export class RouteTester { private async _start(router : Router){ // Dynamic import avoids a static circular dependency between router.ts and mousse.ts - const {Mousse} = await import('../mousse.js'); + const {Mousse} = await import('./mousse.js'); const app = new Mousse().use(router); const port = await new Promise((resolve, reject) => { diff --git a/packages/mousse/src/utils.ts b/packages/mousse/src/utils.ts index 62dc153..52708de 100644 --- a/packages/mousse/src/utils.ts +++ b/packages/mousse/src/utils.ts @@ -1,5 +1,8 @@ export function joinUri(patternParent : string, patternChild : string) { - return `${patternParent.replace(/\/+$/, '')}/${patternChild.replace(/^\/+/, '')}`; + const joined = `${patternParent.replace(/\/+$/, '')}/${patternChild.replace(/^\/+/, '')}`; + + // No trailing slash : a '/' route mounted on '/users' must answer GET /users + return joined.replace(/\/+$/, '') || '/'; } /** diff --git a/packages/mousse/tests/docSchemaTranslator.test.ts b/packages/mousse/tests/docSchemaTranslator.test.ts new file mode 100644 index 0000000..d47921e --- /dev/null +++ b/packages/mousse/tests/docSchemaTranslator.test.ts @@ -0,0 +1,49 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { translateSchema, missingDocSchemaVendors, assertDocSchemaTranslators, HTTPRoute, type StandardSchemaV1, type DocSchemaTranslator } from 'mousse'; + +const schemaOf = (vendor : string) : StandardSchemaV1 => ({ + '~standard' : {version : 1, vendor, validate : (value) => ({value})} +}); + +const translator : DocSchemaTranslator = () => ({type : 'string'}); + +const routeWith = (vendor : string) => new HTTPRoute({ + method : 'get', pattern : '/x', handler : () => {}, + schemas : {Response : schemaOf(vendor)} +}); + +describe('translateSchema', () => { + test('dispatches on the schema vendor', () => { + assert.deepEqual(translateSchema(schemaOf('zod'), new Map([['zod', translator]])), {type : 'string'}); + }); + + test('returns undefined when no translator matches', () => { + assert.equal(translateSchema(schemaOf('zod'), new Map()), undefined); + }); +}); + +describe('missingDocSchemaVendors', () => { + test('collects unregistered vendors once', () => { + const routes = [routeWith('zod'), routeWith('zod'), routeWith('arktype')]; + assert.deepEqual(missingDocSchemaVendors(routes, new Map([['arktype', translator]])), ['zod']); + }); + + test('empty when all vendors are covered or no schemas', () => { + assert.deepEqual(missingDocSchemaVendors([routeWith('zod')], new Map([['zod', translator]])), []); + assert.deepEqual(missingDocSchemaVendors([new HTTPRoute({method : 'get', pattern : '/x', handler : () => {}})], new Map()), []); + }); +}); + +describe('assertDocSchemaTranslators', () => { + test('throws listing every missing vendor', () => { + assert.throws( + () => assertDocSchemaTranslators([routeWith('zod'), routeWith('valibot')], new Map()), + (error : any) => error.message.includes(`'zod'`) && error.message.includes(`'valibot'`) + ); + }); + + test('passes when registry covers the routes', () => { + assert.doesNotThrow(() => assertDocSchemaTranslators([routeWith('zod')], new Map([['zod', translator]]))); + }); +}); diff --git a/packages/mousse/tests/jsonschema.test.ts b/packages/mousse/tests/jsonschema.test.ts new file mode 100644 index 0000000..6afe1e1 --- /dev/null +++ b/packages/mousse/tests/jsonschema.test.ts @@ -0,0 +1,48 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { jsonSchemaToView, extractModels } from 'mousse'; + +describe('jsonSchemaToView', () => { + test('primitives', () => { + assert.equal(jsonSchemaToView({type : 'string'}), 'string'); + assert.equal(jsonSchemaToView({type : 'integer'}), 'number'); + assert.equal(jsonSchemaToView(undefined), 'any'); + assert.equal(jsonSchemaToView({}), 'any'); + }); + + test('enums, consts and unions', () => { + assert.equal(jsonSchemaToView({enum : ['a', 'b']}), '"a" | "b"'); + assert.equal(jsonSchemaToView({const : 42}), '42'); + assert.equal(jsonSchemaToView({anyOf : [{type : 'string'}, {type : 'null'}]}), 'string | null'); + }); + + test('arrays and records', () => { + assert.equal(jsonSchemaToView({type : 'array', items : {type : 'number'}}), 'Array'); + assert.equal(jsonSchemaToView({type : 'object', additionalProperties : {type : 'string'}}), 'Record'); + }); + + test('objects mark optional properties', () => { + const view = jsonSchemaToView({ + type : 'object', + properties : {id : {type : 'number'}, name : {type : 'string'}}, + required : ['id'] + }); + assert.ok(view.includes('id : number')); + assert.ok(view.includes('name? : string')); + }); + + test('$ref renders the model name', () => { + assert.equal(jsonSchemaToView({$ref : '#/$defs/User'}), 'User'); + }); +}); + +describe('extractModels', () => { + test('collects named $defs', () => { + const models = extractModels({ + type : 'object', + properties : {user : {$ref : '#/$defs/User'}}, + $defs : {User : {type : 'object', properties : {id : {type : 'number'}}}} + }); + assert.ok(models.some((model : any) => model.name === 'User')); + }); +}); diff --git a/packages/mousse/tests/schema.test.ts b/packages/mousse/tests/schema.test.ts new file mode 100644 index 0000000..6950c1f --- /dev/null +++ b/packages/mousse/tests/schema.test.ts @@ -0,0 +1,57 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateSchema, validateSchemaSync, SchemaValidationError, type StandardSchemaV1 } from 'mousse'; + +// Minimal Standard Schema implementations, no library dependency +const stringSchema : StandardSchemaV1 = { + '~standard' : { + version : 1, + vendor : 'test', + validate : (value) => typeof value === 'string' + ? {value : value.trim()} + : {issues : [{message : 'must be a string'}]} + } +}; + +const asyncStringSchema : StandardSchemaV1 = { + '~standard' : { + version : 1, + vendor : 'test', + validate : async (value) => typeof value === 'string' + ? {value} + : {issues : [{message : 'must be a string'}]} + } +}; + +describe('validateSchema', () => { + test('returns the schema output value', async () => { + assert.equal(await validateSchema(stringSchema, 'body', ' hello '), 'hello'); + }); + + test('supports async validation', async () => { + assert.equal(await validateSchema(asyncStringSchema, 'body', 'hello'), 'hello'); + }); + + test('throws SchemaValidationError carrying part and issues', async () => { + await assert.rejects(validateSchema(stringSchema, 'query', 42), (error : any) => { + assert.ok(error instanceof SchemaValidationError); + assert.equal(error.part, 'query'); + assert.equal(error.issues[0].message, 'must be a string'); + return true; + }); + }); +}); + +describe('validateSchemaSync', () => { + test('validates synchronously', () => { + assert.equal(validateSchemaSync(stringSchema, 'response', 'ok '), 'ok'); + }); + + test('rejects schemas validating asynchronously', () => { + assert.throws(() => validateSchemaSync(asyncStringSchema, 'response', 'ok'), TypeError); + }); + + test('throws SchemaValidationError on invalid value', () => { + assert.throws(() => validateSchemaSync(stringSchema, 'response', 42), SchemaValidationError); + }); +}); diff --git a/packages/mousse/tests/utils.test.ts b/packages/mousse/tests/utils.test.ts new file mode 100644 index 0000000..3e6e6e8 --- /dev/null +++ b/packages/mousse/tests/utils.test.ts @@ -0,0 +1,67 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { joinUri, parseQueryString } from 'mousse'; +import { matchPattern, definedProps } from '../dist/utils.js'; + +describe('joinUri', () => { + test('joins with a single slash', () => { + assert.equal(joinUri('/users', '/list'), '/users/list'); + assert.equal(joinUri('/users/', '/list'), '/users/list'); + assert.equal(joinUri('/users', 'list'), '/users/list'); + }); + + test('empty parent keeps the child rooted', () => { + assert.equal(joinUri('', '/list'), '/list'); + }); + + test('a mounted root route answers on the mount point itself', () => { + assert.equal(joinUri('/users', '/'), '/users'); + assert.equal(joinUri('/users', ''), '/users'); + assert.equal(joinUri('', '/'), '/'); + }); +}); + +describe('parseQueryString', () => { + test('parses simple pairs', () => { + assert.deepEqual(parseQueryString('a=1&b=2'), {a : '1', b : '2'}); + }); + + test('repeated keys become arrays', () => { + assert.deepEqual(parseQueryString('t=x&t=y'), {t : ['x', 'y']}); + }); + + test('[] suffix is stripped', () => { + assert.deepEqual(parseQueryString('tags[]=x&tags[]=y'), {tags : ['x', 'y']}); + }); + + test('+ decodes to space, key without value becomes empty string', () => { + assert.deepEqual(parseQueryString('q=hello+world&flag'), {q : 'hello world', flag : ''}); + }); + + test('only the first = splits, values keep theirs', () => { + assert.deepEqual(parseQueryString('eq=a=b'), {eq : 'a=b'}); + }); + + test('invalid percent-encoding falls back to the raw string', () => { + assert.deepEqual(parseQueryString('a=%ZZ'), {a : '%ZZ'}); + }); +}); + +describe('matchPattern', () => { + test('matches on segment boundaries only', () => { + assert.equal(matchPattern('/user', '/user'), true); + assert.equal(matchPattern('/user', '/user/me'), true); + assert.equal(matchPattern('/user', '/users'), false); + }); + + test('empty pattern matches everything', () => { + assert.equal(matchPattern('', '/anything'), true); + assert.equal(matchPattern(undefined, '/anything'), true); + }); +}); + +describe('definedProps', () => { + test('drops undefined properties, keeps falsy defined ones', () => { + assert.deepEqual(definedProps({a : 1, b : undefined, c : null, d : 0}), {a : 1, c : null, d : 0}); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8da1c5d..26c6b80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: apps/benchmarks: dependencies: + '@hono/node-server': + specifier: ^1.13.0 + version: 1.19.14(hono@4.12.28) autocannon: specifier: ^8.0.0 version: 8.0.0 @@ -32,6 +35,12 @@ importers: fastify: specifier: ^5.4.0 version: 5.7.2 + hono: + specifier: ^4.6.0 + version: 4.12.28 + koa: + specifier: ^2.15.0 + version: 2.16.4 mousse: specifier: workspace:* version: link:../../packages/mousse @@ -48,6 +57,9 @@ importers: '@types/express': specifier: ^5.0.3 version: 5.0.6 + '@types/koa': + specifier: ^2.15.0 + version: 2.15.2 '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -70,6 +82,9 @@ importers: typescript: specifier: ^5.9.2 version: 5.9.2 + zod: + specifier: ^4.1.0 + version: 4.4.3 packages/mousse: dependencies: @@ -184,6 +199,12 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -247,6 +268,9 @@ packages: cpu: [arm64] os: [win32] + '@types/accepts@1.3.7': + resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + '@types/autocannon@7.12.7': resolution: {integrity: sha512-Pd4nPf7wRpacULa6D/EC9x3CwzFQXwA0z5WFuik/fvJjW44V3WzBTM3jtt8nSBoflUNgswPiMCtgrr1bwnAcMg==} @@ -256,15 +280,33 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/content-disposition@0.5.9': + resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} + + '@types/cookies@0.9.2': + resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==} + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/http-assert@1.5.6': + resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/keygrip@1.0.6': + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + + '@types/koa-compose@3.2.9': + resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==} + + '@types/koa@2.15.2': + resolution: {integrity: sha512-CB+iyjjh1uS5N6/CKwXvw0qA7USMS2WVc4Tjf660yCjhdvqzNr8gdFcIawB41zGGptOQ+d1fnpaQWIIUXYxR3w==} + '@types/mime-types@3.0.1': resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==} @@ -289,6 +331,10 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -362,6 +408,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cache-content-type@1.0.1: + resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} + engines: {node: '>= 6.0.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -384,6 +434,10 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -399,6 +453,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -419,6 +477,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + cross-argv@2.0.0: resolution: {integrity: sha512-YIaY9TR5Nxeb8SMdtrU8asWVM4jqJDNDYlKV21LxtYcfNJhp1kEsgSa6qXwXgzN0WQWGODps0+TlGp2xQSHwOg==} @@ -435,10 +497,20 @@ packages: supports-color: optional: true + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -447,6 +519,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -465,6 +541,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -557,6 +637,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -572,6 +656,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -625,6 +713,18 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hono@4.12.28: + resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} + engines: {node: '>=16.9.0'} + + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -669,6 +769,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -680,6 +784,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -708,6 +816,21 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-convert@2.0.0: + resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} + engines: {node: '>= 10'} + + koa@2.16.4: + resolution: {integrity: sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==} + engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -734,6 +857,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -776,6 +903,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -799,6 +930,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + only@0.0.2: + resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -952,6 +1086,13 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safe-regex2@5.0.0: resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} @@ -1029,6 +1170,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1073,10 +1218,18 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + turbo@2.10.4: resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} hasBin: true + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1121,6 +1274,13 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ylru@1.4.0: + resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==} + engines: {node: '>= 4.0.0'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@assemblyscript/loader@0.19.23': {} @@ -1296,6 +1456,10 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@hono/node-server@1.19.14(hono@4.12.28)': + dependencies: + hono: 4.12.28 + '@inquirer/external-editor@1.0.3(@types/node@26.1.1)': dependencies: chardet: 2.1.1 @@ -1355,6 +1519,10 @@ snapshots: '@turbo/windows-arm64@2.10.4': optional: true + '@types/accepts@1.3.7': + dependencies: + '@types/node': 26.1.1 + '@types/autocannon@7.12.7': dependencies: '@types/node': 26.1.1 @@ -1368,6 +1536,15 @@ snapshots: dependencies: '@types/node': 26.1.1 + '@types/content-disposition@0.5.9': {} + + '@types/cookies@0.9.2': + dependencies: + '@types/connect': 3.4.38 + '@types/express': 5.0.6 + '@types/keygrip': 1.0.6 + '@types/node': 26.1.1 + '@types/express-serve-static-core@5.1.1': dependencies: '@types/node': 26.1.1 @@ -1381,8 +1558,27 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/http-assert@1.5.6': {} + '@types/http-errors@2.0.5': {} + '@types/keygrip@1.0.6': {} + + '@types/koa-compose@3.2.9': + dependencies: + '@types/koa': 2.15.2 + + '@types/koa@2.15.2': + dependencies: + '@types/accepts': 1.3.7 + '@types/content-disposition': 0.5.9 + '@types/cookies': 0.9.2 + '@types/http-assert': 1.5.6 + '@types/http-errors': 2.0.5 + '@types/keygrip': 1.0.6 + '@types/koa-compose': 3.2.9 + '@types/node': 26.1.1 + '@types/mime-types@3.0.1': {} '@types/node@12.20.55': {} @@ -1406,6 +1602,11 @@ snapshots: abstract-logging@2.0.1: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -1504,6 +1705,11 @@ snapshots: bytes@3.1.2: {} + cache-content-type@1.0.1: + dependencies: + mime-types: 2.1.35 + ylru: 1.4.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1529,6 +1735,8 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + co@4.6.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1541,6 +1749,10 @@ snapshots: dependencies: delayed-stream: 1.0.0 + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -1551,6 +1763,11 @@ snapshots: cookie@1.1.1: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + cross-argv@2.0.0: {} cross-spawn@7.0.6: @@ -1563,12 +1780,20 @@ snapshots: dependencies: ms: 2.1.3 + deep-equal@1.0.1: {} + delayed-stream@1.0.0: {} + delegates@1.0.0: {} + + depd@1.1.2: {} + depd@2.0.0: {} dequal@2.0.3: {} + destroy@1.2.0: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: @@ -1585,6 +1810,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} enquirer@2.4.1: @@ -1733,6 +1960,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-extra@7.0.1: @@ -1749,6 +1978,8 @@ snapshots: function-bind@1.1.2: {} + generator-function@2.0.1: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1808,6 +2039,21 @@ snapshots: highlight.js@11.11.1: {} + hono@4.12.28: {} + + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -1844,6 +2090,14 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -1852,6 +2106,13 @@ snapshots: is-promise@4.0.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -1879,6 +2140,45 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + + koa-compose@4.1.0: {} + + koa-convert@2.0.0: + dependencies: + co: 4.6.0 + koa-compose: 4.1.0 + + koa@2.16.4: + dependencies: + accepts: 1.3.8 + cache-content-type: 1.0.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookies: 0.9.1 + debug: 4.4.3 + delegates: 1.0.0 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 1.8.1 + is-generator-function: 1.1.2 + koa-compose: 4.1.0 + koa-convert: 2.0.0 + on-finished: 2.4.1 + only: 0.0.2 + parseurl: 1.3.3 + statuses: 1.5.0 + type-is: 1.6.18 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -1901,6 +2201,8 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -1930,6 +2232,8 @@ snapshots: ms@2.1.3: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} object-inspect@1.13.4: {} @@ -1946,6 +2250,8 @@ snapshots: dependencies: wrappy: 1.0.2 + only@0.0.2: {} + outdent@0.5.0: {} p-filter@2.1.0: @@ -2077,6 +2383,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safe-regex2@5.0.0: dependencies: ret: 0.5.0 @@ -2169,6 +2483,8 @@ snapshots: sprintf-js@1.0.3: {} + statuses@1.5.0: {} + statuses@2.0.2: {} string-width@4.2.3: @@ -2203,6 +2519,8 @@ snapshots: toidentifier@1.0.1: {} + tsscmp@1.0.6: {} + turbo@2.10.4: optionalDependencies: '@turbo/darwin-64': 2.10.4 @@ -2212,6 +2530,11 @@ snapshots: '@turbo/windows-64': 2.10.4 '@turbo/windows-arm64': 2.10.4 + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -2239,3 +2562,7 @@ snapshots: isexe: 2.0.0 wrappy@1.0.2: {} + + ylru@1.4.0: {} + + zod@4.4.3: {} diff --git a/turbo.json b/turbo.json index d3769f7..25fb7a6 100644 --- a/turbo.json +++ b/turbo.json @@ -20,6 +20,14 @@ "bench": { "dependsOn": ["^build"], "cache": false + }, + "test": { + "dependsOn": ["^build"] + }, + "start": { + "dependsOn": ["^build"], + "cache": false, + "persistent": true } } }