diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml index f077ae2..0cd9491 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -13,14 +13,14 @@ body: - type: input attributes: label: What version of Deserve are you using? - placeholder: 0.12.0 + placeholder: 0.15.0 validations: required: true - type: input attributes: label: What version of Deno are you running? description: Deserve runs on the Deno runtime, so the Deno version matters. - placeholder: 2.7.0 + placeholder: 2.8.3 validations: required: true - type: input diff --git a/CHANGELOG.md b/CHANGELOG.md index 43dacb2..09becb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,92 @@ Format inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +#### Request And Response Surface + +- Reading a request now goes through a single frozen `ctx.get.*` accessor exposing `method`, `url`, `pathname`, `request`, `header`, `cookie`, `query`, `param`, `body`, `json`, `text`, `formData`, `blob`, `bytes`, `ip`, `session`, `validated`, and `worker`, so every read of the incoming request flows through one surface +- Reading the client IP through `ctx.get.ip({ direct: true })` now returns the raw TCP peer, while `ctx.get.ip()` keeps returning the resolved client, so both the proxied and the direct address are reachable from the same call +- Writing a response now goes through a single frozen `ctx.set.*` accessor for `header`, `headers`, `cookie`, and `session`, with `header` and `headers` returning the same accessor so writes can chain +- The send helpers gained `ctx.send.empty(status)` for a body-less response and `ctx.send.download(body, filename, options)` for an attachment, the download helper sanitising the filename, stripping control characters, ASCII-escaping the name, adding a non-ASCII `filename*` fallback, defaulting the name to `download`, and serving `application/octet-stream` + +#### Static Serving + +- A static mount can now be backed by a callback as well as a directory, so `router.static(urlPath, source)` accepts either a serve-options object that serves files from a filesystem directory or a handler function that receives the request context and the path stripped of its mount prefix and returns the response itself, letting a mount point at a directory, an object store, or any other source behind the same call +- Static serving now answers `HEAD` alongside `GET`, advertises that ranges are accepted, and turns away any other method with a `405` carrying an `Allow: GET, HEAD` header +- Static serving now answers a conditional request by modification date, so every static response carries a `Last-Modified` header and a request whose `If-Modified-Since` is at or after that time is answered with an empty `304` instead of the body +- Static serving now answers `If-Range`, so a range is served as a partial response only while the `If-Range` date still matches the file, the whole file is returned otherwise, and an `If-Range` value shaped like an entity tag is always treated as stale + +#### Cookies, Sessions, And Auth + +- Cookie serialization is now a dedicated helper that builds a single `Set-Cookie` string from a name, value, and attribute options, percent-encoding the name and value, refusing an empty name, requiring `expires` to be a real date and `maxAge` to be a finite number, and refusing `SameSite=None` unless `secure` is also set +- A session write now drives the cookie straight through `ctx.set.cookie`, so writing data signs and sets the cookie and writing `null` clears it by sending the same cookie back with a zero `Max-Age` +- Basic authentication now accepts an optional `realm`, and the `WWW-Authenticate` challenge it sends on a refusal is built from that realm, defaulting to `Secure Area` + +#### Validation And Websockets + +- A request can now be checked against a per-source contract before it reaches a handler, so the validation middleware takes a schema naming any of body, cookies, headers, or query, runs each named contract against the matching slice of the request, gathers every passing value into one validated record reachable through `ctx.get.validated()`, refuses a schema that names no source or an unsupported one, and turns a contract failure into a `422` whose message joins the collected reasons +- A request arriving with an `Upgrade: websocket` header can now be turned into a live socket, so the WebSocket middleware upgrades only a `GET` whose path matches its configured listener prefix, passes everything else down the chain untouched, and binds connect, message, disconnect, and error callbacks onto the opened socket, refusing a disallowed origin with a `403` and a malformed handshake with a `400` +- A WebSocket handshake that omits `Sec-WebSocket-Version` is now refused with a `400`, and one that names a version other than `13` is refused with a `426` carrying `Sec-WebSocket-Version: 13` and `Upgrade: websocket` + +#### Observability Events + +- The observability event set was expanded so an operator can watch the new lifecycle and rejection moments through `router.on()`. New events are `server:started`, `route:removed`, `route:ignored`, `view:invalidated`, `auth:failed`, `cors:blocked`, `ip:denied`, `body:rejected`, `validate:failed`, `websocket:rejected`, and `static:missing`, each carrying the detail relevant to that moment such as the denied IP, the rejected origin, the failing validation source and reasons, or the missing static path + +### Changed + +#### Rendering + +- The template rendering engine moved out of the framework and is now reached through an injected render function, so a route renders by resolving a template from the configured views directory, compiling, caching, and rendering it to an HTML response, and a render flowing through the engine emits `view:compiled` and `view:rendered`, a failed render emits `view:failed`, and a cache drop emits `view:invalidated` +- Template files are watched in place, so creating or changing a `.dve` file in the views directory drops only that cached compilation and the next request re-reads and re-compiles it, while a missing views directory makes watching a quiet no-op + +#### Context And State + +- Request reading and response writing replaced the older flat properties and setter methods with the grouped `ctx.get.*` and `ctx.set.*` accessors, and reading a validated record, session, or worker handle that was never configured now fails with a clear "not supported" error rather than returning nothing +- Reading the request body in a second, different format now answers with an "already read" failure rather than the previous body-consumed resource error +- The branded per-request state store was dropped in favour of dedicated session, validated, and worker controllers installed onto the context, so framework wiring is no longer carried in a state map a handler could read or clobber + +#### Static Routing + +- A static mount no longer registers a catch-all route, so mounts are tracked on their own, each prefix is normalized with a leading slash and trimmed trailing slashes, and a request is matched against the longest prefix first so a more specific mount wins over a broader one +- Static files are now consulted only after dynamic route matching, so a real route and its own `405`/`Allow` handling take precedence and a static mount is tried as a fallback before a `404` +- A static response now emits a weak entity tag rather than a strong one, still derived from file size and modification time, and a missing static path is refused at registration time while an empty base path normalizes to the root +- The set of file extensions the static server maps to a content type was narrowed to a focused list of common web types with explicit `charset=utf-8` on the text ones, JavaScript now served as `text/javascript`, and anything outside that list served as `application/octet-stream` + +#### Errors And Configuration + +- An error response is now built straight from the context send helpers instead of a separate response factory, and a negotiated or fallback error body keeps the structured problem-details shape carrying type, title, status, and the request path while no longer attaching the baseline security headers and no longer listing the individual validation reasons on a `422` +- Router configuration was reshaped, so the routes directory and route-parameter limit now live under a `routes` option, view configuration is supplied under a single `views` option, hot reload can be turned off with `hotReload: false`, and the request timeout is named `timeoutMs`, while the URL and parameter length limits are always enforced against their defaults + +#### Shutdown And Middleware + +- A shutdown signal now announces itself as a `process:failed` event and the server listens on the full platform set, `SIGHUP`, `SIGINT`, and `SIGTERM` on Unix-like systems and `SIGBREAK` plus `SIGINT` on Windows, registering those listeners alongside an optional abort signal rather than choosing one or the other and removing every handler cleanly on the way out, emitting `server:stopped` once it has drained +- The cross-origin middleware always sets `Vary: Origin` for a non-wildcard policy whether or not the origin matched, answers every preflight with an empty `204`, and attaches the allow headers only when the origin matches +- Body-size limiting now reads the declared `content-length` alone, requiring an integer within range and skipping `GET` and `HEAD`, and no longer rewraps or streams the body to measure it +- A worker that crashes or times out now rejects with the underlying failure cause while still respawning its slot, and the pool's `poolSize` is validated as a positive whole number with the event emitter supplied at creation +- The default session cookie now configures `secure` as `false` by default and keys its options under `name`, and a custom rule that throws inside the cross-site request middleware is simply treated as non-matching + +#### Event Names + +- The lifecycle events were renamed for consistency, so `request:complete` became `request:completed`, `request:error` became `request:failed`, `route:loaded` became `route:added`, `route:reloaded` became `route:updated`, `route:skipped` became `route:ignored`, `reload:error` became `route:failed`, `server:shutdown` became `server:stopped`, `process:error` became `process:failed`, `worker:crash` became `worker:crashed`, `worker:respawn` became `worker:respawned`, `view:error` became `view:failed`, and `csrf:rule-error` became `csrf:failed` + +### Removed + +- The standalone process-fault sentinel was folded into the observability layer, so trapping unhandled rejections and uncaught errors and neutralizing self-directed process-termination calls now switches on only while at least one event listener is subscribed, switches off once the last one leaves, and surfaces each trapped moment as a `process:failed` +- The separate response factory module was removed and its response-construction and attachment-disposition logic merged into the context send helpers and the core handler +- The standalone rendering and validation source trees were removed, with rendering relocated into the core rendering and view classes and validation relocated into the middleware layer + +### Public API + +- `router.static(urlPath, source)` now takes either a `ServeOptions` object or a `StaticFn` handler, and both `ServeOptions` and `StaticFn` are exported from the package root +- The security headers middleware class is now `SecurityHeaders`, renamed from `SecHeaders`, resolving its defaults from per-key entries, and the cross-origin middleware class is now `CORS`, renamed from `Cors` +- `Validator` is exported from the middleware layer as a factory exposing `check(schema)` and `define`, and the validation middleware is built through `Validator.check(schema)`, while the `Mware` factory collection no longer carries a `validator` entry and `Mware.ip()` now takes its options as optional +- The package root export list is now explicit instead of a wildcard, naming the middleware option and event types it surfaces and re-exporting the Typebox contract types, and it no longer re-exports `Define` or `Loader` +- The `Wrap` middleware wrapper is exported from the package root, so a custom middleware can be wrapped with `Wrap.apply(label, middleware)` to catch its thrown errors, map them to a status, and prefix the chosen label onto the message, the same wrapper every built-in middleware uses, replacing the former `WrapMware` function +- The `WebSocket` middleware is available through the `Mware.websocket(options)` factory, and its `WebSocketOptions` type, exposing `listener`, `allowedOrigins`, and the `onConnect`/`onMessage`/`onDisconnect`/`onError` callbacks, is exported from the package root +- `SessionOptions` now requires a `secret` field in place of `cookieSecret`, the cookie name option moved from `cookieName` to `name`, and new `SessionController` and `SessionDefaults` shapes describe the session write surface and its defaults +- The `Observability`, `Rendering`, and `Validation` interface modules were consolidated into the core interface module, and the routing interfaces gained `RouteEntry`, `StaticMount`, and a `ServeHandler` type while dropping the older handler-options, listen-address, and state-key shapes + --- ## [0.14.0] - 2026-06-16 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3151c61..616dc0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ You may use AI to help you contribute, but it must never waste a maintainer's ti Deserve runs on the Deno runtime and nothing from npm, so there is no `node_modules/` to install. -- [Deno](https://github.com/denoland/deno_install) 2.7.0 or later +- [Deno](https://github.com/denoland/deno_install) 2.8.3 or later ## Local Development diff --git a/README.md b/README.md index d02fc50..5766c27 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,14 @@ Build HTTP server effortlessly with zero configuration for productivity. -[![Deno](https://img.shields.io/badge/deno-2.7.0+-000000?logo=deno&logoColor=white)](https://deno.com) [![JSR](https://jsr.io/badges/@neabyte/deserve)](https://jsr.io/@neabyte/deserve) [![CI](https://github.com/NeaByteLab/Deserve/actions/workflows/ci.yaml/badge.svg)](https://github.com/NeaByteLab/Deserve/actions/workflows/ci.yaml) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Deno](https://img.shields.io/badge/deno-2.8.3+-000000?logo=deno&logoColor=white)](https://deno.com) [![JSR](https://jsr.io/badges/@neabyte/deserve)](https://jsr.io/@neabyte/deserve) [![CI](https://github.com/NeaByteLab/Deserve/actions/workflows/ci.yaml/badge.svg)](https://github.com/NeaByteLab/Deserve/actions/workflows/ci.yaml) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -## Features - -- **[Zero Config](https://docs-deserve.neabyte.com/getting-started/installation)** - No build step, point at routes and serve. -- **[File-Based Routing](https://docs-deserve.neabyte.com/core-concepts/file-based-routing)** - Route files in a folder mirror the URL. -- **[Context](https://docs-deserve.neabyte.com/core-concepts/context-object)** - Read request data and respond through one object. -- **[Middleware](https://docs-deserve.neabyte.com/middleware/global)** - Run global or per-path logic before handlers. -- **[Validation](https://docs-deserve.neabyte.com/middleware/validation/overview)** - Check body, query, and params before handlers. -- **[Static Files](https://docs-deserve.neabyte.com/static-file/basic)** - Serve directories with optional cache and etag. -- **[Rendering](https://docs-deserve.neabyte.com/rendering/)** - Build dynamic HTML from templates with streaming. -- **[Error Handling](https://docs-deserve.neabyte.com/error-handling/default-behavior)** - Catch every error with custom or default responses. -- **[Observability](https://docs-deserve.neabyte.com/middleware/observability/overview)** - Subscribe to lifecycle, request, and error events. -- **[Hot Reload](https://docs-deserve.neabyte.com/core-concepts/hot-reload)** - Routes and templates reload without a restart. -- **[Worker Pool](https://docs-deserve.neabyte.com/core-concepts/worker-pool)** - Offload CPU-bound work to keep server responsive. - ## Installation > [!NOTE] -> **Prerequisites:** [Deno](https://deno.com/) 2.7.0 or later. +> **Prerequisites:** [Deno](https://deno.com/) 2.8.3 or later. ```bash # Add Deserve from JSR @@ -42,14 +28,14 @@ Create a routes directory and export HTTP method handlers. Start the server. import { Router } from 'jsr:@neabyte/deserve' // Create router pointing at routes directory -const router = new Router({ routesDir: './routes' }) +const router = new Router({ routes: { directory: './routes' } }) // Optional worker pool for CPU-bound work // const router = new Router({ -// routesDir: './routes', +// routes: { directory: './routes' }, // worker: { scriptURL: import.meta.resolve('./worker.ts'), poolSize: 4 } // }) -// Read handle: ctx.getState('worker' as never) +// Read handle: ctx.get.worker() // Start server on port 8000 await router.serve(8000) @@ -81,10 +67,10 @@ From the repo root (requires [Deno](https://deno.com/)). deno task check ``` -**Test** - run tests (under `tests/`, uses `--allow-read` for fixtures): +**Test** - run tests (under `tests/`, uses `--allow-read --allow-net`): ```bash -# Run tests in tests/ (uses --allow-read for fixtures) +# Run tests in tests/ (uses --allow-read --allow-net) deno task test ``` @@ -96,7 +82,7 @@ Full documentation (EN / ID): **[docs-deserve.neabyte.com](https://docs-deserve. ### DVE Editor (Syntax Highlighting) -- **Cursor / VS Code extension**: See [editor/README.md](editor/README.md) +- **Cursor / VS Code extension**: See [DVE Editor](https://github.com/NeaByteLab/DVE/tree/main/editor) ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 24063bc..6524f87 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,8 +8,8 @@ Deserve is pre-1.0, so fixes land on the latest release. Stay current to receive | Version | Supported | | ------- | --------- | -| 0.12.x | Yes | -| < 0.12 | No | +| 0.15.x | Yes | +| < 0.15 | No | ## Reporting a Vulnerability diff --git a/benchmark/README.md b/benchmark/README.md index 2069d32..73dd9e9 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -13,18 +13,12 @@ Start a server (from repo root), then run `autocannon` from another terminal. ### Start Server -- **Non-worker mode** (view routes + CPU on main thread): +- **Standard mode** (view routes + CPU on main thread): ```bash deno run --allow-net --allow-read benchmark/main.ts ``` -- **Worker mode** (enables `/test-worker`): - - ```bash - deno run --allow-net --allow-read benchmark/main-worker.ts - ``` - - **Listener mode** (one empty event listener attached): ```bash @@ -33,8 +27,7 @@ Start a server (from repo root), then run `autocannon` from another terminal. Notes: -- In non-worker mode, **`/test-worker` returns 503** (`worker not enabled`). -- All view routes (`/test-view*`) are available in both modes. +- All view routes (`/test-view*`) are available. - Listener mode is for measuring observability cost, see [Observability Cost](#observability-cost-logging-on-vs-off). ### Run Benchmark @@ -47,11 +40,10 @@ npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30 ### JSON + CPU Comparison -| Route | What it does | Why it exists | -| -------------- | ---------------------------------------- | -------------------------- | -| `/test` | JSON only (no CPU work) | Baseline throughput | -| `/test-cpu` | 50k `sqrt` loop on **main thread** | Event-loop blocking cost | -| `/test-worker` | Same loop offloaded to a **worker pool** | Offload overhead + scaling | +| Route | What it does | Why it exists | +| ----------- | ---------------------------------- | ------------------------ | +| `/test` | JSON only (no CPU work) | Baseline throughput | +| `/test-cpu` | 50k `sqrt` loop on **main thread** | Event-loop blocking cost | ### DVE View Engine Routes @@ -71,9 +63,6 @@ npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30 # CPU On Main Thread npx autocannon http://localhost:8000/test-cpu -c 500 -p 10 -d 30 -# CPU In Worker (Requires benchmark/main-worker.ts) -npx autocannon http://localhost:8000/test-worker -c 500 -p 10 -d 30 - # DVE Views npx autocannon http://localhost:8000/test-view -c 500 -p 10 -d 30 npx autocannon http://localhost:8000/test-view-each-meta -c 500 -p 10 -d 30 @@ -88,50 +77,56 @@ npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30 - **OS**: macOS 26.5 - **Machine**: Apple M3 Pro, 18 GB RAM -- **Framework**: Deserve (0.12.2) -- **Runtime**: Deno 2.8.2 (V8 14.9.207.2, TypeScript 6.0.3) +- **Framework**: Deserve (0.15.0) +- **Engine**: DVE (0.1.1) +- **Runtime**: Deno 2.9.0 (aarch64-apple-darwin) - **Config**: 500 connections, pipelining 10, duration 30s -## Results — Deno 2.8.2 +## Results - Deno 2.9.0, DVE 0.1.1 -2 runs each, non-worker server, same machine and config. +`/test` and all view routes are 3 runs each, same machine and config. View +results reflect the include source-text cache (an included partial is read +from disk once and reused, instead of a blocking read per request) together +with the DVE 0.1.1 render pipeline (prepared expressions, pre-split paths, +loop scope reuse, and the sink writer). -### JSON + CPU (Non-worker) +### JSON + CPU -| Route | Test 1 | Test 2 | Req/Sec (avg) | Latency (avg) | Total (avg) | -| ----------- | ------- | ------- | ------------- | ------------- | ----------- | -| `/test` | 150,950 | 148,637 | 149,794 | 32.87 ms | 4,494k | -| `/test-cpu` | 22,442 | 22,148 | 22,295 | 223.04 ms | 669k | +| Route | Test 1 | Test 2 | Test 3 | Req/Sec (avg) | Latency (avg) | +| ------- | ------- | ------- | ------- | ------------- | ------------- | +| `/test` | 199,791 | 198,669 | 198,985 | 199,148 | 24.61 ms | -Takeaway: `/test-cpu` blocks the event loop on the main thread, see [worker mode](#start-server) to move CPU work off-thread. +Takeaway: JSON baseline throughput on the main thread. ### Views (DVE Rendering Baseline) -| Route | Test 1 | Test 2 | Req/Sec (avg) | Latency (avg) | Total (avg) | -| ---------------------- | ------- | ------- | ------------- | ------------- | ----------- | -| `/test-view` | 118,026 | 118,323 | 118,175 | 41.81 ms | 3.55M | -| `/test-view-each-meta` | 8,522 | 8,530 | 8,526 | 580.44 ms | 256k | -| `/test-view-include` | 103,633 | 106,489 | 105,061 | 47.09 ms | 3.15M | -| `/test-view-expr` | 79,089 | 79,556 | 79,322 | 62.49 ms | 2.38M | +| Route | Test 1 | Test 2 | Test 3 | Req/Sec (avg) | Latency (avg) | +| ---------------------- | ------- | ------- | ------- | ------------- | ------------- | +| `/test-view` | 157,039 | 157,350 | 154,167 | 156,186 | 31.51 ms | +| `/test-view-each-meta` | 28,173 | 28,367 | 28,425 | 28,322 | 175.59 ms | +| `/test-view-include` | 133,275 | 131,987 | 133,453 | 132,905 | 37.12 ms | +| `/test-view-expr` | 145,139 | 143,403 | 144,670 | 144,404 | 34.12 ms | + +Takeaway: `each` and `include` recovered from the engine extraction. The DVE +0.1.1 render pipeline lifts `each-meta` and expressions well past the earlier +numbers, and `include` runs close to the include-free `/test-view` baseline. ## Observability Cost (Logging On vs Off) -Deserve emits lifecycle events (route, view, worker, request, session, csrf, process). -By default **no listener is attached**, so the request path skips all reporting and -stays cheap. The moment you attach a listener with `router.on(...)`, every request -walks the full reporting path. This section measures that difference on the same -`/test` route. +Deserve emits lifecycle events (route, view, request, session, process). By default **no listener is attached**, so the request path skips all reporting and stays cheap. The moment you attach a listener with `router.on(...)`, every request walks the full reporting path. This section measures that difference on the same `/test` route. ### How to Reproduce -Off (no listener) uses `benchmark/main.ts`. On uses `benchmark/main-log.ts` with an -empty listener, so the result reflects the framework cost only, not any logging work: +Off (no listener) uses `benchmark/main.ts`. On uses `benchmark/main-log.ts` with an empty listener, so the result reflects the framework cost only, not any logging work: ```ts // benchmark/main-log.ts -import { Router } from '@app/index.ts' +import { Router } from '@neabyte/deserve' -const router = new Router({ routesDir: 'benchmark/routes', viewsDir: 'benchmark/views' }) +const router = new Router({ + routes: { directory: 'benchmark/routes' }, + views: { directory: 'benchmark/views' } +}) // Empty listener, observability path active router.on(() => {}) @@ -151,12 +146,10 @@ npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30 | Mode | Run 1 | Run 2 | Run 3 | Req/Sec (avg) | Latency (avg) | | ------------------- | ------- | ------- | ------- | ------------- | ------------- | -| Off (no listener) | 147,434 | 148,492 | 148,949 | 148,292 | 33.21 ms | -| On (empty listener) | 115,880 | 118,030 | 114,016 | 115,975 | 42.61 ms | +| Off (no listener) | 181,158 | 177,024 | 185,173 | 181,118 | 27.12 ms | +| On (empty listener) | 148,309 | 147,170 | 136,182 | 143,887 | 34.30 ms | -Attaching a listener drops throughput to about **78%** of the no-listener baseline -and raises average latency from 33 ms to 43 ms. This gap is the framework reporting -cost, measured with no logging in the listener. +Attaching a listener drops throughput to about **79%** of the no-listener baseline and raises average latency from 27 ms to 34 ms. This gap is the framework reporting cost, measured with no logging in the listener. ### Where the Cost Comes From @@ -172,21 +165,18 @@ const observe = this.events.hasListeners() const requestStart = observe ? performance.now() : 0 ``` -With no listener, `observe` is `false`, `reportRequest` is never called, and -`events.emit(...)` returns immediately. This is why the baseline holds ~148k. +With no listener, `observe` is `false`, `reportRequest` is never called, and `events.emit(...)` returns immediately. This is why the baseline holds ~181k. #### 2. Per-request reporting (paid once a listener exists) -Once `observe` is `true`, **every request** is parsed to build the event metadata. -This is the framework cost, and it runs even with an empty listener: +Once `observe` is `true`, **every request** is parsed to build the event metadata. This is the framework cost, and it runs even with an empty listener: - `performance.now()` is read twice (request start and duration). - `reportRequest()` builds metadata for `request:complete`, plus `request:error` when status >= 400. - `requestMetrics()` reads `content-length` (request and response), parses the URL for host and port, and reads `user-agent`. - One or two event objects are created and dispatched to the listener. -The work your listener itself does is added on top of this, so keep it light on the -hot path. +The work your listener itself does is added on top of this, so keep it light on the hot path. ### Keeping Observability Light @@ -203,14 +193,11 @@ hot path. - **Batch off the request path**: push events into a buffer, flush on a timer to a sink such as a file or an OTel exporter. -Related events you may want to watch: `request:error`, `view:error`, -`reload:error`, `worker:timeout`, `worker:crash`, `session:invalid`, -`csrf:rule-error`, and `process:error`. +Related events you may want to watch: `request:error`, `view:error`, `reload:error`, `session:invalid`, and `process:error`. ## Files -- `benchmark/main.ts`: server entry (non-worker) -- `benchmark/main-worker.ts`: server entry (worker-enabled) +- `benchmark/main.ts`: server entry - `benchmark/main-log.ts`: server entry (one empty event listener) - `benchmark/routes/*.ts`: route handlers - `benchmark/views/*.dve`: DVE templates diff --git a/benchmark/main-log.ts b/benchmark/main-log.ts index 066fa67..0a71be1 100644 --- a/benchmark/main-log.ts +++ b/benchmark/main-log.ts @@ -1,8 +1,14 @@ -import { Router } from '@app/index.ts' +import { Router } from '@neabyte/deserve' -const router = new Router({ routesDir: 'benchmark/routes', viewsDir: 'benchmark/views' }) +const router = new Router({ + routes: { + directory: 'benchmark/routes' + }, + views: { + directory: 'benchmark/views' + } +}) -// Empty listener router.on(() => {}) await router.serve(8000) diff --git a/benchmark/main-worker.ts b/benchmark/main-worker.ts deleted file mode 100644 index 6b2cd90..0000000 --- a/benchmark/main-worker.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Router } from '@app/index.ts' - -const workerCode = ` -const defaultIterations = 50000 -self.onmessage = (e) => { - const data = e.data || {} - const n = Math.max(0, Number(data.iterations) || defaultIterations) - let value = 0 - for (let i = 0; i < n; i++) value += Math.sqrt(i) - self.postMessage({ done: true, value }) -} -export {} -` - -const workerScriptUrl = URL.createObjectURL( - new Blob([workerCode], { type: 'application/javascript' }) -) - -const router = new Router({ - routesDir: 'benchmark/routes', - viewsDir: 'benchmark/views', - worker: { scriptURL: workerScriptUrl, poolSize: 4 } -}) - -await router.serve(8000) diff --git a/benchmark/main.ts b/benchmark/main.ts index 361417c..5b2609a 100644 --- a/benchmark/main.ts +++ b/benchmark/main.ts @@ -1,5 +1,12 @@ -import { Router } from '@app/index.ts' +import { Router } from '@neabyte/deserve' -const router = new Router({ routesDir: 'benchmark/routes', viewsDir: 'benchmark/views' }) +const router = new Router({ + routes: { + directory: 'benchmark/routes' + }, + views: { + directory: 'benchmark/views' + } +}) await router.serve(8000) diff --git a/benchmark/routes/test-cpu.ts b/benchmark/routes/test-cpu.ts index cca8aef..8a96bb9 100644 --- a/benchmark/routes/test-cpu.ts +++ b/benchmark/routes/test-cpu.ts @@ -1,4 +1,4 @@ -import type { Context } from '@app/index.ts' +import type { Context } from '@neabyte/deserve' export function GET(_ctx: Context) { let value = 0 diff --git a/benchmark/routes/test-view-each-meta.ts b/benchmark/routes/test-view-each-meta.ts index 68f2303..482b04d 100644 --- a/benchmark/routes/test-view-each-meta.ts +++ b/benchmark/routes/test-view-each-meta.ts @@ -1,4 +1,4 @@ -import type { Context } from '@app/index.ts' +import type { Context } from '@neabyte/deserve' export async function GET(ctx: Context) { const items = Array.from({ length: 50 }, (_, index) => index) diff --git a/benchmark/routes/test-view-expr.ts b/benchmark/routes/test-view-expr.ts index 8a84bed..84f1fe6 100644 --- a/benchmark/routes/test-view-expr.ts +++ b/benchmark/routes/test-view-expr.ts @@ -1,4 +1,4 @@ -import type { Context } from '@app/index.ts' +import type { Context } from '@neabyte/deserve' export async function GET(ctx: Context) { const user = { name: 'Nea', isAdmin: true } diff --git a/benchmark/routes/test-view-include.ts b/benchmark/routes/test-view-include.ts index ff0e0ac..f7ebf8e 100644 --- a/benchmark/routes/test-view-include.ts +++ b/benchmark/routes/test-view-include.ts @@ -1,4 +1,4 @@ -import type { Context } from '@app/index.ts' +import type { Context } from '@neabyte/deserve' export async function GET(ctx: Context) { return await ctx.render('include', { name: 'Nea' }) diff --git a/benchmark/routes/test-view.ts b/benchmark/routes/test-view.ts index 06029dc..93bef75 100644 --- a/benchmark/routes/test-view.ts +++ b/benchmark/routes/test-view.ts @@ -1,4 +1,4 @@ -import type { Context } from '@app/index.ts' +import type { Context } from '@neabyte/deserve' export async function GET(ctx: Context) { return await ctx.render('hello', { name: 'World' }) diff --git a/benchmark/routes/test-worker.ts b/benchmark/routes/test-worker.ts deleted file mode 100644 index f010c7f..0000000 --- a/benchmark/routes/test-worker.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Context } from '@app/index.ts' - -export async function GET(ctx: Context) { - const worker = ctx.state['worker'] as { run: (p: unknown) => Promise } | undefined - if (!worker?.run) { - return ctx.send.json({ error: 'worker not enabled' }, { status: 503 }) - } - const result = await worker.run<{ done: boolean; value: number }>({ iterations: 50_000 }) - return ctx.send.json({ hello: 'worker', value: result?.value }) -} diff --git a/benchmark/routes/test.ts b/benchmark/routes/test.ts index 3bf184a..3a612e5 100644 --- a/benchmark/routes/test.ts +++ b/benchmark/routes/test.ts @@ -1,4 +1,4 @@ -import type { Context } from '@app/index.ts' +import type { Context } from '@neabyte/deserve' export function GET(ctx: Context) { return ctx.send.json({ hello: 'world!' }) diff --git a/deno.json b/deno.json index bf2d0c3..e040f12 100644 --- a/deno.json +++ b/deno.json @@ -62,18 +62,18 @@ "test": "deno test tests/ --allow-read --allow-net" }, "imports": { + "@neabyte/dve": "jsr:@neabyte/dve@^0.1.1", "@neabyte/fast-router": "jsr:@neabyte/fast-router@^0.1.0", "@neabyte/superwatcher": "jsr:@neabyte/superwatcher@^0.1.1", "@neabyte/typebox": "jsr:@neabyte/typebox@^0.1.1", "@neabyte/utils-core": "jsr:@neabyte/utils-core@^0.2.0", "@std/assert": "jsr:@std/assert@^1.0.19", + "@neabyte/deserve": "./src/index.ts", "@app/": "./src/", "@core/": "./src/core/", "@interfaces/": "./src/interfaces/", "@middleware/": "./src/middleware/", - "@rendering/": "./src/rendering/", "@routing/": "./src/routing/", - "@validation/": "./src/validation/", "@tests/": "./tests/" }, "publish": { diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1887c7f..932c3e9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -133,8 +133,7 @@ export default withMermaid( { text: 'Context Object', link: '/core-concepts/context-object' }, { text: 'Request Handling', link: '/core-concepts/request-handling' }, { text: 'Hot Reload', link: '/core-concepts/hot-reload' }, - { text: 'Multi-Service', link: '/core-concepts/multi-service' }, - { text: 'Worker Pool', link: '/core-concepts/worker-pool' } + { text: 'Multi-Service', link: '/core-concepts/multi-service' } ] }, { @@ -222,14 +221,13 @@ export default withMermaid( text: 'Response', collapsed: true, items: [ - { text: 'JSON Format', link: '/response/json' }, - { text: 'Text Format', link: '/response/text' }, - { text: 'HTML Format', link: '/response/html' }, - { text: 'File Downloads', link: '/response/file' }, - { text: 'Data Downloads', link: '/response/data' }, - { text: 'Stream', link: '/response/stream' }, - { text: 'Redirects', link: '/response/redirect' }, - { text: 'Custom Responses', link: '/response/custom' } + { text: 'JSON', link: '/response/json' }, + { text: 'Text', link: '/response/text' }, + { text: 'HTML', link: '/response/html' }, + { text: 'Downloads', link: '/response/download' }, + { text: 'Empty', link: '/response/empty' }, + { text: 'Custom', link: '/response/custom' }, + { text: 'Redirects', link: '/response/redirect' } ] }, { @@ -269,7 +267,8 @@ export default withMermaid( { text: 'Object Storage', link: '/recipes/object-storage' }, { text: 'Graceful Shutdown', link: '/recipes/graceful-shutdown' }, { text: 'Production Deploy', link: '/recipes/production-deploy' }, - { text: 'Audit Compliance', link: '/recipes/audit-compliance' } + { text: 'Audit Compliance', link: '/recipes/audit-compliance' }, + { text: 'Worker Pool', link: '/recipes/worker-pool' } ] } ] @@ -344,8 +343,7 @@ export default withMermaid( { text: 'Objek Konteks', link: '/id/core-concepts/context-object' }, { text: 'Penanganan Request', link: '/id/core-concepts/request-handling' }, { text: 'Hot Reload', link: '/id/core-concepts/hot-reload' }, - { text: 'Multi-Service', link: '/id/core-concepts/multi-service' }, - { text: 'Worker Pool', link: '/id/core-concepts/worker-pool' } + { text: 'Multi-Service', link: '/id/core-concepts/multi-service' } ] }, { @@ -433,14 +431,13 @@ export default withMermaid( text: 'Response', collapsed: true, items: [ - { text: 'Format JSON', link: '/id/response/json' }, - { text: 'Format Teks', link: '/id/response/text' }, - { text: 'Format HTML', link: '/id/response/html' }, - { text: 'Unduhan File', link: '/id/response/file' }, - { text: 'Unduhan Data', link: '/id/response/data' }, - { text: 'Stream', link: '/id/response/stream' }, - { text: 'Pengalihan', link: '/id/response/redirect' }, - { text: 'Respon Khusus', link: '/id/response/custom' } + { text: 'JSON', link: '/id/response/json' }, + { text: 'Teks', link: '/id/response/text' }, + { text: 'HTML', link: '/id/response/html' }, + { text: 'Unduhan', link: '/id/response/download' }, + { text: 'Kosong', link: '/id/response/empty' }, + { text: 'Kustom', link: '/id/response/custom' }, + { text: 'Pengalihan', link: '/id/response/redirect' } ] }, { @@ -480,7 +477,8 @@ export default withMermaid( { text: 'Object Storage', link: '/id/recipes/object-storage' }, { text: 'Graceful Shutdown', link: '/id/recipes/graceful-shutdown' }, { text: 'Production Deploy', link: '/id/recipes/production-deploy' }, - { text: 'Audit Kepatuhan', link: '/id/recipes/audit-compliance' } + { text: 'Audit Kepatuhan', link: '/id/recipes/audit-compliance' }, + { text: 'Worker Pool', link: '/id/recipes/worker-pool' } ] } ] diff --git a/docs/.vitepress/deserve-deno.d.ts b/docs/.vitepress/deserve-deno.d.ts index e3442dc..8d35f74 100644 --- a/docs/.vitepress/deserve-deno.d.ts +++ b/docs/.vitepress/deserve-deno.d.ts @@ -43,6 +43,27 @@ declare namespace Deno { function upgradeWebSocket(request: Request): { socket: WebSocket; response: Response } + interface FileInfo { + isFile: boolean + isDirectory: boolean + isSymlink: boolean + size: number + mtime: Date | null + atime: Date | null + birthtime: Date | null + } + function stat(path: string | URL): Promise + + interface NetAddr { + transport: 'tcp' | 'udp' + hostname: string + port: number + } + interface ServeHandlerInfo { + remoteAddr: NetAddr + completed: Promise + } + namespace errors { class NotFound extends Error {} class InvalidData extends Error {} diff --git a/docs/.vitepress/deserve-types.ts b/docs/.vitepress/deserve-types.ts index f6ed304..f3ef464 100644 --- a/docs/.vitepress/deserve-types.ts +++ b/docs/.vitepress/deserve-types.ts @@ -1,1348 +1,1143 @@ /// /// /// -// Real module shim for Twoslash docs samples, mapped to `@neabyte/deserve` via -// compilerOptions.paths. Using a real module (not `declare module`) ensures the -// copied JSDoc shows up in hover popups. Mirrors the full public surface of -// src/index.ts (all interface types, Validator/Context/Router/Mware, and the -// re-exported Typebox contract helpers). Keep in sync with src/index.ts. -// ───────────────────────── Core: value/string types ───────────────────────── +// ───────────────────────── Typebox: contract helpers ───────────────────────── -/** Generic string-keyed data record. */ -export type DataRecord = Record - -/** String-to-string key-value record. */ -export type StringRecord = Record - -/** String key-value tuple pair. */ -export type StringPair = [string, string] - -/** - * Sync or async value wrapper. - * @template T - Wrapped value type - */ -export type MaybeAsync = T | Promise - -/** - * Branded key for state access. - * @template T - Value type stored under key - */ -export type StateKey = string & { readonly __stateValue: T } - -/** HTTP method literal union. */ -export type HttpMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT' - -/** 4xx client error status codes. */ -export type ClientErrorCode = - | 400 - | 401 - | 403 - | 404 - | 405 - | 408 - | 409 - | 410 - | 413 - | 414 - | 415 - | 422 - | 429 - -/** 5xx server error status codes. */ -export type ServerErrorCode = 500 | 501 | 502 | 503 | 504 - -/** HTTP status code branded type. */ -export type HttpStatusCode = ClientErrorCode | ServerErrorCode - -/** Valid HTTP redirect status codes. */ -export type RedirectStatus = 301 | 302 | 303 | 307 | 308 - -/** Optional headers for redirect init. */ -export type RedirectInit = Pick +/** Single-input contract function signature */ +export type ContractFn = (input: never) => unknown -/** Body format the request parsed. */ -export type BodyParsedFormat = 'arraybuffer' | 'blob' | 'form' | 'json' | 'text' +/** First parameter type of a contract */ +export type ContractInput = Parameters[0] -/** Error with attached HTTP status code. */ -export type StatusError = Error & { statusCode: number } +/** Guard pass flag or reasons */ +export type GuardVerdict = true | string | readonly string[] /** - * Carrier of an HTTP status code. - * @description Single atom for status-bearing values; widen via S. - * @template S - Confidence of the statusCode value + * Synchronous guard for contract input. + * @description Validates input and returns a verdict. + * @template ContractType - Contract function type */ -export type StatusCarrier = { readonly statusCode: S } +export type GuardFn = ( + input: NoInfer> +) => GuardVerdict -/** Error-like object with unknown statusCode property. */ -export type StatusCodeCarrier = StatusCarrier +/** One guard or guard list */ +export type GuardInput = + | GuardFn + | readonly GuardFn[] -/** Matcher predicate for an IP string. */ -export type IpMatcher = (ip: string) => boolean +// ───────────────────────── interfaces/Core.ts ───────────────────────── /** - * Widened tag carrier for fail-closed reads. - * @description Readonly record exposing one string-typed discriminant key. - * @template K - Discriminant property name + * Internal context control surface. + * @description Framework only hooks for context state. */ -export type TagCarrier = { readonly [P in K]: string } - -/** - * Discriminated-union member with tag. - * @description Joins a readonly tag literal with payload shape. - * @template Tag - Discriminant property name - * @template K - Literal value of the discriminant - * @template Shape - Payload properties for the member - */ -export type TaggedVariant = - & { readonly [P in Tag]: K } - & Readonly +export interface ContextInternal { + /** + * Emit observability event. + * @description Forwards event to context emitter. + * @param event - Event payload to emit + */ + emitEvent(event: EventBase): void + /** + * Finalize raw response headers. + * @description Merges pending headers and cookies. + * @param response - Raw response to finalize + * @returns Same response with merged headers + */ + finalizeRaw(response: globalThis.Response): globalThis.Response + /** + * Read captured framework error. + * @description Returns last error or null. + * @returns Framework error or null + */ + getFrameworkError(): Error | null + /** + * Install session controller. + * @description Enables session reads and writes. + * @param controller - Session controller to install + */ + installSession(controller: SessionController): void + /** + * Install validated data controller. + * @description Enables validated data reads. + * @param controller - Validated controller to install + */ + installValidated(controller: ValidatedController): void + /** + * Install worker pool controller. + * @description Enables worker task dispatch. + * @param controller - Worker controller to install + */ + installWorker(controller: WorkerController): void + /** + * Set decoded route parameters. + * @description Stores parameters for context reads. + * @param params - Route parameter record + */ + setParams(params: StringRecord): void +} /** - * Response factory from payload args. - * @description Appends optional ResponseInit and trailing args to payload. - * @template Args - Leading payload argument tuple - * @template Tail - Optional trailing argument tuple after options - * @template R - Response return wrapper, sync or promised + * Cookie attribute initialization options. + * @description Configures cookie scope and flags. */ -export type ResponseFn< - Args extends readonly unknown[] = [], - Tail extends readonly unknown[] = [], - R extends Response | Promise = Response -> = (...args: [...Args, options?: ResponseInit, ...rest: Tail]) => R - -// ───────────────────────── Core: response + error info ───────────────────────── +export interface CookieInit { + /** Cookie domain scope */ + domain?: string + /** Cookie expiry date or timestamp */ + expires?: Date | number + /** Mark cookie as HTTP only */ + httpOnly?: boolean + /** Cookie max age in seconds */ + maxAge?: number + /** Cookie path scope */ + path?: string + /** Cookie SameSite policy */ + sameSite?: SameSitePolicy + /** Mark cookie as secure */ + secure?: boolean +} /** - * Response helpers on context. - * @description Provides typed methods for common response formats. + * Error information for handlers. + * @description Carries error, request, and status data. */ -export interface SendHelpers { - /** Build custom response with body */ - readonly custom: ResponseFn<[body: BodyInit | null]> - /** Build binary data download response */ - readonly data: ResponseFn<[data: Uint8Array | string, filename: string], [contentType?: string]> - /** Serve file from filesystem path */ - readonly file: ResponseFn<[filePath: string, filename?: string], [], Promise> - /** Build HTML content response */ - readonly html: ResponseFn<[html: string]> - /** Build JSON serialized response */ - readonly json: ResponseFn<[data: unknown]> - /** Build redirect response to URL */ - readonly redirect: (url: string, status?: RedirectStatus, options?: RedirectInit) => Response - /** Build streaming response with ReadableStream */ - readonly stream: ResponseFn<[stream: ReadableStream], [contentType?: string]> - /** Build plain text response */ - readonly text: ResponseFn<[text: string]> -} - -/** Error details for error middleware. */ export interface ErrorInfo { /** Caught error instance */ readonly error: Error - /** HTTP method of failed request */ + /** Request HTTP method */ readonly method: string - /** URL pathname of failed request */ + /** Request path name */ readonly pathname: string - /** HTTP status code for response */ + /** HTTP status code */ readonly statusCode: number - /** Full request URL string */ + /** Full request URL */ readonly url: string } -/** Extracted status code and error. */ -export type ExtractedError = Pick - -/** Structured error problem details payload. */ -export interface ProblemDetails { - /** Problem type URI reference */ - readonly type: string - /** Short human-readable problem summary */ - readonly title: string - /** HTTP status code for problem */ - readonly status: number - /** Optional URI reference of occurrence */ - readonly instance?: string - /** Optional list of validation reasons */ - readonly errors?: readonly string[] -} - -/** Inclusive byte range for partial content. */ -export interface ByteRange { - /** Inclusive end byte offset */ - readonly end: number - /** Inclusive start byte offset */ - readonly start: number -} - -/** Parsed IP address value with version. */ -export interface ParsedIp { - /** Numeric address value */ - readonly value: bigint - /** Address version, 4 or 6 */ - readonly version: 4 | 6 -} - -/** Worker message payload data. */ -export interface WorkerMessageData { - /** True when message indicates error */ - readonly error?: boolean - /** Human-readable message text */ - readonly message?: string -} - -/** - * Callback that builds redirect response. - * @description Constructs redirect Response with status and headers. - * @param url - Target redirect URL - * @param status - HTTP redirect status code - * @param extraHeaders - Additional headers to include - * @returns Redirect Response instance - */ -export type RedirectBuilder = ( - url: string, - status: RedirectStatus, - extraHeaders?: HeadersInit -) => Response - -// ───────────────────────── Core: Context class ───────────────────────── - /** - * Per-request context object. - * @description Wraps the incoming request, route params, response helpers, - * and userland state for a single HTTP request. + * Request reading helper methods. + * @description Reads method, URL, headers, and body. */ -export class Context { - /** Direct TCP peer IP address */ - get directIp(): string | undefined - /** Raw request Headers */ - get headers(): Headers - /** Resolved client IP address */ - get ip(): string | undefined - /** Request pathname from URL */ - get pathname(): string - /** Raw Request object */ - get request(): Request - /** Send helpers for response building */ - get send(): SendHelpers - /** Shared mutable userland request state */ - get state(): DataRecord - /** Full request URL string */ - get url(): string - - /** Read body as ArrayBuffer */ - arrayBuffer(): Promise - /** Read body as Blob */ - blob(): Promise - /** Read body by content type */ - body(): Promise +export interface GetHelpers { /** - * Get cookie by key or all. - * @description Parses Cookie header on first access. - * @param key - Cookie name - * @returns Cookie value or undefined + * Read client IP address. + * @description Returns direct peer when option set. + * @param options - Optional direct IP flag + * @returns Client IP or undefined */ - cookie(): StringRecord - cookie(key: string): string | undefined - /** Read body as FormData */ - formData(): Promise + ip(options?: IpDirectOption): string | undefined /** - * Get typed state value. - * @description Type-safe alternative to `state[key] as T`. - * @template T - Value type encoded in the key - * @param key - Branded state key - * @returns Typed value or undefined + * Read request HTTP method. + * @returns Request method string */ - getState(key: StateKey): T | undefined + method(): string /** - * Build error response via handler. - * @description Uses errorHandler if set else custom response. - * @param statusCode - HTTP status code - * @param error - Error instance - * @returns Error response + * Read parsed request URL. + * @returns Request URL instance */ - handleError(statusCode: number, error: Error): Promise + url(): URL /** - * Get header by name. - * @description Parses headers on first access, keys lowercased. - * @param key - Header name - * @returns Header value or undefined + * Read request path name. + * @returns Request path string */ - header(): StringRecord - header(key: string): string | undefined - /** Read body as JSON */ - json(): Promise + pathname(): string /** - * Get single route param by key. - * @description Returns one named param from route match. - * @param key - Param name from pattern - * @returns Param value or undefined + * Read underlying request instance. + * @returns Request instance */ - param(key: string): string | undefined - /** Get all route path params */ - params(): StringRecord + request(): Request + /** Read request header value or map */ + header: RecordAccessor + /** Read request cookie value or map */ + cookie: RecordAccessor + /** Read query parameter value or map */ + query: RecordAccessor + /** Read route parameter value or map */ + param: RecordAccessor /** - * Get all values for query key. - * @description Returns all query values for repeated key. - * @param key - Query parameter name - * @returns Array of values + * Read request body by type. + * @description Chooses reader from content type. + * @returns Promise resolving to body value + * @template T - Body value type */ - queries(key: string): string[] + body(): Promise /** - * Get query param by key. - * @description Parses search params on first access. - * @param key - Query key - * @returns Query value or undefined + * Read request body as JSON. + * @returns Promise resolving to parsed JSON + * @template T - JSON value type */ - query(): StringRecord - query(key: string): string | undefined + json(): Promise /** - * Redirect response to a URL. - * @description Wraps `ctx.send.redirect` with same builder. - * @param url - Target URL (relative same-origin or explicit absolute http(s)) - * @param status - Redirect status code, defaults to 302 - * @param options - Optional extra headers - * @returns Redirect Response with Location header + * Read request body as text. + * @returns Promise resolving to body text */ - redirect(url: string, status?: RedirectStatus, options?: RedirectInit): Response + text(): Promise /** - * Render template and return HTML response. - * @description Requires viewsDir set in Router, uses ctx.state.view. - * @param templatePath - Path to .dve template relative to viewsDir - * @param data - Data for template - * @returns Response with rendered HTML + * Read request body as form data. + * @returns Promise resolving to form data */ - render(templatePath: string, data?: DataRecord): Promise + formData(): Promise /** - * Set one response header. - * @description Merges one header into response headers. - * @param key - Header name - * @param value - Header value - * @returns this for chaining + * Read request body as blob. + * @returns Promise resolving to body blob */ - setHeader(key: string, value: string): this + blob(): Promise /** - * Set multiple response headers. - * @description Merges headers into response headers. - * @param headers - Key-value map of headers - * @returns this for chaining + * Read request body as bytes. + * @returns Promise resolving to byte array */ - setHeaders(headers: StringRecord): this + bytes(): Promise /** - * Set typed state value. - * @description Type-safe alternative to `state[key] = value`. - * @template T - Value type encoded in the key - * @param key - Branded state key - * @param value - Value matching the key's type - * @throws {StatusError} When the key is a reserved framework key + * Read current session data. + * @returns Session data or null */ - setState(key: StateKey, value: T): void + session(): SessionData | null /** - * Render template with streaming. - * @description Requires viewsDir set in Router, validates before committing. - * @param templatePath - Path to .dve template relative to viewsDir - * @param data - Data for template - * @returns Response with streaming HTML + * Read validated request data. + * @description Requires validate middleware registration. + * @returns Validated data map + * @template SchemaType - Validation schema type */ - streamRender(templatePath: string, data?: DataRecord): Promise - /** Read body as plain text */ - text(): Promise + validated(): ValidatedMap + /** + * Read worker pool controller. + * @returns Worker controller instance + */ + worker(): WorkerController } -// ───────────────────────── Core: handler/middleware function types ───────────────────────── +/** + * Direct IP read option. + * @description Selects direct peer over resolved IP. + */ +export interface IpDirectOption { + /** Read direct peer IP when true */ + direct?: boolean +} /** - * Internal framework-only Context surface. - * @description Members reachable cross-module via the InternalContext symbol. + * Parsed IP value and version. + * @description Holds numeric value and protocol version. */ -export interface ContextInternal { +export interface ParsedIp { + /** Numeric IP address value */ + readonly value: bigint + /** IP protocol version */ + readonly version: 4 | 6 +} + +/** + * RFC problem details payload. + * @description Describes error type, title, and status. + */ +export interface ProblemDetails { + /** Problem type URI */ + readonly type: string + /** Short problem title */ + readonly title: string + /** HTTP status code */ + readonly status: number + /** Request instance path */ + readonly instance?: string + /** Detailed error messages */ + readonly errors?: readonly string[] +} + +/** + * Template render initialization options. + * @description Sets response status and stream flag. + */ +export interface RenderInit { + /** Response HTTP status code */ + status?: HttpStatusCode + /** Stream rendered output when true */ + stream?: boolean +} + +/** + * Router constructor options. + * @description Configures routes, views, and limits. + */ +export interface RouterOptions { + /** Route loading options */ + routes?: RoutesOptions + /** View rendering options */ + views?: ViewsOptions + /** Enable hot reload watching */ + hotReload?: boolean + /** Maximum request URL length */ + maxUrlLength?: number + /** Request timeout in milliseconds */ + timeoutMs?: number + /** Trusted proxy configuration */ + trustProxy?: TrustProxyConfig + /** Worker pool configuration */ + worker?: WorkerPoolOptions +} + +/** + * Route loading options. + * @description Sets routes directory and parameter limit. + */ +export interface RoutesOptions { + /** Routes directory path */ + directory?: string + /** Maximum route parameter length */ + maxParamLength?: number +} + +/** + * Response sending helper methods. + * @description Builds JSON, text, HTML, and redirects. + */ +export interface SendHelpers { /** - * Apply headers and cookies to Response. - * @description Merges accumulated headers and cookies, existing values win. - * @param response - Native Response to finalize - * @returns Same Response with headers applied + * Send JSON response body. + * @description Serializes data as JSON. + * @param data - Data to serialize + * @param options - Optional response init + * @returns JSON response instance + * @template T - Data value type */ - finalizeRaw(response: Response): Response + json(data: T, options?: SendInit): Response /** - * Read captured framework error. - * @description Returns error set by handleError, null when none. - * @returns Framework Error or null + * Send plain text response. + * @param text - Text body to send + * @param options - Optional response init + * @returns Text response instance */ - getFrameworkError(): Error | null + text(text: string, options?: SendInit): Response /** - * Replace request and reset body state. - * @description Swaps the request and clears parsed body state. - * @param req - New request to use + * Send HTML response body. + * @param html - HTML body to send + * @param options - Optional response init + * @returns HTML response instance */ - replaceRequest(req: Request): void + html(html: string, options?: SendInit): Response /** - * Merge percent-decoded route params. - * @description Spread-merges decoded params into existing route params. - * @param params - Params from the router match + * Send custom response body. + * @param body - Response body or null + * @param options - Optional response init + * @returns Custom response instance */ - setParams(params: StringRecord): void + custom(body: BodyInit | null, options?: SendInit): Response /** - * Write reserved framework state key. - * @description Internal write path for framework-wired keys. - * @template T - Value type encoded in the key - * @param key - Branded reserved state key - * @param value - Value matching the key type + * Send file download response. + * @description Adds content disposition header. + * @param body - Download body content + * @param filename - Suggested download filename + * @param options - Optional response init + * @returns Download response instance */ - setInternalState(key: StateKey, value: T): void + download(body: DownloadBody, filename: string, options?: SendInit): Response /** - * Emit lifecycle event on router bus. - * @description No-op when the router has no emitter wired. - * @param event - Lifecycle event to broadcast + * Send empty response body. + * @param status - Optional HTTP status code + * @returns Empty response instance */ - emitEvent(event: EventBase): void - /** Snapshot of accumulated Set-Cookie values */ - readonly responseCookies: readonly string[] - /** Snapshot copy of accumulated response headers */ - readonly responseHeadersMap: StringRecord + empty(status?: HttpStatusCode): Response + /** + * Send redirect response. + * @description Validates and resolves location. + * @param url - Redirect target location + * @param status - Optional redirect status + * @param options - Optional redirect init + * @returns Redirect response instance + */ + redirect(url: string, status?: RedirectStatus, options?: RedirectInit): Response } /** - * Context-receiving function type. - * @description Generic for handlers that take context and return R. - * @template Args - Additional argument types after context - * @template R - Return type wrapped in MaybeAsync + * Static file serving options. + * @description Sets path, ETag, and cache control. */ -export type ContextFn = ( - ctx: Context, - ...args: Args -) => MaybeAsync - -/** Route handler receiving context. */ -export type RouteHandler = ContextFn<[], Response> +export interface ServeOptions { + /** Filesystem path to static directory */ + path: string + /** Enable ETag header generation */ + etag?: boolean + /** Cache-Control max age seconds */ + cacheControl?: number +} /** - * Handler for route error responses. - * @description Produces response from context, status, and error. + * Response setting helper methods. + * @description Sets headers, cookies, and session. */ -export type ErrorHandler = ContextFn<[statusCode: number, error: Error], Response> +export interface SetHelpers { + /** + * Set single response header. + * @param key - Header name to set + * @param value - Header value to set + * @returns Same helpers for chaining + */ + header(key: string, value: string): SetHelpers + /** + * Set multiple response headers. + * @param headers - Header name value record + * @returns Same helpers for chaining + */ + headers(headers: StringRecord): SetHelpers + /** + * Set response cookie value. + * @param name - Cookie name to set + * @param value - Cookie value to set + * @param options - Optional cookie attributes + * @returns Same helpers for chaining + */ + cookie(name: string, value: string, options?: CookieInit): SetHelpers + /** + * Write session data to cookie. + * @param data - Session data or null + * @returns Promise resolving when write completes + */ + session(data: SessionData | null): Promise +} /** - * Custom handler before error response. - * @description Intercepts errors before default error response is built. + * Validated data controller. + * @description Exposes frozen validated value. */ -export type ErrorMiddleware = ContextFn<[error: ErrorInfo], Response | null> - -/** Next function in middleware chain. */ -export type NextFn = () => AsyncMiddlewareResult +export interface ValidatedController { + /** Frozen validated value */ + readonly value: ValidatedValue +} /** - * Middleware function with context. - * @description Processes request with context and next chain. + * View rendering options. + * @description Sets views directory and render limits. */ -export type MiddlewareFn = ContextFn<[next: NextFn], Response | undefined> - -/** Middleware return type alias. */ -export type MiddlewareResult = ReturnType - -/** Async-resolved middleware result promise. */ -export type AsyncMiddlewareResult = Promise> - -/** SameSite cookie attribute value. */ -export type SameSitePolicy = 'Strict' | 'Lax' | 'None' - -// ───────────────────────── Middleware: option shapes ───────────────────────── - -/** Single Basic Auth user credential. */ -export interface BasicAuthUser { - /** Login username string */ - readonly username: string - /** Login password string */ - readonly password: string +export interface ViewsOptions { + /** Views directory path */ + directory?: string + /** Maximum loop iterations per block */ + maxIterations?: number + /** Maximum body executions per render */ + maxRenderIterations?: number + /** Maximum output characters per render */ + maxOutputSize?: number + /** Maximum template size in characters */ + maxTemplateSize?: number } -/** Basic Auth middleware options. */ -export interface BasicAuthOptions { - /** Allowed user credentials list */ - readonly users: readonly BasicAuthUser[] +/** + * Worker pool task controller. + * @description Dispatches payloads to worker pool. + */ +export interface WorkerController { + /** + * Run task on worker pool. + * @param payload - Task payload to dispatch + * @returns Promise resolving to task result + * @template T - Task result type + */ + run(payload: unknown): Promise } -/** Body size limit middleware options. */ -export interface BodyLimitOptions { - /** Maximum body size in bytes */ - readonly limit: number +/** + * Worker response message data. + * @description Carries error flag and message. + */ +export interface WorkerMessageData { + /** Error flag set on failure */ + readonly error?: boolean + /** Error message when failed */ + readonly message?: string } -/** CORS middleware options. */ -export interface CorsOptions { - /** Allowed request header names */ - readonly allowedHeaders?: readonly string[] - /** Allow credentials in requests */ - readonly credentials?: boolean - /** Headers exposed to client scripts */ - readonly exposedHeaders?: readonly string[] - /** Preflight cache duration in seconds */ - readonly maxAge?: number - /** Allowed HTTP methods for CORS */ - readonly methods?: readonly HttpMethod[] - /** Allowed origin or origin list */ - readonly origin?: string | readonly string[] +/** + * Worker pool configuration options. + * @description Sets script, size, and queue limits. + */ +export interface WorkerPoolOptions { + /** Maximum pending task count */ + readonly maxQueueDepth?: number + /** Maximum projected wait milliseconds */ + readonly maxQueueWaitMs?: number + /** Worker pool size */ + readonly poolSize?: number + /** Worker script URL */ + readonly scriptURL: string + /** Per task timeout milliseconds */ + readonly taskTimeoutMs?: number } +/** Supported request body read format */ +export type BodyFormat = 'blob' | 'bytes' | 'form' | 'json' | 'text' + +/** Inclusive byte range start and end */ +export type ByteRange = { readonly start: number; readonly end: number } + +/** Compiled template result from DVE */ +export type CompileResult = { readonly ast: readonly unknown[] } + /** - * CSRF rule predicate over a header value. - * @description Returns true when the value is allowed. - * @param value - Incoming header value to test - * @param ctx - Request context instance - * @returns True when the value passes the rule + * Context bound handler function. + * @description Receives context plus extra arguments. + * @template Args - Extra argument tuple type + * @template R - Handler return value type */ -export type CsrfRulePredicate = (value: string, ctx: Context) => boolean +export type ContextFn = ( + ctx: Context, + ...args: Args +) => MaybeAsync -/** CSRF middleware options. */ -export interface CsrfOptions { - /** Allowed origin, list, or predicate */ - readonly origin?: string | readonly string[] | CsrfRulePredicate - /** Allowed sec-fetch-site, list, or predicate */ - readonly secFetchSite?: string | readonly string[] | CsrfRulePredicate -} +/** Download response body source type */ +export type DownloadBody = ReadableStream | BufferSource | string -/** IP restriction middleware options. */ -export interface IpOptions { - /** Allowed IP, CIDR, or wildcard rules */ - readonly whitelist?: readonly string[] - /** Denied IP, CIDR, or wildcard rules */ - readonly blacklist?: readonly string[] -} +/** Error handling middleware function */ +export type ErrorMiddleware = ContextFn<[info: ErrorInfo], Response | null> -/** Session middleware cookie options. */ -export interface SessionOptions { - /** Session cookie name */ - readonly cookieName?: string - /** Secret key for cookie signing */ - readonly cookieSecret: string - /** Restrict cookie to HTTP only */ - readonly httpOnly?: boolean - /** Cookie expiry in seconds */ - readonly maxAge?: number - /** Cookie path scope */ - readonly path?: string - /** SameSite cookie policy attribute */ - readonly sameSite?: SameSitePolicy - /** Require HTTPS for cookie */ - readonly secure?: boolean -} +/** Union of all lifecycle events */ +export type EventBase = { + [Kind in keyof EventSchemaMap]: LifecycleEvent +}[keyof EventSchemaMap] -/** Derived security header option key union. */ -export type SecurityHeaderKey = - | 'contentSecurityPolicy' - | 'crossOriginEmbedderPolicy' - | 'crossOriginOpenerPolicy' - | 'crossOriginResourcePolicy' - | 'originAgentCluster' - | 'referrerPolicy' - | 'strictTransportSecurity' - | 'xContentTypeOptions' - | 'xDnsPrefetchControl' - | 'xDownloadOptions' - | 'xFrameOptions' - | 'xPermittedCrossDomainPolicies' - | 'xPoweredBy' +/** + * Lifecycle event by kind. + * @description Extracts event matching given kind. + * @template Kind - Event kind discriminator + */ +export type EventByKind = Extract -/** Header value or false to omit. */ -export type SecurityHeaderValue = string | false +/** Event channel internal or external */ +export type EventChannel = 'internal' | 'external' -/** Security header partial options map. */ -export type SecurityHeadersOptions = Partial> +/** Event metadata carrying an error */ +export type EventErrorMeta = { error: Error } /** - * Socket lifecycle callback with event. - * @description Handles WebSocket events with socket and context. - * @template E - Event subtype constraint - * @param socket - WebSocket connection instance - * @param event - DOM event from socket - * @param ctx - Request context instance + * Event listener callback function. + * @description Receives a lifecycle event. + * @param event - Lifecycle event payload */ -export type SocketCallback = ( - socket: WebSocket, - event: E, - ctx: Context -) => void - -/** WebSocket upgrade middleware options. */ -export interface WebSocketOptions { - /** Allowed handshake origins or wildcard */ - readonly allowedOrigins?: readonly string[] | '*' - /** Listener event name override */ - readonly listener?: string - /** Called on socket connection open */ - readonly onConnect?: SocketCallback - /** Called on socket connection close */ - readonly onDisconnect?: SocketCallback - /** Called on socket error event */ - readonly onError?: SocketCallback - /** Called on incoming socket message */ - readonly onMessage?: SocketCallback +export type EventFn = (event: EventBase) => void + +/** Union of all event kinds */ +export type EventKind = keyof EventSchemaMap + +/** Request event metadata with metrics */ +export type EventRequestMeta = RequestMetrics & { + method: string + statusCode: number + url: string + durationMs: number + error?: Error } -/** Middleware bound to optional path. */ -export interface MiddlewareEntry { - /** Middleware handler function */ - readonly handler: MiddlewareFn - /** Path prefix to match */ - readonly path: string +/** Route event metadata path and pattern */ +export type EventRouteMeta = { path: string; pattern: string } + +/** Event kind to metadata schema map */ +export type EventSchemaMap = { + 'server:started': { port: number; hostname: string } + 'server:stopped': Record + 'route:added': EventRouteMeta + 'route:updated': EventRouteMeta + 'route:removed': EventRouteMeta + 'route:ignored': { path: string; reason: string } + 'route:failed': { path: string } & EventErrorMeta + 'view:compiled': EventViewMeta + 'view:rendered': EventViewMeta + 'view:invalidated': { paths: readonly string[] } + 'view:failed': { path: string } & EventErrorMeta + 'session:invalid': { cookieName: string; reason: SessionInvalidReason } + 'csrf:failed': { rule: CsrfRuleName } & EventErrorMeta + 'cors:blocked': { origin: string } + 'auth:failed': { reason: AuthFailReason } + 'ip:denied': { ip: string } + 'validate:failed': { source: ValidationSource; reasons: readonly string[] } + 'body:rejected': { limit: number; declared: number | null } + 'websocket:rejected': { reason: WebSocketRejectReason } + 'static:missing': { path: string } + 'process:failed': { origin: ProcessErrorOrigin } & EventErrorMeta + 'worker:crashed': EventWorkerMeta & EventErrorMeta + 'worker:rejected': { reason: WorkerRejectReason; queueDepth: number; maxQueueDepth: number } + 'worker:respawned': EventWorkerMeta + 'worker:timeout': { timeoutMs: number } & EventWorkerMeta & EventErrorMeta + 'request:completed': EventRequestMeta + 'request:failed': EventRequestMeta } -/** Session cookie options all required. */ -export type SessionCookieOpts = Required> +/** View event metadata path and duration */ +export type EventViewMeta = { path: string; durationMs: number } -/** Decoded signed session cookie result. */ -export type SessionDecodeResult = - | { readonly data: DataRecord } - | { readonly reason: 'tampered' | 'expired' | 'malformed' } +/** Worker event metadata worker index */ +export type EventWorkerMeta = { index: number } + +/** Extracted status code and error pair */ +export type ExtractedError = Pick -// ───────────────────────── Rendering / worker ───────────────────────── +/** Supported HTTP request method names */ +export type HttpMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT' -/** Worker pool creation options. */ -export interface WorkerPoolOptions { - /** Optional lifecycle event emitter */ - readonly emit?: EventEmit - /** Maximum pending tasks before fast-rejecting */ - readonly maxQueueDepth?: number - /** Maximum projected queue wait in ms */ - readonly maxQueueWaitMs?: number - /** Number of workers in pool */ - readonly poolSize?: number - /** URL to worker script module */ - readonly scriptURL: string - /** Per-task timeout in milliseconds */ - readonly taskTimeoutMs?: number -} +/** Supported HTTP response status codes */ +export type HttpStatusCode = + | 200 + | 201 + | 202 + | 204 + | 206 + | 301 + | 302 + | 303 + | 304 + | 307 + | 308 + | 400 + | 401 + | 403 + | 404 + | 405 + | 406 + | 408 + | 409 + | 410 + | 413 + | 414 + | 415 + | 422 + | 426 + | 429 + | 500 + | 501 + | 502 + | 503 + | 504 /** - * Handle to run worker tasks. - * @description Dispatches payloads to pooled worker threads. + * IP matcher predicate function. + * @description Tests whether IP matches a rule. + * @param ip - IP address to test + * @returns True when IP matches */ -export interface WorkerRunHandle { - /** - * Run task on worker. - * @template T - Expected return type - * @param payload - Data to send to worker - * @returns Promise resolving to worker result - */ - run(payload: unknown): Promise -} +export type IpMatcher = (ip: string) => boolean /** - * View engine for templates. - * @description Renders templates to string or readable stream. + * Lifecycle event payload shape. + * @description Holds channel, kind, metadata, and timestamp. + * @template Kind - Event kind discriminator + * @template Metadata - Event metadata shape */ -export interface ViewEngine { - /** - * Render template to string. - * @param templatePath - Path to template file - * @param data - Template data record - * @returns Promise resolving to rendered HTML - */ - render(templatePath: string, data?: DataRecord): Promise - /** - * Render template to readable stream. - * @param templatePath - Path to template file - * @param data - Template data record - * @returns Promise resolving to a ReadableStream of rendered output - */ - streamRender(templatePath: string, data?: DataRecord): Promise -} - -/** Rendering engine constructor options. */ -export interface EngineOptions { - /** Optional lifecycle event emitter */ - readonly emit?: EventEmit - /** Maximum loop iterations per #each block */ - readonly maxIterations?: number - /** Maximum #each body executions per render */ - readonly maxRenderIterations?: number - /** Maximum total output characters per render */ - readonly maxOutputSize?: number - /** Directory path for template views */ - readonly viewsDir: string -} - -/** Compiled DVE template result. */ -export interface CompileResult { - /** Parsed AST node array */ - readonly ast: readonly AstNode[] -} - -/** DVE template parser stack frame. */ -export interface DveStackFrame { - /** True when inside else branch */ - inElse: boolean - /** Block node type discriminant */ - readonly kind: AstBlockKind - /** Parent block AST node */ - readonly node: AstBlockNode -} - -/** Per-render cumulative resource budget. */ -export interface RenderBudget { - /** Total #each body executions this render */ - iterations: number - /** Total output characters this render */ - outputSize: number +export type LifecycleEvent = { + /** Event channel internal or external */ + readonly type: EventChannel + /** Event kind discriminator */ + readonly kind: Kind + /** Frozen event metadata */ + readonly metadata: Readonly + /** Event creation timestamp */ + readonly timestamp: number } /** - * Watchable engine for cache invalidation. - * @description Supports file watching and cache refresh. + * Value or promise of value. + * @description Wraps synchronous or async result. + * @template T - Wrapped value type */ -export interface WatchableEngine extends Pick { - /** - * Invalidate cached template file. - * @param absPath - Absolute path to invalidate - */ - invalidateFile(absPath: string): void - /** - * Emit view:refreshed for changed paths. - * @param paths - Absolute paths that were refreshed - */ - notifyRefresh(paths: readonly string[]): void - /** Refresh all template paths */ - refreshPaths(): void -} - -/** Arithmetic sign for expressions. */ -export type ArithmeticSign = '+' | '-' - -/** Block-level AST node type discriminants. */ -export type AstBlockKind = AstBlockNode['type'] - -/** Block-level AST node with children. */ -export type AstBlockNode = Extract - -/** DVE template AST node union. */ -export type AstNode = - | (TaggedVariant<'type', 'each', { path: string; itemName: string }> & { nodes: AstNode[] }) - | (TaggedVariant<'type', 'if', { path: string }> & { - thenNodes: AstNode[] - elseNodes: AstNode[] - }) - | TaggedVariant<'type', 'include', { templatePath: string }> - | TaggedVariant<'type', 'text', { value: string }> - | TaggedVariant<'type', 'var', { path: string; raw: boolean }> - -/** AST node type discriminant values. */ -export type AstNodeType = AstNode['type'] - -/** Binary operator literals. */ -export type BinaryOp = - | '!=' - | '!==' - | '%' - | '&&' - | '*' - | '/' - | '<' - | '<=' - | '==' - | '===' - | '>' - | '>=' - | '??' - | '||' - | ArithmeticSign - -/** DVE expression AST node union. */ -export type ExprNode = - | TaggedVariant<'type', 'binary', { op: BinaryOp; left: ExprNode; right: ExprNode }> - | TaggedVariant<'type', 'ident', { name: string }> - | TaggedVariant<'type', 'literal', { value: string | number }> - | TaggedVariant<'type', 'member', { object: ExprNode; property: string }> - | TaggedVariant< - 'type', - 'ternary', - { test: ExprNode; consequent: ExprNode; alternate: ExprNode } - > - | TaggedVariant<'type', 'unary', { op: UnaryOp; arg: ExprNode }> - -/** Expression node type discriminant values. */ -export type ExprNodeType = ExprNode['type'] - -/** Node shape exposing op discriminant. */ -export type ExprOpCarrier = TagCarrier<'op'> - -/** DVE expression evaluator token. */ -export type ExprToken = - | TaggedVariant<'kind', 'ident', { value: string }> - | TaggedVariant<'kind', 'number', { value: number }> - | TaggedVariant<'kind', 'op', { value: TokenOp }> - | TaggedVariant<'kind', 'string', { value: string }> - -/** Expression token kind discriminant values. */ -export type ExprTokenKind = ExprToken['kind'] - -/** Node shape exposing type discriminant. */ -export type ExprTypeCarrier = TagCarrier<'type'> - -/** Structural operators in expression tokens. */ -export type StructuralOp = '(' | ')' | '.' | ':' | '?' | '?.' - -/** Template method parameter tuple. */ -export type TemplateArgs = [templatePath: string, data?: DataRecord] - -/** All operator literals in tokens. */ -export type TokenOp = BinaryOp | StructuralOp | UnaryOp - -/** Unary operator literals. */ -export type UnaryOp = '!' | ArithmeticSign - -// ───────────────────────── Routing: static + router options ───────────────────────── - -/** Static file serving options. */ -export interface ServeOptions { - /** Cache-Control max-age in seconds */ - readonly cacheControl?: number - /** Enable ETag header generation */ - readonly etag?: boolean - /** Filesystem path to static directory */ - readonly path: string -} +export type MaybeAsync = T | Promise -/** Trusted proxy configuration for IP resolution. */ -export type TrustProxyConfig = readonly string[] | IpMatcher +/** + * Request middleware function. + * @description Receives context and next continuation. + * @param ctx - Request context instance + * @param next - Next middleware continuation + * @returns Response or undefined promise + */ +export type MiddlewareFn = (ctx: Context, next: NextFn) => ReturnType /** - * Builds error response from status. - * @description Constructs HTTP error response using context and middleware. + * Middleware next continuation function. + * @description Invokes the next middleware in chain. + * @returns Response or undefined promise */ -export interface ErrorResponseBuilder { - /** - * Build error response. - * @param ctx - Request context instance - * @param statusCode - HTTP status code to send - * @param error - Caught error instance - * @param errorMiddleware - Optional error middleware handler - * @returns Promise resolving to error response - */ - build( - ctx: Context, - statusCode: number, - error: Error, - errorMiddleware: ErrorMiddleware | null - ): Promise -} +export type NextFn = () => Promise + +/** Process error origin discriminator */ +export type ProcessErrorOrigin = + | 'process:exit' + | 'process:signal' + | 'uncaughterror' + | 'unhandledrejection' + +/** Global object exposing optional process */ +export type ProcessGlobal = { process?: Record } /** - * Serves static files from path. - * @description Handles static file requests using serve options. + * Record value and key accessor. + * @description Returns full record or single value. */ -export interface StaticHandler { - /** - * Serve static file response. - * @param ctx - Request context instance - * @param options - Static file serving options - * @param urlPath - URL path to resolve - * @returns Promise resolving to file response - */ - serve(ctx: Context, options: ServeOptions, urlPath: string): Promise +export type RecordAccessor = { + (): StringRecord + (key: string): string | undefined } -/** Router constructor and serve options. */ -export interface RouterOptions { - /** Directory path for route modules */ - readonly routesDir?: string - /** Custom error response builder */ - readonly errorResponseBuilder?: ErrorResponseBuilder - /** Maximum route parameter length */ - readonly maxParamLength?: number - /** Maximum request URL length */ - readonly maxUrlLength?: number - /** Request timeout in milliseconds */ - readonly requestTimeoutMs?: number - /** Static file handler instance */ - readonly staticHandler?: StaticHandler - /** Trusted proxy configuration for IP resolution */ - readonly trustProxy?: TrustProxyConfig - /** Directory path for template views */ - readonly viewsDir?: string - /** Maximum loop iterations per #each block */ - readonly maxIterations?: number - /** Maximum #each body executions per render */ - readonly maxRenderIterations?: number - /** Maximum total output characters per render */ - readonly maxOutputSize?: number - /** Worker pool configuration options */ - readonly worker?: WorkerPoolOptions -} +/** Redirect response init headers only */ +export type RedirectInit = Pick -/** Allowed route module file extensions. */ -export type RouteFileExtension = 'cjs' | 'js' | 'jsx' | 'mjs' | 'ts' | 'tsx' - -/** Request handler configuration options. */ -export interface HandlerOptions extends - Partial< - Pick< - EngineOptions, - 'maxIterations' | 'maxRenderIterations' | 'maxOutputSize' | 'viewsDir' - > - > { - /** Custom error response builder */ - readonly errorResponseBuilder?: ErrorResponseBuilder - /** Maximum route parameter length */ - readonly maxParamLength?: number - /** Maximum request URL length */ - readonly maxUrlLength?: number - /** Request timeout in milliseconds */ - readonly requestTimeoutMs?: number - /** Static file handler instance */ - readonly staticHandler?: StaticHandler - /** Trusted proxy configuration for IP resolution */ - readonly trustProxy?: TrustProxyConfig - /** Worker pool configuration options */ - readonly worker?: WorkerPoolOptions -} +/** Allowed HTTP redirect status codes */ +export type RedirectStatus = 301 | 302 | 303 | 307 | 308 -/** Server listen address info. */ -export interface ListenAddr { - /** Bound hostname */ - readonly hostname: string - /** Bound port number */ - readonly port: number -} +/** + * View render function signature. + * @description Renders template with data and options. + * @param template - Template name to render + * @param data - View data for template + * @param options - Render options like status + * @returns Promise resolving to rendered response + */ +export type RenderFn = (template: string, data: ViewData, options: RenderInit) => Promise -/** Per-request context and error holder. */ -export interface RequestHolder { - /** Request context, null before creation */ - ctx: Context | null - /** Framework error captured during handling */ - frameworkError: Error | null - /** Resolved client IP, undefined when unknown */ - clientIp: string | undefined - /** Matched route pattern, undefined when unmatched */ - routePattern: string | undefined - /** Parsed request URL, reused to avoid re-parsing for metrics */ - parsedUrl: URL | undefined -} +/** Resolved rendering options from router */ +export type RenderingOptions = NonNullable['views']> -/** Optional OTel-aligned request metrics. */ -export interface RequestMetrics { - /** Matched route pattern */ +/** Optional request metrics for events */ +export type RequestMetrics = { + ip?: string route?: string - /** Resolved server hostname */ serverAddress?: string - /** Resolved server port number */ serverPort?: number - /** Request User-Agent header value */ userAgent?: string - /** Request body size in bytes */ requestSize?: number - /** Response body size in bytes */ responseSize?: number } -/** Route change entry for hot-reload. */ -export interface RouteChangeEntry { - /** Absolute filesystem path to module */ - readonly fullPath: string - /** Registered route path pattern */ - readonly routePath: string -} +/** Resolved file info and path */ +export type ResolvedFile = { readonly fileInfo: Deno.FileInfo; readonly filePath: string } -/** Shared route entry fields. */ -export interface RouteEntryBase { - /** URL pattern for route matching */ - readonly pattern: string -} +/** Route handler returning a response */ +export type RouteHandler = ContextFn<[], Response> -/** Route entry for type-safe dispatch. */ -export type RouteEntry = - | (RouteEntryBase & { - readonly kind: 'handler' - readonly handler: RouteHandler - }) - | (RouteEntryBase & { - readonly kind: 'static' - readonly execute: (ctx: Context) => Promise - readonly urlPath: string - }) - -/** Loaded route module with method exports. */ +/** Imported route module export record */ export type RouteModule = Record -/** Well-known framework state keys shape. */ -export interface StateKeysMap { - /** Key for the view engine */ - readonly view: StateKey - /** Key for the worker handle */ - readonly worker: StateKey - /** Key for current session data */ - readonly session: StateKey - /** Key for the session setter */ - readonly setSession: StateKey<(data: DataRecord) => Promise> - /** Key for the session clearer */ - readonly clearSession: StateKey<() => void> - /** Key for validated request data */ - readonly validated: StateKey -} +/** + * Dynamic module import function. + * @description Imports module by specifier string. + * @param specifier - Module specifier to import + * @returns Promise resolving to route module + */ +export type RuntimeImport = (specifier: string) => Promise -// ───────────────────────── Observability: events ───────────────────────── +/** Cookie SameSite policy value */ +export type SameSitePolicy = 'Lax' | 'None' | 'Strict' -/** Origin channel of an event. */ -export type EventChannel = 'internal' | 'external' +/** Response send init without status override */ +export type SendInit = Omit & { status?: HttpStatusCode } + +/** Reason a session was rejected */ +export type SessionInvalidReason = 'expired' | 'malformed' | 'tampered' + +/** CSRF rule that threw during evaluation */ +export type CsrfRuleName = 'origin' | 'secFetchSite' + +/** Reason basic auth rejected credentials */ +export type AuthFailReason = 'missing' | 'malformed' | 'invalid' + +/** Reason a websocket handshake was rejected */ +export type WebSocketRejectReason = 'origin' | 'version' | 'malformed' /** - * Lifecycle event envelope with metadata. - * @description Pairs a kind discriminant with its readonly metadata. - * @template Kind - Event kind discriminant literal - * @template Metadata - Event-specific metadata shape + * Validation source reader function. + * @description Reads a value from request helpers. + * @param get - Request reading helpers + * @returns Source value or promise */ -export type LifecycleEvent = { - /** Origin channel of the event */ - readonly type: EventChannel - /** Event kind discriminant value */ - readonly kind: Kind - /** Readonly event-specific metadata */ - readonly metadata: Readonly - /** Creation time in epoch milliseconds */ - readonly timestamp: number -} +export type SourceReader = (get: GetHelpers) => Promise | unknown + +/** Source reader map by validation source */ +export type SourceReaders = Readonly> /** - * Discriminated union of lifecycle events. - * @description Discriminated by kind, with fields under metadata. + * Static file serving function. + * @description Serves response for a URL path. + * @param ctx - Request context instance + * @param urlPath - URL path relative to mount + * @returns Response or promise of response */ -export type EventBase = - | LifecycleEvent<'server:listening', { port: number; hostname: string }> - | LifecycleEvent<'server:shutdown', Record> - | LifecycleEvent< - 'route:loaded' | 'route:reloaded' | 'route:removed', - { routePath: string; pattern: string } - > - | LifecycleEvent<'route:skipped', { routePath: string; reason: string }> - | LifecycleEvent<'route:error' | 'reload:error', { routePath: string; error: Error }> - | LifecycleEvent< - 'process:error', - { error: Error; origin: 'unhandledrejection' | 'uncaughterror' | 'process:exit' } - > - | LifecycleEvent<'view:compiled' | 'view:rendered', { path: string; durationMs: number }> - | LifecycleEvent<'view:refreshed', { paths: readonly string[] }> - | LifecycleEvent<'view:error', { path: string; error: Error }> - | LifecycleEvent< - 'request:complete' | 'request:error', - { - method: string - statusCode: number - url: string - durationMs: number - ip?: string - route?: string - serverAddress?: string - serverPort?: number - userAgent?: string - requestSize?: number - responseSize?: number - error?: Error - } - > - | LifecycleEvent<'worker:timeout', { workerIndex: number; timeoutMs: number; error: Error }> - | LifecycleEvent<'worker:crash', { workerIndex: number; error: Error }> - | LifecycleEvent<'worker:respawn', { workerIndex: number }> - | LifecycleEvent< - 'worker:rejected', - { reason: 'queue-depth' | 'queue-wait'; queueDepth: number; maxQueueDepth: number } - > - | LifecycleEvent< - 'session:invalid', - { cookieName: string; reason: 'tampered' | 'expired' | 'malformed' } - > - | LifecycleEvent<'csrf:rule-error', { rule: 'origin' | 'secFetchSite'; error: Error }> - -/** Discriminant value of a lifecycle event. */ -export type EventKind = EventBase['kind'] +export type StaticFn = (ctx: Context, urlPath: string) => MaybeAsync /** - * Event member selected by kind. - * @description Distributes over the union to keep grouped kinds. - * @template Kind - Event kind discriminant literal + * Object carrying an HTTP status. + * @description Holds a readonly status code value. + * @template S - Status code value type */ -export type EventByKind = EventBase extends infer Member - ? Member extends { kind: infer MemberKind } ? Kind extends MemberKind ? Member : never - : never - : never +export type StatusCarrier = { readonly statusCode: S } -/** Emit function passed into internal subsystems. */ -export type EventEmit = (event: EventBase) => void +/** Error carrying an HTTP status code */ +export type StatusError = Error & StatusCarrier -/** Listener invoked for emitted events. */ -export type EventListener = (event: EventBase) => void +/** Tuple of two string values */ +export type StringPair = [string, string] -// ───────────────────────── Validation: Typebox contracts ───────────────────────── +/** Record of string keys to strings */ +export type StringRecord = Record -/** Single-input contract function signature. */ -export type ContractFn = (input: never) => unknown +/** Trusted proxy rules or matcher */ +export type TrustProxyConfig = readonly string[] | IpMatcher -/** First parameter type of a contract. */ -export type ContractInput = Parameters[0] +/** + * Validated output map by schema. + * @description Maps schema keys to validated outputs. + * @template SchemaType - Validation schema type + */ +export type ValidatedMap = { + readonly [Key in keyof SchemaType]: SchemaType[Key] extends ContractFn + ? ValidatedOutput + : never +} + +/** + * Validated output of a contract. + * @description Awaited return type of contract function. + * @template ContractType - Contract function type + */ +export type ValidatedOutput = Awaited> -/** Contract function or stateful base entry. */ -export type ContractEntry = ContractFn | StatefulBase +/** Frozen validated value record */ +export type ValidatedValue = Readonly> -/** Record mapping names to contract entries. */ -export type ContractMap = Record +/** Validation schema by request source */ +export type ValidationSchema = Partial> -/** Generic record backing a live facade. */ -export type FacadeRecord = Record +/** Request source for validation */ +export type ValidationSource = 'body' | 'cookies' | 'headers' | 'query' -/** Object with an optional then property. */ -export type ThenableLike = { readonly then?: unknown } +/** View template data record */ +export type ViewData = Record -/** - * Recursively immutable version of a type. - * @description Deeply marks objects, maps, and sets readonly. - * @template SourceType - Source type to make immutable - */ -export type DeepImmutable = SourceType extends (...args: never[]) => unknown - ? SourceType - : SourceType extends ReadonlyMap - ? ReadonlyMap> - : SourceType extends ReadonlySet ? ReadonlySet> - : SourceType extends object - ? { readonly [Key in keyof SourceType]: DeepImmutable } - : SourceType +/** Reason a worker task was rejected */ +export type WorkerRejectReason = 'queue-depth' | 'queue-wait' + +// ───────────────────────── interfaces/Middleware.ts ───────────────────────── /** - * Normalized contract output for the facade. - * @description Flattens promise outputs to single await. - * @template OutputType - Raw contract return type + * Basic auth middleware options. + * @description Configures allowed users and realm. */ -export type UnwrapOutput = OutputType extends PromiseLike - ? Promise> - : OutputType +export interface BasicAuthOptions { + /** Allowed basic auth users */ + readonly users: readonly BasicAuthUser[] + /** Optional authentication realm name */ + readonly realm?: string +} /** - * Reducer advancing state by a step. - * @description Computes next state from current and args. - * @template ValueType - Stored state value type - * @template ArgsType - Step argument tuple type + * Basic auth user credentials. + * @description Holds username and password pair. */ -export type StepFn = ( - state: ValueType, - ...args: ArgsType -) => ValueType +export interface BasicAuthUser { + /** Account username value */ + readonly username: string + /** Account password value */ + readonly password: string +} /** - * Payload type derived from step arguments. - * @description Extracts payload or marks absence of payload. - * @template ArgsType - Step argument tuple type + * Body limit middleware options. + * @description Sets maximum allowed request body bytes. */ -export type PayloadOf = ArgsType extends [infer PayloadType] - ? PayloadType - : NoPayloadMarker - -/** Marker for payload-less steps. */ -export type NoPayloadMarker = { readonly __noPayload: true } +export interface BodyLimitOptions { + /** Maximum request body size in bytes */ + readonly limit: number +} /** - * Base shape for stateful entries. - * @description Carries the unique stateful identity mark. + * CORS middleware options. + * @description Configures allowed origins, methods, and headers. */ -export interface StatefulBase { - /** Unique mark identifying stateful entries */ - readonly __stateful: true +export interface CorsOptions { + /** Allowed request header names */ + readonly allowedHeaders?: readonly string[] + /** Allow credentials on requests */ + readonly credentials?: boolean + /** Exposed response header names */ + readonly exposedHeaders?: readonly string[] + /** Preflight cache max age seconds */ + readonly maxAge?: number + /** Allowed HTTP request methods */ + readonly methods?: readonly HttpMethod[] + /** Allowed request origin values */ + readonly origin?: string | readonly string[] } /** - * Fully typed stateful contract. - * @description Holds initial state and a step function. - * @template ValueType - Stored state value type - * @template PayloadType - Step payload argument type + * CSRF middleware options. + * @description Configures origin and fetch site rules. */ -export interface StatefulContract extends StatefulBase { - /** Initial state value for the contract */ - readonly initialState: ValueType - /** Reducer advancing state with a payload */ - readonly stepFn: (state: ValueType, payload: PayloadType) => ValueType +export interface CsrfOptions { + /** Allowed origin rule or predicate */ + readonly origin?: string | readonly string[] | CsrfRulePredicate + /** Allowed fetch site rule or predicate */ + readonly secFetchSite?: string | readonly string[] | CsrfRulePredicate } /** - * Untyped stateful entry record. - * @description Internal stateful entry before activation. - * @template ValueType - Stored state value type + * IP filter middleware options. + * @description Configures whitelist and blacklist rules. */ -export interface StatefulEntry extends StatefulBase { - /** Initial state value for the entry */ - readonly initialState: ValueType - /** Reducer stored without payload typing */ - readonly stepFn: unknown +export interface IpOptions { + /** Allowed IP or CIDR rules */ + readonly whitelist?: readonly string[] + /** Blocked IP or CIDR rules */ + readonly blacklist?: readonly string[] } /** - * Callable handle for stateful contracts. - * @description Advances state and exposes current value. - * @template ValueType - Stored state value type - * @template PayloadType - Step payload argument type + * Session controller for context. + * @description Exposes session state and write method. */ -export interface StateHandle { - (...args: [PayloadType] extends [NoPayloadMarker] ? [] : [payload: PayloadType]): ValueType - /** Read the current immutable state */ - get(): DeepImmutable +export interface SessionController { + /** Current session state or null */ + readonly state: SessionData | null + /** + * Write session data to cookie. + * @description Persists or clears session state. + * @param data - Session data or null + * @returns Promise resolving when write completes + */ + write(data: SessionData | null): Promise } /** - * Public stateful contract builder API. - * @description Creates stateful contract entries from steps. + * Default session cookie values. + * @description Holds name and cookie attribute defaults. */ -export interface StatefulApi { - state( - initialState: ValueType, - stepFn: StepFn - ): StatefulContract> +export interface SessionDefaults { + /** Session cookie name */ + readonly name: string + /** Mark cookie as HTTP only */ + readonly httpOnly: boolean + /** Cookie max age in seconds */ + readonly maxAge: number + /** Cookie path scope */ + readonly path: string + /** Cookie SameSite policy */ + readonly sameSite: SameSitePolicy + /** Mark cookie as secure */ + readonly secure: boolean } /** - * Activated entry from a map. - * @description Resolves stateful or plain contract entries. - * @template EntryType - Source contract entry type + * WebSocket upgrade middleware options. + * @description Configures listener path, origin policy, and lifecycle callbacks. */ -export type LiveEntry = EntryType extends - StatefulContract ? StateHandle - : EntryType extends (...args: infer ArgsType) => infer OutputType - ? (...args: ArgsType) => UnwrapOutput - : never +export interface WebSocketOptions { + /** Allowed handshake origins or wildcard */ + readonly allowedOrigins?: readonly string[] | '*' + /** Path prefix that triggers an upgrade */ + readonly listener?: string + /** Called on socket connection open */ + readonly onConnect?: SocketCallback + /** Called on socket connection close */ + readonly onDisconnect?: SocketCallback + /** Called on socket error event */ + readonly onError?: SocketCallback + /** Called on incoming socket message */ + readonly onMessage?: SocketCallback +} /** - * Facade with activated contract entries. - * @description Maps each entry to its live form. - * @template ContractMapType - Source contract map type + * CSRF rule predicate function. + * @description Validates value against request context. + * @param value - Header value to validate + * @param ctx - Request context instance + * @returns True when value is allowed */ -export type LoadedFacade = { - readonly [Key in keyof ContractMapType]: LiveEntry -} +export type CsrfRulePredicate = (value: string, ctx: Context) => boolean -/** Guard pass flag or failure reasons. */ -export type GuardVerdict = true | string | readonly string[] +/** Security header configuration key */ +export type SecurityHeaderKey = + | 'contentSecurityPolicy' + | 'crossOriginEmbedderPolicy' + | 'crossOriginOpenerPolicy' + | 'crossOriginResourcePolicy' + | 'originAgentCluster' + | 'referrerPolicy' + | 'strictTransportSecurity' + | 'xContentTypeOptions' + | 'xDnsPrefetchControl' + | 'xDownloadOptions' + | 'xFrameOptions' + | 'xPermittedCrossDomainPolicies' + +/** Security header value or disable flag */ +export type SecurityHeaderValue = string | false + +/** Security headers middleware options map */ +export type SecurityHeadersOptions = Partial> + +/** Session data key value record */ +export type SessionData = Record + +/** Session decode success or failure result */ +export type SessionDecodeResult = + | { readonly data: SessionData } + | { readonly reason: SessionInvalidReason } + +/** Session options with required secret */ +export type SessionOptions = { readonly secret: string } & Partial /** - * Synchronous guard for contract input. - * @description Validates input and returns a verdict. - * @template ContractType - Contract function type + * Socket lifecycle callback function. + * @description Receives the socket, originating event, and request context. + * @template E - Event subtype delivered to the callback + * @param socket - WebSocket connection instance + * @param event - Event emitted by the socket + * @param ctx - Request context instance */ -export type GuardFn = ( - input: NoInfer> -) => GuardVerdict +export type SocketCallback = ( + socket: WebSocket, + event: E, + ctx: Context +) => void -/** One guard or guard list. */ -export type GuardInput = - | GuardFn - | readonly GuardFn[] +// ───────────────────────── interfaces/Routing.ts ───────────────────────── /** - * Wrap a contract with guards. - * @description Validates input then delegates to the contract. - * @param contract - Contract function to wrap - * @param guard - Optional guard or list of guards - * @returns Wrapped contract function - * @template ContractType - Contract function type + * Scoped middleware registration entry. + * @description Pairs path prefix with middleware handler. */ -export function Define( - contract: ContractType, - guard?: GuardInput -): ContractType - -// deno-lint-ignore no-namespace -export namespace Define { - /** - * Build a stateful contract. - * @description Stores initial state and a step reducer. - * @param initialState - Initial state seed value - * @param stepFn - Reducer advancing the state - * @returns Stateful contract entry - * @template ValueType - Stored state value type - * @template ArgsType - Step argument tuple type - */ - export function state( - initialState: ValueType, - stepFn: StepFn - ): StatefulContract> +export interface MiddlewareEntry { + /** Path prefix scoping the middleware */ + readonly path: string + /** Middleware handler function */ + readonly handler: MiddlewareFn } /** - * Activate a contract map. - * @description Converts entries into live callable facade. - * @param contractMap - Map of contract entries - * @returns Facade with live entries - * @template ContractMapType - Contract map type + * Mutable per request state holder. + * @description Carries context, error, URL, and pattern. */ -export function Loader( - contractMap: ContractMapType -): LoadedFacade - -/** Per-source validation contract schema. */ -export interface ValidationSchema { - /** Contract for raw request body */ - readonly body?: ContractFn - /** Contract for parsed request cookies */ - readonly cookies?: ContractFn - /** Contract for request header record */ - readonly headers?: ContractFn - /** Contract for parsed JSON body */ - readonly json?: ContractFn - /** Contract for matched route params */ - readonly params?: ContractFn - /** Contract for query string record */ - readonly query?: ContractFn +export interface RequestHolder { + /** Active request context or null */ + ctx: Context | null + /** Captured framework error or null */ + frameworkError: Error | null + /** Parsed request URL or undefined */ + parsedUrl: URL | undefined + /** Matched route pattern or undefined */ + routePattern: string | undefined } -/** Allowed validation source key. */ -export type ValidationSource = keyof ValidationSchema +/** + * Route file change descriptor. + * @description Holds full path and route path. + */ +export interface RouteChange { + /** Absolute path to route file */ + readonly fullPath: string + /** Route path relative to directory */ + readonly routePath: string +} /** - * Extract raw data from a source. - * @description Reads one validation source from the context. - * @param ctx - Request context instance - * @returns Source value, possibly async + * Registered route lookup entry. + * @description Pairs route handler with its pattern. */ -export type SourceExtractor = (ctx: Context) => MaybeAsync +export interface RouteEntry { + /** Route handler function */ + readonly handler: RouteHandler + /** Route pattern string */ + readonly pattern: string +} /** - * Validated output mapped from a schema. - * @description Maps each source to its contract output type. - * @template SchemaType - Validation schema being validated + * Static mount registration entry. + * @description Pairs URL prefix with serving handler. */ -export type ValidatedData = { - readonly [Key in keyof SchemaType]: SchemaType[Key] extends (input: never) => infer OutputType - ? Awaited - : never +export interface StaticMount { + /** URL prefix for static files */ + readonly urlPrefix: string + /** Serving handler for the mount */ + readonly handler: StaticFn } /** - * Standalone contract validation helpers. - * @description Validates input and reads validated request data. + * Deno server request handler. + * @description Handles request and returns response promise. + * @param req - Incoming request instance + * @param info - Optional Deno serve handler info + * @returns Promise resolving to response + */ +export type ServeHandler = (req: Request, info?: Deno.ServeHandlerInfo) => Promise + +// ───────────────────────── Public values: Context class ───────────────────────── + +/** + * Per request context object. + * @description Exposes request reading and response building helpers. */ -export class Validator { +export class Context { + /** Frozen request reading helpers */ + get get(): GetHelpers + /** Frozen response setting helpers */ + get set(): SetHelpers + /** Frozen response sending helpers */ + get send(): SendHelpers + + /** + * Build error response from handler. + * @description Routes through error handler then default. + * @param statusCode - HTTP status code + * @param error - Error instance to report + * @returns Promise resolving to error response + */ + handleError(statusCode: number, error: Error): Promise /** - * Validate input against a contract. - * @description Runs contract, maps failures to status errors. - * @param contract - Typebox contract function - * @param input - Value to validate - * @returns Validated contract result - * @template ContractType - Contract function type + * Render template into HTML response. + * @description Requires a configured view engine. + * @param template - Template name to render + * @param data - View data for template + * @param options - Render options like status + * @returns Promise resolving to rendered response + * @throws When view engine is not configured */ - static check( - contract: ContractType, - input: ContractInput - ): ReturnType + render(template: string, data?: ViewData, options?: RenderInit): Promise +} + +// ───────────────────────── Public values: Validator ───────────────────────── + +/** + * Wrap a contract with guards. + * @description Validates input then delegates to the contract. + * @param contract - Contract function to wrap + * @param guard - Optional guard or list of guards + * @returns Wrapped contract function + * @template ContractType - Contract function type + */ +declare function Define( + contract: ContractType, + guard?: GuardInput +): ContractType + +/** + * Validator factory collection. + * @description Exposes validation check and schema define. + */ +export const Validator: { /** - * Read validated data from context. - * @description Returns state set by the validator middleware. - * @param ctx - Request context instance - * @returns Validated data for the schema + * Create validation middleware from schema. + * @param schema - Validation schema keyed by source + * @returns Middleware function validating request data * @template SchemaType - Validation schema type */ - static read(ctx: Context): ValidatedData + check(schema: SchemaType): MiddlewareFn + /** Define a contract with optional guards */ + readonly define: typeof Define } -// ───────────────────────── Prebuilt middleware factory ───────────────────────── +// ───────────────────────── Public values: Mware factory ───────────────────────── /** - * Prebuilt middleware factories. - * @description Common middleware creators for auth, CORS, session. + * Middleware factory collection. + * @description Convenience factories for built-in middleware. */ export const Mware: { - /** Basic Auth middleware factory */ + /** Basic auth middleware factory */ basicAuth(options: BasicAuthOptions): MiddlewareFn /** Body size limit middleware factory */ bodyLimit(options: BodyLimitOptions): MiddlewareFn @@ -1351,70 +1146,77 @@ export const Mware: { /** CSRF middleware factory */ csrf(options?: CsrfOptions): MiddlewareFn /** IP restriction middleware factory */ - ip(options: IpOptions): MiddlewareFn + ip(options?: IpOptions): MiddlewareFn /** Security headers middleware factory */ securityHeaders(options?: SecurityHeadersOptions): MiddlewareFn /** Session middleware factory */ session(options: SessionOptions): MiddlewareFn - /** Request validation middleware factory */ - validator(schema: ValidationSchema): MiddlewareFn /** WebSocket upgrade middleware factory */ websocket(options?: WebSocketOptions): MiddlewareFn } +// ───────────────────────── Public values: Wrap ───────────────────────── + /** - * Wrap middleware with try/catch and label. - * @description Catches errors and calls ctx.handleError, preserves original error. - * @param label - Context label for error diagnostics - * @param middleware - Middleware to run - * @returns Middleware that delegates and catches + * Middleware error wrapper utility. + * @description Catches errors and prefixes label to message. */ -export function WrapMware(label: string, middleware: MiddlewareFn): MiddlewareFn +export const Wrap: { + /** + * Wrap middleware with error handling. + * @description Catches thrown errors and routes to handler. + * @param label - Context label for error diagnostics + * @param middleware - Middleware function to wrap + * @returns Wrapped middleware function + */ + apply(label: string, middleware: MiddlewareFn): MiddlewareFn +} -// ───────────────────────── Routing: Router class ───────────────────────── +// ───────────────────────── Public values: Router class ───────────────────────── /** - * HTTP router with file-based routing. - * @description Registers routes, middleware, static files, and serves them. + * HTTP router with file based routing. + * @description Registers routes, middleware, and static mounts. */ export class Router { /** - * Create router with routes and options. - * @description Sets Handler options and routes directory. - * @param options - Routes dir, error builder, static handler, worker pool + * Create router with options. + * @description Sets routes, views, and runtime limits. + * @param options - Router constructor options */ constructor(options?: RouterOptions) /** * Set error middleware for all errors. - * @description Replaces or adds error handler before default response. - * @param errorHandler - Function receiving ctx and error info + * @description Runs before the default error response. + * @param handler - Error middleware to register */ - catch(errorHandler: ErrorMiddleware): void + catch(handler: ErrorMiddleware): void /** - * Subscribe to lifecycle and error events. - * @description Listener receives every event, filter via event.type. + * Subscribe to lifecycle events. + * @description Listener receives every emitted event. * @param listener - Callback invoked for each event * @returns Unsubscribe function */ - on(listener: EventListener): () => void + on(listener: EventFn): () => void /** * Scan routes and start HTTP server. - * @description Serves on port/host, optional AbortSignal for shutdown. + * @description Serves on port and host until aborted. * @param port - Port number, env PORT or 8000 * @param hostname - Host, default 0.0.0.0 * @param signal - Optional abort to stop server + * @returns Promise resolving when server stops */ serve(port?: number, hostname?: string, signal?: AbortSignal): Promise /** - * Register static route at URL path. - * @description Serves files from options.path under urlPath. + * Register static mount at URL path. + * @description Serves files from source under urlPath. * @param urlPath - URL prefix for static files - * @param options - Path, etag, cacheControl + * @param source - Static handler or serve options */ - static(urlPath: string, options: ServeOptions): void + static(urlPath: string, source: StaticFn | ServeOptions): void /** - * Add global or path-scoped middleware. - * @description Scopes middleware to path prefix when string given. + * Add global or path scoped middleware. + * @description Scopes middleware to a path prefix when given. * @param handlers - One or more middleware functions */ use(...handlers: MiddlewareFn[]): void diff --git a/docs/by-design/bearer-auth.md b/docs/by-design/bearer-auth.md index 4a70c18..f017f8f 100644 --- a/docs/by-design/bearer-auth.md +++ b/docs/by-design/bearer-auth.md @@ -22,24 +22,23 @@ Bearer is the opposite. The token format, the signature, and the trust source al A token guard is a small composition over parts that already ship: -- **Read the header** - [`ctx.header('authorization')`](/core-concepts/context-object#request-data-access) returns the raw `Authorization` value. +- **Read the header** - [`ctx.get.header('authorization')`](/core-concepts/context-object#ctx-get-header-key) returns the raw `Authorization` value. - **Run early** - [global middleware](/middleware/global) runs before route handlers and can stop a request by returning a `Response`. - **Reject cleanly** - [`ctx.handleError(401, ...)`](/core-concepts/context-object#error-handling) routes through [`router.catch()`](/error-handling/object-details) when one is set. -- **Carry the result** - [`ctx.state`](/core-concepts/context-object#sharing-state) hands the decoded identity to the handler downstream. +- **Carry the result** - [`ctx.set.session(...)`](/core-concepts/context-object#ctx-set-session-data) signs the decoded identity into a cookie the handler reads back, covered in [session middleware](/middleware/session). ## A Bearer Guard -This middleware pulls the token out of the header, verifies it, and stores the result for later handlers. The `verifyToken` placeholder stands in for the scheme of choice, a JWT check, a JWKS lookup, or an introspection call. +This middleware pulls the token out of the header, verifies it, and stores the result for later handlers. The `verifyToken` placeholder stands in for the scheme of choice, a JWT check, a JWKS lookup, or an introspection call. Storing the identity needs the [session middleware](/middleware/session) registered first. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function verifyToken(token: string): Promise<{ userId: string } | null> // ---cut--- router.use(async (ctx, next) => { - const header = ctx.header('authorization') + const header = ctx.get.header('authorization') const spaceIndex = header ? header.indexOf(' ') : -1 const scheme = spaceIndex > 0 ? header!.slice(0, spaceIndex) : '' @@ -56,39 +55,38 @@ router.use(async (ctx, next) => { } // Hand the identity to handlers - ctx.state.userId = claims.userId + await ctx.set.session({ userId: claims.userId }) return await next() }) await router.serve(8000) ``` -The handler then reads the identity straight from state, with no token parsing of its own. +The handler then reads the identity straight from the session, with no token parsing of its own. ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Read what the guard stored - const userId = ctx.state.userId - return ctx.send.json({ userId }) + const session = ctx.get.session() + return ctx.send.json({ userId: session?.userId }) } ``` ## Routing Failures Through One Handler -The guard above returns the `401` from inside the middleware. To send every auth failure through one place, wrap the middleware with [`WrapMware`](/middleware/global#wrapping-middleware-with-error-handling) and throw on rejection, then shape the reply with [`router.catch()`](/error-handling/object-details). +The guard above returns the `401` from inside the middleware. To send every auth failure through one place, wrap the middleware with [`Wrap.apply`](/middleware/global#wrapping-middleware-with-error-handling) and throw on rejection, then shape the reply with [`router.catch()`](/error-handling/object-details). ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap, type Context } from '@neabyte/deserve' const router = new Router() declare function verifyToken(token: string): Promise<{ userId: string } | null> // ---cut--- // Throws reach router.catch when wrapped -const bearer = WrapMware('Bearer', async (ctx: Context, next) => { - const header = ctx.header('authorization') +const bearer = Wrap.apply('Bearer', async (ctx: Context, next) => { + const header = ctx.get.header('authorization') if (!header?.toLowerCase().startsWith('bearer ')) { throw new Error('Missing Bearer token') } @@ -96,7 +94,7 @@ const bearer = WrapMware('Bearer', async (ctx: Context, next) => { if (!claims) { throw new Error('Invalid token') } - ctx.state.userId = claims.userId + await ctx.set.session({ userId: claims.userId }) return await next() }) @@ -121,22 +119,21 @@ This is the same wrapping pattern [Basic Auth](/middleware/basic-auth) uses inte A token guard often belongs on an API prefix while public pages stay open. Path-specific middleware scopes the check to one prefix, the same form shown in [global middleware](/middleware/global#path-specific-middleware). ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function verifyToken(token: string): Promise<{ userId: string } | null> // ---cut--- // Guard only the /api routes router.use('/api', async (ctx, next) => { - const header = ctx.header('authorization') + const header = ctx.get.header('authorization') const claims = header?.toLowerCase().startsWith('bearer ') ? await verifyToken(header.slice(7).trim()) : null if (!claims) { return await ctx.handleError(401, new Error('Invalid token')) } - ctx.state.userId = claims.userId + await ctx.set.session({ userId: claims.userId }) return await next() }) ``` diff --git a/docs/by-design/cache.md b/docs/by-design/cache.md index 5eaa06c..ab6693f 100644 --- a/docs/by-design/cache.md +++ b/docs/by-design/cache.md @@ -29,7 +29,7 @@ import type { Context } from '@neabyte/deserve' const cache = new Map() export async function GET(ctx: Context): Promise { - const key = ctx.pathname + const key = ctx.get.pathname() // Serve the cached value when present const hit = cache.get(key) @@ -40,7 +40,7 @@ export async function GET(ctx: Context): Promise { }) } - // Build it once, then store for next time + // Build once, then store for reuse const data = await buildExpensiveData() cache.set(key, data) return ctx.send.json({ @@ -64,10 +64,10 @@ const ttlMs = 30_000 const cache = new Map() export function GET(ctx: Context): Response { - const key = ctx.pathname + const key = ctx.get.pathname() const entry = cache.get(key) - // Fresh entry wins, expired one is dropped + // Fresh entry wins, expired one drops if (entry && Date.now() < entry.expiresAt) { return ctx.send.json({ source: 'cache', @@ -96,4 +96,4 @@ Two cases call for more than a process-local map. A cache that must survive a re ## Per-Request Sharing -Caching across requests is one need, passing a value along a single request is another. A value computed in middleware and read by the handler does not belong in a cache at all, it belongs in [`ctx.state`](/core-concepts/context-object#sharing-state), which lives for exactly one request and is gone when the response is sent. +Caching across requests is one need, passing a value along a single request is another. A value computed in middleware and read by the handler does not belong in a cache at all. For per-user identity the signed [session](/middleware/session) carries it through `ctx.set.session()` and `ctx.get.session()`, and for validated input the [validate middleware](/middleware/validation/overview) hands it on through `ctx.get.validated()`. Anything else the handler re-derives from the request it already holds. diff --git a/docs/by-design/compress.md b/docs/by-design/compress.md index f05cf99..cc7ccf3 100644 --- a/docs/by-design/compress.md +++ b/docs/by-design/compress.md @@ -56,15 +56,15 @@ To keep one response uncompressed, set a header the runtime treats as a stop sig import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // Block any layer from rewriting the body - ctx.setHeader('Cache-Control', 'no-transform') + // Block any layer from rewriting body + ctx.set.header('Cache-Control', 'no-transform') return ctx.send.json({ message: 'sent verbatim' }) } ``` -Setting headers through [`ctx.setHeader`](/core-concepts/context-object#response-headers) is the same path used everywhere else, so this opt-out reads like any other header. +Setting headers through [`ctx.set.header`](/core-concepts/context-object#ctx-set-header-key-value) is the same path used everywhere else, so this opt-out reads like any other header. ## Already-Encoded Bodies diff --git a/docs/by-design/https-redirect.md b/docs/by-design/https-redirect.md index 7a7021f..45cb2af 100644 --- a/docs/by-design/https-redirect.md +++ b/docs/by-design/https-redirect.md @@ -38,21 +38,21 @@ await router.serve(8000) ## Reading the Real Scheme -When the app does need to know whether the client used HTTPS, the answer rides in a forwarded header the proxy sets, not in the local connection. A trusted proxy adds [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto), read through [`ctx.header`](/core-concepts/context-object#request-data-access). +When the app does need to know whether the client used HTTPS, the answer rides in a forwarded header the proxy sets, not in the local connection. A trusted proxy adds [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto), read through [`ctx.get.header`](/core-concepts/context-object#ctx-get-header-key). ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Scheme the client actually used - const proto = ctx.header('x-forwarded-proto') ?? 'http' + const proto = ctx.get.header('x-forwarded-proto') ?? 'http' return ctx.send.json({ secure: proto === 'https' }) } ``` -Trust this header only behind a proxy configured through [`trustProxy`](/getting-started/server-configuration#client-ip-resolution), the same trust boundary [`ctx.ip`](/by-design/request-id#the-ip-is-the-source-of-truth) relies on. An untrusted client can set any header, so the value means nothing without that boundary. +Trust this header only behind a proxy configured through [`trustProxy`](/getting-started/server-configuration#client-ip-resolution), the same trust boundary [`ctx.get.ip()`](/core-concepts/context-object#ctx-get-ip-options) relies on. An untrusted client can set any header, so the value means nothing without that boundary. ## Serving HTTPS Directly diff --git a/docs/by-design/locale-redirect.md b/docs/by-design/locale-redirect.md index d9446ed..825abfd 100644 --- a/docs/by-design/locale-redirect.md +++ b/docs/by-design/locale-redirect.md @@ -14,14 +14,14 @@ Language choice is a product decision, not a transport rule. Which locales exist ## Reading the Preference -The browser sends its language list in the [`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) header, read through [`ctx.header`](/core-concepts/context-object#request-data-access). A small match against the locales the app supports gives the target, then [`ctx.send.redirect`](/response/redirect) sends the visitor there. +The browser sends its language list in the [`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) header, read through [`ctx.get.header`](/core-concepts/context-object#ctx-get-header-key). A small match against the locales the app supports gives the target, then [`ctx.send.redirect`](/response/redirect) sends the visitor there. ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Read the browser language hint - const header = ctx.header('accept-language') ?? '' + const header = ctx.get.header('accept-language') ?? '' const supported = ['en', 'id'] // Match a supported locale or default @@ -37,7 +37,7 @@ A 302 keeps the redirect temporary, so a later visit can still be matched again. ## Sharing the Choice With Later Routes -When several routes need the resolved locale, middleware can resolve it once and store it in [`ctx.state`](/core-concepts/context-object#sharing-state) instead of redirecting, so each handler reads the same value. +When several routes need the resolved locale, middleware can resolve it once and store it in the signed [session](/middleware/session) instead of redirecting, so each handler reads the same value through `ctx.get.session()`. ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -46,15 +46,16 @@ const router = new Router() // ---cut--- router.use(async (ctx, next) => { // Resolve locale once for the request - const header = ctx.header('accept-language') ?? '' + const header = ctx.get.header('accept-language') ?? '' const preferred = header.split(',')[0]?.slice(0, 2) ?? 'en' // Share it with the route handlers - ctx.state.locale = ['en', 'id'].includes(preferred) ? preferred : 'en' + const locale = ['en', 'id'].includes(preferred) ? preferred : 'en' + await ctx.set.session({ locale }) return await next() }) await router.serve(8000) ``` -The redirect form sends the visitor to a localized URL, while the state form keeps one URL and passes the locale inward. Both stay in plain route files, so the rule is where the language matters and nowhere else. +The redirect form sends the visitor to a localized URL, while the session form keeps one URL and passes the locale inward. Both stay in plain route files, so the rule is where the language matters and nowhere else. diff --git a/docs/by-design/method-override.md b/docs/by-design/method-override.md index 9175dbe..232b12d 100644 --- a/docs/by-design/method-override.md +++ b/docs/by-design/method-override.md @@ -22,7 +22,7 @@ Deserve also routes on the real `req.method`, which a handler cannot rewrite mid ## Every Method Is a Route -A route file exports one function per method, and the name is the method. There is no table to register and no verb to translate. A file like `items/[id].ts` reads its `id` from the path through [`ctx.param`](/core-concepts/context-object#request-data-access). +A route file exports one function per method, and the name is the method. There is no table to register and no verb to translate. A file like `items/[id].ts` reads its `id` from the path through [`ctx.get.param`](/core-concepts/context-object#ctx-get-param-key). ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -30,7 +30,7 @@ import type { Context } from '@neabyte/deserve' // Read one item by id export function GET(ctx: Context): Response { return ctx.send.json({ - id: ctx.param('id') + id: ctx.get.param('id') }) } @@ -67,7 +67,7 @@ await fetch( ) ``` -Building stateless or stateful is the same move, just drop the files. A stateless REST endpoint is a handler that reads the request and replies, while a stateful flow adds the [session middleware](/middleware/session) and reads per-user data from [`ctx.state`](/core-concepts/context-object#sharing-state). The method stays real either way, with nothing to disguise on the way in. +Building stateless or stateful is the same move, just drop the files. A stateless REST endpoint is a handler that reads the request and replies, while a stateful flow adds the [session middleware](/middleware/session) and reads per-user data through `ctx.get.session()`. The method stays real either way, with nothing to disguise on the way in. A full REST or RESTful API falls out of this with no extra wiring. The verbs already line up with the actions, `GET` to read, `POST` to create, `PUT` and `PATCH` to update, `DELETE` to remove, so a resource is just a route file with those handlers. The behavior reads the same across every endpoint, which is what makes the whole API feel seamless. diff --git a/docs/by-design/rate-limit.md b/docs/by-design/rate-limit.md index b5951e6..22d52ff 100644 --- a/docs/by-design/rate-limit.md +++ b/docs/by-design/rate-limit.md @@ -16,18 +16,17 @@ A single built-in answer would fit one taste and fight every other one. So the d A limiter needs four things, and each one already ships: -- **A key per client** - read `ctx.ip` for the resolved visitor IP, or `ctx.header('x-api-key')` for an API key. See [Client IP](/core-concepts/context-object#client-ip). +- **A key per client** - read `ctx.get.ip()` for the resolved visitor IP, or `ctx.get.header('x-api-key')` for an API key. See [`ctx.get.ip()`](/core-concepts/context-object#ctx-get-ip-options). - **A place to run early** - [global middleware](/middleware/global) runs before every route handler and can stop a request by returning a `Response`. - **A way to block** - return `ctx.send.text(...)` or `ctx.send.json(...)` with status `429` to end the request right there. -- **A way to inform** - `ctx.setHeader(...)` adds the standard rate limit headers so a client can back off. +- **A way to inform** - `ctx.set.header(...)` adds the standard rate limit headers so a client can back off. ## A Fixed Window Limiter This middleware counts requests per IP inside a fixed time window. When the count passes the limit, the request stops with a `429`. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -35,12 +34,12 @@ const router = new Router() const windowMs = 60_000 const maxRequests = 100 -// Track count and reset time per key +// Track count and reset per key const hits = new Map() router.use(async (ctx, next) => { // Pick the client key - const key = ctx.ip ?? 'unknown' + const key = ctx.get.ip() ?? 'unknown' const now = Date.now() const entry = hits.get(key) @@ -59,7 +58,7 @@ router.use(async (ctx, next) => { // Over the cap, block with 429 if (entry.count > maxRequests) { const retryAfter = Math.ceil((entry.resetAt - now) / 1000) - ctx.setHeader('Retry-After', String(retryAfter)) + ctx.set.header('Retry-After', String(retryAfter)) return ctx.send.text( 'Too Many Requests', { @@ -82,8 +81,7 @@ The `Map` lives in memory, so the count resets when the process restarts and is Clients behave better when they can see their budget. The standard headers report the cap, the remaining hits, and when the window resets. Set them on every response, not only on a block. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() const windowMs = 60_000 @@ -91,7 +89,7 @@ const maxRequests = 100 const hits = new Map() // ---cut--- router.use(async (ctx, next) => { - const key = ctx.ip ?? 'unknown' + const key = ctx.get.ip() ?? 'unknown' const now = Date.now() let entry = hits.get(key) @@ -108,7 +106,7 @@ router.use(async (ctx, next) => { const remaining = Math.max(0, maxRequests - entry.count) // Report the budget on every response - ctx.setHeaders({ + ctx.set.headers({ 'X-RateLimit-Limit': String(maxRequests), 'X-RateLimit-Remaining': String(remaining), 'X-RateLimit-Reset': String(Math.ceil(entry.resetAt / 1000)) @@ -135,15 +133,14 @@ router.use(async (ctx, next) => { A login form needs a tighter limit than a public page. Path-specific middleware applies the rule to one prefix and leaves the rest untouched. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function isOverLimit(key: string): boolean // ---cut--- // Guard only the auth routes router.use('/auth', async (ctx, next) => { - const key = ctx.ip ?? 'unknown' + const key = ctx.get.ip() ?? 'unknown' if (isOverLimit(key)) { return ctx.send.json( { @@ -162,11 +159,11 @@ This is the same path-specific form covered in [global middleware](/middleware/g ## Shaping the Block Response -The examples above return the `429` straight from the middleware. To route every block through one place, throw inside [`WrapMware`](/middleware/global#wrapping-middleware-with-error-handling) and shape the reply with [`router.catch()`](/error-handling/object-details). That keeps the limit rule and the error format apart, which helps when several middlewares share one response style. +The examples above return the `429` straight from the middleware. To route every block through one place, throw inside [`Wrap.apply`](/middleware/global#wrapping-middleware-with-error-handling) and shape the reply with [`router.catch()`](/error-handling/object-details). That keeps the limit rule and the error format apart, which helps when several middlewares share one response style. ## Watching the Limit Work -The limiter blocks requests, and the [observability events](/middleware/observability/overview) report what happened. A blocked request finishes with status `429`, so it arrives as a `request:error` event. Subscribe once to count blocks or trace which keys hit the cap. +The limiter blocks requests, and the [observability events](/middleware/observability/overview) report what happened. A blocked request finishes with status `429`, so it arrives as a `request:failed` event. Subscribe once to count blocks or trace which keys hit the cap. ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -175,7 +172,7 @@ const router = new Router() // ---cut--- router.on((event) => { // Log every request that was blocked - if (event.kind === 'request:error' && event.metadata.statusCode === 429) { + if (event.kind === 'request:failed' && event.metadata.statusCode === 429) { console.log('Rate limited:', event.metadata.ip, event.metadata.url) } }) diff --git a/docs/by-design/request-id.md b/docs/by-design/request-id.md index bd644c4..e053aea 100644 --- a/docs/by-design/request-id.md +++ b/docs/by-design/request-id.md @@ -14,9 +14,9 @@ So a random ID is fine as a log label but wrong as a source of truth. Deserve al ## The IP Is the Source of Truth -Every request carries [`ctx.ip`](/core-concepts/context-object#client-ip), the resolved client address. It is not read raw from a header. The framework walks the forwarding chain through trusted hops and stops at the first hop it does not trust, so a spoofed header from an untrusted peer never wins. +Every request carries [`ctx.get.ip()`](/core-concepts/context-object#ctx-get-ip-options), the resolved client address. It is not read raw from a header. The framework walks the forwarding chain through trusted hops and stops at the first hop it does not trust, so a spoofed header from an untrusted peer never wins. -- **No trusted proxy** - `ctx.ip` is the direct TCP peer, and forwarding headers are ignored entirely. +- **No trusted proxy** - `ctx.get.ip()` is the direct TCP peer, and forwarding headers are ignored entirely. - **Behind a trusted proxy** - the chain is walked right to left through trusted hops, honoring [`X-Forwarded-For`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For), the [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239) `Forwarded` header, and single-IP headers like `cf-connecting-ip`. - **Configured once** - [`trustProxy`](/getting-started/server-configuration#client-ip-resolution) decides which peers count as trusted, so trusting them is a deliberate choice, not a default. @@ -28,18 +28,18 @@ Each request gets its own [Context](/core-concepts/context-object), built once w ## Correlating Without a Random ID -For correlating logs, the [lifecycle events](/middleware/observability/overview) already carry what a request ID was meant to provide. Every `request:complete` event includes the resolved `ip`, the `url`, and the `durationMs` in its metadata, plus a `timestamp` on the envelope, so a log line identifies the request from real values rather than a made-up one. +For correlating logs, the [lifecycle events](/middleware/observability/overview) already carry what a request ID was meant to provide. Every `request:completed` event includes the resolved `ip`, the `url`, and the `durationMs` in its metadata, plus a `timestamp` on the envelope, so a log line identifies the request from real values rather than a made-up one. ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Correlate by real IP and time - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { ip, url } = event.metadata as { ip?: string, url: string } console.log(`${event.timestamp} ${ip ?? 'unknown'} ${url}`) } @@ -50,17 +50,16 @@ await router.serve(8000) ## When a Label Really Helps -A short-lived label for tying log lines together within one request is still possible, and it lives in [`ctx.state`](/core-concepts/context-object#sharing-state) like any other per-request value. The point is to treat it as a convenience, not an identity, since the trustworthy answer is `ctx.ip`. +A short-lived label for tying log lines together is still possible. When the handler needs to read it back, the signed [session](/middleware/session) carries it like any other per-request value. The point is to treat it as a convenience, not an identity, since the trustworthy answer is `ctx.get.ip()`. ```typescript twoslash -import type { Context } from '@neabyte/deserve' import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.use(async (ctx, next) => { // A label for logs, not trust - ctx.state.label = crypto.randomUUID() + await ctx.set.session({ label: crypto.randomUUID() }) return await next() }) ``` diff --git a/docs/by-design/server-timing.md b/docs/by-design/server-timing.md index 87a12e7..478d1ac 100644 --- a/docs/by-design/server-timing.md +++ b/docs/by-design/server-timing.md @@ -14,18 +14,18 @@ Those metrics are a detail of a single handler, not a framework-wide policy. One ## The Duration Is Already Measured -Every `request:complete` event carries `durationMs`, the measured time for the whole request, alongside the `route` and `method`. For dashboards and logs that is the number to read, with no header and no per-route code. See [Request Logging](/middleware/observability/logging) for turning it into a log line. +Every `request:completed` event carries `durationMs`, the measured time for the whole request, alongside the `route` and `method`. For dashboards and logs that is the number to read, with no header and no per-route code. See [Request Logging](/middleware/observability/logging) for turning it into a log line. ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Read the measured request duration - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { route, durationMs } = event.metadata as { route?: string, durationMs: number } console.log(`${route ?? 'unknown'} took ${Math.round(durationMs)}ms`) } @@ -36,19 +36,19 @@ await router.serve(8000) ## Emitting the Header When Wanted -For a route that does want the metric in DevTools, the header is one [`ctx.setHeader`](/core-concepts/context-object#response-headers) call. Time the work, then write a `Server-Timing` entry with a name and the duration in milliseconds. +For a route that does want the metric in DevTools, the header is one [`ctx.set.header`](/core-concepts/context-object#ctx-set-header-key-value) call. Time the work, then write a `Server-Timing` entry with a name and the duration in milliseconds. ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export async function GET(ctx: Context): Promise { - // Time the work this route cares about + // Time the work this route does const start = performance.now() const data = await loadData() const ms = (performance.now() - start).toFixed(1) - // Expose it to DevTools for this route - ctx.setHeader('Server-Timing', `db;dur=${ms}`) + // Expose it to DevTools per route + ctx.set.header('Server-Timing', `db;dur=${ms}`) return ctx.send.json(data) } diff --git a/docs/by-design/tracing.md b/docs/by-design/tracing.md index b5c852a..7611bb2 100644 --- a/docs/by-design/tracing.md +++ b/docs/by-design/tracing.md @@ -17,14 +17,14 @@ So the decision is to stop at the data, not the transport. Deserve emits a compl These three sit outside the framework on purpose: - **Auto-instrumentation** - Deserve does not wrap libraries or open spans for outbound calls. Each request emits one finished event, and a span is built from it in the listener. -- **Trace context propagation** - no `traceparent` header is read or written. A handler that needs distributed context reads the header through [`ctx.header('traceparent')`](/core-concepts/context-object#request-data-access) and threads it onward. +- **Trace context propagation** - no `traceparent` header is read or written. A handler that needs distributed context reads the header through [`ctx.get.header('traceparent')`](/core-concepts/context-object#ctx-get-header-key) and threads it onward. - **Span hierarchy** - events are flat, one per request, not a parent-child tree. Nested spans are assembled in the backend, or in the listener, from data the events provide. What does ship is the data a span needs, already collected and named to match. ## The Data Is Already There -Every request emits `request:complete`, and a request with status `400` or higher also emits `request:error`. Both carry the same envelope, and the metadata is the truth a span is built from: +Every request emits `request:completed`, and a request with status `400` or higher also emits `request:failed`. Both carry the same envelope, and the metadata is the truth a span is built from: - **`timestamp`** - event creation time in epoch milliseconds, the span start anchor. - **`durationMs`** - measured request duration, the span length. @@ -42,13 +42,13 @@ This listener turns each finished request into a span-shaped record and hands it import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) declare function exportSpan(span: Record): void // ---cut--- router.on((event) => { // Build a span from each finished request - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const m = event.metadata as { method: string url: string @@ -83,14 +83,14 @@ The attribute keys above are the OpenTelemetry HTTP span names, so the record dr ## Continuing an Incoming Trace -Distributed tracing links spans across services through the `traceparent` header. Deserve does not parse it, so a handler that joins an existing trace reads the header from [Context](/core-concepts/context-object#request-data-access) and carries it forward. +Distributed tracing links spans across services through the `traceparent` header. Deserve does not parse it, so a handler that joins an existing trace reads the header from [Context](/core-concepts/context-object#ctx-get-header-key) and carries it forward. ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export async function GET(ctx: Context): Promise { // Read upstream trace context when present - const traceparent = ctx.header('traceparent') + const traceparent = ctx.get.header('traceparent') // Forward it on outbound calls const upstream = await fetch('https://api.internal/data', { @@ -101,7 +101,7 @@ export async function GET(ctx: Context): Promise { } ``` -The same `ctx.state` used for [sharing state](/core-concepts/context-object#sharing-state) holds a span ID across middleware and handler when one listener opens a span early and another closes it. +For a span ID that must live across middleware and handler, the signed [session](/middleware/session) carries it when one listener opens a span early and another reads it back. ## Where the Data Goes diff --git a/docs/core-concepts/context-object.md b/docs/core-concepts/context-object.md index b556459..f3865de 100644 --- a/docs/core-concepts/context-object.md +++ b/docs/core-concepts/context-object.md @@ -1,27 +1,23 @@ --- -description: "The Context object passed to every handler: request access, response helpers, params, state, and cookies." +description: "The Context object passed to every handler: request reading, response setting, response sending, and error handling." --- # Context Object -The `Context` object wraps the native `Request` and provides convenient methods for accessing request data, setting response headers, and sending responses. +The `Context` object wraps the native `Request` and gives every handler a single surface for reading the request, shaping the response, and forwarding errors. One `Context` flows from middleware to route handler, so data stays cached and consistent across the whole request. -## What is Context? +## What Is Context Context is a wrapper around Deno's native `Request` object, and every incoming request is wrapped in one Context that flows from middleware to route handler. Working through Context instead of the raw `Request` brings: - **Lazy parsing** - data is parsed only when a method reads it -- **Convenient methods** - simple APIs for common operations -- **Response utilities** - built-in methods for sending responses -- **Header management** - easy response header changes - -## Why Context? - -Context avoids repeated parsing and reprocessing during the request lifecycle, since the handler receives one Context object that persists the whole way from middleware to route handler. +- **Three namespaces** - `ctx.get` reads, `ctx.set` shapes, `ctx.send` sends +- **Cached reads** - body, cookies, and params parse once and reuse the cache +- **Error routing** - `ctx.handleError()` forwards failures to one place ## Creating Context -Deserve creates Context automatically when a request arrives: +Deserve creates Context automatically when a request arrives, so a handler only declares it as a parameter: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -34,194 +30,451 @@ export function GET(ctx: Context): Response { } ``` -## Context Structure +## Three Namespaces + +Context splits its API into three frozen namespaces, each with one job: + +| Namespace | Purpose | Example | +| --------- | ------- | ------- | +| `ctx.get` | Read request data | `ctx.get.header('host')` | +| `ctx.set` | Shape the response | `ctx.set.header('X-Custom', 'value')` | +| `ctx.send` | Build and send the response | `ctx.send.json({ ok: true })` | -Context wraps several key pieces: +The namespaces are frozen, so they cannot be reassigned or mutated at runtime. This keeps the request contract predictable across middleware and handlers. -1. **Original Request** - access via `ctx.request` -2. **Parsed URL** - used internally for query params -3. **Route Parameters** - extracted from dynamic routes -4. **Response Headers** - set before sending the response +## Reading Request Data -## Lazy Parsing +### `ctx.get.ip(options?)` -Context parses lazily for performance, so query, body, cookie, and header data is read only when the matching method runs, and the result is cached for later calls. Reading the body is async, so a handler that awaits it becomes `async` and returns `Promise`: +Reads the client IP address. Pass `{ direct: true }` to read the direct TCP peer instead of the resolved IP: ```typescript twoslash import type { Context } from '@neabyte/deserve' +declare const ctx: Context // ---cut--- -export async function GET(ctx: Context): Promise { - // Query parses on first read - const query = ctx.query() - // Repeat calls reuse the cache +// Resolved IP, honors trustProxy +const client = ctx.get.ip() + +// Direct TCP peer, ignores forwarded headers +const peer = ctx.get.ip({ direct: true }) +``` + +Both return `undefined` when the peer is unknown. Without a matching [`trustProxy`](/getting-started/server-configuration#client-ip-resolution) rule, both return the same direct peer address. + +### `ctx.get.method()` + +Reads the request HTTP method: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const method = ctx.get.method() // 'GET', 'POST', etc +``` + +### `ctx.get.url()` + +Reads the parsed request URL instance: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const url = ctx.get.url() // URL instance +const fullUrl = url.href // 'http://localhost:8000/api/users?sort=name' +``` + +### `ctx.get.pathname()` + +Reads the pathname portion of the URL: +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const path = ctx.get.pathname() // '/api/users/123' +``` + +### `ctx.get.request()` + +Reads the underlying native `Request` instance: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const req = ctx.get.request() // Request instance +``` + +### `ctx.get.header(key?)` + +Reads one header by key or every header at once. Keys match case-insensitively: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Read one header by name +const contentType = ctx.get.header('content-type') + +// Read all headers as a record +const headers = ctx.get.header() +``` + +### `ctx.get.cookie(key?)` + +Reads one cookie by key or every cookie at once. Cookies parse once and cache for later calls: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Read one cookie by name +const sessionId = ctx.get.cookie('sessionId') + +// Read all cookies as a record +const cookies = ctx.get.cookie() // { sessionId: 'abc123', theme: 'dark' } +``` + +### `ctx.get.query(key?)` + +Reads one query parameter by key or every query parameter at once. The first value wins for duplicate keys: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// URL: /search?q=deno&limit=10 +const q = ctx.get.query('q') // 'deno' +const all = ctx.get.query() // { q: 'deno', limit: '10' } + +// URL: /search?tag=deno&tag=typescript +ctx.get.query('tag') // 'deno', first value wins +ctx.get.query() // { tag: 'deno' } +``` + +### `ctx.get.param(key?)` + +Reads one route parameter by key or every route parameter at once. Values are percent-decoded once before the handler reads them: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Route: /users/[id]/posts/[postId] +// URL: /users/123/posts/456 +const id = ctx.get.param('id') // '123' +const all = ctx.get.param() // { id: '123', postId: '456' } +``` + +### `ctx.get.body()` + +Parses the request body automatically based on the `Content-Type` header. JSON, form-data, and text are all handled. Reading is async, so a handler that awaits it becomes `async`: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { // Body parses based on Content-Type - const body = await ctx.body() + const body = await ctx.get.body() + return ctx.send.json({ received: body }) +} +``` - // Return query and body together - return ctx.send.json({ - query, - body - }) +The body can only be read once. A second call with the same format returns the cached value, while a second call with a different format throws a **409 Conflict**. + +### `ctx.get.json()` + +Parses the request body as JSON, regardless of the `Content-Type` header: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { + // Parse body as JSON + const body = await ctx.get.json() + return ctx.send.json({ received: body }) } ``` -## Request Data Access +### `ctx.get.text()` + +Reads the request body as raw text: -Request data is reached through Context methods, where query, params, headers, and cookies are synchronous while body readers are async: +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { + // Read body as plain text + const text = await ctx.get.text() + return ctx.send.text(text) +} +``` -- **Query Parameters** - `ctx.query()`, `ctx.queries()` -- **Route Parameters** - `ctx.param()`, `ctx.params()` -- **Headers** - `ctx.header()`, `ctx.headers` -- **Cookies** - `ctx.cookie()` -- **Body (async)** - `await ctx.body()`, `await ctx.json()`, `await ctx.formData()`, `await ctx.text()`, `await ctx.arrayBuffer()`, `await ctx.blob()` -- **URL Information** - `ctx.url`, `ctx.pathname` -- **Client IP** - `ctx.ip`, `ctx.directIp` +### `ctx.get.formData()` -## Response Utilities +Parses the request body as form data and returns a `FormData` object: -Send responses using `ctx.send`, with one method per response type: +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { + // Parse body as form data + const formData = await ctx.get.formData() + const name = formData.get('name') + return ctx.send.json({ name }) +} +``` -- [`ctx.send.json()`](/response/json) - JSON response -- [`ctx.send.text()`](/response/text) - plain text -- [`ctx.send.html()`](/response/html) - HTML content -- [`ctx.send.file()`](/response/file) - file download -- [`ctx.send.data()`](/response/data) - in-memory data download -- [`ctx.send.stream()`](/response/stream) - stream response (ReadableStream) -- [`ctx.send.redirect()`](/response/redirect) - redirect -- [`ctx.send.custom()`](/response/custom) - custom response -- `ctx.handleError()` - route a failure through [error handling](/error-handling/object-details) +### `ctx.get.blob()` -The `ctx.redirect()` shorthand maps straight to `ctx.send.redirect()`: +Reads the request body as a `Blob`, which suits file uploads and binary handling: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- -export function GET(ctx: Context): Response { - // Shorthand for ctx.send.redirect - return ctx.redirect('/new-location', 301) +export async function POST(ctx: Context): Promise { + // Read body as Blob + const blob = await ctx.get.blob() + return ctx.send.json({ + type: blob.type, + size: blob.size + }) } ``` -## Response Headers +### `ctx.get.bytes()` -Set response headers before sending: +Reads the request body as a `Uint8Array`, which suits binary data processing: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- -export function GET(ctx: Context): Response { - ctx.setHeader('X-Custom', 'value') - ctx.setHeader('Cache-Control', 'no-cache') +export async function POST(ctx: Context): Promise { + // Read body as byte array + const bytes = await ctx.get.bytes() return ctx.send.json({ - data: 'test' + size: bytes.byteLength }) } ``` -### Setting Multiple Headers +### `ctx.get.session()` + +Reads the current session data. Requires the [session middleware](/middleware/session) to be registered, otherwise returns `null`: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Read current session data +const session = ctx.get.session() +``` + +### `ctx.get.validated()` -`setHeaders()` applies several headers at once: +Reads validated request data. Requires the [validate middleware](/middleware/validation/overview) to be registered. Throws when the middleware is missing: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Read data that already passed validation +const validated = ctx.get.validated() +``` + +### `ctx.get.worker()` + +Reads the worker pool controller for dispatching CPU-bound tasks. Requires a [worker pool](/recipes/worker-pool) to be configured. Throws when no pool is configured: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Get worker pool controller +const worker = ctx.get.worker() +``` + +## Shaping the Response + +### `ctx.set.header(key, value)` + +Sets a single response header. Returns the `ctx.set` namespace for chaining: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - ctx.setHeaders({ + // Set one header, then chain another + ctx.set + .header('X-Custom', 'value') + .header('Cache-Control', 'no-cache') + return ctx.send.json({ data: 'test' }) +} +``` + +### `ctx.set.headers(record)` + +Sets multiple response headers at once. Returns the `ctx.set` namespace for chaining: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Set several headers at once + ctx.set.headers({ 'X-Custom': 'value', 'Cache-Control': 'no-cache', 'X-Request-ID': 'abc123' }) - return ctx.send.json({ - data: 'test' - }) + return ctx.send.json({ data: 'test' }) } ``` -### URL and Pathname +### `ctx.set.cookie(name, value, options?)` -URL details are read directly from Context: - -- `ctx.url` - full URL string -- `ctx.pathname` - pathname portion of the URL, such as `/api/users/123` +Sets a response cookie with optional attributes. Returns the `ctx.set` namespace for chaining: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - const fullUrl = ctx.url // 'http://localhost:8000/api/users/123?sort=name' - const path = ctx.pathname // '/api/users/123' - return ctx.send.json({ - path, - fullUrl + // Set a cookie with attributes + ctx.set.cookie('session', 'abc123', { + httpOnly: true, + maxAge: 3600, + path: '/', + sameSite: 'Lax', + secure: true }) + return ctx.send.json({ ok: true }) } ``` -### Client IP +The `options` object accepts `domain`, `expires`, `httpOnly`, `maxAge`, `path`, `sameSite`, and `secure`. + +### `ctx.set.session(data)` + +Writes session data through the session controller. Requires the [session middleware](/middleware/session) to be registered. Throws when the middleware is missing: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Write session data +await ctx.set.session({ userId: '123' }) + +// Clear session data +await ctx.set.session(null) +``` + +## Sending the Response -The client IP is read from Context, and both values are `undefined` when the peer is unknown: +### `ctx.send.json(data, options?)` -- `ctx.ip` - resolved client IP, honors [`trustProxy`](/getting-started/server-configuration#client-ip-resolution) -- `ctx.directIp` - direct TCP peer, ignores forwarded headers +Sends a JSON response: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - const client = ctx.ip // real visitor IP - const peer = ctx.directIp // direct connection IP - return ctx.send.json({ - client, - peer - }) + return ctx.send.json( + { message: 'Hello' }, + { status: 200 } + ) } ``` -## Sharing State +### `ctx.send.text(text, options?)` -Context carries request-scoped state so middleware and handlers can pass values along the chain. `ctx.state` is a plain object shared for the whole request: +Sends a plain text response: ```typescript twoslash import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + return ctx.send.text('Hello World') +} +``` -const router = new Router() +### `ctx.send.html(html, options?)` + +Sends an HTML response: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' // ---cut--- -router.use(async (ctx, next) => { - // Attach a value for later handlers - ctx.state.requestId = crypto.randomUUID() - return await next() -}) +export function GET(ctx: Context): Response { + return ctx.send.html('

Hello World

') +} +``` + +### `ctx.send.custom(body, options?)` + +Sends a custom response body. Use this for streams, blobs, or any `BodyInit`: +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- export function GET(ctx: Context): Response { - // Read what middleware stored - return ctx.send.json({ - id: ctx.state.requestId + // Send a readable stream as response + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('Hello')) + controller.close() + } }) + return ctx.send.custom(stream) } ``` -For typed access, `setState` and `getState` use a key and a value type: +### `ctx.send.download(body, filename, options?)` + +Sends a file download response with a `Content-Disposition` header: ```typescript twoslash import type { Context } from '@neabyte/deserve' -declare const ctx: Context // ---cut--- -// Store a typed value -ctx.setState('userId' as never, '123') +export function GET(ctx: Context): Response { + // Trigger a file download + return ctx.send.download( + 'Hello World', + 'hello.txt' + ) +} +``` + +### `ctx.send.empty(status?)` + +Sends an empty response body with an optional status code: -// Read it back with the same type -const userId = ctx.getState('userId' as never) +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // 204 No Content + return ctx.send.empty(204) +} ``` -The `as never` on the key is deliberate, not a workaround to copy blindly. State keys are a branded type, so the framework can reserve a few names for its own wiring and reject them at compile time. A plain string does not carry that brand, and `as never` is what tells the type system this string is a valid key. The value type stays real and checked, so `getState(...)` still returns `string | undefined`. +### `ctx.send.redirect(url, status?, options?)` + +Sends a redirect response. The status defaults to `302`. The target URL is resolved against the request URL and blocked from crossing origins unless passed as a full `https://` or `http://` URL: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Redirect to a new location + return ctx.send.redirect('/new-location', 301) +} +``` -Some keys are reserved for framework wiring and are read-only through `getState`. Calling `setState` on one throws a 500 error. The reserved keys are `view`, `worker`, `session`, `setSession`, and `clearSession`. The [worker pool](/core-concepts/worker-pool) and [session middleware](/middleware/session) read their handles this way. +Allowed redirect statuses are `301`, `302`, `303`, `307`, and `308`. Any other status throws. ## Rendering Templates -When the router has a `viewsDir`, Context can render DVE templates directly: +When the router has a `views.directory` configured, Context can render DVE templates directly: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -230,18 +483,26 @@ export async function GET(ctx: Context): Promise { // Render a template to an HTML response return await ctx.render( 'home.dve', - { - title: 'Welcome' - } + { title: 'Welcome' } ) } ``` -`ctx.streamRender()` streams the same output for large pages. Both throw when no `viewsDir` is configured. See [Template Syntax](/rendering/syntax) for the template grammar and [Streaming Rendering](/rendering/streaming) for the streaming path. +Pass `{ stream: true }` as the third argument to stream the output for large pages: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Stream a large template render +await ctx.render('dashboard.dve', { users: [] }, { stream: true }) +``` + +Both throw when no `views.directory` is configured. See [Template Syntax](/rendering/syntax) for the template grammar and [Streaming Rendering](/rendering/streaming) for the streaming path. ## Error Handling -`ctx.handleError()` builds an error response and is async, so a handler that calls it becomes `async` and awaits the result: +`ctx.handleError()` builds an error response and forwards it through the global error handler set with `router.catch()`. It is async, so a handler that calls it becomes `async` and awaits the result: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -250,12 +511,12 @@ declare const isAuthorized: boolean export async function GET(ctx: Context): Promise { try { if (!isAuthorized) { + // Forward to the error handler return await ctx.handleError(401, new Error('Unauthorized')) } - return ctx.send.json({ - data: 'success' - }) + return ctx.send.json({ data: 'success' }) } catch (error) { + // Catch unexpected failures return await ctx.handleError(500, error as Error) } } @@ -263,14 +524,14 @@ export async function GET(ctx: Context): Promise { ### How It Works -`ctx.handleError()` respects the global error handler set with `router.catch()`: +`ctx.handleError()` respects the global error handler set with [`router.catch()`](/error-handling/object-details): -- **When `router.catch()` is defined** - the custom error handler runs -- **When no error handler exists** - a simple response carries the status code +- **When `router.catch()` is defined** - the custom error handler runs and can shape the response +- **When no error handler exists** - a default response carries the status code, negotiated as JSON or HTML based on the `Accept` header ### Use in Middleware -Middleware can call `ctx.handleError()` to trigger error handling: +Middleware can call `ctx.handleError()` to trigger error handling the same way a handler does: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -280,17 +541,19 @@ declare const isValid: boolean // ---cut--- router.use(async (ctx, next) => { if (!isValid) { - // This routes through router.catch() when defined + // Routes through router.catch() when defined return await ctx.handleError(401, new Error('Unauthorized')) } return await next() }) ``` +See [Error Handling](/error-handling/object-details) for the full centralized pattern, and [Defense in Depth](/error-handling/defense-in-depth) for how errors are caught in layers. + ## Context Lifecycle -1. **Request arrives** - Deserve creates Context with Request and URL -2. **Route matching** - route parameters are extracted and added to Context +1. **Request arrives** - Deserve creates Context with the `Request`, parsed `URL`, client IP, and optional renderer +2. **Route matching** - route parameters are extracted and installed on Context 3. **Middleware execution** - Context passes through the middleware chain -4. **Route handler** - the handler receives Context -5. **Response sent** - Context methods build the Response +4. **Route handler** - the handler receives Context and reads or sends through the three namespaces +5. **Response sent** - `ctx.send.*` or `ctx.handleError()` builds the final `Response` diff --git a/docs/core-concepts/file-based-routing.md b/docs/core-concepts/file-based-routing.md index 1e658d4..ab912ab 100644 --- a/docs/core-concepts/file-based-routing.md +++ b/docs/core-concepts/file-based-routing.md @@ -31,7 +31,7 @@ routes/ - `about.ts`, `about.js`, `about.mjs` → `/about` - `users.ts`, `users.js`, `users.cjs` → `/users` -All supported extensions (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) work identically. +All supported extensions (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) work identically. A filename can only have one dot separating the name from the extension, so `about.ts` loads but `about.config.ts` does not. ### 2. Folders Create Nested Routes @@ -44,7 +44,7 @@ All supported extensions (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) work ide - `[userId].ts` → `:userId` parameter - `[postId].ts` → `:postId` parameter -Dynamic segments are matched by [Route Patterns](/core-concepts/route-patterns) and read with `ctx.param()` from [Request Handling](/core-concepts/request-handling#route-parameters). +Dynamic segments are matched by [Route Patterns](/core-concepts/route-patterns) and read with `ctx.get.param()` from [Request Handling](/core-concepts/request-handling#route-parameters). ### 4. HTTP Methods Are Exported Functions @@ -59,7 +59,8 @@ export function GET(ctx: Context): Response { } export async function POST(ctx: Context): Promise { - const data = await ctx.body() + // Read parsed request body + const data = await ctx.get.body() return ctx.send.json({ message: 'User created', data @@ -70,6 +71,8 @@ export async function POST(ctx: Context): Promise { // export function [method](ctx: Context): Response { ... } ``` +A route file must export at least one HTTP method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`). A file whose name never forms a loadable pattern, such as one starting with `_`, is skipped during the scan and emits a [`route:ignored`](/middleware/observability/events#routes) event. A file with a loadable name but no exported method is a mistake worth catching early, so the scan throws `Deno.errors.InvalidData` at startup, and an export that is not a function throws `TypeError`. + ### 5. Case-Sensitive URLs URLs are case-sensitive following HTTP standards: @@ -79,16 +82,15 @@ URLs are case-sensitive following HTTP standards: ### 6. Valid Filename Characters -Files can contain specific rules: +The last segment of a route path (the filename without extension) can contain: - `a-z`, `A-Z`, `0-9` - Alphanumeric characters -- `_` - Underscore (do not prefix path segment - see below) +- `_` - Underscore (do not prefix a segment with it, see below) - `-` - Dash -- `.` - Dot - `~` - Tilde - `+` - Plus sign - `[` `]` - Brackets for dynamic parameters -**Skipped segments:** Folders or file names that **start with** `_` or `@` are not registered as routes (e.g. `_layout.ts`, `@middleware.ts`, folder `_components/`). Useful for support files that are not endpoints. +**Skipped segments:** Folders or file names that **start with** `_` or `@` are not registered as routes (for example `_layout.ts`, `@middleware.ts`, folder `_components/`). Useful for support files that are not endpoints. Edited route files reload on the fly without a restart, covered in [Hot Reload](/core-concepts/hot-reload). diff --git a/docs/core-concepts/hot-reload.md b/docs/core-concepts/hot-reload.md index 56df8bb..3da5708 100644 --- a/docs/core-concepts/hot-reload.md +++ b/docs/core-concepts/hot-reload.md @@ -4,29 +4,39 @@ description: "Hot reload in Deserve: how route and template changes are detected # Hot Reload -Deserve automatically watches the `routesDir` and `viewsDir` directories for file changes, and when a file is created, modified, or deleted the server picks up the change on the next request with no restart required. +Deserve automatically watches the routes directory and views directory for file changes, and when a file is created, modified, or deleted the server picks up the change on the next request with no restart required. ## Zero Configuration -Hot reload starts automatically when the server starts: +Hot reload starts automatically when the server starts, unless `hotReload: false` is passed to the router: ```typescript twoslash import { Router } from '@neabyte/deserve' const app = new Router({ - routesDir: './routes', - viewsDir: './views' + routes: { directory: './routes' }, + views: { directory: './views' } }) // Watchers start automatically app.serve(3000) ``` +To disable hot reload entirely, set `hotReload: false`. This suits production deployments where file watching is unnecessary: + +```typescript twoslash +import { Router } from '@neabyte/deserve' +// ---cut--- +const app = new Router({ + hotReload: false +}) +``` + ## What Gets Watched ### Route Files -All files with supported extensions (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) inside `routesDir` are watched recursively. +All files with supported extensions (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) inside the routes directory are watched recursively. | Event | Behavior | | ----------------- | ------------------------------------------------------------------------------ | @@ -36,7 +46,7 @@ All files with supported extensions (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs ### Template Files -All `.dve` files inside `viewsDir` are watched recursively, so [template](/rendering/) edits show on the next render without a restart. +All `.dve` files inside the views directory are watched recursively, so [template](/rendering/) edits show on the next render without a restart. | Event | Behavior | | ----------------- | ------------------------------------------------------------------------------ | @@ -46,17 +56,17 @@ All `.dve` files inside `viewsDir` are watched recursively, so [template](/rende ## Error Isolation -A bad file is caught and never crashes the server or the other routes. Because the new module is imported and validated before the old one is dropped, a failed reload leaves the previous working version in place rather than killing the route. Each failure surfaces as a [`route:error` or `reload:error`](/middleware/observability/events#routes) observability event, so logging stays in one place and nothing prints to the console on its own. +A bad file is caught and never crashes the server or the other routes. Because the new module is imported and validated before the old one is dropped, a failed reload leaves the previous working version in place rather than killing the route. Each failure surfaces as a [`route:failed`](/middleware/observability/events) observability event, so logging stays in one place and nothing prints to the console on its own. ![An abstract view of why reloading stays safe, where applying a file change live rests on three mechanisms that hold together, isolating each file with a try catch so a bad one never crashes the others, busting the module cache with a timestamp query so stale code never contaminates the new, and reloading in sequence by validating the new module then swapping it in after a debounce, which together deliver live edits with no downtime, no crash, and no contamination](/diagrams/hot-reload-principles.png) ### Malformed Syntax -Invalid syntax fails the import, so the swap never happens and the last good route keeps serving. The failure arrives as a `reload:error` event carrying the route path and the parse error. +Invalid syntax fails the import, so the swap never happens and the last good route keeps serving. The failure arrives as a `route:failed` event carrying the route path and the parse error. ### Missing HTTP Method Exports -A file with no valid HTTP method export (`GET`, `POST`, etc.) fails validation before the swap, so the route is left untouched and the reason rides the same `reload:error` event. +A file with no valid HTTP method export (`GET`, `POST`, etc.) fails validation before the swap, so the route is left untouched and the reason rides the same `route:failed` event. ### Runtime Errors in Handlers @@ -75,18 +85,18 @@ Multiple file changes within the debounce window are batched into a single opera ### Route Reloading -![The route reload sequence as the watcher runs it, where the watcher detects a change and debounces for 150ms, the module is re-imported with a timestamp query to bypass the cache, then it is validated for an HTTP method, and only after both pass does FastRouter.remove drop the old pattern and the new handlers register while emitting route:reloaded, and a failure at import or validate instead emits reload:error before any swap so the old route keeps serving and the server stays alive](/diagrams/hot-reload-route-sequence.png) +![The route reload sequence as the watcher runs it, where the watcher detects a change and debounces for 150ms, the module is re-imported with a timestamp query to bypass the cache, then it is validated for an HTTP method, and only after both pass does it remove the old route and register the new handlers while emitting route:updated, and a failure at import or validate instead emits route:failed before any swap so the old route keeps serving and the server stays alive](/diagrams/hot-reload-route-sequence.png) -1. The watcher detects a change in `routesDir` and waits out the debounce window +1. The watcher detects a change in the routes directory and waits out the debounce window 2. The file path resolves to a route pattern 3. The module is re-imported with a cache-busting query string (`?t=timestamp`) to bypass the module cache 4. The module is validated for at least one HTTP method export -5. Only after the import and validation pass, the old pattern is dropped and the new handlers register, then a `route:reloaded` event fires -6. If any step before the swap fails, the old route is left serving and a `reload:error` event fires instead +5. Only after the import and validation pass, the old pattern is dropped and the new handlers register, then a `route:updated` event fires +6. If any step before the swap fails, the old route is left serving and a `route:failed` event fires instead ### Template Reloading -1. The watcher detects a change in `viewsDir` and waits out the debounce window +1. The watcher detects a change in the views directory and waits out the debounce window 2. The changed file's compiled AST entry is cleared from the cache 3. The discovered template paths set is reset -4. On the next `render()` or `streamRender()` call, the engine re-reads the file from disk, re-parses it, and caches the result +4. On the next `ctx.render()` call, the engine re-reads the file from disk, re-parses it, and caches the result diff --git a/docs/core-concepts/multi-service.md b/docs/core-concepts/multi-service.md index 76f60d1..cad5b5f 100644 --- a/docs/core-concepts/multi-service.md +++ b/docs/core-concepts/multi-service.md @@ -19,14 +19,14 @@ import { Router } from '@neabyte/deserve' // One Router per service const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) const web = new Router({ - routesDir: './services/web/routes', - viewsDir: './services/web/views' + routes: { directory: './services/web/routes' }, + views: { directory: './services/web/views' } }) // Run every service together @@ -41,11 +41,11 @@ That is the entire entry point. ## Router Isolation -Every `Router` runs in request-level isolation. Each one owns its own radix-tree router, middleware stack, Superwatcher instance, and optional template engine. They do not share any internal state unless it is explicitly wired up, while the process underneath is still shared, which is what makes the [shared code and state](#sharing-code-and-state) below possible. +Every `Router` runs in request-level isolation. Each one owns its own radix-tree router, middleware stack, file watcher, and optional template engine. They do not share any internal state unless it is explicitly wired up, while the process underneath is still shared, which is what makes the [shared code and state](#sharing-code-and-state) below possible. Faults are contained at two levels. A throw inside one handler becomes an error response for that one request, so the rest of that service and every other service keep serving. A deeper fault that escapes a handler, like an unhandled rejection or an attempt to exit the process, is trapped process-wide by [process protection](/getting-started/server-configuration#process-protection) and surfaced as an event rather than a shutdown, so no service goes down. -![Each router keeps its own FastRouter, middleware, and watcher in isolation, with the Web router also holding a DVE engine](/diagrams/router-isolation.png) +![Each router keeps its own route table, middleware, and watcher in isolation, with the Web router also holding a DVE engine](/diagrams/router-isolation.png) If a route in API throws, only that request gets a 500. Auth and Web, and every other API request, keep serving normally. @@ -139,7 +139,8 @@ import { sessions } from '../../../shared/sessions.ts' // Auth saves the session on login export async function POST(ctx: Context): Promise { - const body = (await ctx.json()) as { username?: string } + // Read JSON body from request + const body = (await ctx.get.json()) as { username?: string } const id = crypto.randomUUID() sessions.set(id, { username: body?.username, @@ -158,16 +159,13 @@ import { sessions } from '../../../shared/sessions.ts' // API reads the same store directly export function GET(ctx: Context): Response { - const id = ctx.header('x-session-id') + // Read session ID from header + const id = ctx.get.header('x-session-id') const session = id ? sessions.get(id) : undefined if (!session) { return ctx.send.json( - { - error: 'Not authenticated' - }, - { - status: 401 - } + { error: 'Not authenticated' }, + { status: 401 } ) } return ctx.send.json({ @@ -209,7 +207,8 @@ import { emit } from '../../../../shared/bus.ts' // Emit an event after creating a user export async function POST(ctx: Context): Promise { - const user = await ctx.json() + // Read JSON body from request + const user = await ctx.get.json() emit('user:created', user) return ctx.send.json({ created: true @@ -279,7 +278,7 @@ import { Mware, Router } from '@neabyte/deserve' // API gets CORS and a body limit const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) api.use(Mware.cors({ origin: '*' @@ -290,7 +289,7 @@ api.use(Mware.bodyLimit({ // Auth gets security headers const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) auth.use(Mware.securityHeaders({ xFrameOptions: 'DENY' @@ -298,8 +297,8 @@ auth.use(Mware.securityHeaders({ // Web runs without middleware const web = new Router({ - routesDir: './services/web/routes', - viewsDir: './services/web/views' + routes: { directory: './services/web/routes' }, + views: { directory: './services/web/views' } }) // Run every service together @@ -325,7 +324,8 @@ export function logger(service: string): MiddlewareFn { const response = await next() const duration = Date.now() - start const status = response?.status ?? 0 - console.log(`[${service}] ${ctx.request.method} ${ctx.pathname} ${status} ${duration}ms`) + // Read method and path from ctx.get + console.log(`[${service}] ${ctx.get.method()} ${ctx.get.pathname()} ${status} ${duration}ms`) return response } } @@ -347,21 +347,21 @@ One error handler applies with [`router.catch()`](/error-handling/object-details ```typescript twoslash // shared/errors.ts // One error handler shape for all -import type { Context, ErrorInfo, ErrorMiddleware } from '@neabyte/deserve' +import type { Context, ErrorInfo, ErrorMiddleware, HttpStatusCode } from '@neabyte/deserve' export function errorHandler(service: string): ErrorMiddleware { - return (ctx: Context, error: ErrorInfo): Response | null => { + return (ctx: Context, info: ErrorInfo): Response | null => { console.error( - `[${service}] ${error.method} ${error.pathname} ${error.statusCode} - ${error.error?.message}` + `[${service}] ${info.method} ${info.pathname} ${info.statusCode} - ${info.error?.message}` ) return ctx.send.json( { service, - error: error.error?.message ?? 'Unknown error', - statusCode: error.statusCode, - path: error.pathname + error: info.error?.message ?? 'Unknown error', + statusCode: info.statusCode, + path: info.pathname }, - { status: error.statusCode } + { status: info.statusCode as HttpStatusCode } ) } } @@ -369,50 +369,51 @@ export function errorHandler(service: string): ErrorMiddleware { ### Wrapping Middleware with Labels -`WrapMware` tags an individual middleware with a label, so when that middleware throws, the error log includes the label and points straight to which middleware in which service failed. Its signature and base behavior are covered in [Global Middleware](/middleware/global#wrapping-middleware-with-error-handling), and it acts as one layer in [Defense in Depth](/error-handling/defense-in-depth): +`Wrap.apply` tags an individual middleware with a label, so when that middleware throws, the error log includes the label and points straight to which middleware in which service failed. Its signature and base behavior are covered in [Global Middleware](/middleware/global#wrapping-middleware-with-error-handling), and it acts as one layer in [Defense in Depth](/error-handling/defense-in-depth): ```typescript // main.ts -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap } from '@neabyte/deserve' import { logger } from './shared/logger.ts' import { errorHandler } from './shared/errors.ts' // Label each middleware for error logs -const apiAuth = WrapMware('APIAuth', async (ctx, next) => { - if (!ctx.header('authorization')) { +const apiAuth = Wrap.apply('APIAuth', async (ctx, next) => { + // Read authorization header + if (!ctx.get.header('authorization')) { throw new Error('Missing API key') } return await next() }) -const authRateLimit = WrapMware('AuthRateLimit', async (ctx, next) => { +const authRateLimit = Wrap.apply('AuthRateLimit', async (ctx, next) => { // rate limit logic return await next() }) -const webCache = WrapMware('WebCache', async (ctx, next) => { +const webCache = Wrap.apply('WebCache', async (ctx, next) => { // cache logic return await next() }) // Wire logger, middleware, error handler const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) api.use(logger('API')) api.use(apiAuth) api.catch(errorHandler('API')) const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) auth.use(logger('Auth')) auth.use(authRateLimit) auth.catch(errorHandler('Auth')) const web = new Router({ - routesDir: './services/web/routes', - viewsDir: './services/web/views' + routes: { directory: './services/web/routes' }, + views: { directory: './services/web/views' } }) web.use(logger('Web')) web.use(webCache) @@ -430,7 +431,7 @@ When `apiAuth` throws, the log reads `[API] GET /users 500 - APIAuth - Missing A ### OpenTelemetry -Since every request already flows through shared middleware, plugging in OpenTelemetry follows the same pattern. One OTel middleware applies to every service, so all spans from all ports go to one collector, which gives distributed tracing, latency dashboards, and error rate metrics across the entire system without instrumenting each service separately: +Since every request already flows through shared middleware, plugging in [OpenTelemetry](https://opentelemetry.io/) follows the same pattern. One OTel middleware applies to every service, so all spans from all ports go to one collector, which gives distributed tracing, latency dashboards, and error rate metrics across the entire system without instrumenting each service separately: ![A single OTel middleware collects spans from every service and exports them to an OTel Collector, then on to Jaeger, Grafana, or Datadog](/diagrams/observability.png) @@ -450,8 +451,8 @@ export function otelMiddleware(service: string): MiddlewareFn { console.log(JSON.stringify({ traceId: crypto.randomUUID(), service, - method: ctx.request.method, - path: ctx.pathname, + method: ctx.get.method(), + path: ctx.get.pathname(), status, durationMs: Math.round(duration * 100) / 100, timestamp: new Date().toISOString() @@ -479,7 +480,7 @@ A team can work on different services at the same time, with one person refactor All services run in one container. One image, one process, all ports: ```dockerfile -FROM denoland/deno:2.7.0 +FROM denoland/deno:2.8.3 WORKDIR /app COPY . . @@ -492,7 +493,7 @@ CMD ["deno", "run", "-A", "main.ts"] ### Reverse Proxy -Put Nginx or Caddy in front to route by domain to each service port: +Put [Nginx](https://nginx.org/) or [Caddy](https://caddyserver.com/) in front to route by domain to each service port: ![A reverse proxy such as Nginx or Caddy maps each hostname to a per-service port: api.example.com to 3001, auth.example.com to 3002, and example.com to 3003](/diagrams/reverse-proxy.png) diff --git a/docs/core-concepts/philosophy.md b/docs/core-concepts/philosophy.md index abe0d33..7f0f0b5 100644 --- a/docs/core-concepts/philosophy.md +++ b/docs/core-concepts/philosophy.md @@ -40,7 +40,7 @@ Code should read cleanly, patterns should stay predictable, and errors should po Simple and safe belong in the same sentence. A serving router protects the process from accidental shutdown through [process protection](/getting-started/server-configuration#process-protection), and faults are caught in layers through [defense in depth](/error-handling/defense-in-depth). Staying small is part of staying safe, since less code means less that can go wrong. -Staying safe also means staying current. Attack surfaces keep shifting, so each release tracks them and ships the fixes alongside the features. A new version is rarely just new capabilities, it usually carries most of the attack vectors patched from the version before it, which makes running the latest the safe choice rather than the optional one. Keeping the dependency current is one command with [`deno update`](https://docs.deno.com/runtime/reference/cli/update/), where `deno update --latest` pulls the newest release regardless of semver so the patches reach you the moment they ship. +Staying safe also means staying current. Attack surfaces keep shifting, so each release tracks them and ships the fixes alongside the features. A new version is rarely just new capabilities, it usually carries most of the attack vectors patched from the version before it, which makes running the latest the safe choice rather than the optional one. Keeping the dependency current is one command with [`deno update`](https://docs.deno.com/runtime/reference/cli/update/), where `deno update --latest` pulls the newest release regardless of semver so the patches reach the moment they ship. ## Small on Purpose diff --git a/docs/core-concepts/request-handling.md b/docs/core-concepts/request-handling.md index 44f0702..46d624d 100644 --- a/docs/core-concepts/request-handling.md +++ b/docs/core-concepts/request-handling.md @@ -6,32 +6,32 @@ description: "How Deserve parses and handles incoming requests, including body p > **Reference**: [Deno Request API Documentation](https://docs.deno.com/deploy/classic/api/runtime-request/) -Deserve provides a `Context` object that wraps the native `Request`, so query, route params, headers, cookies, and body all come through Context without manual parsing. For the full Context surface, including response helpers and state, see [Context Object](/core-concepts/context-object). +Deserve provides a `Context` object that wraps the native `Request`, so query, route params, headers, cookies, and body all come through Context without manual parsing. For the full Context surface, including response helpers and error handling, see [Context Object](/core-concepts/context-object). -A handler receives one `Context` and reads whatever it needs from it: +A handler receives one `Context` and reads whatever it needs from the `ctx.get` namespace: ```typescript twoslash import type { Context } from '@neabyte/deserve' -// Read request data from ctx +// Read request data from ctx.get export function GET(ctx: Context): Response { - const query = ctx.query() + const query = ctx.get.query() return ctx.send.json({ query }) } ``` -The sections below cover each kind of input, and [Method Reference](#method-reference) lists every reader with its return type. +The sections below cover each kind of input. Every reader lives on `ctx.get` and is documented in full in [Context Object](/core-concepts/context-object). ## Query Parameters -Query strings are parsed on first access, then cached. Two readers cover every case, `query()` for a single value and `queries()` for repeated keys: +Query strings are parsed on first access, then cached. `ctx.get.query()` returns the full record, and `ctx.get.query(key)` returns one value. The first value wins for duplicate keys: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // URL: /search?q=deno&limit=10 export function GET(ctx: Context): Response { - const query = ctx.query() + const query = ctx.get.query() return ctx.send.json({ search: query.q, limit: parseInt(query.limit || '10') @@ -39,22 +39,20 @@ export function GET(ctx: Context): Response { } ``` -When a key repeats in the URL, `query()` keeps the **last value** while `queries()` returns **all of them**: +When a key repeats in the URL, the first value is kept: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- // URL: /search?tag=deno&tag=typescript -ctx.query('tag') // 'typescript', last value wins -ctx.queries('tag') // ['deno', 'typescript'], every value +ctx.get.query('tag') // 'deno', first value wins +ctx.get.query() // { tag: 'deno' } ``` -Reach for `queries()` on array or multi-select inputs, and `query()` everywhere else. The full signatures live in [Method Reference](#method-reference). - ## Route Parameters -Dynamic segments from [file-based routing](/core-concepts/file-based-routing) arrive as route params, read one at a time with `param()` or all at once with `params()`: +Dynamic segments from [file-based routing](/core-concepts/file-based-routing) arrive as route params, read one at a time with `ctx.get.param(key)` or all at once with `ctx.get.param()`: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -62,215 +60,168 @@ import type { Context } from '@neabyte/deserve' // routes/users/[id]/posts/[postId].ts // URL: /users/123/posts/456 export function GET(ctx: Context): Response { - const id = ctx.param('id') // '123' - const all = ctx.params() // { id: '123', postId: '456' } - return ctx.send.json({ - id, - all - }) + const id = ctx.get.param('id') // '123' + const all = ctx.get.param() // { id: '123', postId: '456' } + return ctx.send.json({ id, all }) } ``` Values are percent-decoded once before the handler reads them. How patterns are matched is covered in [Route Patterns](/core-concepts/route-patterns). -## Method Reference - -### `ctx.query(key?)` +## Headers -Returns all query parameters as an object, and falls back to the **last value for duplicate keys**. +Headers are read through `ctx.get.header()`. Pass a key to read one header, or call with no arguments to read all headers as a record. Keys match case-insensitively: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// URL: /search?q=deno&limit=10 -ctx.query() // { q: 'deno', limit: '10' } - -// URL: /search?tag=deno&tag=typescript -ctx.query() // { tag: 'typescript' } ← last value only +// Read one header by name +const contentType = ctx.get.header('content-type') -// Single parameter -const q = ctx.query('q') // Returns: 'deno' +// Read all headers as a record +const headers = ctx.get.header() ``` -### `ctx.queries(key)` - -Returns **all values** for one query parameter key as an array. +For direct access to the native `Headers` object, use `ctx.get.request().headers`: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// URL: /search?tags=deno&tags=typescript -const tags = ctx.queries('tags') // ['deno', 'typescript'] ← all values - -// query() covers single or last value, while queries() covers arrays and multi-select +// Access raw Headers API +const contentType = ctx.get.request().headers.get('Content-Type') ``` -### `ctx.param(key)` +## Cookies -Returns a single route parameter value. +Cookies are read through `ctx.get.cookie()`. Pass a key to read one cookie, or call with no arguments to read all cookies as a record. Cookies parse once and cache for later calls: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// Route: /users/[id] -// URL: /users/123 -const id = ctx.param('id') // '123' +// Read one cookie by name +const sessionId = ctx.get.cookie('sessionId') + +// Read all cookies as a record +const cookies = ctx.get.cookie() // { sessionId: 'abc123', theme: 'dark' } ``` -### `ctx.params()` +## Body -Returns all route parameters as an object. +The body is read through one of several async methods on `ctx.get`. The format is chosen automatically by `ctx.get.body()` based on the `Content-Type` header, or forced by calling a specific reader: -```typescript twoslash -import type { Context } from '@neabyte/deserve' -declare const ctx: Context -// ---cut--- -// Route: /users/[id]/posts/[postId] -// URL: /users/123/posts/456 -const params = ctx.params() // { id: '123', postId: '456' } -``` +| Method | Format | Content-Type | +| ------ | ------ | ------------ | +| `ctx.get.body()` | Auto-detected | JSON, form-data, or text | +| `ctx.get.json()` | JSON | Any | +| `ctx.get.text()` | Plain text | Any | +| `ctx.get.formData()` | Form data | Any | +| `ctx.get.blob()` | Blob | Any | +| `ctx.get.bytes()` | Uint8Array | Any | -### `ctx.body()` +The body can only be read once. A second call with the same format returns the cached value, while a second call with a different format throws a **409 Conflict**. -Parses the request body automatically as JSON, form-data, or text. +### Auto-detected Body ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/users with JSON body export async function POST(ctx: Context): Promise { - const body = await ctx.body() // { name: 'John', age: 30 } - return ctx.send.json({ - created: body - }) + // Body parses based on Content-Type + const body = await ctx.get.body() + return ctx.send.json({ created: body }) } ``` -### `ctx.json()` - -Parses the request body as JSON. +### JSON Body ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/users with JSON body export async function POST(ctx: Context): Promise { - const body = await ctx.json() // { name: 'John', age: 30 } - return ctx.send.json({ - created: body - }) + // Parse body as JSON + const body = await ctx.get.json() + return ctx.send.json({ created: body }) } ``` -### `ctx.formData()` - -Parses the request body as form data and returns a `FormData` object. +### Form Data ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/users with form data export async function POST(ctx: Context): Promise { - const formData = await ctx.formData() // FormData object - const name = formData.get('name') // 'John' + // Parse body as form data + const formData = await ctx.get.formData() + const name = formData.get('name') return ctx.send.json({ name }) } ``` -### `ctx.text()` - -Reads the request body as raw text. +### Raw Text ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/text with plain text export async function POST(ctx: Context): Promise { - const text = await ctx.text() // 'Hello World' + // Read body as plain text + const text = await ctx.get.text() return ctx.send.text(text) } ``` -### `ctx.arrayBuffer()` - -Reads the request body as an ArrayBuffer, which suits binary data processing. +### Binary Data ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/upload with binary data export async function POST(ctx: Context): Promise { - const buffer = await ctx.arrayBuffer() // ArrayBuffer object - // Process binary data... - return ctx.send.json({ - size: buffer.byteLength - }) + // Read body as byte array + const bytes = await ctx.get.bytes() + return ctx.send.json({ size: bytes.byteLength }) } ``` -### `ctx.blob()` +## URL and Pathname -Reads the request body as a Blob, which suits file uploads and binary handling. +URL details are read through `ctx.get.url()` and `ctx.get.pathname()`: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- -// POST /api/upload with file data -export async function POST(ctx: Context): Promise { - const blob = await ctx.blob() // Blob object - // Process file data... +export function GET(ctx: Context): Response { + const url = ctx.get.url() // URL instance + const pathname = ctx.get.pathname() // '/api/users/123' return ctx.send.json({ - type: blob.type, - size: blob.size + path: pathname, + fullUrl: url.href }) } ``` -### `ctx.header(key?)` +## Client IP -Reads one header by key or every header at once, matching keys case-insensitively and lowercasing them. +The client IP is read through `ctx.get.ip()`. Pass `{ direct: true }` to read the direct TCP peer instead of the resolved IP: ```typescript twoslash import type { Context } from '@neabyte/deserve' -declare const ctx: Context // ---cut--- -// Get specific header -const contentType = ctx.header('content-type') - -// Get all headers as object -const headers = ctx.header() -``` - -### `ctx.headers` - -Exposes the raw Headers object for direct access. - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -declare const ctx: Context -// ---cut--- -// Access raw Headers API -const contentType = ctx.headers.get('Content-Type') +export function GET(ctx: Context): Response { + const client = ctx.get.ip() // resolved visitor IP + const peer = ctx.get.ip({ direct: true }) // direct TCP peer + return ctx.send.json({ client, peer }) +} ``` -### `ctx.cookie(key?)` - -Reads one cookie by key or every cookie at once. - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -declare const ctx: Context -// ---cut--- -// Get specific cookie -const sessionId = ctx.cookie('sessionId') - -// Get all cookies -const cookies = ctx.cookie() // { sessionId: 'abc123', theme: 'dark' } -``` +Both return `undefined` when the peer is unknown. Without a matching [`trustProxy`](/getting-started/server-configuration#client-ip-resolution) rule, both return the same direct peer address. The [IP restriction middleware](/middleware/ip) uses `ctx.get.ip()` for its allow and deny rules. ## Validating Before The Handler -Every reader above hands back the raw value as it arrived, so a handler still checks the shape itself. A schema moves those checks ahead of the handler, runs a contract against each source, and leaves only data that already passed. See [Validation Overview](/middleware/validation/overview) for how `ctx.json()`, `ctx.query()`, and the other readers feed a contract. +Every reader above hands back the raw value as it arrived, so a handler still checks the shape itself. A schema moves those checks ahead of the handler, runs a contract against each source, and leaves only data that already passed. See [Validation Overview](/middleware/validation/overview) for how `ctx.get.json()`, `ctx.get.query()`, and the other readers feed a contract. diff --git a/docs/core-concepts/route-patterns.md b/docs/core-concepts/route-patterns.md index a7ac58e..99a9fb5 100644 --- a/docs/core-concepts/route-patterns.md +++ b/docs/core-concepts/route-patterns.md @@ -1,5 +1,5 @@ --- -description: "Route pattern syntax in Deserve including dynamic params, wildcards, and matching rules." +description: "Route pattern syntax in Deserve including dynamic params and matching rules." --- # Route Patterns @@ -28,9 +28,9 @@ When a request arrives, the engine looks up the method and pathname, then applie - **HEAD falls back to GET** - a `HEAD` with no handler reuses the `GET` handler - **Wrong method** - a known path with no handler for that method returns **405** with an `Allow` header listing the methods that do exist - **Unknown path** - no match returns **404** through the [error handler](/error-handling/object-details) -- **Oversized input** - a URL past `maxUrlLength` or a param past `maxParamLength` returns **414**, both tunable in [Server Configuration](/getting-started/server-configuration) +- **Oversized input** - a URL past `maxUrlLength` or a param past `routes.maxParamLength` returns **414**, both tunable in [Routes Configuration](/getting-started/routes-configuration) -Params are percent-decoded once before the handler reads them, so `ctx.param('id')` returns the decoded value. +Params are percent-decoded once before the handler reads them, so `ctx.get.param('id')` returns the decoded value. ## Dynamic Parameters @@ -42,7 +42,7 @@ A `[param]` folder or file becomes a named `:param` slot in the pattern. Each br | `users/[id]/posts/[postId].ts` | `/users/:id/posts/:postId` | `id`, `postId` | | `api/v1/users/[userId]/posts/[postId].ts` | `/api/v1/users/:userId/posts/:postId` | `userId`, `postId` | -The matched values are read inside a handler with `ctx.param()` and `ctx.params()`, covered in [Request Handling](/core-concepts/request-handling#route-parameters). +The matched values are read inside a handler with `ctx.get.param('id')` for one value or `ctx.get.param()` for the full map, covered in [Request Handling](/core-concepts/request-handling#route-parameters). ## Pattern Examples @@ -90,15 +90,11 @@ import type { Context } from '@neabyte/deserve' // Reject non-numeric ids with 400 export function GET(ctx: Context): Response { - const id = ctx.param('id') + const id = ctx.get.param('id') if (!id || !/^\d+$/.test(id)) { return ctx.send.json( - { - error: 'Invalid user ID' - }, - { - status: 400 - } + { error: 'Invalid user ID' }, + { status: 400 } ) } return ctx.send.json({ diff --git a/docs/core-concepts/worker-pool.md b/docs/core-concepts/worker-pool.md deleted file mode 100644 index 85b0915..0000000 --- a/docs/core-concepts/worker-pool.md +++ /dev/null @@ -1,216 +0,0 @@ ---- -description: "Offloading CPU-bound work to a pool of Deno workers via the Deserve worker pool API." ---- - -# Worker Pool - -> **Reference**: [Deno Workers API](https://docs.deno.com/runtime/manual/workers/) - -The worker pool offloads CPU-bound work to a pool of Deno Workers so the main thread stays responsive. Once a worker pool is configured, route handlers reach the worker handle through `ctx.getState('worker' as never)` and dispatch tasks with `run(payload)`. - -## When to Use - -Use the worker pool when a route does **CPU-bound work** (e.g. heavy math, parsing, compression) that would block the event loop. For I/O-bound work (file, network), the main thread is usually enough. - -## Basic Usage - -### 1. Configure Router with Worker - -Pass `worker` when creating the router, along with a **script URL** that resolves to a module (for example via `import.meta.resolve()` or `URL.createObjectURL()` for inline code): - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -// Resolve worker script as a module -const workerScriptUrl = import.meta.resolve('./worker.ts') - -// Enable the pool on the router -const router = new Router({ - routesDir: './routes', - worker: { - scriptURL: workerScriptUrl, - poolSize: 4 - } -}) - -await router.serve(8000) -``` - -### 2. Implement the Worker Script - -The worker script must listen for `message` and reply with `postMessage`. Payload and result must be **structured-clone serializable** (no functions or symbols): - -```typescript -// worker.ts -self.onmessage = (e: MessageEvent) => { - const data = e.data as { iterations?: number } - const n = Math.max(0, Number(data?.iterations) || 50_000) - let value = 0 - for (let i = 0; i < n; i++) { - value += Math.sqrt(i) - } - self.postMessage({ - done: true, - value - }) -} -``` - -To report an error from the worker, send an object with `error: true` and optional `message`: - -```typescript -self.postMessage({ - error: true, - message: 'Computation failed' -}) -``` - -### 3. Use in a Route - -The worker handle lives in framework state, so `ctx.getState` reaches it with the `WorkerRunHandle` type. A router created without `worker` leaves the handle undefined, which is the moment to return 503: - -```typescript twoslash -// routes/heavy.ts -import type { Context, WorkerRunHandle } from '@neabyte/deserve' - -export async function GET(ctx: Context): Promise { - const worker = ctx.getState('worker' as never) - if (!worker) { - return ctx.send.json( - { - error: 'Worker not enabled' - }, - { - status: 503 - } - ) - } - const result = await worker.run<{ done: boolean; value: number }>({ - iterations: 50_000 - }) - return ctx.send.json({ - value: result?.value - }) -} -``` - -## Router Options - -### `scriptURL` - -Worker script URL. Must point to a **module** (Deno runs workers with `type: 'module'`). Typical sources: - -- **File path:** `import.meta.resolve('./worker.ts')` -- **Inline script:** `URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))` - -### `poolSize` - -Number of workers in the pool. Default is **4**. Minimum is 1. Tasks are dispatched round-robin. - -```typescript -worker: { - scriptURL: workerScriptUrl, - poolSize: 8 -} -``` - -### `taskTimeoutMs` - -Per-task timeout in milliseconds. Default is **5000**. A task that runs longer rejects with a timeout error, the slot is reclaimed, and the worker is respawned. The reclaim surfaces as a [`worker:timeout`](/middleware/observability/events#workers) event followed by [`worker:respawn`](/middleware/observability/events#workers). - -```typescript -worker: { - scriptURL: workerScriptUrl, - taskTimeoutMs: 10_000 -} -``` - -### `maxQueueDepth` - -Maximum accepted-but-unsettled tasks the pool holds before turning new work away. Default is the worker count times **8**, so a pool of 4 holds up to 32. Once the ceiling is hit a new dispatch is refused immediately rather than queued, which keeps a flood of work from piling up without bound: - -```typescript -worker: { - scriptURL: workerScriptUrl, - poolSize: 4, - maxQueueDepth: 64 -} -``` - -### `maxQueueWaitMs` - -Maximum projected wait, measured as the chosen slot's pending count times `taskTimeoutMs`, before a dispatch is refused. Default is **2000**. A task that would otherwise sit behind a long backlog is turned away fast instead of waiting: - -```typescript -worker: { - scriptURL: workerScriptUrl, - maxQueueWaitMs: 5_000 -} -``` - -A refused dispatch rejects right away and surfaces as a [`worker:rejected`](/middleware/observability/events#workers) event, with `reason` saying whether `maxQueueDepth` or `maxQueueWaitMs` tripped it. - -## Complete Example (Inline Worker) - -Using an inline worker script with `Blob` and `createObjectURL`: - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const workerCode = ` -self.onmessage = (e) => { - const data = e.data || {} - const n = Math.max(0, Number(data.iterations) || 50000) - let value = 0 - for (let i = 0; i < n; i++) value += Math.sqrt(i) - self.postMessage({ - done: true, - value - }) -} -export {} -` - -const workerScriptUrl = URL.createObjectURL( - new Blob( - [workerCode], - { - type: 'application/javascript' - } - ) -) - -const router = new Router({ - routesDir: './routes', - worker: { - scriptURL: workerScriptUrl, - poolSize: 4 - } -}) - -await router.serve(8000) -``` - -## Error Handling - -- **No pool:** A router created without `worker` leaves `ctx.getState('worker' as never)` undefined. Return 503 or a clear message when the route requires a worker. -- **Worker error:** When the worker calls `postMessage({ error: true, message: '...' })`, `worker.run()` rejects with an `Error` carrying that message. Without a message, the error reads `Worker returned an error with no message`. -- **Worker crash:** When the worker throws or crashes, `run()` rejects with `Worker task failed before responding`, and the slot recovers on its own. -- **Task timeout:** When a task runs past `taskTimeoutMs` (default 5000), `run()` rejects with `Worker task exceeded ms timeout`. -- **Refused under load:** When the pool is at `maxQueueDepth` or the projected wait passes `maxQueueWaitMs`, `run()` rejects with a queue-full or slot-busy error before the task ever starts. - -Every one of these faults also streams through the observability bus as a [worker event](/middleware/observability/events#workers), so a stall, crash, recovery, or refusal is visible without touching the request path. Catch a rejected task and forward it to the [centralized error handler](/error-handling/object-details): - -```typescript -try { - const result = await worker.run(payload) - return ctx.send.json(result) -} catch (err) { - // Route the failure through error handling - return await ctx.handleError(500, err as Error) -} -``` - -## Structured Clone Only - -Payload and result are sent via `postMessage` / `onmessage`, so only **structured-clone serializable** data is allowed, which covers plain objects, arrays, primitives, `Date`, `RegExp`, `Map`, `Set`, and similar values. Functions, symbols, and non-cloneable class instances cannot cross that boundary. diff --git a/docs/core-concepts/zero-dependency.md b/docs/core-concepts/zero-dependency.md index a896d7f..d05ade9 100644 --- a/docs/core-concepts/zero-dependency.md +++ b/docs/core-concepts/zero-dependency.md @@ -24,7 +24,7 @@ Deno was designed around safer defaults, and this choice follows that lead. The Security should be the starting point, not a later upgrade. That belief is not a promise of perfection, it is a direction. A smaller dependency tree, [process protection](/getting-started/server-configuration#process-protection), and [layered error handling](/error-handling/defense-in-depth) all point the same way, toward a server that stays safe even when something goes wrong. -![A process sentinel interposes known termination calls, so self-targeted Deno.exit and process.exit are blocked and unhandled rejections are trapped while a kill aimed at another pid still passes through, keeping the process alive and emitting a process error event](/diagrams/zero-dep-process-guard.png) +![A process sentinel interposes known termination calls, so self-targeted Deno.exit and process.exit are blocked and unhandled rejections are trapped while a kill aimed at another pid still passes through, keeping the process alive and emitting a process:failed event](/diagrams/zero-dep-process-guard.png) ## Open and Auditable diff --git a/docs/error-handling/default-behavior.md b/docs/error-handling/default-behavior.md index 21d592a..c7ea11f 100644 --- a/docs/error-handling/default-behavior.md +++ b/docs/error-handling/default-behavior.md @@ -4,9 +4,9 @@ description: "How Deserve handles uncaught errors by default and the responses i # Default Error Behavior -This error handling mechanism catches every error that occurs during server runtime, which covers route handler errors, middleware failures, route not found scenarios, static file errors, and any other uncaught exception during request processing. Without a custom error handler set through `router.catch()`, Deserve falls back to this default behavior so the server never crashes from unhandled errors. +This error handling mechanism catches every error that occurs during server runtime, which covers route handler errors, middleware failures, route not found scenarios, static file errors, and any other uncaught exception during request processing. Without a custom error handler set through `router.catch()`, Deserve falls back to this default behavior so the server never crashes from unhandled errors. The same fallback applies when a custom handler runs but returns something other than a `Response`, so the default handler always produces the final reply. -![When an error occurs, the request routes to a custom handler if router.catch is defined, otherwise to the default handler that returns JSON or HTML by Accept, then a single response](/diagrams/default-error-behavior.png) +![When an error occurs, the request routes to a custom handler if router.catch is defined, otherwise to the default handler that returns JSON or HTML by Accept, and a custom handler that returns a non-Response also falls back to the default handler, then a single response](/diagrams/default-error-behavior.png) ## Basic Default Behavior @@ -16,7 +16,7 @@ Without a call to `router.catch()`, Deserve handles every error with a default r import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // No router.catch, defaults take over @@ -34,7 +34,7 @@ The default error response (without custom `router.catch()`) follows the client' Also: - **Status Code**: Preserves the original error status code (404, 500, etc.) -- **Headers**: Includes headers set via `ctx.setHeader()` before the error +- **Headers**: Includes headers set via `ctx.set.header()` before the error ```typescript // Example default response (client requests JSON) @@ -84,17 +84,17 @@ When the path matches a route but the method has no handler, the response is `40 ### 422 - Validation Failed -When a [validation](/middleware/validation/overview) contract rejects request input, the default response adds an `errors` array listing each failure reason: +When a [validation](/middleware/validation/overview) contract rejects request input, the default response is a plain problem-details body with the 422 status: ```typescript // POST /users with an invalid body // Status: 422 // Content-Type: application/problem+json -// Body: { "type": "about:blank", "title": "...", "status": 422, -// "instance": "/users", "errors": ["name must not be empty"] } +// Body: { "type": "about:blank", "title": "Unprocessable Entity", +// "status": 422, "instance": "/users" } ``` -Only a 422 carries `errors`, and every other status keeps a reason-free body. How a contract produces those reasons lives in [Reading Validated Data](/middleware/validation/reading-data#how-failures-surface). +The default body never lists the failure reasons. Those reasons ride on `error.error.cause` as a string array, so a custom [`router.catch()`](/error-handling/object-details#validation-errors) reads them to build a field-level response. How a contract produces those reasons lives in [Reading Validated Data](/middleware/validation/reading-data#how-failures-surface). ### 500 - Server Errors diff --git a/docs/error-handling/defense-in-depth.md b/docs/error-handling/defense-in-depth.md index e8d072d..ab750dd 100644 --- a/docs/error-handling/defense-in-depth.md +++ b/docs/error-handling/defense-in-depth.md @@ -6,7 +6,7 @@ description: "Layered error handling in Deserve to keep services available under Errors in Deserve pass through several layers, and each layer is a chance to catch, shape, or record a failure. When one layer lets an error through, the next one still holds, so the server keeps responding and never crashes. -![Five layered error defenses: route handler try/catch, WrapMware labeled catch, router.catch custom handler, default handler with masked message, and the process guard that never crashes](/diagrams/defense-in-depth.png) +![Five layered error defenses: route handler try/catch, Wrap.apply labeled catch, router.catch custom handler, default handler with masked message, and the process guard that never crashes](/diagrams/defense-in-depth.png) ## Layer 1 - Route Handler @@ -17,7 +17,7 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { try { - const data = await ctx.body() + const data = await ctx.get.body() return ctx.send.json({ success: true }) @@ -39,16 +39,16 @@ Anything thrown past this point falls to the next layer. ## Layer 2 - Labeled Middleware -`WrapMware` wraps a middleware so a throw becomes a labeled error routed to the error handler. The label points straight at the failing middleware: +`Wrap.apply` wraps a middleware so a throw becomes a labeled error routed to the error handler. The label points straight at the failing middleware: ```typescript twoslash -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap } from '@neabyte/deserve' const router = new Router() // ---cut--- // Throws here reach router.catch with a label -const auth = WrapMware('Auth', async (ctx, next) => { - if (!ctx.header('authorization')) { +const auth = Wrap.apply('Auth', async (ctx, next) => { + if (!ctx.get.header('authorization')) { throw new Error('Missing token') } return await next() @@ -64,7 +64,7 @@ See [Global Middleware](/middleware/global#wrapping-middleware-with-error-handli `router.catch()` receives every uncaught error and shapes the client response. It runs for handler errors, middleware errors, not-found, and static file errors alike: ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -75,7 +75,7 @@ router.catch((ctx, error) => { error: 'Something went wrong' }, { - status: error.statusCode + status: error.statusCode as HttpStatusCode } ) }) @@ -93,11 +93,11 @@ When no `router.catch()` is set, or the custom handler returns something other t // 404 -> "Not Found" ``` -The default response also carries the built-in [security headers](/middleware/security-headers). See [Default Behavior](/error-handling/default-behavior) for the full response shape. +When the [security headers](/middleware/security-headers) middleware runs before the fault, its headers stay on the error response too. See [Default Behavior](/error-handling/default-behavior) for the full response shape. ## Layer 5 - Process Guard -The outermost layer runs process-wide. A serving router traps unhandled rejections, uncaught errors, and blocked termination attempts, then reports each as a `process:error` event instead of letting the process die: +The outermost layer runs process-wide. A serving router traps unhandled rejections, uncaught errors, and blocked termination attempts, then reports each as a `process:failed` event instead of letting the process die: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -105,7 +105,7 @@ import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.on((event) => { - if (event.kind === 'process:error') { + if (event.kind === 'process:failed') { const { origin, error } = event.metadata as { origin: string; error: Error } console.error(`process fault [${origin}]`, error.message) } @@ -121,7 +121,7 @@ Shaping a response and recording a failure are separate jobs. `router.catch()` c ![One failed request fans out to two independent hooks, where router.catch shapes the Response the client receives with a controlled status and body, and router.on records the same failure into logs and metrics without affecting the reply](/diagrams/obs-catch-vs-on.png) ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -132,14 +132,14 @@ router.catch((ctx, info) => { error: 'Something went wrong' }, { - status: info.statusCode + status: info.statusCode as HttpStatusCode } ) }) // Record the failure for later router.on((event) => { - if (event.kind === 'request:error') { + if (event.kind === 'request:failed') { const { url, error } = event.metadata as { url: string; error?: Error } console.error(url, error?.message) } diff --git a/docs/error-handling/object-details.md b/docs/error-handling/object-details.md index 203ff51..d431d41 100644 --- a/docs/error-handling/object-details.md +++ b/docs/error-handling/object-details.md @@ -4,17 +4,17 @@ description: "Customize error responses with router.catch() and the ErrorInfo ob # Error Object Details -Deserve provides error handling for route execution errors, validation errors, not found errors, static file errors, and custom error responses. +Every fault in Deserve flows through one place. Route handler throws, validation failures, missing routes, and static file errors all arrive at the same handler, where a custom reply takes over from the [default response](/error-handling/default-behavior). ## Basic Error Handling Handle errors with the `router.catch()` method: ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Catch errors from any route @@ -28,7 +28,7 @@ router.catch((ctx, error) => { method: error.method, url: error.url }, - { status: error.statusCode } + { status: error.statusCode as HttpStatusCode } ) }) @@ -46,7 +46,7 @@ The error handler receives the context object and an error object with these pro - **`error.error`** - the original Error instance ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -61,7 +61,7 @@ router.catch((ctx, error) => { method: error.method, url: error.url }, - { status: error.statusCode } + { status: error.statusCode as HttpStatusCode } ) }) ``` @@ -121,7 +121,7 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { try { - const data = await ctx.body() + const data = await ctx.get.body() // Process data... return ctx.send.json({ success: true @@ -141,7 +141,7 @@ export async function POST(ctx: Context): Promise { ## Validation Errors -A rejected [validation](/middleware/validation/overview) contract throws a **422 Unprocessable Content** and preserves the failure reasons on `error.cause` as a string array. The same `router.catch` handles it, so reading the reasons turns a failure into a field-level response: +A rejected [validation](/middleware/validation/overview) contract throws a **422 Unprocessable Entity** and keeps the failure reasons on `error.error.cause` as a string array. The same `router.catch` handles it, so reading the reasons turns a failure into a field-level response: ```typescript twoslash import { Router } from '@neabyte/deserve' diff --git a/docs/getting-started/built-for-teams.md b/docs/getting-started/built-for-teams.md index 543b1a6..5ba69a6 100644 --- a/docs/getting-started/built-for-teams.md +++ b/docs/getting-started/built-for-teams.md @@ -25,7 +25,7 @@ routes/ No registry to cross-check, no decorators to trace. The path on disk is the path on the wire, covered in [File-based Routing](/core-concepts/file-based-routing). -![The folder is the map: createPattern turns each file path directly into a URL pattern, so routes/index.ts becomes GET /, routes/users/index.ts becomes GET /users, routes/users/[id].ts becomes GET /users/:id, and files prefixed with an underscore are skipped as private](/diagrams/team-folder-map.png) +![The folder is the map: each file path maps directly to a URL pattern, so routes/index.ts becomes GET /, routes/users/index.ts becomes GET /users, routes/users/[id].ts becomes GET /users/:id, and files prefixed with an underscore are skipped as private](/diagrams/team-folder-map.png) ## A Junior Ships on Day One @@ -57,7 +57,8 @@ import type { Context } from '@neabyte/deserve' // Method name is the HTTP verb export async function POST(ctx: Context): Promise { - const order = await ctx.body() + // Read parsed JSON body + const order = await ctx.get.body() return ctx.send.json( { created: true, @@ -68,7 +69,7 @@ export async function POST(ctx: Context): Promise { } ``` -A reviewer reads `POST` and knows the verb, reads `ctx.body()` and knows the input, reads `ctx.send.json()` and knows the output. The same pattern holds across every file, which is the [developer experience](/core-concepts/philosophy#core-beliefs) the framework aims for. Details live in [Request Handling](/core-concepts/request-handling) and the [Context Object](/core-concepts/context-object). +A reviewer reads `POST` and knows the verb, reads `ctx.get.body()` and knows the input, reads `ctx.send.json()` and knows the output. The same pattern holds across every file, which is the [developer experience](/core-concepts/philosophy#core-beliefs) the framework aims for. Details live in [Request Handling](/core-concepts/request-handling) and the [Context Object](/core-concepts/context-object). ## Shared Rules in One Place @@ -112,10 +113,10 @@ Larger teams often split an app into services. Deserve runs several routers in a import { Router } from '@neabyte/deserve' const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) // Each service owns its folder and port @@ -127,7 +128,7 @@ await Promise.all([ Each service has its own folder, port, and file watcher, so teams move in parallel without stepping on each other. The full pattern, including shared code and a shared error handler, is in [Multi-Service](/core-concepts/multi-service). -![Many hands, one process: a single Deno process runs an API router owned by dev A on port 3001 and an Auth router owned by dev B on port 3002, each with its own routesDir and file watcher, so the two developers work in parallel without separate deployments or network glue](/diagrams/team-many-hands.png) +![Many hands, one process: a single Deno process runs an API router owned by dev A on port 3001 and an Auth router owned by dev B on port 3002, each with its own routes directory and file watcher, so the two developers work in parallel without separate deployments or network glue](/diagrams/team-many-hands.png) ## Where to Go Next diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 4e8f9b3..6796b39 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -8,13 +8,13 @@ Add Deserve to a Deno project in one command, then move on to the ideas behind i ## Prerequisites -- [Deno](https://github.com/denoland/deno_install) 2.7.0+ installed +- [Deno](https://github.com/denoland/deno_install) 2.8.3+ installed Staying on the latest Deno release is a good idea, since Deserve runs on the runtime and every performance update to Deno carries straight through to Deserve. ## Install Deserve -Deno's package manager adds Deserve to the project. This command writes the dependency into `deno.json` and generates `deno.lock`: +[Deno's package manager](https://docs.deno.com/runtime/reference/cli/add/) adds Deserve to the project. This command writes the dependency into `deno.json` and generates `deno.lock`: ::: code-group diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index ea553fa..bdc49b3 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -1,10 +1,10 @@ --- -description: "Build your first Deserve HTTP server and route in under five minutes." +description: "Build a first Deserve HTTP server and route in under five minutes." --- # Quick Start -Get a Deserve server running in under 5 minutes. +Get a Deserve server running in under 5 minutes. Every snippet here is copy-paste ready, so open `main.ts` in an editor and follow along. ## Project Structure @@ -19,12 +19,12 @@ This guide ends with the following project structure: ## 1. Create the Server -Create `main.ts`: +Create `main.ts`. The `Router` scans `./routes` by default, so no configuration is needed for a basic setup: ```typescript twoslash import { Router } from '@neabyte/deserve' -// Router defaults routesDir to ./routes +// Router scans ./routes by default const router = new Router() // Listen on port 8000 @@ -33,7 +33,7 @@ await router.serve(8000) ## 2. Create the First Route -Create a `routes` folder and add `index.ts`: +Create a `routes` folder and add `index.ts`. The exported function name is the HTTP method, and `Context` carries the request and response helpers: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -50,6 +50,8 @@ export function GET(ctx: Context): Response { ## 3. Run the Server +Deno needs network and read permissions for the server and route files: + ```bash deno run --allow-net --allow-read main.ts ``` @@ -68,3 +70,11 @@ The response looks like this: "timestamp": "2077-01-01T00:00:00.000Z" } ``` + +## Where to Go Next + +- [Installation](/getting-started/installation) - add Deserve to an existing project +- [Server Configuration](/getting-started/server-configuration) - hostname binding, shutdown, and process protection +- [Routes Configuration](/getting-started/routes-configuration) - route loading, limits, and advanced hooks +- [Context Object](/core-concepts/context-object) - the full request and response API +- [File-based Routing](/core-concepts/file-based-routing) - how folders map to URLs diff --git a/docs/getting-started/routes-configuration.md b/docs/getting-started/routes-configuration.md index ec939db..3e23d04 100644 --- a/docs/getting-started/routes-configuration.md +++ b/docs/getting-started/routes-configuration.md @@ -4,27 +4,29 @@ description: "Configure the routes directory, parameter limits, and request time # Routes Configuration -Configure the Deserve routes directory to match the project structure. +Configure the Deserve routes directory to match the project structure. Every option lives on the `RouterOptions` object passed to `new Router(...)`. ## Router Options -The `Router` constructor accepts one options object. The everyday pair is `routesDir` for the route folder and `requestTimeoutMs` for a request deadline. The sections below cover route loading, request size limits, template render limits, and the two advanced hooks `errorResponseBuilder` and `staticHandler`. Two related options live on their own pages, `trustProxy` under [Client IP Resolution](/getting-started/server-configuration#client-ip-resolution) and the `worker` pool under [Worker Pool](/core-concepts/worker-pool). +The `Router` constructor accepts one options object. The everyday pair is `routes.directory` for the route folder and `timeoutMs` for a request deadline. The sections below cover route loading, request size limits, template render limits, and the two advanced hooks `trustProxy` and `worker`. Two related options live on their own pages, `trustProxy` under [Client IP Resolution](/getting-started/server-configuration#client-ip-resolution) and the `worker` pool under [Worker Pool](/recipes/worker-pool). ```typescript twoslash import { Router } from '@neabyte/deserve' // Custom routes folder and timeout const router = new Router({ - routesDir: 'src/routes', - requestTimeoutMs: 30_000 + routes: { + directory: './src/routes' + }, + timeoutMs: 30_000 }) ``` -## Configuration Options +## routes -### `routesDir` +### `routes.directory` -The directory containing the route files: +The directory containing the route files. Defaults to `./routes`: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -34,24 +36,47 @@ const defaultRouter = new Router() // Read routes from ./src/api const router = new Router({ - routesDir: 'src/api' + routes: { + directory: './src/api' + } +}) +``` + +### `routes.maxParamLength` + +Maximum length of a single route parameter value. A longer value is rejected with **414 URI Too Long**. The default is `1024`: + +```typescript twoslash +import { Router } from '@neabyte/deserve' +// ---cut--- +const router = new Router({ + routes: { + directory: './routes', + maxParamLength: 512 + } }) ``` -### `requestTimeoutMs` +## views -Optional timeout in milliseconds for the full request (middleware + route handler). If exceeded, the server responds with **503 Service Unavailable**. Omit or leave undefined for no timeout. +### `views.directory` + +The directory containing DVE template files. Defaults to `./views`. When omitted, `ctx.render()` throws because no view engine is configured: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - requestTimeoutMs: 30_000 + routes: { + directory: './routes' + }, + views: { + directory: './views' + } }) ``` -### `maxIterations` +### `views.maxIterations` Maximum iterations allowed per {{#each}} block in DVE templates. The cap prevents event loop starvation from one unbounded loop. The default is `100_000`, and exceeding it makes the engine throw so the server responds with **400 Bad Request**. @@ -59,15 +84,19 @@ Maximum iterations allowed per {{#each}} block in DVE templat import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - viewsDir: './views', - maxIterations: 50_000 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxIterations: 50_000 + } }) ``` -For datasets larger than the limit, use [`streamRender`](/rendering/streaming) instead, and see [Performance and Limits](/rendering/performance#iteration-limit) for how the cap behaves. For CPU-intensive rendering, consider offloading to a [worker pool](/core-concepts/worker-pool). +For datasets larger than the limit, use [`ctx.render`](/core-concepts/context-object#rendering-templates) with `stream: true` instead, and see [Performance and Limits](/rendering/performance#iteration-limit) for how the cap behaves. For CPU-intensive rendering, consider offloading to a [worker pool](/recipes/worker-pool). -### `maxRenderIterations` +### `views.maxRenderIterations` Maximum total {{#each}} body executions across one render, summed over every loop including nested ones. Where `maxIterations` guards a single loop, this guards the whole page. The default is `1_000_000`, and exceeding it responds with **400 Bad Request**. @@ -75,13 +104,17 @@ Maximum total {{#each}} body executions across one render, su import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - viewsDir: './views', - maxRenderIterations: 500_000 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxRenderIterations: 500_000 + } }) ``` -### `maxOutputSize` +### `views.maxOutputSize` Maximum total output characters produced by one render. The cap stops a small template from expanding into a huge response. The default is `5_000_000`, and exceeding it responds with **400 Bad Request**. @@ -89,88 +122,80 @@ Maximum total output characters produced by one render. The cap stops a small te import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - viewsDir: './views', - maxOutputSize: 1_000_000 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxOutputSize: 1_000_000 + } }) ``` -### `maxUrlLength` +### `views.maxTemplateSize` -Maximum length of the request URL in characters. A longer URL is rejected with **414 URI Too Long** before any route runs. The default is `8192`: +Maximum size of a single template file in characters. The cap stops an oversized template from consuming memory before it ever compiles. The default is `1_000_000`, set by the [DVE engine](https://jsr.io/@neabyte/dve), and exceeding it responds with **400 Bad Request**. The same cap applies to every included or layout file the engine resolves. ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - maxUrlLength: 4096 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxTemplateSize: 500_000 + } }) ``` -### `maxParamLength` +## maxUrlLength -Maximum length of a single route parameter value. A longer value is rejected with **414 URI Too Long**. The default is `1024`: +Maximum length of the request URL in characters. A longer URL is rejected with **414 URI Too Long** before any route runs. The default is `8192`: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - maxParamLength: 512 + maxUrlLength: 4096 }) ``` -### `errorResponseBuilder` +## timeoutMs -Advanced option that replaces how error responses are built. It receives the context, status code, error, and the handler set with [`router.catch()`](/error-handling/object-details), and returns the final `Response`. Most apps shape errors through `router.catch()` instead, covered in [Error Handling](/error-handling/object-details): +Optional timeout in milliseconds for the full request (middleware + route handler). If exceeded, the server responds with **503 Service Unavailable**. Omit or leave undefined for no timeout. See also [Server Configuration](/getting-started/server-configuration#request-timeout): ```typescript twoslash -import type { Context, ErrorMiddleware } from '@neabyte/deserve' import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - errorResponseBuilder: { - // Build a custom error response - async build( - ctx: Context, - statusCode: number, - error: Error, - errorMiddleware: ErrorMiddleware | null - ) { - return ctx.send.json( - { - failed: true, - statusCode - }, - { status: statusCode } - ) - } - } + routes: { + directory: './routes' + }, + timeoutMs: 30_000 }) ``` -### `staticHandler` +## hotReload -Advanced option that replaces how static files are served. It receives the context, the [static options](/static-file/basic#static-file-options) for the matched route, and the URL path, then returns the `Response`. The default implementation already guards path traversal, so override it only for a custom backend such as object storage: +Enables or disables file watching for routes and views. Defaults to `true`. Set to `false` to disable [hot reload](/core-concepts/hot-reload) entirely, which suits production deployments where file watching is unnecessary: ```typescript twoslash -import type { Context, ServeOptions } from '@neabyte/deserve' import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - staticHandler: { - // Serve files from a custom backend - async serve(ctx: Context, options: ServeOptions, urlPath: string) { - return ctx.send.text(`requested ${urlPath}`) - } - } + hotReload: false }) ``` -Register the static route itself with [`router.static()`](/static-file/basic), which this handler then fulfills. +## trustProxy + +Controls how the real client IP is resolved behind a proxy or load balancer. See [Client IP Resolution](/getting-started/server-configuration#client-ip-resolution) for the full guide. + +## worker + +Configures a worker pool for offloading CPU-bound work. See [Worker Pool](/recipes/worker-pool) for the full guide. ## Supported File Extensions @@ -193,7 +218,9 @@ No extra configuration is needed, since Deserve detects them automatically. import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes' + routes: { + directory: './routes' + } }) ``` @@ -203,7 +230,9 @@ const router = new Router({ import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: `${Deno.cwd()}/routes` + routes: { + directory: `${Deno.cwd()}/routes` + } }) ``` @@ -211,6 +240,8 @@ const router = new Router({ import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: '/absolute/path/to/routes' + routes: { + directory: '/absolute/path/to/routes' + } }) ``` diff --git a/docs/getting-started/server-configuration.md b/docs/getting-started/server-configuration.md index 3aa524c..01aba66 100644 --- a/docs/getting-started/server-configuration.md +++ b/docs/getting-started/server-configuration.md @@ -6,11 +6,11 @@ description: "Configure how the Deserve server listens, shuts down gracefully, a > **Reference**: [Deno.serve API Documentation](https://docs.deno.com/api/deno/~/Deno.serve) -Configure a Deserve server with hostname binding and graceful shutdown. +Configure a Deserve server with hostname binding, graceful shutdown, and process protection. Every option lives on the [`RouterOptions`](/getting-started/routes-configuration) object passed to `new Router(...)`. ## Basic Server Setup -The simplest way to start a server: +The simplest way to start a server. The `Router` scans `./routes` by default, so no configuration is needed for a basic setup: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -23,9 +23,9 @@ await router.serve(8000) This starts the server on `0.0.0.0:8000`, which covers all interfaces. -## Enhanced Serve Method +## Serve Method -Deserve's enhanced `serve` method supports three parameters: +`router.serve()` accepts three optional parameters: ```typescript // Method signatures @@ -34,6 +34,8 @@ async serve(port?: number, hostname?: string): Promise async serve(port?: number, hostname?: string, signal?: AbortSignal): Promise ``` +When `port` is omitted, the server reads `PORT` from the environment and falls back to `8000`. When `hostname` is omitted, it binds `0.0.0.0`. + ## Hostname Binding ### Bind to Specific Interface @@ -69,38 +71,40 @@ await router.serve(8000, '0.0.0.0') ## Request Timeout -A request timeout is set when creating the router. When middleware and the route handler do not finish within that time, the server responds with **503 Service Unavailable**: +A request timeout is set with `timeoutMs` on the router options. When middleware and the route handler do not finish within that time, the server responds with **503 Service Unavailable**: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - requestTimeoutMs: 30_000 + timeoutMs: 30_000 }) await router.serve(8000) ``` -Omit `requestTimeoutMs` for no timeout (default). +Omit `timeoutMs` for no timeout (default). The full set of router options is listed in [Routes Configuration](/getting-started/routes-configuration). ## Template Iteration Limit -The `maxIterations` option caps the iterations per {{#each}} block in DVE templates, which prevents event loop starvation from one unbounded loop. The default is `100_000`: +The `views.maxIterations` option caps the iterations per {{#each}} block in DVE templates, which prevents event loop starvation from one unbounded loop. The default is `100_000`: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - viewsDir: './views', - maxIterations: 50_000 + views: { + directory: './views', + maxIterations: 50_000 + } }) await router.serve(8000) ``` -If a template exceeds the limit, the server responds with **400 Bad Request**. Two companion caps, `maxRenderIterations` for the whole-page loop budget and `maxOutputSize` for total output characters, behave the same way and are listed in [Routes Configuration](/getting-started/routes-configuration#configuration-options). The full rendering behavior lives in [Performance and Limits](/rendering/performance#iteration-limit). For large datasets, use [`streamRender`](/rendering/streaming) instead. For CPU-intensive rendering, consider offloading to a [worker pool](/core-concepts/worker-pool). +If a template exceeds the limit, the server responds with **400 Bad Request**. Two companion caps, `views.maxRenderIterations` for the whole-page loop budget and `views.maxOutputSize` for total output characters, behave the same way and are listed in [Routes Configuration](/getting-started/routes-configuration#views). The full rendering behavior lives in [Performance and Limits](/rendering/performance#iteration-limit). For large datasets, use [`ctx.render`](/core-concepts/context-object#rendering-templates) with `stream: true` instead. For CPU-intensive rendering, consider offloading to a [worker pool](/recipes/worker-pool). ## Client IP Resolution -The `trustProxy` option controls how the real client IP is resolved when the server runs behind a proxy or load balancer. Without it, `ctx.ip` returns the direct TCP peer: +The `trustProxy` option controls how the real client IP is resolved when the server runs behind a proxy or load balancer. Without it, `ctx.get.ip()` returns the direct TCP peer: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -114,7 +118,7 @@ const router = new Router({ await router.serve(8000) ``` -When the direct peer matches a trusted rule, Deserve reads the forwarded headers to find the real visitor IP. It checks `CF-Connecting-IP` and `X-Real-IP` first, then walks the `X-Forwarded-For` and RFC 7239 `Forwarded` chain from right to left through trusted hops. +When the direct peer matches a trusted rule, Deserve reads the forwarded headers to find the real visitor IP. It checks `CF-Connecting-IP` and `X-Real-IP` first, then walks the `X-Forwarded-For` and [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239) `Forwarded` chain from right to left through trusted hops. `trustProxy` accepts these values: @@ -122,16 +126,16 @@ When the direct peer matches a trusted rule, Deserve reads the forwarded headers - **Exact IPs or CIDR ranges** - for example `'10.0.0.0/8'` - **A predicate** - `(ip: string) => boolean` -The resolved IP is available on the request context: +The resolved IP is available on the request context through `ctx.get.ip()`: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Real visitor IP after trustProxy - const client = ctx.ip + const client = ctx.get.ip() // Direct TCP peer, ignores forwarded headers - const peer = ctx.directIp + const peer = ctx.get.ip({ direct: true }) return ctx.send.json({ client, peer @@ -139,7 +143,7 @@ export function GET(ctx: Context): Response { } ``` -Without a matching `trustProxy` rule, `ctx.ip` and `ctx.directIp` return the same direct peer address. The [IP restriction middleware](/middleware/ip) uses `ctx.ip` for its allow and deny rules. +Without a matching `trustProxy` rule, `ctx.get.ip()` and `ctx.get.ip({ direct: true })` return the same direct peer address. The [IP restriction middleware](/middleware/ip) uses `ctx.get.ip()` for its allow and deny rules. ## Graceful Shutdown @@ -158,14 +162,14 @@ ac.abort() ### Process Signal Handling -Without an `AbortSignal`, the router listens for `SIGINT` and `SIGTERM` itself (only `SIGINT` on Windows) and drains gracefully on either one. No manual signal wiring is needed: +Without an `AbortSignal`, the router listens for `SIGINT`, `SIGTERM`, and `SIGHUP` itself (only `SIGINT` and `SIGBREAK` on Windows) and drains gracefully on any of them. No manual signal wiring is needed: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() -// SIGINT and SIGTERM drain automatically +// Signals drain automatically await router.serve(8000, '127.0.0.1') ``` @@ -173,7 +177,7 @@ Pass an `AbortSignal` when shutdown needs to be driven from code instead of a si ## Process Protection -A serving router installs a process sentinel that keeps the service alive through faults that would normally take it down. This matters because Deserve runs many things in one process - [hot reload](/core-concepts/multi-service#hot-reload) watchers, [worker pools](/core-concepts/worker-pool), and often several [services side by side](/core-concepts/multi-service). One dependency calling `Deno.exit()` should not drop every service at once. +A serving router installs a process sentinel that keeps the service alive through faults that would normally take it down. This matters because Deserve runs many things in one process - [hot reload](/core-concepts/hot-reload) watchers, [worker pools](/recipes/worker-pool), and often several [services side by side](/core-concepts/multi-service). One dependency calling `Deno.exit()` should not drop every service at once. ### What Is Blocked @@ -186,7 +190,7 @@ A `kill` aimed at another PID still passes through, so only self-termination is ### Not Silent -Every blocked call is reported, never swallowed in silence. The sentinel emits a [`process:error`](/middleware/observability/events#process) event with `origin: 'process:exit'` and a message naming the blocked call, for example `Blocked Deno.exit(0) - process termination is not permitted from application code`. Unhandled rejections and uncaught errors surface the same way with `origin: 'unhandledrejection'` or `'uncaughterror'`. +Every blocked call is reported, never swallowed in silence. The sentinel emits a [`process:failed`](/middleware/observability/events) event with `origin: 'process:exit'` and a message naming the blocked call, for example `Blocked Deno.exit(0) process termination is not permitted from application code`. Unhandled rejections and uncaught errors surface the same way with `origin: 'unhandledrejection'` or `'uncaughterror'`. Subscribe to see them: @@ -196,7 +200,7 @@ import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.on((event) => { - if (event.kind === 'process:error') { + if (event.kind === 'process:failed') { const { origin, error } = event.metadata as { origin: string; error: Error } // Logs the blocked or uncaught fault console.error(`[${origin}]`, error.message) @@ -204,7 +208,7 @@ router.on((event) => { }) ``` -See [Error Reporting](/middleware/observability/errors) for the full pattern. +See [Error Reporting](/error-handling/object-details) for the full pattern. ### Threat Model diff --git a/docs/id/by-design/bearer-auth.md b/docs/id/by-design/bearer-auth.md index 9e8cf9a..dade1f3 100644 --- a/docs/id/by-design/bearer-auth.md +++ b/docs/id/by-design/bearer-auth.md @@ -1,10 +1,10 @@ --- -description: "Kenapa Deserve membawa Basic Auth tapi tidak Bearer, dan cara menyusun auth token untuk skema apa pun." +description: "Kenapa Deserve membawa Basic Auth tapi tidak ada middleware Bearer, dan cara menyusun auth token untuk skema apa pun." --- # Bearer Auth -Deserve membawa [Basic Auth](/id/middleware/basic-auth) tapi tidak ada middleware Bearer, dan pemisahan antara keduanya adalah intinya. +Deserve membawa [Basic Auth](/id/middleware/basic-auth) tapi tidak ada middleware Bearer, dan pemisahan antara keduanya adalah seluruh intinya. ## Kenapa Tidak Dibawa @@ -22,24 +22,23 @@ Bearer sebaliknya. Format token, tanda tangan, dan sumber kepercayaan semuanya b Penjaga token adalah komposisi kecil di atas bagian yang sudah ada: -- **Baca header** - [`ctx.header('authorization')`](/id/core-concepts/context-object#akses-data-request) mengembalikan nilai `Authorization` mentah. +- **Baca header** - [`ctx.get.header('authorization')`](/id/core-concepts/context-object#ctx-get-header-key) mengembalikan nilai `Authorization` mentah. - **Berjalan lebih awal** - [middleware global](/id/middleware/global) berjalan sebelum route handler dan bisa menghentikan request dengan mengembalikan `Response`. - **Tolak bersih** - [`ctx.handleError(401, ...)`](/id/core-concepts/context-object#penanganan-error) mengarah lewat [`router.catch()`](/id/error-handling/object-details) saat satu diatur. -- **Bawa hasilnya** - [`ctx.state`](/id/core-concepts/context-object#berbagi-state) menyerahkan identitas terdekode ke handler di hilir. +- **Bawa hasilnya** - [`ctx.set.session(...)`](/id/core-concepts/context-object#ctx-set-session-data) menandatangani identitas terdekode ke sebuah cookie yang dibaca balik handler, dibahas di [middleware session](/id/middleware/session). ## Sebuah Penjaga Bearer -Middleware ini menarik token dari header, memverifikasinya, dan menyimpan hasilnya untuk handler berikutnya. Placeholder `verifyToken` mewakili skema pilihan, sebuah pengecekan JWT, lookup JWKS, atau panggilan introspeksi. +Middleware ini menarik token dari header, memverifikasinya, dan menyimpan hasilnya untuk handler berikutnya. Placeholder `verifyToken` mewakili skema pilihan, sebuah pengecekan JWT, lookup JWKS, atau panggilan introspeksi. Menyimpan identitas butuh [middleware session](/id/middleware/session) terdaftar lebih dulu. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function verifyToken(token: string): Promise<{ userId: string } | null> // ---cut--- router.use(async (ctx, next) => { - const header = ctx.header('authorization') + const header = ctx.get.header('authorization') const spaceIndex = header ? header.indexOf(' ') : -1 const scheme = spaceIndex > 0 ? header!.slice(0, spaceIndex) : '' @@ -56,39 +55,38 @@ router.use(async (ctx, next) => { } // Serahkan identitas ke handler - ctx.state.userId = claims.userId + await ctx.set.session({ userId: claims.userId }) return await next() }) await router.serve(8000) ``` -Handler lalu membaca identitas langsung dari state, tanpa parsing token sendiri. +Handler lalu membaca identitas langsung dari session, tanpa parsing token sendiri. ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Baca apa yang disimpan penjaga - const userId = ctx.state.userId - return ctx.send.json({ userId }) + const session = ctx.get.session() + return ctx.send.json({ userId: session?.userId }) } ``` ## Mengarahkan Kegagalan Lewat Satu Handler -Penjaga di atas mengembalikan `401` dari dalam middleware. Untuk mengirim setiap kegagalan auth lewat satu tempat, bungkus middleware dengan [`WrapMware`](/id/middleware/global#membungkus-middleware-dengan-penanganan-error) dan lempar saat ditolak, lalu bentuk balasannya dengan [`router.catch()`](/id/error-handling/object-details). +Penjaga di atas mengembalikan `401` dari dalam middleware. Untuk mengirim setiap kegagalan auth lewat satu tempat, bungkus middleware dengan [`Wrap.apply`](/id/middleware/global#membungkus-middleware-dengan-penanganan-error) dan lempar saat ditolak, lalu bentuk balasannya dengan [`router.catch()`](/id/error-handling/object-details). ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap, type Context } from '@neabyte/deserve' const router = new Router() declare function verifyToken(token: string): Promise<{ userId: string } | null> // ---cut--- // Lemparan sampai router.catch saat dibungkus -const bearer = WrapMware('Bearer', async (ctx: Context, next) => { - const header = ctx.header('authorization') +const bearer = Wrap.apply('Bearer', async (ctx: Context, next) => { + const header = ctx.get.header('authorization') if (!header?.toLowerCase().startsWith('bearer ')) { throw new Error('Missing Bearer token') } @@ -96,7 +94,7 @@ const bearer = WrapMware('Bearer', async (ctx: Context, next) => { if (!claims) { throw new Error('Invalid token') } - ctx.state.userId = claims.userId + await ctx.set.session({ userId: claims.userId }) return await next() }) @@ -121,22 +119,21 @@ Ini pola pembungkusan yang sama dipakai [Basic Auth](/id/middleware/basic-auth) Penjaga token sering cocok di prefix API sementara halaman publik tetap terbuka. Middleware per-path membatasi pengecekan ke satu prefix, bentuk yang sama ditunjukkan di [middleware global](/id/middleware/global#middleware-per-path). ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function verifyToken(token: string): Promise<{ userId: string } | null> // ---cut--- // Jaga hanya rute /api router.use('/api', async (ctx, next) => { - const header = ctx.header('authorization') + const header = ctx.get.header('authorization') const claims = header?.toLowerCase().startsWith('bearer ') ? await verifyToken(header.slice(7).trim()) : null if (!claims) { return await ctx.handleError(401, new Error('Invalid token')) } - ctx.state.userId = claims.userId + await ctx.set.session({ userId: claims.userId }) return await next() }) ``` diff --git a/docs/id/by-design/cache.md b/docs/id/by-design/cache.md index 823bbc8..0167d51 100644 --- a/docs/id/by-design/cache.md +++ b/docs/id/by-design/cache.md @@ -29,7 +29,7 @@ import type { Context } from '@neabyte/deserve' const cache = new Map() export async function GET(ctx: Context): Promise { - const key = ctx.pathname + const key = ctx.get.pathname() // Sajikan nilai cache ketika ada const hit = cache.get(key) @@ -64,7 +64,7 @@ const ttlMs = 30_000 const cache = new Map() export function GET(ctx: Context): Response { - const key = ctx.pathname + const key = ctx.get.pathname() const entry = cache.get(key) // Entri segar menang, yang lama dibuang @@ -96,4 +96,4 @@ Dua kasus menuntut lebih dari map lokal-proses. Sebuah cache yang harus bertahan ## Berbagi Per-Request -Caching lintas request adalah satu kebutuhan, mengoper sebuah nilai sepanjang satu request adalah kebutuhan lain. Sebuah nilai yang dihitung di middleware dan dibaca handler tak masuk cache sama sekali, ia masuk [`ctx.state`](/id/core-concepts/context-object#berbagi-state), yang hidup persis satu request dan hilang saat response dikirim. +Caching lintas request adalah satu kebutuhan, mengoper sebuah nilai sepanjang satu request adalah kebutuhan lain. Sebuah nilai yang dihitung di middleware dan dibaca handler tak masuk cache sama sekali. Untuk identitas per-pengguna [session](/id/middleware/session) bertanda tangan membawanya lewat `ctx.set.session()` dan `ctx.get.session()`, dan untuk input tervalidasi [middleware validate](/id/middleware/validation/overview) menyerahkannya lewat `ctx.get.validated()`. Apa pun selain itu handler turunkan ulang dari request yang sudah dipegangnya. diff --git a/docs/id/by-design/compress.md b/docs/id/by-design/compress.md index 330ea6e..082521a 100644 --- a/docs/id/by-design/compress.md +++ b/docs/id/by-design/compress.md @@ -57,14 +57,14 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Larang lapisan apa pun menulis ulang body - ctx.setHeader('Cache-Control', 'no-transform') + ctx.set.header('Cache-Control', 'no-transform') return ctx.send.json({ message: 'sent verbatim' }) } ``` -Mengatur header lewat [`ctx.setHeader`](/id/core-concepts/context-object#header-response) adalah jalur yang sama dipakai di tempat lain, jadi opt-out ini terbaca seperti header lain. +Mengatur header lewat [`ctx.set.header`](/id/core-concepts/context-object#ctx-set-header-key-value) adalah jalur yang sama dipakai di tempat lain, jadi opt-out ini terbaca seperti header lain. ## Body yang Sudah Ter-encode diff --git a/docs/id/by-design/https-redirect.md b/docs/id/by-design/https-redirect.md index aed1127..62acad3 100644 --- a/docs/id/by-design/https-redirect.md +++ b/docs/id/by-design/https-redirect.md @@ -38,21 +38,21 @@ await router.serve(8000) ## Membaca Skema Asli -Ketika aplikasi memang perlu tahu apakah klien memakai HTTPS, jawabannya ada di header forwarded yang diatur proxy, bukan di koneksi lokal. Proxy tepercaya menambahkan [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto), dibaca lewat [`ctx.header`](/id/core-concepts/context-object#akses-data-request). +Ketika aplikasi memang perlu tahu apakah klien memakai HTTPS, jawabannya ada di header forwarded yang diatur proxy, bukan di koneksi lokal. Proxy tepercaya menambahkan [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto), dibaca lewat [`ctx.get.header`](/id/core-concepts/context-object#ctx-get-header-key). ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Skema yang benar-benar dipakai klien - const proto = ctx.header('x-forwarded-proto') ?? 'http' + const proto = ctx.get.header('x-forwarded-proto') ?? 'http' return ctx.send.json({ secure: proto === 'https' }) } ``` -Percayai header ini hanya di belakang proxy yang dikonfigurasi lewat [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien), batas kepercayaan yang sama diandalkan [`ctx.ip`](/id/by-design/request-id#ip-adalah-sumber-kebenaran). Klien yang tak tepercaya bisa mengatur header apa pun, jadi nilainya tak berarti tanpa batas itu. +Percayai header ini hanya di belakang proxy yang dikonfigurasi lewat [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien), batas kepercayaan yang sama diandalkan [`ctx.get.ip()`](/id/core-concepts/context-object#ctx-get-ip-options). Klien yang tak tepercaya bisa mengatur header apa pun, jadi nilainya tak berarti tanpa batas itu. ## Menyajikan HTTPS Langsung diff --git a/docs/id/by-design/locale-redirect.md b/docs/id/by-design/locale-redirect.md index ba95a11..521b32c 100644 --- a/docs/id/by-design/locale-redirect.md +++ b/docs/id/by-design/locale-redirect.md @@ -14,14 +14,14 @@ Pilihan bahasa adalah keputusan produk, bukan aturan transport. Locale mana yang ## Membaca Preferensi -Browser mengirim daftar bahasanya di header [`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language), dibaca lewat [`ctx.header`](/id/core-concepts/context-object#akses-data-request). Sebuah pencocokan kecil terhadap locale yang didukung aplikasi memberi targetnya, lalu [`ctx.send.redirect`](/id/response/redirect) mengirim pengunjung ke sana. +Browser mengirim daftar bahasanya di header [`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language), dibaca lewat [`ctx.get.header`](/id/core-concepts/context-object#ctx-get-header-key). Sebuah pencocokan kecil terhadap locale yang didukung aplikasi memberi targetnya, lalu [`ctx.send.redirect`](/id/response/redirect) mengirim pengunjung ke sana. ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Baca petunjuk bahasa browser - const header = ctx.header('accept-language') ?? '' + const header = ctx.get.header('accept-language') ?? '' const supported = ['en', 'id'] // Cocokkan locale didukung atau default @@ -37,7 +37,7 @@ Sebuah 302 menjaga redirect tetap sementara, jadi kunjungan berikutnya tetap bis ## Berbagi Pilihan dengan Rute Berikutnya -Ketika beberapa rute butuh locale yang diresolusi, middleware bisa meresolusinya sekali dan menyimpannya di [`ctx.state`](/id/core-concepts/context-object#berbagi-state) alih-alih redirect, jadi tiap handler membaca nilai yang sama. +Ketika beberapa rute butuh locale yang diresolusi, middleware bisa meresolusinya sekali dan menyimpannya di [session](/id/middleware/session) bertanda tangan alih-alih redirect, jadi tiap handler membaca nilai yang sama lewat `ctx.get.session()`. ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -46,15 +46,16 @@ const router = new Router() // ---cut--- router.use(async (ctx, next) => { // Resolusi locale sekali per request - const header = ctx.header('accept-language') ?? '' + const header = ctx.get.header('accept-language') ?? '' const preferred = header.split(',')[0]?.slice(0, 2) ?? 'en' // Bagikan ke route handler - ctx.state.locale = ['en', 'id'].includes(preferred) ? preferred : 'en' + const locale = ['en', 'id'].includes(preferred) ? preferred : 'en' + await ctx.set.session({ locale }) return await next() }) await router.serve(8000) ``` -Bentuk redirect mengirim pengunjung ke URL terlokalisasi, sementara bentuk state menjaga satu URL dan mengoper locale ke dalam. Keduanya tinggal di file rute polos, jadi aturannya ada di mana bahasa penting dan tak di tempat lain. +Bentuk redirect mengirim pengunjung ke URL terlokalisasi, sementara bentuk session menjaga satu URL dan mengoper locale ke dalam. Keduanya tinggal di file rute polos, jadi aturannya ada di mana bahasa penting dan tak di tempat lain. diff --git a/docs/id/by-design/method-override.md b/docs/id/by-design/method-override.md index 1fbb967..b04bf1f 100644 --- a/docs/id/by-design/method-override.md +++ b/docs/id/by-design/method-override.md @@ -22,7 +22,7 @@ Deserve juga merutekan pada `req.method` asli, yang tak bisa ditulis ulang handl ## Setiap Metode Adalah Rute -Sebuah file rute mengekspor satu fungsi per metode, dan namanya adalah metodenya. Tak ada tabel untuk didaftarkan dan tak ada verb untuk diterjemahkan. Sebuah file seperti `items/[id].ts` membaca `id`-nya dari path lewat [`ctx.param`](/id/core-concepts/context-object#akses-data-request). +Sebuah file rute mengekspor satu fungsi per metode, dan namanya adalah metodenya. Tak ada tabel untuk didaftarkan dan tak ada verb untuk diterjemahkan. Sebuah file seperti `items/[id].ts` membaca `id`-nya dari path lewat [`ctx.get.param`](/id/core-concepts/context-object#ctx-get-param-key). ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -30,7 +30,7 @@ import type { Context } from '@neabyte/deserve' // Baca satu item lewat id export function GET(ctx: Context): Response { return ctx.send.json({ - id: ctx.param('id') + id: ctx.get.param('id') }) } @@ -67,7 +67,7 @@ await fetch( ) ``` -Membangun stateless atau stateful adalah gerakan yang sama, tinggal tambahkan filenya. Sebuah endpoint REST stateless adalah handler yang membaca request dan membalas, sementara alur stateful menambah [middleware session](/id/middleware/session) dan membaca data per-pengguna dari [`ctx.state`](/id/core-concepts/context-object#berbagi-state). Metodenya tetap asli di kedua jalur, tanpa apa pun untuk disamarkan saat masuk. +Membangun stateless atau stateful adalah gerakan yang sama, tinggal tambahkan filenya. Sebuah endpoint REST stateless adalah handler yang membaca request dan membalas, sementara alur stateful menambah [middleware session](/id/middleware/session) dan membaca data per-pengguna lewat `ctx.get.session()`. Metodenya tetap asli di kedua jalur, tanpa apa pun untuk disamarkan saat masuk. Sebuah API REST atau RESTful penuh muncul dari sini tanpa konfigurasi tambahan. Verb-nya sudah sejajar dengan aksinya, `GET` untuk membaca, `POST` untuk membuat, `PUT` dan `PATCH` untuk memperbarui, `DELETE` untuk menghapus, jadi sebuah resource hanyalah file rute dengan handler itu. Perilakunya terbaca sama di setiap endpoint, yang membuat seluruh API terasa mulus. diff --git a/docs/id/by-design/rate-limit.md b/docs/id/by-design/rate-limit.md index e6bbf24..7d0d3b8 100644 --- a/docs/id/by-design/rate-limit.md +++ b/docs/id/by-design/rate-limit.md @@ -16,18 +16,17 @@ Satu jawaban bawaan akan cocok dengan satu selera dan melawan setiap selera lain Sebuah limiter butuh empat hal, dan masing-masing sudah ada: -- **Kunci per klien** - baca `ctx.ip` untuk IP pengunjung yang diresolusi, atau `ctx.header('x-api-key')` untuk API key. Lihat [IP Klien](/id/core-concepts/context-object#ip-klien). +- **Kunci per klien** - baca `ctx.get.ip()` untuk IP pengunjung yang diresolusi, atau `ctx.get.header('x-api-key')` untuk API key. Lihat [`ctx.get.ip()`](/id/core-concepts/context-object#ctx-get-ip-options). - **Tempat berjalan lebih awal** - [middleware global](/id/middleware/global) berjalan sebelum setiap route handler dan bisa menghentikan request dengan mengembalikan `Response`. - **Cara memblokir** - kembalikan `ctx.send.text(...)` atau `ctx.send.json(...)` dengan status `429` untuk mengakhiri request di situ. -- **Cara memberitahu** - `ctx.setHeader(...)` menambah header rate limit standar supaya klien bisa mundur. +- **Cara memberitahu** - `ctx.set.header(...)` menambah header rate limit standar supaya klien bisa mundur. ## Limiter Fixed Window Middleware ini menghitung request per IP dalam jendela waktu tetap. Ketika hitungannya melewati batas, request berhenti dengan `429`. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -40,7 +39,7 @@ const hits = new Map() router.use(async (ctx, next) => { // Pilih kunci klien - const key = ctx.ip ?? 'unknown' + const key = ctx.get.ip() ?? 'unknown' const now = Date.now() const entry = hits.get(key) @@ -59,7 +58,7 @@ router.use(async (ctx, next) => { // Lewat batas, blokir dengan 429 if (entry.count > maxRequests) { const retryAfter = Math.ceil((entry.resetAt - now) / 1000) - ctx.setHeader('Retry-After', String(retryAfter)) + ctx.set.header('Retry-After', String(retryAfter)) return ctx.send.text( 'Too Many Requests', { @@ -82,8 +81,7 @@ await router.serve(8000) Klien berperilaku lebih baik ketika bisa melihat anggarannya. Header standar melaporkan batas, sisa hit, dan kapan jendela reset. Atur di setiap response, bukan hanya saat diblokir. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() const windowMs = 60_000 @@ -91,7 +89,7 @@ const maxRequests = 100 const hits = new Map() // ---cut--- router.use(async (ctx, next) => { - const key = ctx.ip ?? 'unknown' + const key = ctx.get.ip() ?? 'unknown' const now = Date.now() let entry = hits.get(key) @@ -108,7 +106,7 @@ router.use(async (ctx, next) => { const remaining = Math.max(0, maxRequests - entry.count) // Laporkan anggaran di setiap response - ctx.setHeaders({ + ctx.set.headers({ 'X-RateLimit-Limit': String(maxRequests), 'X-RateLimit-Remaining': String(remaining), 'X-RateLimit-Reset': String(Math.ceil(entry.resetAt / 1000)) @@ -135,15 +133,14 @@ router.use(async (ctx, next) => { Form login butuh batas lebih ketat ketimbang halaman publik. Middleware per-path menerapkan aturan ke satu prefix dan membiarkan sisanya tak tersentuh. ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function isOverLimit(key: string): boolean // ---cut--- // Jaga hanya rute auth router.use('/auth', async (ctx, next) => { - const key = ctx.ip ?? 'unknown' + const key = ctx.get.ip() ?? 'unknown' if (isOverLimit(key)) { return ctx.send.json( { @@ -162,11 +159,11 @@ Ini bentuk per-path yang sama dibahas di [middleware global](/id/middleware/glob ## Membentuk Response Blokir -Contoh di atas mengembalikan `429` langsung dari middleware. Untuk mengarahkan setiap blokir lewat satu tempat, lempar di dalam [`WrapMware`](/id/middleware/global#membungkus-middleware-dengan-penanganan-error) dan bentuk balasannya dengan [`router.catch()`](/id/error-handling/object-details). Itu memisahkan aturan batas dan format error, yang membantu saat beberapa middleware berbagi satu gaya response. +Contoh di atas mengembalikan `429` langsung dari middleware. Untuk mengarahkan setiap blokir lewat satu tempat, lempar di dalam [`Wrap.apply`](/id/middleware/global#membungkus-middleware-dengan-penanganan-error) dan bentuk balasannya dengan [`router.catch()`](/id/error-handling/object-details). Itu memisahkan aturan batas dan format error, yang membantu saat beberapa middleware berbagi satu gaya response. ## Mengamati Limiter Bekerja -Limiter memblokir request, dan [event observability](/id/middleware/observability/overview) melaporkan apa yang terjadi. Sebuah request yang diblokir selesai dengan status `429`, jadi ia tiba sebagai event `request:error`. Berlangganan sekali untuk menghitung blokir atau melacak kunci mana yang menyentuh batas. +Limiter memblokir request, dan [event observability](/id/middleware/observability/overview) melaporkan apa yang terjadi. Sebuah request yang diblokir selesai dengan status `429`, jadi ia tiba sebagai event `request:failed`. Berlangganan sekali untuk menghitung blokir atau melacak kunci mana yang menyentuh batas. ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -175,7 +172,7 @@ const router = new Router() // ---cut--- router.on((event) => { // Catat setiap request yang diblokir - if (event.kind === 'request:error' && event.metadata.statusCode === 429) { + if (event.kind === 'request:failed' && event.metadata.statusCode === 429) { console.log('Rate limited:', event.metadata.ip, event.metadata.url) } }) diff --git a/docs/id/by-design/request-id.md b/docs/id/by-design/request-id.md index 7d4c6eb..06e5cc9 100644 --- a/docs/id/by-design/request-id.md +++ b/docs/id/by-design/request-id.md @@ -14,9 +14,9 @@ Jadi ID acak baik sebagai label log tapi salah sebagai sumber kebenaran. Deserve ## IP Adalah Sumber Kebenaran -Setiap request membawa [`ctx.ip`](/id/core-concepts/context-object#ip-klien), alamat klien yang diresolusi. Nilai itu tak dibaca mentah dari header. Framework menelusuri rantai forwarding lewat hop tepercaya dan berhenti di hop pertama yang tak dipercayainya, jadi header palsu dari peer tak tepercaya tak pernah menang. +Setiap request membawa [`ctx.get.ip()`](/id/core-concepts/context-object#ctx-get-ip-options), alamat klien yang diresolusi. Nilai itu tak dibaca mentah dari header. Framework menelusuri rantai forwarding lewat hop tepercaya dan berhenti di hop pertama yang tak dipercayainya, jadi header palsu dari peer tak tepercaya tak pernah menang. -- **Tanpa proxy tepercaya** - `ctx.ip` adalah peer TCP langsung, dan header forwarding diabaikan sepenuhnya. +- **Tanpa proxy tepercaya** - `ctx.get.ip()` adalah peer TCP langsung, dan header forwarding diabaikan sepenuhnya. - **Di belakang proxy tepercaya** - rantai ditelusuri kanan ke kiri lewat hop tepercaya, menghormati [`X-Forwarded-For`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For), header `Forwarded` [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239), dan header IP-tunggal seperti `cf-connecting-ip`. - **Dikonfigurasi sekali** - [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien) memutuskan peer mana yang dianggap tepercaya, jadi mempercayainya adalah pilihan sengaja, bukan default. @@ -28,18 +28,18 @@ Setiap request mendapat [Context](/id/core-concepts/context-object)-nya sendiri, ## Mengkorelasi Tanpa ID Acak -Untuk mengkorelasi log, [event siklus hidup](/id/middleware/observability/overview) sudah membawa apa yang dimaksudkan request ID. Setiap event `request:complete` menyertakan `ip` yang diresolusi, `url`, dan `durationMs` di metadata-nya, plus `timestamp` di amplopnya, jadi sebuah baris log mengidentifikasi request dari nilai asli ketimbang yang dibuat-buat. +Untuk mengkorelasi log, [event siklus hidup](/id/middleware/observability/overview) sudah membawa apa yang dimaksudkan request ID. Setiap event `request:completed` menyertakan `ip` yang diresolusi, `url`, dan `durationMs` di metadata-nya, plus `timestamp` di amplopnya, jadi sebuah baris log mengidentifikasi request dari nilai asli ketimbang yang dibuat-buat. ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Korelasi dengan IP dan waktu asli - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { ip, url } = event.metadata as { ip?: string, url: string } console.log(`${event.timestamp} ${ip ?? 'unknown'} ${url}`) } @@ -50,17 +50,16 @@ await router.serve(8000) ## Ketika Sebuah Label Memang Membantu -Sebuah label berumur pendek untuk mengikat baris log dalam satu request tetap mungkin, dan label itu tinggal di [`ctx.state`](/id/core-concepts/context-object#berbagi-state) seperti nilai per-request lain. Intinya memperlakukannya sebagai kemudahan, bukan identitas, karena jawaban tepercaya adalah `ctx.ip`. +Sebuah label berumur pendek untuk mengikat baris log tetap mungkin. Ketika handler perlu membacanya balik, [session](/id/middleware/session) bertanda tangan membawanya seperti nilai per-request lain. Intinya memperlakukannya sebagai kemudahan, bukan identitas, karena jawaban tepercaya adalah `ctx.get.ip()`. ```typescript twoslash -import type { Context } from '@neabyte/deserve' import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.use(async (ctx, next) => { // Label untuk log, bukan kepercayaan - ctx.state.label = crypto.randomUUID() + await ctx.set.session({ label: crypto.randomUUID() }) return await next() }) ``` diff --git a/docs/id/by-design/server-timing.md b/docs/id/by-design/server-timing.md index 91854ad..0b48e13 100644 --- a/docs/id/by-design/server-timing.md +++ b/docs/id/by-design/server-timing.md @@ -14,18 +14,18 @@ Metrik itu adalah detail satu handler, bukan kebijakan seluruh framework. Satu r ## Durasi Sudah Diukur -Setiap event `request:complete` membawa `durationMs`, waktu terukur untuk seluruh request, di samping `route` dan `method`. Untuk dashboard dan log itulah angka yang dibaca, tanpa header dan tanpa kode per-rute. Lihat [Request Logging](/id/middleware/observability/logging) untuk mengubahnya jadi baris log. +Setiap event `request:completed` membawa `durationMs`, waktu terukur untuk seluruh request, di samping `route` dan `method`. Untuk dashboard dan log itulah angka yang dibaca, tanpa header dan tanpa kode per-rute. Lihat [Request Logging](/id/middleware/observability/logging) untuk mengubahnya jadi baris log. ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Baca durasi request terukur - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { route, durationMs } = event.metadata as { route?: string, durationMs: number } console.log(`${route ?? 'unknown'} took ${Math.round(durationMs)}ms`) } @@ -36,7 +36,7 @@ await router.serve(8000) ## Mengirim Header Ketika Diinginkan -Untuk sebuah rute yang memang mau metrik itu di DevTools, header-nya adalah satu panggilan [`ctx.setHeader`](/id/core-concepts/context-object#header-response). Ukur kerjanya, lalu tulis sebuah entri `Server-Timing` dengan nama dan durasi dalam milidetik. +Untuk sebuah rute yang memang mau metrik itu di DevTools, header-nya adalah satu panggilan [`ctx.set.header`](/id/core-concepts/context-object#ctx-set-header-key-value). Ukur kerjanya, lalu tulis sebuah entri `Server-Timing` dengan nama dan durasi dalam milidetik. ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -48,7 +48,7 @@ export async function GET(ctx: Context): Promise { const ms = (performance.now() - start).toFixed(1) // Paparkan ke DevTools untuk rute ini - ctx.setHeader('Server-Timing', `db;dur=${ms}`) + ctx.set.header('Server-Timing', `db;dur=${ms}`) return ctx.send.json(data) } diff --git a/docs/id/by-design/tracing.md b/docs/id/by-design/tracing.md index 9645f11..70abb0b 100644 --- a/docs/id/by-design/tracing.md +++ b/docs/id/by-design/tracing.md @@ -17,14 +17,14 @@ Jadi keputusannya adalah berhenti di data, bukan di transport. Deserve memancark Ketiga ini duduk di luar framework dengan sengaja: - **Auto-instrumentasi** - Deserve tidak membungkus library atau membuka span untuk panggilan keluar. Tiap request memancarkan satu event selesai, dan sebuah span dibangun darinya di listener. -- **Propagasi konteks trace** - tak ada header `traceparent` yang dibaca atau ditulis. Sebuah handler yang butuh konteks terdistribusi membaca header lewat [`ctx.header('traceparent')`](/id/core-concepts/context-object#akses-data-request) dan meneruskannya. +- **Propagasi konteks trace** - tak ada header `traceparent` yang dibaca atau ditulis. Sebuah handler yang butuh konteks terdistribusi membaca header lewat [`ctx.get.header('traceparent')`](/id/core-concepts/context-object#ctx-get-header-key) dan meneruskannya. - **Hierarki span** - event-nya datar, satu per request, bukan pohon induk-anak. Span bersarang dirakit di backend, atau di listener, dari data yang disediakan event. Yang memang dibawa adalah data yang dibutuhkan sebuah span, sudah dikumpulkan dan dinamai agar cocok. ## Datanya Sudah Ada -Setiap request memancarkan `request:complete`, dan sebuah request dengan status `400` atau lebih tinggi juga memancarkan `request:error`. Keduanya membawa amplop yang sama, dan metadata-nya adalah kebenaran tempat sebuah span dibangun: +Setiap request memancarkan `request:completed`, dan sebuah request dengan status `400` atau lebih tinggi juga memancarkan `request:failed`. Keduanya membawa amplop yang sama, dan metadata-nya adalah kebenaran tempat sebuah span dibangun: - **`timestamp`** - waktu pembuatan event dalam milidetik epoch, jangkar mulai span. - **`durationMs`** - durasi request terukur, panjang span. @@ -42,13 +42,13 @@ Listener ini mengubah tiap request selesai jadi rekaman berbentuk span dan menye import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) declare function exportSpan(span: Record): void // ---cut--- router.on((event) => { // Bangun span dari tiap request selesai - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const m = event.metadata as { method: string url: string @@ -83,14 +83,14 @@ Kunci atribut di atas adalah nama span HTTP OpenTelemetry, jadi rekaman itu masu ## Melanjutkan Trace yang Masuk -Distributed tracing menghubungkan span lintas layanan lewat header `traceparent`. Deserve tidak menguraikannya, jadi sebuah handler yang bergabung ke trace yang ada membaca header dari [Context](/id/core-concepts/context-object#akses-data-request) dan membawanya maju. +Distributed tracing menghubungkan span lintas layanan lewat header `traceparent`. Deserve tidak menguraikannya, jadi sebuah handler yang bergabung ke trace yang ada membaca header dari [Context](/id/core-concepts/context-object#ctx-get-header-key) dan membawanya maju. ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export async function GET(ctx: Context): Promise { // Baca konteks trace hulu bila ada - const traceparent = ctx.header('traceparent') + const traceparent = ctx.get.header('traceparent') // Teruskan pada panggilan keluar const upstream = await fetch('https://api.internal/data', { @@ -101,7 +101,7 @@ export async function GET(ctx: Context): Promise { } ``` -`ctx.state` yang sama dipakai untuk [berbagi state](/id/core-concepts/context-object#berbagi-state) menahan sebuah span ID lintas middleware dan handler ketika satu listener membuka span lebih awal dan lain menutupnya. +Untuk span ID yang harus hidup lintas middleware dan handler, [session](/id/middleware/session) bertanda tangan membawanya ketika satu listener membuka span lebih awal dan lain membacanya balik. ## Ke Mana Datanya Pergi diff --git a/docs/id/core-concepts/context-object.md b/docs/id/core-concepts/context-object.md index 6327603..c8c52f0 100644 --- a/docs/id/core-concepts/context-object.md +++ b/docs/id/core-concepts/context-object.md @@ -1,27 +1,23 @@ --- -description: "Objek Context yang diteruskan ke setiap handler: akses request, helper response, param, state, dan cookie." +description: "Objek Context yang diteruskan ke setiap handler: baca request, atur response, kirim response, dan tangani error." --- # Objek Context -Objek `Context` membungkus `Request` native dan menyediakan method nyaman untuk mengakses data request, mengatur header response, dan mengirim response. +Objek `Context` membungkus `Request` native dan memberi setiap handler satu permukaan untuk membaca request, membentuk response, dan meneruskan error. Satu `Context` mengalir dari middleware ke route handler, jadi data tetap ter-cache dan konsisten sepanjang request. -## Apa Itu Context? +## Apa Itu Context Context adalah pembungkus di sekitar objek `Request` native Deno, dan setiap request masuk dibungkus dalam satu Context yang mengalir dari middleware ke route handler. Bekerja lewat Context alih-alih `Request` mentah membawa: - **Parsing tertunda** - data diurai hanya saat sebuah method membacanya -- **Method nyaman** - API sederhana untuk operasi umum -- **Utilitas response** - method bawaan untuk mengirim response -- **Manajemen header** - perubahan header response yang mudah - -## Kenapa Context? - -Context menghindari parsing dan pemrosesan ulang berulang selama siklus hidup request, karena handler menerima satu objek Context yang bertahan sepanjang jalan dari middleware ke route handler. +- **Tiga namespace** - `ctx.get` membaca, `ctx.set` membentuk, `ctx.send` mengirim +- **Baca ter-cache** - body, cookie, dan param diurai sekali lalu memakai ulang cache +- **Routing error** - `ctx.handleError()` meneruskan kegagalan ke satu tempat ## Membuat Context -Deserve membuat Context otomatis ketika request tiba: +Deserve membuat Context otomatis ketika request tiba, jadi handler cukup mendeklarasikannya sebagai parameter: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -34,194 +30,451 @@ export function GET(ctx: Context): Response { } ``` -## Struktur Context +## Tiga Namespace + +Context membagi API-nya menjadi tiga namespace beku, masing-masing dengan satu tugas: + +| Namespace | Tujuan | Contoh | +| --------- | ------ | ------ | +| `ctx.get` | Baca data request | `ctx.get.header('host')` | +| `ctx.set` | Bentuk response | `ctx.set.header('X-Custom', 'value')` | +| `ctx.send` | Bangun dan kirim response | `ctx.send.json({ ok: true })` | -Context membungkus beberapa bagian kunci: +Namespace bersifat beku, jadi tidak bisa ditugaskan ulang atau dimutasi saat runtime. Ini menjaga kontrak request tetap dapat ditebak di seluruh middleware dan handler. -1. **Request Asli** - akses lewat `ctx.request` -2. **URL Terurai** - dipakai internal untuk query param -3. **Parameter Rute** - diekstrak dari rute dinamis -4. **Header Response** - diatur sebelum mengirim response +## Membaca Data Request -## Parsing Tertunda +### `ctx.get.ip(options?)` -Context menunda parsing demi performa, jadi data query, body, cookie, dan header dibaca hanya saat method yang cocok berjalan, dan hasilnya di-cache untuk panggilan berikutnya. Membaca body bersifat async, jadi handler yang me-await-nya menjadi `async` dan mengembalikan `Promise`: +Membaca alamat IP klien. Beri `{ direct: true }` untuk membaca peer TCP langsung alih-alih IP yang diresolusi: ```typescript twoslash import type { Context } from '@neabyte/deserve' +declare const ctx: Context // ---cut--- -export async function GET(ctx: Context): Promise { - // Query diurai pada baca pertama - const query = ctx.query() - // Panggilan ulang pakai cache +// IP yang diresolusi, menghormati trustProxy +const client = ctx.get.ip() + +// Peer TCP langsung, abaikan header forwarded +const peer = ctx.get.ip({ direct: true }) +``` - // Body diurai berdasarkan Content-Type - const body = await ctx.body() +Keduanya mengembalikan `undefined` ketika peer tidak diketahui. Tanpa aturan [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien) yang cocok, keduanya mengembalikan alamat peer langsung yang sama. - // Kembalikan query dan body bersama - return ctx.send.json({ - query, - body - }) +### `ctx.get.method()` + +Membaca metode HTTP request: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const method = ctx.get.method() // 'GET', 'POST', dll +``` + +### `ctx.get.url()` + +Membaca instance URL request yang sudah diurai: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const url = ctx.get.url() // URL instance +const fullUrl = url.href // 'http://localhost:8000/api/users?sort=name' +``` + +### `ctx.get.pathname()` + +Membaca bagian pathname dari URL: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const path = ctx.get.pathname() // '/api/users/123' +``` + +### `ctx.get.request()` + +Membaca instance `Request` native di bawahnya: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +const req = ctx.get.request() // Request instance +``` + +### `ctx.get.header(key?)` + +Membaca satu header berdasarkan kunci atau setiap header sekaligus. Kunci dicocokkan tanpa membedakan huruf besar kecil: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Baca satu header berdasarkan nama +const contentType = ctx.get.header('content-type') + +// Baca semua header sebagai record +const headers = ctx.get.header() +``` + +### `ctx.get.cookie(key?)` + +Membaca satu cookie berdasarkan kunci atau setiap cookie sekaligus. Cookie diurai sekali lalu di-cache untuk panggilan berikutnya: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Baca satu cookie berdasarkan nama +const sessionId = ctx.get.cookie('sessionId') + +// Baca semua cookie sebagai record +const cookies = ctx.get.cookie() // { sessionId: 'abc123', theme: 'dark' } +``` + +### `ctx.get.query(key?)` + +Membaca satu parameter query berdasarkan kunci atau setiap parameter query sekaligus. Nilai pertama menang untuk kunci ganda: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// URL: /search?q=deno&limit=10 +const q = ctx.get.query('q') // 'deno' +const all = ctx.get.query() // { q: 'deno', limit: '10' } + +// URL: /search?tag=deno&tag=typescript +ctx.get.query('tag') // 'deno', nilai pertama menang +ctx.get.query() // { tag: 'deno' } +``` + +### `ctx.get.param(key?)` + +Membaca satu parameter rute berdasarkan kunci atau setiap parameter rute sekaligus. Nilai di-percent-decode sekali sebelum handler membacanya: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Route: /users/[id]/posts/[postId] +// URL: /users/123/posts/456 +const id = ctx.get.param('id') // '123' +const all = ctx.get.param() // { id: '123', postId: '456' } +``` + +### `ctx.get.body()` + +Mengurai body request otomatis berdasarkan header `Content-Type`. JSON, form-data, dan teks semua ditangani. Membaca bersifat async, jadi handler yang me-await-nya menjadi `async`: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { + // Body diparsing dari Content-Type + const body = await ctx.get.body() + return ctx.send.json({ received: body }) } ``` -## Akses Data Request +Body hanya bisa dibaca sekali. Panggilan kedua dengan format sama mengembalikan nilai dari cache, sedangkan panggilan kedua dengan format berbeda melempar **409 Conflict**. -Data request dijangkau lewat method Context, di mana query, params, header, dan cookie bersifat sinkron sementara pembaca body bersifat async: +### `ctx.get.json()` -- **Parameter Query** - `ctx.query()`, `ctx.queries()` -- **Parameter Rute** - `ctx.param()`, `ctx.params()` -- **Header** - `ctx.header()`, `ctx.headers` -- **Cookie** - `ctx.cookie()` -- **Body (async)** - `await ctx.body()`, `await ctx.json()`, `await ctx.formData()`, `await ctx.text()`, `await ctx.arrayBuffer()`, `await ctx.blob()` -- **Info URL** - `ctx.url`, `ctx.pathname` -- **IP Klien** - `ctx.ip`, `ctx.directIp` +Mengurai body request sebagai JSON, terlepas dari header `Content-Type`: -## Utilitas Response +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { + // Parsing body sebagai JSON + const body = await ctx.get.json() + return ctx.send.json({ received: body }) +} +``` + +### `ctx.get.text()` + +Membaca body request sebagai teks mentah: -Kirim response memakai `ctx.send`, dengan satu method per jenis response: +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { + // Baca body sebagai teks polos + const text = await ctx.get.text() + return ctx.send.text(text) +} +``` -- [`ctx.send.json()`](/id/response/json) - response JSON -- [`ctx.send.text()`](/id/response/text) - teks polos -- [`ctx.send.html()`](/id/response/html) - konten HTML -- [`ctx.send.file()`](/id/response/file) - unduhan berkas -- [`ctx.send.data()`](/id/response/data) - unduhan data dalam memori -- [`ctx.send.stream()`](/id/response/stream) - response stream (ReadableStream) -- [`ctx.send.redirect()`](/id/response/redirect) - pengalihan -- [`ctx.send.custom()`](/id/response/custom) - response khusus -- `ctx.handleError()` - alihkan kegagalan lewat [penanganan error](/id/error-handling/object-details) +### `ctx.get.formData()` -Pintasan `ctx.redirect()` memetakan langsung ke `ctx.send.redirect()`: +Mengurai body request sebagai form data dan mengembalikan objek `FormData`: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- -export function GET(ctx: Context): Response { - // Pintasan untuk ctx.send.redirect - return ctx.redirect('/new-location', 301) +export async function POST(ctx: Context): Promise { + // Parsing body sebagai form data + const formData = await ctx.get.formData() + const name = formData.get('name') + return ctx.send.json({ name }) } ``` -## Header Response +### `ctx.get.blob()` -Atur header response sebelum mengirim: +Membaca body request sebagai `Blob`, yang cocok untuk unggahan berkas dan penanganan biner: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- -export function GET(ctx: Context): Response { - ctx.setHeader('X-Custom', 'value') - ctx.setHeader('Cache-Control', 'no-cache') +export async function POST(ctx: Context): Promise { + // Baca body sebagai Blob + const blob = await ctx.get.blob() + return ctx.send.json({ + type: blob.type, + size: blob.size + }) +} +``` + +### `ctx.get.bytes()` + +Membaca body request sebagai `Uint8Array`, yang cocok untuk pemrosesan data biner: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export async function POST(ctx: Context): Promise { + // Baca body sebagai byte array + const bytes = await ctx.get.bytes() return ctx.send.json({ - data: 'test' + size: bytes.byteLength }) } ``` -### Mengatur Banyak Header +### `ctx.get.session()` + +Membaca data session saat ini. Membutuhkan [middleware session](/id/middleware/session) terdaftar, jika tidak mengembalikan `null`: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Baca data session saat ini +const session = ctx.get.session() +``` + +### `ctx.get.validated()` -`setHeaders()` menerapkan beberapa header sekaligus: +Membaca data request yang sudah tervalidasi. Membutuhkan [middleware validate](/id/middleware/validation/overview) terdaftar. Melempar saat middleware tidak ada: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Baca data yang sudah lolos validasi +const validated = ctx.get.validated() +``` + +### `ctx.get.worker()` + +Membaca controller worker pool untuk mengirim tugas CPU-bound. Membutuhkan [worker pool](/id/recipes/worker-pool) terkonfigurasi. Melempar saat tidak ada pool: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Ambil controller worker pool +const worker = ctx.get.worker() +``` + +## Membentuk Response + +### `ctx.set.header(key, value)` + +Mengatur satu header response. Mengembalikan namespace `ctx.set` untuk chaining: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - ctx.setHeaders({ + // Atur satu header, lalu chain yang lain + ctx.set + .header('X-Custom', 'value') + .header('Cache-Control', 'no-cache') + return ctx.send.json({ data: 'test' }) +} +``` + +### `ctx.set.headers(record)` + +Mengatur beberapa header response sekaligus. Mengembalikan namespace `ctx.set` untuk chaining: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Atur beberapa header sekaligus + ctx.set.headers({ 'X-Custom': 'value', 'Cache-Control': 'no-cache', 'X-Request-ID': 'abc123' }) - return ctx.send.json({ - data: 'test' - }) + return ctx.send.json({ data: 'test' }) } ``` -### URL dan Pathname +### `ctx.set.cookie(name, value, options?)` -Detail URL dibaca langsung dari Context: - -- `ctx.url` - string URL lengkap -- `ctx.pathname` - bagian pathname dari URL, seperti `/api/users/123` +Mengatur cookie response dengan atribut opsional. Mengembalikan namespace `ctx.set` untuk chaining: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - const fullUrl = ctx.url // 'http://localhost:8000/api/users/123?sort=name' - const path = ctx.pathname // '/api/users/123' - return ctx.send.json({ - path, - fullUrl + // Atur cookie dengan atribut + ctx.set.cookie('session', 'abc123', { + httpOnly: true, + maxAge: 3600, + path: '/', + sameSite: 'Lax', + secure: true }) + return ctx.send.json({ ok: true }) } ``` -### IP Klien +Objek `options` menerima `domain`, `expires`, `httpOnly`, `maxAge`, `path`, `sameSite`, dan `secure`. + +### `ctx.set.session(data)` + +Menulis data session lewat session controller. Membutuhkan [middleware session](/id/middleware/session) terdaftar. Melempar saat middleware tidak ada: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Tulis data session +await ctx.set.session({ userId: '123' }) + +// Hapus data session +await ctx.set.session(null) +``` + +## Mengirim Response -IP klien dibaca dari Context, dan kedua nilai bernilai `undefined` ketika peer tidak diketahui: +### `ctx.send.json(data, options?)` -- `ctx.ip` - IP klien yang diresolusi, menghormati [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien) -- `ctx.directIp` - peer TCP langsung, abaikan header forwarded +Mengirim response JSON: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - const client = ctx.ip // IP pengunjung asli - const peer = ctx.directIp // IP koneksi langsung - return ctx.send.json({ - client, - peer - }) + return ctx.send.json( + { message: 'Hello' }, + { status: 200 } + ) } ``` -## Berbagi State +### `ctx.send.text(text, options?)` -Context membawa state lingkup-request supaya middleware dan handler bisa mengoper nilai sepanjang rantai. `ctx.state` adalah objek polos yang dibagikan untuk seluruh request: +Mengirim response teks polos: ```typescript twoslash import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + return ctx.send.text('Hello World') +} +``` -const router = new Router() +### `ctx.send.html(html, options?)` + +Mengirim response HTML: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' // ---cut--- -router.use(async (ctx, next) => { - // Lampirkan nilai untuk handler berikutnya - ctx.state.requestId = crypto.randomUUID() - return await next() -}) +export function GET(ctx: Context): Response { + return ctx.send.html('

Hello World

') +} +``` + +### `ctx.send.custom(body, options?)` + +Mengirim body response khusus. Pakai ini untuk stream, blob, atau `BodyInit` apa pun: +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- export function GET(ctx: Context): Response { - // Baca apa yang disimpan middleware - return ctx.send.json({ - id: ctx.state.requestId + // Kirim readable stream sebagai response + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('Hello')) + controller.close() + } }) + return ctx.send.custom(stream) } ``` -Untuk akses bertipe, `setState` dan `getState` memakai sebuah kunci dan tipe nilai: +### `ctx.send.download(body, filename, options?)` + +Mengirim response unduhan berkas dengan header `Content-Disposition`: ```typescript twoslash import type { Context } from '@neabyte/deserve' -declare const ctx: Context // ---cut--- -// Simpan nilai bertipe -ctx.setState('userId' as never, '123') +export function GET(ctx: Context): Response { + // Picu unduhan berkas + return ctx.send.download( + 'Hello World', + 'hello.txt' + ) +} +``` + +### `ctx.send.empty(status?)` + +Mengirim body response kosong dengan status code opsional: -// Baca kembali dengan tipe yang sama -const userId = ctx.getState('userId' as never) +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // 204 No Content + return ctx.send.empty(204) +} ``` -`as never` pada kunci disengaja, bukan trik untuk disalin begitu saja. Kunci state adalah tipe bermerek, jadi framework bisa mencadangkan beberapa nama untuk kebutuhan internalnya sendiri dan menolaknya saat kompilasi. String polos tidak membawa merek itu, dan `as never` itulah yang memberi tahu sistem tipe bahwa string ini adalah kunci yang valid. Tipe nilai tetap nyata dan terperiksa, jadi `getState(...)` tetap mengembalikan `string | undefined`. +### `ctx.send.redirect(url, status?, options?)` + +Mengirim response redirect. Status default ke `302`. URL target diresolusi terhadap URL request dan diblokir dari menyeberang origin kecuali diberikan sebagai URL `https://` atau `http://` lengkap: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Redirect ke lokasi baru + return ctx.send.redirect('/new-location', 301) +} +``` -Beberapa kunci dicadangkan untuk kebutuhan internal framework dan hanya bisa dibaca lewat `getState`. Memanggil `setState` pada salah satunya melempar error 500. Kunci yang dicadangkan adalah `view`, `worker`, `session`, `setSession`, dan `clearSession`. [Worker pool](/id/core-concepts/worker-pool) dan [middleware session](/id/middleware/session) membaca handle-nya dengan cara ini. +Status redirect yang diizinkan adalah `301`, `302`, `303`, `307`, dan `308`. Status lain melempar. ## Merender Template -Ketika router punya `viewsDir`, Context bisa merender template DVE langsung: +Ketika router punya `views.directory` terkonfigurasi, Context bisa merender template DVE langsung: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -230,18 +483,26 @@ export async function GET(ctx: Context): Promise { // Render template ke response HTML return await ctx.render( 'home.dve', - { - title: 'Welcome' - } + { title: 'Welcome' } ) } ``` -`ctx.streamRender()` men-stream keluaran yang sama untuk halaman besar. Keduanya melempar ketika tidak ada `viewsDir` yang dikonfigurasi. Lihat [Sintaks Template](/id/rendering/syntax) untuk tata bahasa template dan [Streaming Rendering](/id/rendering/streaming) untuk jalur streaming. +Beri `{ stream: true }` sebagai argumen ketiga untuk men-stream keluaran pada halaman besar: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +// ---cut--- +// Stream render template besar +await ctx.render('dashboard.dve', { users: [] }, { stream: true }) +``` + +Keduanya melempar ketika tidak ada `views.directory` yang dikonfigurasi. Lihat [Sintaks Template](/id/rendering/syntax) untuk tata bahasa template dan [Streaming Rendering](/id/rendering/streaming) untuk jalur streaming. ## Penanganan Error -`ctx.handleError()` membangun response error dan bersifat async, jadi handler yang memanggilnya menjadi `async` dan me-await hasilnya: +`ctx.handleError()` membangun response error dan meneruskannya lewat error handler global yang diatur dengan `router.catch()`. Method ini async, jadi handler yang memanggilnya menjadi `async` dan me-await hasilnya: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -250,12 +511,12 @@ declare const isAuthorized: boolean export async function GET(ctx: Context): Promise { try { if (!isAuthorized) { + // Teruskan ke error handler return await ctx.handleError(401, new Error('Unauthorized')) } - return ctx.send.json({ - data: 'success' - }) + return ctx.send.json({ data: 'success' }) } catch (error) { + // Tangkap kegagalan tak terduga return await ctx.handleError(500, error as Error) } } @@ -263,14 +524,14 @@ export async function GET(ctx: Context): Promise { ### Cara Kerja -`ctx.handleError()` menghormati error handler global yang diatur dengan `router.catch()`: +`ctx.handleError()` menghormati error handler global yang diatur dengan [`router.catch()`](/id/error-handling/object-details): -- **Ketika `router.catch()` didefinisikan** - error handler khusus berjalan -- **Ketika tidak ada error handler** - response sederhana membawa status code +- **Ketika `router.catch()` didefinisikan** - error handler khusus berjalan dan bisa membentuk response +- **Ketika tidak ada error handler** - response default membawa status code, dinegosiasi sebagai JSON atau HTML berdasarkan header `Accept` ### Pakai di Middleware -Middleware bisa memanggil `ctx.handleError()` untuk memicu penanganan error: +Middleware bisa memanggil `ctx.handleError()` untuk memicu penanganan error sama seperti handler: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -280,17 +541,19 @@ declare const isValid: boolean // ---cut--- router.use(async (ctx, next) => { if (!isValid) { - // Ini dialihkan lewat router.catch() saat didefinisikan + // Dialihkan lewat router.catch() saat didefinisikan return await ctx.handleError(401, new Error('Unauthorized')) } return await next() }) ``` +Lihat [Penanganan Error](/id/error-handling/object-details) untuk pola terpusat lengkapnya, dan [Defense in Depth](/id/error-handling/defense-in-depth) untuk cara error ditangkap berlapis. + ## Siklus Hidup Context -1. **Request tiba** - Deserve membuat Context dengan Request dan URL -2. **Pencocokan rute** - parameter rute diekstrak dan ditambahkan ke Context +1. **Request tiba** - Deserve membuat Context dengan `Request`, `URL` terurai, IP klien, dan renderer opsional +2. **Pencocokan rute** - parameter rute diekstrak dan dipasang ke Context 3. **Eksekusi middleware** - Context melewati rantai middleware -4. **Route handler** - handler menerima Context -5. **Response dikirim** - method Context membangun Response +4. **Route handler** - handler menerima Context dan membaca atau mengirim lewat tiga namespace +5. **Response dikirim** - `ctx.send.*` atau `ctx.handleError()` membangun `Response` akhir diff --git a/docs/id/core-concepts/file-based-routing.md b/docs/id/core-concepts/file-based-routing.md index 526f5fb..76c0a5b 100644 --- a/docs/id/core-concepts/file-based-routing.md +++ b/docs/id/core-concepts/file-based-routing.md @@ -31,7 +31,7 @@ routes/ - `about.ts`, `about.js`, `about.mjs` → `/about` - `users.ts`, `users.js`, `users.cjs` → `/users` -Semua ekstensi yang didukung (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) bekerja identik. +Semua ekstensi yang didukung (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) bekerja identik. Nama berkas hanya boleh punya satu titik yang memisahkan nama dari ekstensi, jadi `about.ts` termuat tapi `about.config.ts` tidak. ### 2. Folder Membuat Rute Bersarang @@ -44,7 +44,7 @@ Semua ekstensi yang didukung (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) beke - `[userId].ts` → parameter `:userId` - `[postId].ts` → parameter `:postId` -Segmen dinamis dicocokkan oleh [Pola Rute](/id/core-concepts/route-patterns) dan dibaca dengan `ctx.param()` dari [Penanganan Request](/id/core-concepts/request-handling#parameter-rute). +Segmen dinamis dicocokkan oleh [Pola Rute](/id/core-concepts/route-patterns) dan dibaca dengan `ctx.get.param()` dari [Penanganan Request](/id/core-concepts/request-handling#parameter-rute). ### 4. Metode HTTP Adalah Fungsi yang Diekspor @@ -59,7 +59,8 @@ export function GET(ctx: Context): Response { } export async function POST(ctx: Context): Promise { - const data = await ctx.body() + // Baca body request yang sudah diparsing + const data = await ctx.get.body() return ctx.send.json({ message: 'User created', data @@ -70,6 +71,8 @@ export async function POST(ctx: Context): Promise { // export function [method](ctx: Context): Response { ... } ``` +Berkas rute wajib mengekspor minimal satu metode HTTP (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`). Berkas yang namanya tidak pernah membentuk pola yang bisa dimuat, misalnya diawali `_`, dilewati saat pemindaian dan memancarkan event [`route:ignored`](/id/middleware/observability/events#rute). Berkas dengan nama yang bisa dimuat tapi tanpa metode terekspor adalah kesalahan yang layak ditangkap sejak awal, jadi pemindaian melempar `Deno.errors.InvalidData` saat startup, dan export yang bukan fungsi melempar `TypeError`. + ### 5. URL Membedakan Huruf Besar dan Kecil URL membedakan huruf besar dan kecil mengikuti standar HTTP: @@ -79,12 +82,11 @@ URL membedakan huruf besar dan kecil mengikuti standar HTTP: ### 6. Karakter Nama Berkas yang Valid -Nama berkas boleh memakai karakter berikut: +Segmen terakhir dari path rute (nama berkas tanpa ekstensi) boleh memakai karakter berikut: - `a-z`, `A-Z`, `0-9` - Karakter alfanumerik -- `_` - Garis bawah (jangan jadi awalan segmen path - lihat di bawah) +- `_` - Garis bawah (jangan jadi awalan segmen - lihat di bawah) - `-` - Strip -- `.` - Titik - `~` - Tilde - `+` - Tanda plus - `[` `]` - Kurung siku untuk parameter dinamis diff --git a/docs/id/core-concepts/hot-reload.md b/docs/id/core-concepts/hot-reload.md index 69d295f..2beeb1f 100644 --- a/docs/id/core-concepts/hot-reload.md +++ b/docs/id/core-concepts/hot-reload.md @@ -4,29 +4,39 @@ description: "Hot reload di Deserve: cara perubahan rute dan template terdeteksi # Hot Reload -Deserve otomatis memantau direktori `routesDir` dan `viewsDir` untuk perubahan berkas, dan ketika berkas dibuat, diubah, atau dihapus, server menangkap perubahan pada request berikutnya tanpa perlu restart. +Deserve otomatis memantau direktori routes dan direktori views untuk perubahan berkas, dan ketika berkas dibuat, diubah, atau dihapus, server menangkap perubahan pada request berikutnya tanpa perlu restart. ## Tanpa Konfigurasi -Hot reload mulai otomatis saat server dimulai: +Hot reload mulai otomatis saat server dimulai, kecuali `hotReload: false` diberikan ke router: ```typescript twoslash import { Router } from '@neabyte/deserve' const app = new Router({ - routesDir: './routes', - viewsDir: './views' + routes: { directory: './routes' }, + views: { directory: './views' } }) // Watcher mulai otomatis app.serve(3000) ``` +Untuk mematikan hot reload sepenuhnya, set `hotReload: false`. Ini cocok untuk deployment produksi di mana pemantauan berkas tidak diperlukan: + +```typescript twoslash +import { Router } from '@neabyte/deserve' +// ---cut--- +const app = new Router({ + hotReload: false +}) +``` + ## Apa yang Dipantau ### Berkas Rute -Semua berkas dengan ekstensi yang didukung (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) di dalam `routesDir` dipantau secara rekursif. +Semua berkas dengan ekstensi yang didukung (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`) di dalam direktori routes dipantau secara rekursif. | Event | Perilaku | | ----------------- | ---------------------------------------------------------------------------- | @@ -36,7 +46,7 @@ Semua berkas dengan ekstensi yang didukung (`.ts`, `.js`, `.tsx`, `.jsx`, `.mjs` ### Berkas Template -Semua berkas `.dve` di dalam `viewsDir` dipantau secara rekursif, jadi suntingan [template](/id/rendering/) muncul pada render berikutnya tanpa restart. +Semua berkas `.dve` di dalam direktori views dipantau secara rekursif, jadi suntingan [template](/id/rendering/) muncul pada render berikutnya tanpa restart. | Event | Perilaku | | ----------------- | ------------------------------------------------------------------------------ | @@ -46,17 +56,17 @@ Semua berkas `.dve` di dalam `viewsDir` dipantau secara rekursif, jadi suntingan ## Isolasi Error -Berkas buruk ditangkap dan tidak pernah membuat server atau rute lain crash. Karena modul baru diimpor dan divalidasi sebelum yang lama dilepas, reload yang gagal membiarkan versi baik sebelumnya tetap di tempatnya alih-alih mematikan rute. Tiap kegagalan muncul sebagai event observability [`route:error` atau `reload:error`](/id/middleware/observability/events#rute), jadi logging tinggal di satu tempat dan tidak ada yang tercetak ke konsol sendiri. +Berkas buruk ditangkap dan tidak pernah membuat server atau rute lain crash. Karena modul baru diimpor dan divalidasi sebelum yang lama dilepas, reload yang gagal membiarkan versi baik sebelumnya tetap di tempatnya alih-alih mematikan rute. Tiap kegagalan muncul sebagai event observability [`route:failed`](/id/middleware/observability/events), jadi logging tinggal di satu tempat dan tidak ada yang tercetak ke konsol sendiri. ![Pandangan abstrak kenapa reload tetap aman, di mana menerapkan perubahan berkas secara live bertumpu pada tiga mekanisme yang berpegangan bersama, mengisolasi tiap berkas dengan try catch agar yang buruk tak pernah membuat yang lain crash, membuang cache modul dengan query timestamp agar kode basi tak pernah mengontaminasi yang baru, dan memuat ulang secara berurutan dengan memvalidasi modul baru lalu menukarnya masuk setelah debounce, yang bersama menghadirkan edit live tanpa downtime, tanpa crash, dan tanpa kontaminasi](/diagrams/hot-reload-principles.png) ### Sintaks Tidak Valid -Sintaks tidak valid menggagalkan impor, jadi pertukaran tak pernah terjadi dan rute baik terakhir tetap melayani. Kegagalan tiba sebagai event `reload:error` yang membawa path rute dan error parse-nya. +Sintaks tidak valid menggagalkan impor, jadi pertukaran tak pernah terjadi dan rute baik terakhir tetap melayani. Kegagalan tiba sebagai event `route:failed` yang membawa path rute dan error parse-nya. ### Ekspor Metode HTTP Hilang -Berkas tanpa ekspor metode HTTP yang valid (`GET`, `POST`, dll.) gagal validasi sebelum pertukaran, jadi rute dibiarkan utuh dan alasannya dilaporkan lewat event `reload:error` yang sama. +Berkas tanpa ekspor metode HTTP yang valid (`GET`, `POST`, dll.) gagal validasi sebelum pertukaran, jadi rute dibiarkan utuh dan alasannya dilaporkan lewat event `route:failed` yang sama. ### Error Runtime di Handler @@ -75,18 +85,18 @@ Beberapa perubahan berkas dalam jendela debounce digabung jadi satu operasi, men ### Memuat Ulang Rute -![Urutan reload rute sebagaimana watcher menjalankannya, di mana watcher mendeteksi perubahan lalu mendebounce 150ms, modul diimpor ulang dengan query timestamp untuk melewati cache, lalu divalidasi punya metode HTTP, dan hanya setelah keduanya lolos FastRouter.remove melepas pola lama dan handler baru didaftarkan sambil memancarkan route:reloaded, dan kegagalan saat impor atau validasi malah memancarkan reload:error sebelum pertukaran apa pun sehingga rute lama tetap melayani dan server tetap hidup](/diagrams/hot-reload-route-sequence.png) +![Urutan reload rute sebagaimana watcher menjalankannya, di mana watcher mendeteksi perubahan lalu mendebounce 150ms, modul diimpor ulang dengan query timestamp untuk melewati cache, lalu divalidasi punya metode HTTP, dan hanya setelah keduanya lolos pola lama dilepas dan handler baru didaftarkan sambil memancarkan route:updated, dan kegagalan saat impor atau validasi malah memancarkan route:failed sebelum pertukaran apa pun sehingga rute lama tetap melayani dan server tetap hidup](/diagrams/hot-reload-route-sequence.png) -1. Watcher mendeteksi perubahan di `routesDir` dan menunggu jendela debounce +1. Watcher mendeteksi perubahan di direktori routes dan menunggu jendela debounce 2. Path berkas diresolusi ke pola rute 3. Modul diimpor ulang dengan query cache-busting (`?t=timestamp`) untuk melewati cache modul 4. Modul divalidasi punya minimal satu ekspor metode HTTP -5. Hanya setelah impor dan validasi lolos, pola lama dilepas dan handler baru didaftarkan, lalu event `route:reloaded` menyala -6. Jika langkah mana pun sebelum pertukaran gagal, rute lama dibiarkan melayani dan event `reload:error` menyala sebagai gantinya +5. Hanya setelah impor dan validasi lolos, pola lama dilepas dan handler baru didaftarkan, lalu event `route:updated` menyala +6. Jika langkah mana pun sebelum pertukaran gagal, rute lama dibiarkan melayani dan event `route:failed` menyala sebagai gantinya ### Memuat Ulang Template -1. Watcher mendeteksi perubahan di `viewsDir` dan menunggu jendela debounce +1. Watcher mendeteksi perubahan di direktori views dan menunggu jendela debounce 2. Entri AST terkompilasi berkas yang berubah dibersihkan dari cache 3. Set path template yang ditemukan direset -4. Pada panggilan `render()` atau `streamRender()` berikutnya, mesin membaca ulang berkas dari disk, mengurai ulang, dan menyimpan hasilnya +4. Pada panggilan `ctx.render()` berikutnya, mesin membaca ulang berkas dari disk, mengurai ulang, dan menyimpan hasilnya diff --git a/docs/id/core-concepts/multi-service.md b/docs/id/core-concepts/multi-service.md index 210c22b..ea7ebf3 100644 --- a/docs/id/core-concepts/multi-service.md +++ b/docs/id/core-concepts/multi-service.md @@ -19,14 +19,14 @@ import { Router } from '@neabyte/deserve' // Satu Router per service const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) const web = new Router({ - routesDir: './services/web/routes', - viewsDir: './services/web/views' + routes: { directory: './services/web/routes' }, + views: { directory: './services/web/views' } }) // Jalankan setiap service bersama @@ -41,11 +41,11 @@ Itu seluruh entry point-nya. ## Isolasi Router -Setiap `Router` berjalan dalam isolasi tingkat request. Masing-masing punya radix-tree router, middleware stack, instance Superwatcher, dan template engine opsional sendiri. Mereka tidak berbagi state internal kecuali dihubungkan secara eksplisit, sementara proses di bawahnya tetap dibagi, dan itulah yang membuat [berbagi kode dan state](#berbagi-kode-dan-state) di bawah ini bisa dilakukan. +Setiap `Router` berjalan dalam isolasi tingkat request. Masing-masing punya radix-tree router, middleware stack, file watcher, dan template engine opsional sendiri. Mereka tidak berbagi state internal kecuali dihubungkan secara eksplisit, sementara proses di bawahnya tetap dibagi, dan itulah yang membuat [berbagi kode dan state](#berbagi-kode-dan-state) di bawah ini bisa dilakukan. Kesalahan terkurung di dua tingkat. Sebuah throw di dalam satu handler menjadi error response untuk satu request itu, jadi sisa service itu dan setiap service lain tetap melayani. Kesalahan yang lebih dalam yang lolos dari handler, seperti unhandled rejection atau upaya keluar dari proses, dijebak di seluruh proses oleh [proteksi proses](/id/getting-started/server-configuration#proteksi-proses) dan dimunculkan sebagai event alih-alih shutdown, jadi tidak ada service yang mati. -![Setiap router punya FastRouter, middleware, dan watcher sendiri secara terisolasi, dengan router Web juga memegang DVE engine](/diagrams/router-isolation.png) +![Setiap router punya tabel rute, middleware, dan watcher sendiri secara terisolasi, dengan router Web juga memegang DVE engine](/diagrams/router-isolation.png) Jika sebuah rute di API melempar, hanya request itu yang mendapat 500. Auth dan Web, serta setiap request API lain, tetap melayani normal. @@ -139,7 +139,8 @@ import { sessions } from '../../../shared/sessions.ts' // Auth menyimpan session saat login export async function POST(ctx: Context): Promise { - const body = (await ctx.json()) as { username?: string } + // Baca body JSON dari request + const body = (await ctx.get.json()) as { username?: string } const id = crypto.randomUUID() sessions.set(id, { username: body?.username, @@ -158,16 +159,13 @@ import { sessions } from '../../../shared/sessions.ts' // API membaca store yang sama langsung export function GET(ctx: Context): Response { - const id = ctx.header('x-session-id') + // Baca session ID dari header + const id = ctx.get.header('x-session-id') const session = id ? sessions.get(id) : undefined if (!session) { return ctx.send.json( - { - error: 'Not authenticated' - }, - { - status: 401 - } + { error: 'Not authenticated' }, + { status: 401 } ) } return ctx.send.json({ @@ -209,7 +207,8 @@ import { emit } from '../../../../shared/bus.ts' // Pancarkan event setelah membuat user export async function POST(ctx: Context): Promise { - const user = await ctx.json() + // Baca body JSON dari request + const user = await ctx.get.json() emit('user:created', user) return ctx.send.json({ created: true @@ -279,7 +278,7 @@ import { Mware, Router } from '@neabyte/deserve' // API mendapat CORS dan body limit const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) api.use(Mware.cors({ origin: '*' @@ -290,7 +289,7 @@ api.use(Mware.bodyLimit({ // Auth mendapat security headers const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) auth.use(Mware.securityHeaders({ xFrameOptions: 'DENY' @@ -298,8 +297,8 @@ auth.use(Mware.securityHeaders({ // Web berjalan tanpa middleware const web = new Router({ - routesDir: './services/web/routes', - viewsDir: './services/web/views' + routes: { directory: './services/web/routes' }, + views: { directory: './services/web/views' } }) // Jalankan setiap service bersama @@ -325,7 +324,8 @@ export function logger(service: string): MiddlewareFn { const response = await next() const duration = Date.now() - start const status = response?.status ?? 0 - console.log(`[${service}] ${ctx.request.method} ${ctx.pathname} ${status} ${duration}ms`) + // Baca method dan path dari ctx.get + console.log(`[${service}] ${ctx.get.method()} ${ctx.get.pathname()} ${status} ${duration}ms`) return response } } @@ -347,21 +347,21 @@ Satu error handler berlaku dengan [`router.catch()`](/id/error-handling/object-d ```typescript twoslash // shared/errors.ts // Satu bentuk error handler untuk semua -import type { Context, ErrorInfo, ErrorMiddleware } from '@neabyte/deserve' +import type { Context, ErrorInfo, ErrorMiddleware, HttpStatusCode } from '@neabyte/deserve' export function errorHandler(service: string): ErrorMiddleware { - return (ctx: Context, error: ErrorInfo): Response | null => { + return (ctx: Context, info: ErrorInfo): Response | null => { console.error( - `[${service}] ${error.method} ${error.pathname} ${error.statusCode} - ${error.error?.message}` + `[${service}] ${info.method} ${info.pathname} ${info.statusCode} - ${info.error?.message}` ) return ctx.send.json( { service, - error: error.error?.message ?? 'Unknown error', - statusCode: error.statusCode, - path: error.pathname + error: info.error?.message ?? 'Unknown error', + statusCode: info.statusCode, + path: info.pathname }, - { status: error.statusCode } + { status: info.statusCode as HttpStatusCode } ) } } @@ -369,50 +369,51 @@ export function errorHandler(service: string): ErrorMiddleware { ### Membungkus Middleware dengan Label -`WrapMware` menandai middleware individual dengan label, jadi saat middleware itu melempar, log error menyertakan label dan menunjuk langsung middleware mana di service mana yang gagal. Signature dan perilaku dasarnya dibahas di [Global Middleware](/id/middleware/global#membungkus-middleware-dengan-penanganan-error), dan ia bertindak sebagai satu lapisan dalam [Defense in Depth](/id/error-handling/defense-in-depth): +`Wrap.apply` menandai middleware individual dengan label, jadi saat middleware itu melempar, log error menyertakan label dan menunjuk langsung middleware mana di service mana yang gagal. Signature dan perilaku dasarnya dibahas di [Global Middleware](/id/middleware/global#membungkus-middleware-dengan-penanganan-error), dan ia bertindak sebagai satu lapisan dalam [Defense in Depth](/id/error-handling/defense-in-depth): ```typescript // main.ts -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap } from '@neabyte/deserve' import { logger } from './shared/logger.ts' import { errorHandler } from './shared/errors.ts' // Beri label tiap middleware untuk log error -const apiAuth = WrapMware('APIAuth', async (ctx, next) => { - if (!ctx.header('authorization')) { +const apiAuth = Wrap.apply('APIAuth', async (ctx, next) => { + // Baca header authorization + if (!ctx.get.header('authorization')) { throw new Error('Missing API key') } return await next() }) -const authRateLimit = WrapMware('AuthRateLimit', async (ctx, next) => { +const authRateLimit = Wrap.apply('AuthRateLimit', async (ctx, next) => { // logika rate limit return await next() }) -const webCache = WrapMware('WebCache', async (ctx, next) => { +const webCache = Wrap.apply('WebCache', async (ctx, next) => { // logika cache return await next() }) // Sambungkan logger, middleware, error handler const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) api.use(logger('API')) api.use(apiAuth) api.catch(errorHandler('API')) const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) auth.use(logger('Auth')) auth.use(authRateLimit) auth.catch(errorHandler('Auth')) const web = new Router({ - routesDir: './services/web/routes', - viewsDir: './services/web/views' + routes: { directory: './services/web/routes' }, + views: { directory: './services/web/views' } }) web.use(logger('Web')) web.use(webCache) @@ -430,7 +431,7 @@ Ketika `apiAuth` melempar, log terbaca `[API] GET /users 500 - APIAuth - Missing ### OpenTelemetry -Karena setiap request sudah mengalir lewat middleware bersama, memasang OpenTelemetry mengikuti pola yang sama. Satu middleware OTel berlaku ke setiap service, jadi semua span dari semua port menuju satu collector, yang memberi distributed tracing, dashboard latensi, dan metrik error rate di seluruh sistem tanpa menginstrumentasi tiap service secara terpisah: +Karena setiap request sudah mengalir lewat middleware bersama, memasang [OpenTelemetry](https://opentelemetry.io/) mengikuti pola yang sama. Satu middleware OTel berlaku ke setiap service, jadi semua span dari semua port menuju satu collector, yang memberi distributed tracing, dashboard latensi, dan metrik error rate di seluruh sistem tanpa menginstrumentasi tiap service secara terpisah: ![Satu middleware OTel mengumpulkan span dari setiap service dan mengekspornya ke OTel Collector, lalu ke Jaeger, Grafana, atau Datadog](/diagrams/observability.png) @@ -450,8 +451,8 @@ export function otelMiddleware(service: string): MiddlewareFn { console.log(JSON.stringify({ traceId: crypto.randomUUID(), service, - method: ctx.request.method, - path: ctx.pathname, + method: ctx.get.method(), + path: ctx.get.pathname(), status, durationMs: Math.round(duration * 100) / 100, timestamp: new Date().toISOString() @@ -479,7 +480,7 @@ Tim bisa bekerja di service berbeda pada saat bersamaan, dengan satu orang meref Semua service berjalan di satu container. Satu image, satu proses, semua port: ```dockerfile -FROM denoland/deno:2.7.0 +FROM denoland/deno:2.8.3 WORKDIR /app COPY . . @@ -492,7 +493,7 @@ CMD ["deno", "run", "-A", "main.ts"] ### Reverse Proxy -Letakkan Nginx atau Caddy di depan untuk mengarahkan domain ke setiap port service: +Letakkan [Nginx](https://nginx.org/) atau [Caddy](https://caddyserver.com/) di depan untuk mengarahkan domain ke setiap port service: ![Reverse proxy seperti Nginx atau Caddy memetakan tiap hostname ke port per-service: api.example.com ke 3001, auth.example.com ke 3002, dan example.com ke 3003](/diagrams/reverse-proxy.png) diff --git a/docs/id/core-concepts/request-handling.md b/docs/id/core-concepts/request-handling.md index ef2b325..4f9aa20 100644 --- a/docs/id/core-concepts/request-handling.md +++ b/docs/id/core-concepts/request-handling.md @@ -6,32 +6,32 @@ description: "Cara Deserve mengurai dan menangani request masuk, termasuk parsin > **Referensi**: [Dokumentasi API Request Deno](https://docs.deno.com/deploy/classic/api/runtime-request/) -Deserve menyediakan objek `Context` yang membungkus `Request` native, jadi query, route param, header, cookie, dan body semua lewat Context tanpa parsing manual. Untuk permukaan Context lengkap, termasuk helper response dan state, lihat [Objek Context](/id/core-concepts/context-object). +Deserve menyediakan objek `Context` yang membungkus `Request` native, jadi query, route param, header, cookie, dan body semua lewat Context tanpa parsing manual. Untuk permukaan Context lengkap, termasuk helper response dan penanganan error, lihat [Objek Context](/id/core-concepts/context-object). -Sebuah handler menerima satu `Context` dan membaca apa pun yang dibutuhkannya: +Sebuah handler menerima satu `Context` dan membaca apa pun yang dibutuhkannya dari namespace `ctx.get`: ```typescript twoslash import type { Context } from '@neabyte/deserve' -// Baca data request dari ctx +// Baca data request dari ctx.get export function GET(ctx: Context): Response { - const query = ctx.query() + const query = ctx.get.query() return ctx.send.json({ query }) } ``` -Bagian di bawah membahas tiap jenis input, dan [Referensi Method](#referensi-method) mendaftar tiap pembaca dengan tipe kembaliannya. +Bagian di bawah membahas tiap jenis input. Setiap pembaca berada di `ctx.get` dan didokumentasikan lengkap di [Objek Context](/id/core-concepts/context-object). ## Parameter Query -Query string diurai saat akses pertama, lalu di-cache. Dua pembaca menangani setiap kasus, `query()` untuk satu nilai dan `queries()` untuk kunci berulang: +Query string diurai saat akses pertama, lalu di-cache. `ctx.get.query()` mengembalikan record lengkap, dan `ctx.get.query(key)` mengembalikan satu nilai. Nilai pertama menang untuk kunci ganda: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // URL: /search?q=deno&limit=10 export function GET(ctx: Context): Response { - const query = ctx.query() + const query = ctx.get.query() return ctx.send.json({ search: query.q, limit: parseInt(query.limit || '10') @@ -39,22 +39,20 @@ export function GET(ctx: Context): Response { } ``` -Ketika sebuah kunci berulang di URL, `query()` menyimpan **nilai terakhir** sementara `queries()` mengembalikan **semuanya**: +Ketika sebuah kunci berulang di URL, nilai pertama yang disimpan: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- // URL: /search?tag=deno&tag=typescript -ctx.query('tag') // 'typescript', nilai terakhir menang -ctx.queries('tag') // ['deno', 'typescript'], semua nilai +ctx.get.query('tag') // 'deno', nilai pertama menang +ctx.get.query() // { tag: 'deno' } ``` -Gunakan `queries()` pada input array atau pilihan-ganda, dan `query()` untuk selainnya. Signature lengkap ada di [Referensi Method](#referensi-method). - ## Parameter Rute -Segmen dinamis dari [routing berbasis file](/id/core-concepts/file-based-routing) tiba sebagai route param, dibaca satu per satu dengan `param()` atau sekaligus dengan `params()`: +Segmen dinamis dari [routing berbasis file](/id/core-concepts/file-based-routing) tiba sebagai route param, dibaca satu per satu dengan `ctx.get.param(key)` atau sekaligus dengan `ctx.get.param()`: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -62,215 +60,168 @@ import type { Context } from '@neabyte/deserve' // routes/users/[id]/posts/[postId].ts // URL: /users/123/posts/456 export function GET(ctx: Context): Response { - const id = ctx.param('id') // '123' - const all = ctx.params() // { id: '123', postId: '456' } - return ctx.send.json({ - id, - all - }) + const id = ctx.get.param('id') // '123' + const all = ctx.get.param() // { id: '123', postId: '456' } + return ctx.send.json({ id, all }) } ``` Nilai di-percent-decode sekali sebelum handler membacanya. Cara pola dicocokkan dibahas di [Pola Rute](/id/core-concepts/route-patterns). -## Referensi Method - -### `ctx.query(key?)` +## Header -Mengembalikan semua parameter query sebagai objek, dan mengembalikan **nilai terakhir untuk kunci ganda**. +Header dibaca lewat `ctx.get.header()`. Beri sebuah kunci untuk membaca satu header, atau panggil tanpa argumen untuk membaca semua header sebagai record. Kunci dicocokkan tanpa membedakan huruf besar kecil: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// URL: /search?q=deno&limit=10 -ctx.query() // { q: 'deno', limit: '10' } - -// URL: /search?tag=deno&tag=typescript -ctx.query() // { tag: 'typescript' } ← nilai terakhir saja +// Baca satu header berdasarkan nama +const contentType = ctx.get.header('content-type') -// Parameter tunggal -const q = ctx.query('q') // Returns: 'deno' +// Baca semua header sebagai record +const headers = ctx.get.header() ``` -### `ctx.queries(key)` - -Mengembalikan **semua nilai** untuk satu kunci parameter query sebagai array. +Untuk akses langsung ke objek `Headers` native, pakai `ctx.get.request().headers`: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// URL: /search?tags=deno&tags=typescript -const tags = ctx.queries('tags') // ['deno', 'typescript'] ← semua nilai - -// query() menangani nilai tunggal atau terakhir, queries() menangani array dan pilihan-ganda +// Akses Headers API mentah +const contentType = ctx.get.request().headers.get('Content-Type') ``` -### `ctx.param(key)` +## Cookie -Mengembalikan satu nilai parameter rute. +Cookie dibaca lewat `ctx.get.cookie()`. Beri sebuah kunci untuk membaca satu cookie, atau panggil tanpa argumen untuk membaca semua cookie sebagai record. Cookie diurai sekali lalu di-cache untuk panggilan berikutnya: ```typescript twoslash import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// Route: /users/[id] -// URL: /users/123 -const id = ctx.param('id') // '123' +// Baca satu cookie berdasarkan nama +const sessionId = ctx.get.cookie('sessionId') + +// Baca semua cookie sebagai record +const cookies = ctx.get.cookie() // { sessionId: 'abc123', theme: 'dark' } ``` -### `ctx.params()` +## Body -Mengembalikan semua parameter rute sebagai objek. +Body dibaca lewat salah satu dari beberapa method async di `ctx.get`. Formatnya dipilih otomatis oleh `ctx.get.body()` berdasarkan header `Content-Type`, atau dipaksa dengan memanggil pembaca tertentu: -```typescript twoslash -import type { Context } from '@neabyte/deserve' -declare const ctx: Context -// ---cut--- -// Route: /users/[id]/posts/[postId] -// URL: /users/123/posts/456 -const params = ctx.params() // { id: '123', postId: '456' } -``` +| Method | Format | Content-Type | +| ------ | ------ | ------------ | +| `ctx.get.body()` | Deteksi otomatis | JSON, form-data, atau teks | +| `ctx.get.json()` | JSON | Apa saja | +| `ctx.get.text()` | Teks polos | Apa saja | +| `ctx.get.formData()` | Form data | Apa saja | +| `ctx.get.blob()` | Blob | Apa saja | +| `ctx.get.bytes()` | Uint8Array | Apa saja | -### `ctx.body()` +Body hanya bisa dibaca sekali. Panggilan kedua dengan format sama mengembalikan nilai dari cache, sedangkan panggilan kedua dengan format berbeda melempar **409 Conflict**. -Mengurai body request otomatis sebagai JSON, form-data, atau teks. +### Body Deteksi Otomatis ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/users dengan body JSON export async function POST(ctx: Context): Promise { - const body = await ctx.body() // { name: 'John', age: 30 } - return ctx.send.json({ - created: body - }) + // Body diparsing dari Content-Type + const body = await ctx.get.body() + return ctx.send.json({ created: body }) } ``` -### `ctx.json()` - -Mengurai body request sebagai JSON. +### Body JSON ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/users dengan body JSON export async function POST(ctx: Context): Promise { - const body = await ctx.json() // { name: 'John', age: 30 } - return ctx.send.json({ - created: body - }) + // Parsing body sebagai JSON + const body = await ctx.get.json() + return ctx.send.json({ created: body }) } ``` -### `ctx.formData()` - -Mengurai body request sebagai form data dan mengembalikan objek `FormData`. +### Form Data ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/users dengan form data export async function POST(ctx: Context): Promise { - const formData = await ctx.formData() // FormData object - const name = formData.get('name') // 'John' + // Parsing body sebagai form data + const formData = await ctx.get.formData() + const name = formData.get('name') return ctx.send.json({ name }) } ``` -### `ctx.text()` - -Membaca body request sebagai teks mentah. +### Teks Mentah ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/text dengan teks polos export async function POST(ctx: Context): Promise { - const text = await ctx.text() // 'Hello World' + // Baca body sebagai teks polos + const text = await ctx.get.text() return ctx.send.text(text) } ``` -### `ctx.arrayBuffer()` - -Membaca body request sebagai ArrayBuffer, yang cocok untuk pemrosesan data biner. +### Data Biner ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- // POST /api/upload dengan data biner export async function POST(ctx: Context): Promise { - const buffer = await ctx.arrayBuffer() // ArrayBuffer object - // Proses data biner... - return ctx.send.json({ - size: buffer.byteLength - }) + // Baca body sebagai byte array + const bytes = await ctx.get.bytes() + return ctx.send.json({ size: bytes.byteLength }) } ``` -### `ctx.blob()` +## URL dan Pathname -Membaca body request sebagai Blob, yang cocok untuk unggahan berkas dan penanganan biner. +Detail URL dibaca lewat `ctx.get.url()` dan `ctx.get.pathname()`: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- -// POST /api/upload dengan data berkas -export async function POST(ctx: Context): Promise { - const blob = await ctx.blob() // Blob object - // Proses data berkas... +export function GET(ctx: Context): Response { + const url = ctx.get.url() // URL instance + const pathname = ctx.get.pathname() // '/api/users/123' return ctx.send.json({ - type: blob.type, - size: blob.size + path: pathname, + fullUrl: url.href }) } ``` -### `ctx.header(key?)` +## IP Klien -Membaca satu header berdasarkan kunci atau setiap header sekaligus, mencocokkan kunci tanpa membedakan huruf besar kecil dan menjadikannya huruf kecil. +IP klien dibaca lewat `ctx.get.ip()`. Beri `{ direct: true }` untuk membaca peer TCP langsung alih-alih IP yang sudah diresolusi: ```typescript twoslash import type { Context } from '@neabyte/deserve' -declare const ctx: Context // ---cut--- -// Ambil header spesifik -const contentType = ctx.header('content-type') - -// Ambil semua header sebagai objek -const headers = ctx.header() -``` - -### `ctx.headers` - -Mengekspos objek Headers mentah untuk akses langsung. - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -declare const ctx: Context -// ---cut--- -// Akses Headers API mentah -const contentType = ctx.headers.get('Content-Type') +export function GET(ctx: Context): Response { + const client = ctx.get.ip() // IP pengunjung yang diresolusi + const peer = ctx.get.ip({ direct: true }) // peer TCP langsung + return ctx.send.json({ client, peer }) +} ``` -### `ctx.cookie(key?)` - -Membaca satu cookie berdasarkan kunci atau setiap cookie sekaligus. - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -declare const ctx: Context -// ---cut--- -// Ambil cookie spesifik -const sessionId = ctx.cookie('sessionId') - -// Ambil semua cookie -const cookies = ctx.cookie() // { sessionId: 'abc123', theme: 'dark' } -``` +Keduanya mengembalikan `undefined` ketika peer tidak diketahui. Tanpa aturan [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien) yang cocok, keduanya mengembalikan alamat peer langsung yang sama. [Middleware pembatasan IP](/id/middleware/ip) memakai `ctx.get.ip()` untuk aturan allow dan deny-nya. ## Memvalidasi Sebelum Handler -Setiap pembaca di atas mengembalikan nilai mentah seperti saat tiba, jadi handler tetap memeriksa bentuknya sendiri. Sebuah schema memindahkan pemeriksaan itu ke depan handler, menjalankan kontrak terhadap tiap sumber, dan menyisakan hanya data yang sudah lolos. Lihat [Ringkasan Validasi](/id/middleware/validation/overview) untuk bagaimana `ctx.json()`, `ctx.query()`, dan pembaca lain mengisi sebuah kontrak. +Setiap pembaca di atas mengembalikan nilai mentah seperti saat tiba, jadi handler tetap memeriksa bentuknya sendiri. Sebuah schema memindahkan pemeriksaan itu ke depan handler, menjalankan kontrak terhadap tiap sumber, dan menyisakan hanya data yang sudah lolos. Lihat [Ringkasan Validasi](/id/middleware/validation/overview) untuk bagaimana `ctx.get.json()`, `ctx.get.query()`, dan pembaca lain mengisi sebuah kontrak. diff --git a/docs/id/core-concepts/route-patterns.md b/docs/id/core-concepts/route-patterns.md index e76ae6b..68bfae2 100644 --- a/docs/id/core-concepts/route-patterns.md +++ b/docs/id/core-concepts/route-patterns.md @@ -1,5 +1,5 @@ --- -description: "Sintaks pola rute di Deserve termasuk parameter dinamis, wildcard, dan aturan pencocokan." +description: "Sintaks pola rute di Deserve termasuk parameter dinamis dan aturan pencocokan." --- # Pola Rute @@ -28,9 +28,9 @@ Ketika request tiba, mesin mencari metode dan pathname, lalu menerapkan beberapa - **HEAD mengikuti GET** - sebuah `HEAD` tanpa handler memakai ulang handler `GET` - **Metode salah** - path dikenal tanpa handler untuk metode itu mengembalikan **405** dengan header `Allow` yang mendaftar metode yang memang ada - **Path tidak dikenal** - tanpa kecocokan mengembalikan **404** lewat [error handler](/id/error-handling/object-details) -- **Input terlalu besar** - URL melewati `maxUrlLength` atau param melewati `maxParamLength` mengembalikan **414**, keduanya bisa disetel di [Konfigurasi Server](/id/getting-started/server-configuration) +- **Input terlalu besar** - URL melewati `maxUrlLength` atau param melewati `routes.maxParamLength` mengembalikan **414**, keduanya bisa disetel di [Konfigurasi Routes](/id/getting-started/routes-configuration) -Param di-percent-decode sekali sebelum handler membacanya, jadi `ctx.param('id')` mengembalikan nilai yang sudah didekode. +Param di-percent-decode sekali sebelum handler membacanya, jadi `ctx.get.param('id')` mengembalikan nilai yang sudah didekode. ## Parameter Dinamis @@ -42,7 +42,7 @@ Folder atau berkas `[param]` menjadi slot bernama `:param` di pola. Tiap kurung | `users/[id]/posts/[postId].ts` | `/users/:id/posts/:postId` | `id`, `postId` | | `api/v1/users/[userId]/posts/[postId].ts` | `/api/v1/users/:userId/posts/:postId` | `userId`, `postId` | -Nilai yang dicocokkan dibaca di dalam handler dengan `ctx.param()` dan `ctx.params()`, dibahas di [Penanganan Request](/id/core-concepts/request-handling#parameter-rute). +Nilai yang dicocokkan dibaca di dalam handler dengan `ctx.get.param('id')` untuk satu nilai atau `ctx.get.param()` untuk map lengkap, dibahas di [Penanganan Request](/id/core-concepts/request-handling#parameter-rute). ## Contoh Pola @@ -90,15 +90,11 @@ import type { Context } from '@neabyte/deserve' // Tolak id non-numerik dengan 400 export function GET(ctx: Context): Response { - const id = ctx.param('id') + const id = ctx.get.param('id') if (!id || !/^\d+$/.test(id)) { return ctx.send.json( - { - error: 'Invalid user ID' - }, - { - status: 400 - } + { error: 'Invalid user ID' }, + { status: 400 } ) } return ctx.send.json({ @@ -107,4 +103,4 @@ export function GET(ctx: Context): Response { } ``` -Handler yang memvalidasi beberapa param, atau ingin kegagalannya membawa alasan tingkat field, menjalankan kontrak [validasi](/id/middleware/validation/overview) dengan `Validator.check` alih-alih regex inline. Param diperiksa di dalam handler karena baru tersedia setelah middleware berjalan, dibahas di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#memeriksa-params-di-handler). +Handler yang memvalidasi beberapa param, atau ingin kegagalannya membawa alasan tingkat field, menjalankan kontrak [validasi](/id/middleware/validation/overview) dengan `Validator.check` alih-alih regex inline. Param diperiksa di dalam handler karena baru tersedia setelah middleware berjalan, dibahas di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#memeriksa-param-di-handler). diff --git a/docs/id/core-concepts/worker-pool.md b/docs/id/core-concepts/worker-pool.md deleted file mode 100644 index 68d0ad3..0000000 --- a/docs/id/core-concepts/worker-pool.md +++ /dev/null @@ -1,216 +0,0 @@ ---- -description: "Mengalihkan kerja terikat-CPU ke pool worker Deno lewat API worker pool Deserve." ---- - -# Worker Pool - -> **Referensi**: [API Workers Deno](https://docs.deno.com/runtime/manual/workers/) - -Worker pool mengalihkan kerja terikat-CPU ke pool Worker Deno supaya thread utama tetap responsif. Setelah worker pool dikonfigurasi, route handler menjangkau handle worker lewat `ctx.getState('worker' as never)` dan mengirim tugas dengan `run(payload)`. - -## Kapan Dipakai - -Pakai worker pool ketika sebuah rute melakukan **kerja terikat-CPU** (misalnya matematika berat, parsing, kompresi) yang akan memblokir event loop. Untuk kerja terikat-I/O (berkas, jaringan), thread utama biasanya sudah cukup. - -## Penggunaan Dasar - -### 1. Konfigurasi Router dengan Worker - -Berikan `worker` saat membuat router, bersama **script URL** yang meresolusi ke sebuah modul (misalnya lewat `import.meta.resolve()` atau `URL.createObjectURL()` untuk kode inline): - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -// Resolusi script worker sebagai modul -const workerScriptUrl = import.meta.resolve('./worker.ts') - -// Aktifkan pool pada router -const router = new Router({ - routesDir: './routes', - worker: { - scriptURL: workerScriptUrl, - poolSize: 4 - } -}) - -await router.serve(8000) -``` - -### 2. Implementasi Script Worker - -Script worker harus mendengarkan `message` dan membalas dengan `postMessage`. Payload dan hasil harus **dapat diserialisasi structured-clone** (tanpa fungsi atau simbol): - -```typescript -// worker.ts -self.onmessage = (e: MessageEvent) => { - const data = e.data as { iterations?: number } - const n = Math.max(0, Number(data?.iterations) || 50_000) - let value = 0 - for (let i = 0; i < n; i++) { - value += Math.sqrt(i) - } - self.postMessage({ - done: true, - value - }) -} -``` - -Untuk melaporkan error dari worker, kirim objek dengan `error: true` dan `message` opsional: - -```typescript -self.postMessage({ - error: true, - message: 'Computation failed' -}) -``` - -### 3. Pakai di Rute - -Handle worker tinggal di framework state, jadi `ctx.getState` menjangkaunya dengan tipe `WorkerRunHandle`. Router yang dibuat tanpa `worker` membiarkan handle undefined, dan di situlah saatnya mengembalikan 503: - -```typescript twoslash -// routes/heavy.ts -import type { Context, WorkerRunHandle } from '@neabyte/deserve' - -export async function GET(ctx: Context): Promise { - const worker = ctx.getState('worker' as never) - if (!worker) { - return ctx.send.json( - { - error: 'Worker not enabled' - }, - { - status: 503 - } - ) - } - const result = await worker.run<{ done: boolean; value: number }>({ - iterations: 50_000 - }) - return ctx.send.json({ - value: result?.value - }) -} -``` - -## Opsi Router - -### `scriptURL` - -URL script worker. Harus menunjuk ke sebuah **modul** (Deno menjalankan worker dengan `type: 'module'`). Sumber umum: - -- **Path berkas:** `import.meta.resolve('./worker.ts')` -- **Script inline:** `URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))` - -### `poolSize` - -Jumlah worker dalam pool. Default adalah **4**. Minimum 1. Tugas dikirim round-robin. - -```typescript -worker: { - scriptURL: workerScriptUrl, - poolSize: 8 -} -``` - -### `taskTimeoutMs` - -Timeout per tugas dalam milidetik. Default adalah **5000**. Tugas yang berjalan lebih lama ditolak dengan error timeout, slot direklaim, dan worker dijalankan ulang. Reklaim ini muncul sebagai event [`worker:timeout`](/id/middleware/observability/events#worker) lalu [`worker:respawn`](/id/middleware/observability/events#worker). - -```typescript -worker: { - scriptURL: workerScriptUrl, - taskTimeoutMs: 10_000 -} -``` - -### `maxQueueDepth` - -Maksimum tugas diterima-tapi-belum-selesai yang ditahan pool sebelum menolak pekerjaan baru. Default adalah jumlah worker dikali **8**, jadi pool 4 menahan hingga 32. Begitu batas tercapai, dispatch baru ditolak langsung alih-alih diantrekan, sehingga banjir pekerjaan tidak menumpuk tanpa batas: - -```typescript -worker: { - scriptURL: workerScriptUrl, - poolSize: 4, - maxQueueDepth: 64 -} -``` - -### `maxQueueWaitMs` - -Maksimum proyeksi tunggu, diukur sebagai jumlah tugas pending pada slot terpilih dikali `taskTimeoutMs`, sebelum dispatch ditolak. Default adalah **2000**. Tugas yang seharusnya menunggu di belakang antrean panjang ditolak cepat alih-alih menunggu: - -```typescript -worker: { - scriptURL: workerScriptUrl, - maxQueueWaitMs: 5_000 -} -``` - -Dispatch yang ditolak langsung gagal dan muncul sebagai event [`worker:rejected`](/id/middleware/observability/events#worker), dengan `reason` menyebut apakah `maxQueueDepth` atau `maxQueueWaitMs` yang memicunya. - -## Contoh Lengkap (Worker Inline) - -Memakai script worker inline dengan `Blob` dan `createObjectURL`: - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const workerCode = ` -self.onmessage = (e) => { - const data = e.data || {} - const n = Math.max(0, Number(data.iterations) || 50000) - let value = 0 - for (let i = 0; i < n; i++) value += Math.sqrt(i) - self.postMessage({ - done: true, - value - }) -} -export {} -` - -const workerScriptUrl = URL.createObjectURL( - new Blob( - [workerCode], - { - type: 'application/javascript' - } - ) -) - -const router = new Router({ - routesDir: './routes', - worker: { - scriptURL: workerScriptUrl, - poolSize: 4 - } -}) - -await router.serve(8000) -``` - -## Penanganan Error - -- **Tanpa pool:** Router yang dibuat tanpa `worker` membiarkan `ctx.getState('worker' as never)` undefined. Kembalikan 503 atau pesan jelas ketika rute butuh worker. -- **Error worker:** Ketika worker memanggil `postMessage({ error: true, message: '...' })`, `worker.run()` ditolak dengan `Error` yang membawa pesan itu. Tanpa pesan, error berbunyi `Worker returned an error with no message`. -- **Crash worker:** Ketika worker melempar atau crash, `run()` ditolak dengan `Worker task failed before responding`, dan slot pulih dengan sendirinya. -- **Timeout tugas:** Ketika tugas berjalan melewati `taskTimeoutMs` (default 5000), `run()` ditolak dengan `Worker task exceeded ms timeout`. -- **Ditolak di bawah beban:** Ketika pool mencapai `maxQueueDepth` atau proyeksi tunggu melewati `maxQueueWaitMs`, `run()` ditolak dengan error antrean-penuh atau slot-sibuk sebelum tugas sempat mulai. - -Setiap kesalahan ini juga mengalir lewat bus observability sebagai [event worker](/id/middleware/observability/events#worker), jadi stall, crash, pemulihan, atau penolakan terlihat tanpa menyentuh jalur request. Tangkap tugas yang ditolak dan teruskan ke [error handler terpusat](/id/error-handling/object-details): - -```typescript -try { - const result = await worker.run(payload) - return ctx.send.json(result) -} catch (err) { - // Alihkan kegagalan lewat penanganan error - return await ctx.handleError(500, err as Error) -} -``` - -## Hanya Structured Clone - -Payload dan hasil dikirim lewat `postMessage` / `onmessage`, jadi hanya data yang **dapat diserialisasi structured-clone** yang diizinkan, yang mencakup objek polos, array, primitif, `Date`, `RegExp`, `Map`, `Set`, dan nilai serupa. Fungsi, simbol, dan instance kelas yang tidak dapat diklon tidak bisa melewati batas itu. diff --git a/docs/id/error-handling/default-behavior.md b/docs/id/error-handling/default-behavior.md index 61d6e79..6cf82ba 100644 --- a/docs/id/error-handling/default-behavior.md +++ b/docs/id/error-handling/default-behavior.md @@ -16,7 +16,7 @@ Tanpa panggilan ke `router.catch()`, Deserve menangani setiap error dengan respo import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Tanpa router.catch, default mengambil alih @@ -34,7 +34,7 @@ Response error default (tanpa `router.catch()` khusus) mengikuti header `Accept` Juga: - **Status Code**: Mempertahankan status code error asli (404, 500, dll.) -- **Header**: Mencakup header yang diatur lewat `ctx.setHeader()` sebelum error +- **Header**: Mencakup header yang diatur lewat `ctx.set.header()` sebelum error ```typescript // Contoh response default (klien minta JSON) @@ -84,17 +84,17 @@ Ketika path cocok dengan sebuah rute tapi metodenya tidak punya handler, respons ### 422 - Validasi Gagal -Ketika kontrak [validasi](/id/middleware/validation/overview) menolak input request, response default menambahkan array `errors` yang mendaftar tiap alasan kegagalan: +Ketika kontrak [validasi](/id/middleware/validation/overview) menolak input request, response default adalah body problem-details polos dengan status 422: ```typescript // POST /users dengan body tidak valid // Status: 422 // Content-Type: application/problem+json -// Body: { "type": "about:blank", "title": "...", "status": 422, -// "instance": "/users", "errors": ["name must not be empty"] } +// Body: { "type": "about:blank", "title": "Unprocessable Entity", +// "status": 422, "instance": "/users" } ``` -Hanya 422 yang membawa `errors`, dan setiap status lain tetap berbody tanpa alasan. Bagaimana sebuah kontrak menghasilkan alasan itu ada di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#cara-kegagalan-muncul). +Body default tidak pernah mendaftar alasan kegagalan. Alasan itu menumpang di `error.error.cause` sebagai array string, jadi sebuah [`router.catch()`](/id/error-handling/object-details#error-validasi) khusus membacanya untuk membangun response tingkat field. Bagaimana sebuah kontrak menghasilkan alasan itu ada di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#cara-kegagalan-muncul). ### 500 - Error Server diff --git a/docs/id/error-handling/defense-in-depth.md b/docs/id/error-handling/defense-in-depth.md index bcc2eca..2d52310 100644 --- a/docs/id/error-handling/defense-in-depth.md +++ b/docs/id/error-handling/defense-in-depth.md @@ -6,7 +6,7 @@ description: "Penanganan error berlapis di Deserve untuk menjaga service tetap t Error di Deserve melewati beberapa lapisan, dan tiap lapisan adalah kesempatan untuk menangkap, membentuk, atau mencatat kegagalan. Ketika satu lapisan meloloskan error, lapisan berikutnya tetap menahan, jadi server terus merespons dan tidak pernah crash. -![Lima lapis pertahanan error: try/catch route handler, WrapMware labeled catch, custom handler router.catch, default handler dengan pesan tersamar, dan process guard yang tidak pernah crash](/diagrams/defense-in-depth.png) +![Lima lapis pertahanan error: try/catch route handler, Wrap.apply labeled catch, custom handler router.catch, default handler dengan pesan tersamar, dan process guard yang tidak pernah crash](/diagrams/defense-in-depth.png) ## Lapis 1 - Route Handler @@ -17,7 +17,7 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { try { - const data = await ctx.body() + const data = await ctx.get.body() return ctx.send.json({ success: true }) @@ -39,16 +39,16 @@ Apa pun yang dilempar melewati titik ini diteruskan ke lapisan berikutnya. ## Lapis 2 - Middleware Berlabel -`WrapMware` membungkus sebuah middleware sehingga lemparan menjadi error berlabel yang dialihkan ke error handler. Label menunjuk langsung ke middleware yang gagal: +`Wrap.apply` membungkus sebuah middleware sehingga lemparan menjadi error berlabel yang dialihkan ke error handler. Label menunjuk langsung ke middleware yang gagal: ```typescript twoslash -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap } from '@neabyte/deserve' const router = new Router() // ---cut--- // Lemparan di sini sampai ke router.catch dengan label -const auth = WrapMware('Auth', async (ctx, next) => { - if (!ctx.header('authorization')) { +const auth = Wrap.apply('Auth', async (ctx, next) => { + if (!ctx.get.header('authorization')) { throw new Error('Missing token') } return await next() @@ -64,7 +64,7 @@ Lihat [Global Middleware](/id/middleware/global#membungkus-middleware-dengan-pen `router.catch()` menerima setiap error yang tak tertangkap dan membentuk response klien. Handler ini berjalan untuk error handler, error middleware, not-found, dan error berkas statis sama saja: ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -75,7 +75,7 @@ router.catch((ctx, error) => { error: 'Something went wrong' }, { - status: error.statusCode + status: error.statusCode as HttpStatusCode } ) }) @@ -93,11 +93,11 @@ Ketika tidak ada `router.catch()` yang diatur, atau handler khusus mengembalikan // 404 -> "Not Found" ``` -Response default juga membawa [security headers](/id/middleware/security-headers) bawaan. Lihat [Perilaku Default](/id/error-handling/default-behavior) untuk bentuk response lengkapnya. +Ketika middleware [security headers](/id/middleware/security-headers) berjalan sebelum kesalahan, header-nya tetap ada di response error juga. Lihat [Perilaku Default](/id/error-handling/default-behavior) untuk bentuk response lengkapnya. ## Lapis 5 - Process Guard -Lapisan terluar berjalan tingkat proses. Router yang sedang melayani menjebak unhandled rejection, uncaught error, dan upaya terminasi yang diblokir, lalu melaporkan tiap kejadian sebagai event `process:error` alih-alih membiarkan proses mati: +Lapisan terluar berjalan tingkat proses. Router yang sedang melayani menjebak unhandled rejection, uncaught error, dan upaya terminasi yang diblokir, lalu melaporkan tiap kejadian sebagai event `process:failed` alih-alih membiarkan proses mati: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -105,7 +105,7 @@ import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.on((event) => { - if (event.kind === 'process:error') { + if (event.kind === 'process:failed') { const { origin, error } = event.metadata as { origin: string; error: Error } console.error(`process fault [${origin}]`, error.message) } @@ -121,7 +121,7 @@ Membentuk response dan mencatat kegagalan adalah tugas terpisah. `router.catch() ![Satu request gagal menyebar ke dua hook independen, di mana router.catch membentuk Response yang diterima klien dengan status dan body terkontrol, dan router.on mencatat kegagalan yang sama ke log dan metrik tanpa memengaruhi balasan](/diagrams/obs-catch-vs-on.png) ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -132,14 +132,14 @@ router.catch((ctx, info) => { error: 'Something went wrong' }, { - status: info.statusCode + status: info.statusCode as HttpStatusCode } ) }) // Catat kegagalan untuk nanti router.on((event) => { - if (event.kind === 'request:error') { + if (event.kind === 'request:failed') { const { url, error } = event.metadata as { url: string; error?: Error } console.error(url, error?.message) } diff --git a/docs/id/error-handling/object-details.md b/docs/id/error-handling/object-details.md index 1b6b5e9..eb518d3 100644 --- a/docs/id/error-handling/object-details.md +++ b/docs/id/error-handling/object-details.md @@ -4,17 +4,17 @@ description: "Sesuaikan response error dengan router.catch() dan objek ErrorInfo # Detail Objek Error -Deserve menyediakan penanganan error untuk error eksekusi rute, error validasi, error tidak ditemukan, error berkas statis, dan response error khusus. +Setiap kesalahan di Deserve mengalir lewat satu tempat. Lemparan route handler, kegagalan validasi, rute yang hilang, dan error berkas statis semua tiba di handler yang sama, tempat balasan khusus mengambil alih dari [response default](/id/error-handling/default-behavior). ## Penanganan Error Dasar Tangani error dengan method `router.catch()`: ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Tangkap error dari rute mana pun @@ -28,7 +28,7 @@ router.catch((ctx, error) => { method: error.method, url: error.url }, - { status: error.statusCode } + { status: error.statusCode as HttpStatusCode } ) }) @@ -46,7 +46,7 @@ Error handler menerima objek context dan objek error dengan properti ini: - **`error.error`** - instance Error asli ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { Router, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // ---cut--- @@ -61,7 +61,7 @@ router.catch((ctx, error) => { method: error.method, url: error.url }, - { status: error.statusCode } + { status: error.statusCode as HttpStatusCode } ) }) ``` @@ -121,7 +121,7 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { try { - const data = await ctx.body() + const data = await ctx.get.body() // Proses data... return ctx.send.json({ success: true @@ -141,26 +141,23 @@ export async function POST(ctx: Context): Promise { ## Error Validasi -Kembalikan status code yang sesuai untuk error validasi: +Kontrak [validasi](/id/middleware/validation/overview) yang ditolak melempar **422 Unprocessable Entity** dan menjaga alasan kegagalan di `error.error.cause` sebagai array string. `router.catch` yang sama menanganinya, jadi membaca alasan itu mengubah kegagalan menjadi response tingkat field: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' +import { Router } from '@neabyte/deserve' + +const router = new Router() // ---cut--- -export async function POST(ctx: Context): Promise { - const data = await ctx.body() as DataRecord - if (!data.email) { +router.catch((ctx, error) => { + if (error.statusCode === 422 && Array.isArray(error.error.cause)) { + // Munculkan tiap alasan validasi return ctx.send.json( - { - error: 'Email is required' - }, - { - status: 400 - } + { error: 'Validation failed', reasons: error.error.cause }, + { status: 422 } ) } - // Proses data valid... - return ctx.send.json({ - success: true - }) -} + return null +}) ``` + +Bagaimana sebuah kontrak menghasilkan alasan itu ada di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#cara-kegagalan-muncul), yang menjaga aturan validasi di satu tempat dan pembentukan response di sini. diff --git a/docs/id/getting-started/built-for-teams.md b/docs/id/getting-started/built-for-teams.md index 277c7f2..1b95e85 100644 --- a/docs/id/getting-started/built-for-teams.md +++ b/docs/id/getting-started/built-for-teams.md @@ -25,7 +25,7 @@ routes/ Tidak ada registry untuk dicek silang, tidak ada decorator untuk dilacak. Path di disk adalah path di jaringan, dibahas di [File-based Routing](/id/core-concepts/file-based-routing). -![Folder adalah peta: createPattern mengubah tiap path berkas langsung menjadi pola URL, jadi routes/index.ts menjadi GET /, routes/users/index.ts menjadi GET /users, routes/users/[id].ts menjadi GET /users/:id, dan berkas berawalan underscore dilewati sebagai privat](/diagrams/team-folder-map.png) +![Folder adalah peta: tiap path berkas langsung memetakan ke pola URL, jadi routes/index.ts menjadi GET /, routes/users/index.ts menjadi GET /users, routes/users/[id].ts menjadi GET /users/:id, dan berkas berawalan underscore dilewati sebagai privat](/diagrams/team-folder-map.png) ## Junior Merilis di Hari Pertama @@ -57,7 +57,8 @@ import type { Context } from '@neabyte/deserve' // Nama metode adalah verb HTTP export async function POST(ctx: Context): Promise { - const order = await ctx.body() + // Baca body JSON yang sudah diparsing + const order = await ctx.get.body() return ctx.send.json( { created: true, @@ -68,7 +69,7 @@ export async function POST(ctx: Context): Promise { } ``` -Reviewer membaca `POST` dan tahu verb-nya, membaca `ctx.body()` dan tahu input-nya, membaca `ctx.send.json()` dan tahu output-nya. Pola yang sama berlaku di setiap berkas, yang merupakan [pengalaman pengembang](/id/core-concepts/philosophy#keyakinan-inti) yang dituju framework. Detail ada di [Request Handling](/id/core-concepts/request-handling) dan [Objek Context](/id/core-concepts/context-object). +Reviewer membaca `POST` dan tahu verb-nya, membaca `ctx.get.body()` dan tahu input-nya, membaca `ctx.send.json()` dan tahu output-nya. Pola yang sama berlaku di setiap berkas, yang merupakan [pengalaman pengembang](/id/core-concepts/philosophy#keyakinan-inti) yang dituju framework. Detail ada di [Request Handling](/id/core-concepts/request-handling) dan [Objek Context](/id/core-concepts/context-object). ## Aturan Bersama di Satu Tempat @@ -112,10 +113,10 @@ Tim yang lebih besar sering memecah aplikasi menjadi beberapa service. Deserve m import { Router } from '@neabyte/deserve' const api = new Router({ - routesDir: './services/api/routes' + routes: { directory: './services/api/routes' } }) const auth = new Router({ - routesDir: './services/auth/routes' + routes: { directory: './services/auth/routes' } }) // Tiap service punya folder dan port @@ -127,7 +128,7 @@ await Promise.all([ Tiap service punya folder, port, dan file watcher sendiri, jadi tim bergerak paralel tanpa saling mengganggu. Pola lengkapnya, termasuk kode bersama dan error handler bersama, ada di [Multi-Service](/id/core-concepts/multi-service). -![Banyak tangan, satu proses: satu proses Deno menjalankan router API milik dev A di port 3001 dan router Auth milik dev B di port 3002, masing-masing dengan routesDir dan file watcher sendiri, jadi kedua developer bekerja paralel tanpa deployment terpisah atau lapisan jaringan](/diagrams/team-many-hands.png) +![Banyak tangan, satu proses: satu proses Deno menjalankan router API milik dev A di port 3001 dan router Auth milik dev B di port 3002, masing-masing dengan direktori routes dan file watcher sendiri, jadi kedua developer bekerja paralel tanpa deployment terpisah atau lapisan jaringan](/diagrams/team-many-hands.png) ## Langkah Berikutnya diff --git a/docs/id/getting-started/installation.md b/docs/id/getting-started/installation.md index d53c32a..608f4d3 100644 --- a/docs/id/getting-started/installation.md +++ b/docs/id/getting-started/installation.md @@ -8,13 +8,13 @@ Tambahkan Deserve ke proyek Deno dalam satu perintah, lalu lanjut ke gagasan di ## Prasyarat -- [Deno](https://github.com/denoland/deno_install) 2.7.0+ terpasang +- [Deno](https://github.com/denoland/deno_install) 2.8.3+ terpasang Tetap pada rilis Deno terbaru itu ide bagus, karena Deserve berjalan di atas runtime dan setiap pembaruan performa Deno mengalir langsung ke Deserve. ## Install Deserve -Package manager Deno menambahkan Deserve ke proyek. Perintah ini menulis dependensi ke `deno.json` dan menghasilkan `deno.lock`: +[Package manager Deno](https://docs.deno.com/runtime/reference/cli/add/) menambahkan Deserve ke proyek. Perintah ini menulis dependensi ke `deno.json` dan menghasilkan `deno.lock`: ::: code-group diff --git a/docs/id/getting-started/quick-start.md b/docs/id/getting-started/quick-start.md index 9f4ce0f..98884b7 100644 --- a/docs/id/getting-started/quick-start.md +++ b/docs/id/getting-started/quick-start.md @@ -4,7 +4,7 @@ description: "Bangun server HTTP dan rute Deserve pertama dalam kurang dari lima # Mulai Cepat -Jalankan server Deserve dalam kurang dari 5 menit. +Jalankan server Deserve dalam kurang dari 5 menit. Setiap snippet di sini siap salin-tempel, jadi buka `main.ts` di editor dan ikuti. ## Struktur Proyek @@ -19,12 +19,12 @@ Panduan ini berakhir dengan struktur proyek berikut: ## 1. Buat Server -Buat `main.ts`: +Buat `main.ts`. `Router` memindai `./routes` secara default, jadi tidak perlu konfigurasi untuk setup dasar: ```typescript twoslash import { Router } from '@neabyte/deserve' -// Router default routesDir ke ./routes +// Router memindai ./routes secara default const router = new Router() // Dengarkan di port 8000 @@ -33,7 +33,7 @@ await router.serve(8000) ## 2. Buat Rute Pertama -Buat folder `routes` dan tambahkan `index.ts`: +Buat folder `routes` dan tambahkan `index.ts`. Nama fungsi yang diekspor adalah metode HTTP, dan `Context` membawa helper request dan response: ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -50,6 +50,8 @@ export function GET(ctx: Context): Response { ## 3. Jalankan Server +Deno butuh izin network dan read untuk server dan berkas rute: + ```bash deno run --allow-net --allow-read main.ts ``` @@ -68,3 +70,11 @@ Response-nya terlihat seperti ini: "timestamp": "2077-01-01T00:00:00.000Z" } ``` + +## Ke Mana Selanjutnya + +- [Instalasi](/id/getting-started/installation) - tambahkan Deserve ke proyek yang sudah ada +- [Konfigurasi Server](/id/getting-started/server-configuration) - hostname binding, shutdown, dan proteksi proses +- [Konfigurasi Routes](/id/getting-started/routes-configuration) - pemuatan rute, batas, dan hook lanjutan +- [Objek Context](/id/core-concepts/context-object) - API request dan response lengkap +- [Routing Berbasis File](/id/core-concepts/file-based-routing) - cara folder memetakan ke URL diff --git a/docs/id/getting-started/routes-configuration.md b/docs/id/getting-started/routes-configuration.md index c9e9f38..2760728 100644 --- a/docs/id/getting-started/routes-configuration.md +++ b/docs/id/getting-started/routes-configuration.md @@ -2,29 +2,31 @@ description: "Konfigurasi direktori routes, batas parameter, dan timeout request di Router Deserve." --- -# Konfigurasi Rute +# Konfigurasi Routes -Konfigurasi direktori routes Deserve agar cocok dengan struktur proyek. +Konfigurasi direktori routes Deserve agar cocok dengan struktur proyek. Setiap opsi berada di objek `RouterOptions` yang dioper ke `new Router(...)`. ## Opsi Router -Konstruktor `Router` menerima satu objek opsi. Pasangan sehari-hari adalah `routesDir` untuk folder rute dan `requestTimeoutMs` untuk tenggat request. Bagian di bawah membahas pemuatan rute, batas ukuran request, batas render template, dan dua kait lanjutan `errorResponseBuilder` serta `staticHandler`. Dua opsi terkait ada di halaman sendiri, `trustProxy` di [Resolusi IP Klien](/id/getting-started/server-configuration#resolusi-ip-klien) dan `worker` pool di [Worker Pool](/id/core-concepts/worker-pool). +Konstruktor `Router` menerima satu objek opsi. Pasangan sehari-hari adalah `routes.directory` untuk folder rute dan `timeoutMs` untuk tenggat request. Bagian di bawah membahas pemuatan rute, batas ukuran request, batas render template, dan dua kait lanjutan `trustProxy` serta `worker`. Dua opsi terkait ada di halaman sendiri, `trustProxy` di [Resolusi IP Klien](/id/getting-started/server-configuration#resolusi-ip-klien) dan `worker` pool di [Worker Pool](/id/recipes/worker-pool). ```typescript twoslash import { Router } from '@neabyte/deserve' // Folder rute dan timeout khusus const router = new Router({ - routesDir: 'src/routes', - requestTimeoutMs: 30_000 + routes: { + directory: './src/routes' + }, + timeoutMs: 30_000 }) ``` -## Opsi Konfigurasi +## routes -### `routesDir` +### `routes.directory` -Direktori yang berisi berkas rute: +Direktori yang berisi berkas rute. Default ke `./routes`: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -34,24 +36,47 @@ const defaultRouter = new Router() // Baca rute dari ./src/api const router = new Router({ - routesDir: 'src/api' + routes: { + directory: './src/api' + } +}) +``` + +### `routes.maxParamLength` + +Panjang maksimum satu nilai parameter rute. Nilai lebih panjang ditolak dengan **414 URI Too Long**. Default-nya `1024`: + +```typescript twoslash +import { Router } from '@neabyte/deserve' +// ---cut--- +const router = new Router({ + routes: { + directory: './routes', + maxParamLength: 512 + } }) ``` -### `requestTimeoutMs` +## views -Timeout opsional dalam milidetik untuk seluruh request (middleware + route handler). Jika terlampaui, server membalas dengan **503 Service Unavailable**. Hilangkan atau biarkan undefined untuk tanpa timeout. +### `views.directory` + +Direktori yang berisi berkas template DVE. Default ke `./views`. Ketika dihilangkan, `ctx.render()` melempar karena tidak ada view engine yang dikonfigurasi: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - requestTimeoutMs: 30_000 + routes: { + directory: './routes' + }, + views: { + directory: './views' + } }) ``` -### `maxIterations` +### `views.maxIterations` Iterasi maksimum yang diizinkan per blok {{#each}} di template DVE. Batas ini mencegah event loop kelaparan akibat satu perulangan tak terbatas. Default-nya `100_000`, dan melampauinya membuat mesin melempar sehingga server membalas dengan **400 Bad Request**. @@ -59,15 +84,19 @@ Iterasi maksimum yang diizinkan per blok {{#each}} di templat import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - viewsDir: './views', - maxIterations: 50_000 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxIterations: 50_000 + } }) ``` -Untuk dataset lebih besar dari batas, gunakan [`streamRender`](/id/rendering/streaming), dan lihat [Performa dan Batas](/id/rendering/performance#batas-iterasi) untuk perilaku batasnya. Untuk rendering berat CPU, pertimbangkan mengalihkan ke [worker pool](/id/core-concepts/worker-pool). +Untuk dataset lebih besar dari batas, gunakan [`ctx.render`](/id/core-concepts/context-object#merender-template) dengan `stream: true`, dan lihat [Performa dan Batas](/id/rendering/performance#batas-iterasi) untuk perilaku batasnya. Untuk rendering berat CPU, pertimbangkan mengalihkan ke [worker pool](/id/recipes/worker-pool). -### `maxRenderIterations` +### `views.maxRenderIterations` Total maksimum eksekusi badan {{#each}} dalam satu render, dijumlahkan dari setiap perulangan termasuk yang bersarang. Jika `maxIterations` membatasi satu perulangan, opsi ini membatasi seluruh halaman. Default-nya `1_000_000`, dan melampauinya membalas dengan **400 Bad Request**. @@ -75,13 +104,17 @@ Total maksimum eksekusi badan {{#each}} dalam satu render, di import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - viewsDir: './views', - maxRenderIterations: 500_000 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxRenderIterations: 500_000 + } }) ``` -### `maxOutputSize` +### `views.maxOutputSize` Total maksimum karakter keluaran yang dihasilkan satu render. Batas ini mencegah template kecil membengkak menjadi response besar. Default-nya `5_000_000`, dan melampauinya membalas dengan **400 Bad Request**. @@ -89,88 +122,80 @@ Total maksimum karakter keluaran yang dihasilkan satu render. Batas ini mencegah import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - viewsDir: './views', - maxOutputSize: 1_000_000 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxOutputSize: 1_000_000 + } }) ``` -### `maxUrlLength` +### `views.maxTemplateSize` -Panjang maksimum URL request dalam karakter. URL lebih panjang ditolak dengan **414 URI Too Long** sebelum rute mana pun berjalan. Default-nya `8192`: +Ukuran maksimum satu berkas template dalam karakter. Batas ini mencegah template berukuran berlebih menghabiskan memori sebelum dikompilasi. Default-nya `1_000_000`, diatur oleh [DVE engine](https://jsr.io/@neabyte/dve), dan melampauinya membalas dengan **400 Bad Request**. Batas sama berlaku ke setiap berkas include atau layout yang diresolusi mesin. ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - maxUrlLength: 4096 + routes: { + directory: './routes' + }, + views: { + directory: './views', + maxTemplateSize: 500_000 + } }) ``` -### `maxParamLength` +## maxUrlLength -Panjang maksimum satu nilai parameter rute. Nilai lebih panjang ditolak dengan **414 URI Too Long**. Default-nya `1024`: +Panjang maksimum URL request dalam karakter. URL lebih panjang ditolak dengan **414 URI Too Long** sebelum rute mana pun berjalan. Default-nya `8192`: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - maxParamLength: 512 + maxUrlLength: 4096 }) ``` -### `errorResponseBuilder` +## timeoutMs -Opsi lanjutan yang mengganti cara response error dibangun. Opsi ini menerima context, status code, error, dan handler yang diatur dengan [`router.catch()`](/id/error-handling/object-details), lalu mengembalikan `Response` final. Kebanyakan aplikasi membentuk error lewat `router.catch()` saja, dibahas di [Penanganan Error](/id/error-handling/object-details): +Timeout opsional dalam milidetik untuk seluruh request (middleware + route handler). Jika terlampaui, server membalas dengan **503 Service Unavailable**. Hilangkan atau biarkan undefined untuk tanpa timeout. Lihat juga [Konfigurasi Server](/id/getting-started/server-configuration#request-timeout): ```typescript twoslash -import type { Context, ErrorMiddleware } from '@neabyte/deserve' import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - errorResponseBuilder: { - // Bangun response error khusus - async build( - ctx: Context, - statusCode: number, - error: Error, - errorMiddleware: ErrorMiddleware | null - ) { - return ctx.send.json( - { - failed: true, - statusCode - }, - { status: statusCode } - ) - } - } + routes: { + directory: './routes' + }, + timeoutMs: 30_000 }) ``` -### `staticHandler` +## hotReload -Opsi lanjutan yang mengganti cara berkas statis dilayani. Opsi ini menerima context, [opsi statis](/id/static-file/basic#opsi-static-file) untuk rute yang cocok, dan path URL, lalu mengembalikan `Response`. Implementasi default sudah menjaga path traversal, jadi ganti hanya untuk backend khusus seperti object storage: +Mengaktifkan atau menonaktifkan pemantauan berkas untuk routes dan views. Default ke `true`. Set ke `false` untuk menonaktifkan [hot reload](/id/core-concepts/hot-reload) sepenuhnya, yang cocok untuk deployment produksi di mana pemantauan berkas tidak diperlukan: ```typescript twoslash -import type { Context, ServeOptions } from '@neabyte/deserve' import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes', - staticHandler: { - // Layani berkas dari backend khusus - async serve(ctx: Context, options: ServeOptions, urlPath: string) { - return ctx.send.text(`requested ${urlPath}`) - } - } + hotReload: false }) ``` -Daftarkan rute statisnya sendiri dengan [`router.static()`](/id/static-file/basic), yang lalu dipenuhi handler ini. +## trustProxy + +Mengontrol cara IP klien asli diresolusi di balik proxy atau load balancer. Lihat [Resolusi IP Klien](/id/getting-started/server-configuration#resolusi-ip-klien) untuk panduan lengkapnya. + +## worker + +Mengonfigurasi worker pool untuk mengalihkan pekerjaan berat CPU. Lihat [Worker Pool](/id/recipes/worker-pool) untuk panduan lengkapnya. ## Ekstensi Berkas yang Didukung @@ -193,7 +218,9 @@ Tidak perlu konfigurasi tambahan, karena Deserve mendeteksinya otomatis. import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: 'routes' + routes: { + directory: './routes' + } }) ``` @@ -203,7 +230,9 @@ const router = new Router({ import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: `${Deno.cwd()}/routes` + routes: { + directory: `${Deno.cwd()}/routes` + } }) ``` @@ -211,6 +240,8 @@ const router = new Router({ import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - routesDir: '/absolute/path/to/routes' + routes: { + directory: '/absolute/path/to/routes' + } }) ``` diff --git a/docs/id/getting-started/server-configuration.md b/docs/id/getting-started/server-configuration.md index 114bc31..99a6f93 100644 --- a/docs/id/getting-started/server-configuration.md +++ b/docs/id/getting-started/server-configuration.md @@ -6,11 +6,11 @@ description: "Konfigurasi cara server Deserve listen, shutdown dengan baik, dan > **Referensi**: [Dokumentasi API Deno.serve](https://docs.deno.com/api/deno/~/Deno.serve) -Konfigurasi server Deserve dengan hostname binding dan graceful shutdown. +Konfigurasi server Deserve dengan hostname binding, graceful shutdown, dan proteksi proses. Setiap opsi berada di objek [`RouterOptions`](/id/getting-started/routes-configuration) yang dioper ke `new Router(...)`. ## Setup Server Dasar -Cara paling sederhana memulai server: +Cara paling sederhana memulai server. `Router` memindai `./routes` secara default, jadi tidak perlu konfigurasi untuk setup dasar: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -23,9 +23,9 @@ await router.serve(8000) Ini memulai server di `0.0.0.0:8000`, yang mencakup semua interface. -## Method Serve yang Diperluas +## Method Serve -Method `serve` Deserve yang diperluas mendukung tiga parameter: +`router.serve()` menerima tiga parameter opsional: ```typescript // Signature method @@ -34,6 +34,8 @@ async serve(port?: number, hostname?: string): Promise async serve(port?: number, hostname?: string, signal?: AbortSignal): Promise ``` +Ketika `port` dihilangkan, server membaca `PORT` dari environment dan jatuh ke `8000`. Ketika `hostname` dihilangkan, ia bind ke `0.0.0.0`. + ## Hostname Binding ### Bind ke Interface Spesifik @@ -69,38 +71,40 @@ await router.serve(8000, '0.0.0.0') ## Request Timeout -Request timeout diatur saat membuat router. Ketika middleware dan route handler tidak selesai dalam waktu itu, server membalas dengan **503 Service Unavailable**: +Request timeout diatur dengan `timeoutMs` pada opsi router. Ketika middleware dan route handler tidak selesai dalam waktu itu, server membalas dengan **503 Service Unavailable**: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - requestTimeoutMs: 30_000 + timeoutMs: 30_000 }) await router.serve(8000) ``` -Hilangkan `requestTimeoutMs` untuk tanpa timeout (default). +Hilangkan `timeoutMs` untuk tanpa timeout (default). Daftar lengkap opsi router ada di [Konfigurasi Routes](/id/getting-started/routes-configuration). ## Batas Iterasi Template -Opsi `maxIterations` membatasi iterasi per blok {{#each}} di template DVE, yang mencegah event loop kelaparan akibat satu perulangan tak terbatas. Default-nya `100_000`: +Opsi `views.maxIterations` membatasi iterasi per blok {{#each}} di template DVE, yang mencegah event loop kelaparan akibat satu perulangan tak terbatas. Default-nya `100_000`: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - viewsDir: './views', - maxIterations: 50_000 + views: { + directory: './views', + maxIterations: 50_000 + } }) await router.serve(8000) ``` -Jika template melewati batas, server membalas dengan **400 Bad Request**. Dua batas pendamping, `maxRenderIterations` untuk anggaran perulangan seluruh halaman dan `maxOutputSize` untuk total karakter keluaran, berperilaku sama dan tercantum di [Konfigurasi Rute](/id/getting-started/routes-configuration#opsi-konfigurasi). Perilaku rendering lengkap ada di [Performa dan Batas](/id/rendering/performance#batas-iterasi). Untuk dataset besar, gunakan [`streamRender`](/id/rendering/streaming). Untuk rendering berat CPU, pertimbangkan mengalihkan ke [worker pool](/id/core-concepts/worker-pool). +Jika template melewati batas, server membalas dengan **400 Bad Request**. Dua batas pendamping, `views.maxRenderIterations` untuk anggaran perulangan seluruh halaman dan `views.maxOutputSize` untuk total karakter keluaran, berperilaku sama dan tercantum di [Konfigurasi Routes](/id/getting-started/routes-configuration#views). Perilaku rendering lengkap ada di [Performa dan Batas](/id/rendering/performance#batas-iterasi). Untuk dataset besar, gunakan [`ctx.render`](/id/core-concepts/context-object#merender-template) dengan `stream: true`. Untuk rendering berat CPU, pertimbangkan mengalihkan ke [worker pool](/id/recipes/worker-pool). ## Resolusi IP Klien -Opsi `trustProxy` mengontrol cara IP klien asli diresolusi ketika server berjalan di balik proxy atau load balancer. Tanpa itu, `ctx.ip` mengembalikan peer TCP langsung: +Opsi `trustProxy` mengontrol cara IP klien asli diresolusi ketika server berjalan di balik proxy atau load balancer. Tanpa itu, `ctx.get.ip()` mengembalikan peer TCP langsung: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -114,7 +118,7 @@ const router = new Router({ await router.serve(8000) ``` -Ketika peer langsung cocok dengan aturan tepercaya, Deserve membaca header forwarded untuk menemukan IP pengunjung asli. Ia memeriksa `CF-Connecting-IP` dan `X-Real-IP` lebih dulu, lalu menelusuri rantai `X-Forwarded-For` dan `Forwarded` RFC 7239 dari kanan ke kiri melewati hop tepercaya. +Ketika peer langsung cocok dengan aturan tepercaya, Deserve membaca header forwarded untuk menemukan IP pengunjung asli. Ia memeriksa `CF-Connecting-IP` dan `X-Real-IP` lebih dulu, lalu menelusuri rantai `X-Forwarded-For` dan `Forwarded` [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239) dari kanan ke kiri melewati hop tepercaya. `trustProxy` menerima nilai-nilai ini: @@ -122,16 +126,16 @@ Ketika peer langsung cocok dengan aturan tepercaya, Deserve membaca header forwa - **IP persis atau rentang CIDR** - misalnya `'10.0.0.0/8'` - **Sebuah predikat** - `(ip: string) => boolean` -IP yang diresolusi tersedia di konteks request: +IP yang diresolusi tersedia di konteks request lewat `ctx.get.ip()`: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // IP pengunjung asli setelah trustProxy - const client = ctx.ip + const client = ctx.get.ip() // Peer TCP langsung, abaikan header forwarded - const peer = ctx.directIp + const peer = ctx.get.ip({ direct: true }) return ctx.send.json({ client, peer @@ -139,7 +143,7 @@ export function GET(ctx: Context): Response { } ``` -Tanpa aturan `trustProxy` yang cocok, `ctx.ip` dan `ctx.directIp` mengembalikan alamat peer langsung yang sama. [Middleware pembatasan IP](/id/middleware/ip) memakai `ctx.ip` untuk aturan izin dan tolaknya. +Tanpa aturan `trustProxy` yang cocok, `ctx.get.ip()` dan `ctx.get.ip({ direct: true })` mengembalikan alamat peer langsung yang sama. [Middleware pembatasan IP](/id/middleware/ip) memakai `ctx.get.ip()` untuk aturan izin dan tolaknya. ## Graceful Shutdown @@ -158,14 +162,14 @@ ac.abort() ### Penanganan Sinyal Proses -Tanpa `AbortSignal`, router mendengarkan `SIGINT` dan `SIGTERM` sendiri (hanya `SIGINT` di Windows) dan menyelesaikan request berjalan dengan rapi pada salah satunya. Tidak perlu menyiapkan sinyal secara manual: +Tanpa `AbortSignal`, router mendengarkan `SIGINT`, `SIGTERM`, dan `SIGHUP` sendiri (hanya `SIGINT` dan `SIGBREAK` di Windows) dan menyelesaikan request berjalan dengan rapi pada salah satunya. Tidak perlu menyiapkan sinyal secara manual: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() -// SIGINT dan SIGTERM menuntaskan request otomatis +// Sinyal menuntaskan request otomatis await router.serve(8000, '127.0.0.1') ``` @@ -173,7 +177,7 @@ Berikan `AbortSignal` ketika shutdown perlu dikendalikan dari kode alih-alih dar ## Proteksi Proses -Router yang sedang melayani memasang sentinel proses yang menjaga service tetap hidup melewati kesalahan yang biasanya menjatuhkannya. Ini penting karena Deserve menjalankan banyak hal dalam satu proses - watcher [hot reload](/id/core-concepts/multi-service#hot-reload), [worker pool](/id/core-concepts/worker-pool), dan sering beberapa [service berdampingan](/id/core-concepts/multi-service). Satu dependensi yang memanggil `Deno.exit()` semestinya tidak menjatuhkan semua service sekaligus. +Router yang sedang melayani memasang sentinel proses yang menjaga service tetap hidup melewati kesalahan yang biasanya menjatuhkannya. Ini penting karena Deserve menjalankan banyak hal dalam satu proses - watcher [hot reload](/id/core-concepts/hot-reload), [worker pool](/id/recipes/worker-pool), dan sering beberapa [service berdampingan](/id/core-concepts/multi-service). Satu dependensi yang memanggil `Deno.exit()` semestinya tidak menjatuhkan semua service sekaligus. ### Apa yang Diblokir @@ -186,7 +190,7 @@ Selama server berjalan, panggilan terminasi ini dicegat dan diubah jadi no-op: ### Tidak Diam -Setiap panggilan yang diblokir dilaporkan, tidak pernah ditelan dalam diam. Sentinel memancarkan event [`process:error`](/id/middleware/observability/events#process) dengan `origin: 'process:exit'` dan pesan yang menyebut panggilan yang diblokir, misalnya `Blocked Deno.exit(0) - process termination is not permitted from application code`. Unhandled rejection dan uncaught error muncul dengan cara yang sama dengan `origin: 'unhandledrejection'` atau `'uncaughterror'`. +Setiap panggilan yang diblokir dilaporkan, tidak pernah ditelan dalam diam. Sentinel memancarkan event [`process:failed`](/id/middleware/observability/events) dengan `origin: 'process:exit'` dan pesan yang menyebut panggilan yang diblokir, misalnya `Blocked Deno.exit(0) process termination is not permitted from application code`. Unhandled rejection dan uncaught error muncul dengan cara yang sama dengan `origin: 'unhandledrejection'` atau `'uncaughterror'`. Berlangganan untuk melihatnya: @@ -196,7 +200,7 @@ import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.on((event) => { - if (event.kind === 'process:error') { + if (event.kind === 'process:failed') { const { origin, error } = event.metadata as { origin: string; error: Error } // Catat kesalahan yang diblokir atau tak tertangkap console.error(`[${origin}]`, error.message) @@ -204,7 +208,7 @@ router.on((event) => { }) ``` -Lihat [Pelaporan Error](/id/middleware/observability/errors) untuk pola lengkapnya. +Lihat [Pelaporan Error](/id/error-handling/object-details) untuk pola lengkapnya. ### Model Ancaman diff --git a/docs/id/middleware/basic-auth.md b/docs/id/middleware/basic-auth.md index b2d6784..6e0ebac 100644 --- a/docs/id/middleware/basic-auth.md +++ b/docs/id/middleware/basic-auth.md @@ -101,9 +101,34 @@ router.use( ) ``` +## Realm Kustom + +Opsi `realm` menamai area terlindungi di prompt browser dan default ke `'Secure Area'`: + +```typescript twoslash +import { Mware, Router } from '@neabyte/deserve' + +const router = new Router() +// ---cut--- +// Namai area yang muncul di prompt +router.use( + Mware.basicAuth({ + realm: 'Admin Panel', + users: [ + { + username: 'admin', + password: 'secret' + } + ] + }) +) +``` + ## Penanganan Error -Login yang gagal menghasilkan **401 Unauthorized** dan header `WWW-Authenticate: Basic realm="Secure Area"`, yang membuat browser menampilkan prompt login. Kredensial diperiksa dalam waktu konstan untuk menghindari kebocoran timing, dan array `users` kosong melempar `Deno.errors.InvalidData` saat middleware dibuat. Response 401 dialirkan ke [error handler terpusat](/id/error-handling/object-details), jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). +Login yang gagal menghasilkan **401 Unauthorized** dan header `WWW-Authenticate: Basic realm="..."`, yang membuat browser menampilkan prompt login. Realm default ke `'Secure Area'` dan bisa diganti lewat opsi `realm`. Kredensial diperiksa dalam waktu konstan untuk menghindari kebocoran timing, dan array `users` kosong melempar `Deno.errors.InvalidData` saat middleware dibuat. + +Tiap penolakan memancarkan event `auth:failed` dengan alasannya - `missing`, `malformed`, atau `invalid` - dibahas di [Referensi Event](/id/middleware/observability/events). Response 401 dialirkan ke [error handler terpusat](/id/error-handling/object-details), jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). ## Autentikasi Browser diff --git a/docs/id/middleware/body-limit.md b/docs/id/middleware/body-limit.md index 45c21e2..520857b 100644 --- a/docs/id/middleware/body-limit.md +++ b/docs/id/middleware/body-limit.md @@ -6,7 +6,7 @@ description: "Batasi ukuran body request masuk untuk mencegah payload yang terla > **Referensi**: [RFC 7230 HTTP/1.1 Message Syntax and Routing](https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.1) -Middleware Body Limit memberlakukan ukuran maksimum body request. Ketika body ada pada metode yang mengizinkannya, stream body dibungkus dengan limiter sehingga ukurannya diberlakukan saat byte tiba, bukan hanya dari header, yang mencegah payload besar membebani server. +Middleware Body Limit memberlakukan ukuran maksimum body request dengan memeriksa header `Content-Length`. Ketika request membawa body pada metode yang mengizinkannya, middleware menolak payload berukuran berlebih sebelum body dibaca, yang mencegah payload besar membebani server. ## Penggunaan Dasar @@ -56,7 +56,7 @@ router.use('/api', Mware.bodyLimit({ ### `limit` -Ukuran body maksimum dalam byte: +Ukuran body maksimum dalam byte. Harus angka positif berhingga, jika tidak middleware melempar `Deno.errors.InvalidData` saat dibuat: ```typescript // 1MB (1,048,576 byte) @@ -71,44 +71,14 @@ limit: 10 * 1024 * 1024 ## Cara Kerja -Ketika request bisa membawa body, middleware memeriksa ukuran yang dideklarasikan dulu, lalu membungkus stream body dengan limiter byte sehingga ukurannya diberlakukan saat body dibaca, bukan hanya dari header: +Middleware memeriksa ukuran yang dideklarasikan dari header `Content-Length` sebelum body dibaca: -1. **GET atau HEAD** - tidak ada yang dibungkus dan request lolos. -2. **Content-Length** - ketika ada tanpa `Transfer-Encoding`, request ditolak sebelum body dibaca jika nilainya bukan angka, negatif, atau di atas `limit`. -3. **Body ada** - pada metode yang mengizinkan body, stream dibungkus dengan limiter. Ketika klien mengirim byte lebih banyak dari `limit`, pembacaan berhenti dan middleware membalas dengan **413**. +1. **GET atau HEAD** - request lolos tanpa pemeriksaan, karena metode ini tidak membawa body +2. **Content-Length ada** - ketika nilainya bukan angka, negatif, atau di atas `limit`, request ditolak dengan **413** sebelum body dibaca +3. **Tanpa Content-Length** - request lolos, dan body dibaca normal oleh handler -### RFC 7230 - -- Ketika `Transfer-Encoding` dan `Content-Length` sama-sama ada, `Transfer-Encoding` didahulukan. -- Body chunked atau panjang tak diketahui tetap dibatasi oleh stream yang dibungkus, dan hanya byte yang dibaca yang dihitung terhadap batas. - -Middleware ini membatasi berapa byte yang boleh dibawa sebuah body. Memeriksa bentuk byte itu adalah langkah terpisah yang dijalankan kontrak [validasi](/id/middleware/validation/overview) setelah body berada dalam batas. - -## Contoh Lengkap - -```typescript twoslash -import { Mware, Router } from '@neabyte/deserve' - -const router = new Router({ - routesDir: './routes' -}) - -// Batas global 1MB -router.use(Mware.bodyLimit({ - limit: 1024 * 1024 -})) - -// Batas lebih besar untuk upload dan API -router.use('/uploads', Mware.bodyLimit({ - limit: 5 * 1024 * 1024 -})) -router.use('/api', Mware.bodyLimit({ - limit: 10 * 1024 * 1024 -})) - -await router.serve(8000) -``` +Ini membatasi berapa byte yang boleh dideklarasikan sebuah body. Memeriksa bentuk byte itu adalah langkah terpisah yang dijalankan kontrak [validasi](/id/middleware/validation/overview) setelah body berada dalam batas. ## Penanganan Error -Ketika batas terlampaui, middleware gagal dengan status **413** dan pesan `Request body exceeds bytes limit`, baik saat `Content-Length` yang dideklarasikan memicunya sebelum body dibaca maupun saat stream yang terlalu besar memicunya begitu byte tambahan tiba. Kegagalan itu dialirkan ke [error handler terpusat](/id/error-handling/object-details) seperti error lain, jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). +Ketika batas terlampaui, middleware gagal dengan status **413** dan pesan `Request body exceeds bytes limit`. Kegagalan itu dialirkan ke [error handler terpusat](/id/error-handling/object-details) seperti error lain, jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). Event observability `body:rejected` juga menyala dengan batas dan ukuran yang dideklarasikan, dibahas di [Referensi Event](/id/middleware/observability/events). diff --git a/docs/id/middleware/cors.md b/docs/id/middleware/cors.md index c30e83e..9059eb0 100644 --- a/docs/id/middleware/cors.md +++ b/docs/id/middleware/cors.md @@ -80,7 +80,7 @@ origin: '*' ### `methods` -Tentukan metode HTTP yang diizinkan: +Tentukan metode HTTP yang diizinkan. Default ke ketujuh metode yang didukung: ```typescript methods: [ @@ -95,7 +95,7 @@ methods: [ ### `allowedHeaders` -Tentukan header yang diizinkan: +Tentukan header yang diizinkan. Default ke `Content-Type`, `Authorization`, dan `X-Requested-With`: ```typescript allowedHeaders: [ @@ -126,7 +126,7 @@ credentials: true // Izinkan cookie dan header authorization ### `maxAge` -Atur durasi cache preflight dalam detik: +Atur durasi cache preflight dalam detik. Default ke `86400` (24 jam): ```typescript maxAge: 3600 // Cache request preflight selama 1 jam @@ -147,10 +147,10 @@ Setiap opsi punya default, jadi `Mware.cors()` tanpa argumen mengizinkan origin ## Cara Kerja -- **Tanpa header Origin** - request lolos tanpa disentuh, karena bukan lintas-origin. -- **Preflight OPTIONS** - origin yang cocok mendapat **204 No Content** dengan header CORS, dan origin yang tidak cocok mendapat **403 Forbidden**. -- **Request sebenarnya** - origin yang cocok menerima `Access-Control-Allow-Origin` plus kredensial dan header yang diekspos saat dikonfigurasi. -- **Header Vary** - `Vary: Origin` ditambahkan setiap kali `origin` bukan wildcard `'*'`, jadi cache tetap benar. +- **Tanpa header Origin** - request lolos tanpa disentuh, karena bukan lintas-origin +- **Preflight OPTIONS** - origin yang cocok mendapat **204 No Content** dengan header CORS. Origin yang tidak cocok juga mendapat **204** tapi tanpa header CORS, jadi browser memblokir request sebenarnya. Event `cors:blocked` menyala untuk origin yang tidak cocok +- **Request sebenarnya** - origin yang cocok menerima `Access-Control-Allow-Origin` plus kredensial dan header yang diekspos saat dikonfigurasi. Origin yang tidak cocok tidak mendapat header CORS dan event `cors:blocked` menyala +- **Header Vary** - `Vary: Origin` ditambahkan setiap kali `origin` bukan wildcard `'*'`, jadi cache tetap benar ## Kredensial dan Wildcard @@ -170,49 +170,6 @@ router.use( ) ``` -## Contoh Lengkap - -```typescript twoslash -import { Mware, Router } from '@neabyte/deserve' - -const router = new Router({ - routesDir: './routes' -}) - -// CORS produksi dengan opsi lengkap -router.use( - Mware.cors({ - origin: [ - 'http://localhost:3000', - 'http://localhost:5173', - 'https://yourdomain.com' - ], - methods: [ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'PATCH', - 'OPTIONS' - ], - allowedHeaders: [ - 'Content-Type', - 'Authorization', - 'X-Requested-With', - 'X-Custom-Header' - ], - exposedHeaders: [ - 'X-Total-Count', - 'X-Page-Count' - ], - credentials: true, - maxAge: 3600 - }) -) - -await router.serve(8000) -``` - ## Header CORS Umum ### Header Request diff --git a/docs/id/middleware/csrf.md b/docs/id/middleware/csrf.md index 50fb2be..79af47e 100644 --- a/docs/id/middleware/csrf.md +++ b/docs/id/middleware/csrf.md @@ -102,4 +102,4 @@ type CsrfRulePredicate = (value: string, ctx: Context) => boolean Ketika request diblokir, middleware menghasilkan **403** dan pesan `Request blocked by CSRF protection`. Kegagalan itu dialirkan ke [error handler terpusat](/id/error-handling/object-details), jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). -Aturan `origin` atau `secFetchSite` kustom yang melempar gagal pemeriksaannya sendiri dan jatuh aman ke penolakan, dan kesalahannya muncul sebagai event [`csrf:rule-error`](/id/middleware/observability/events#middleware) yang menyebut aturan mana yang rusak alih-alih tetap tersembunyi. +Aturan `origin` atau `secFetchSite` kustom yang melempar gagal pemeriksaannya sendiri dan jatuh aman ke penolakan, dan kesalahannya muncul sebagai event [`csrf:failed`](/id/middleware/observability/events) yang menyebut aturan mana yang rusak alih-alih tetap tersembunyi. diff --git a/docs/id/middleware/global.md b/docs/id/middleware/global.md index 4b5785c..4baaa9f 100644 --- a/docs/id/middleware/global.md +++ b/docs/id/middleware/global.md @@ -21,7 +21,7 @@ const router = new Router() // Catat setiap request, lalu lanjut router.use(async (ctx, next) => { - console.log(`${ctx.request.method} ${ctx.url}`) + console.log(`${ctx.get.method()} ${ctx.get.url().href}`) return await next() }) @@ -34,14 +34,16 @@ await router.serve(8000) type MiddlewareFn = ( ctx: Context, next: () => Promise -) => Response | undefined | Promise +) => Promise ``` -- **Kembalikan `await next()`** - lanjut ke middleware atau route handler berikutnya, yang memungkinkan modifikasi dan inspeksi response. -- **Kembalikan `Response`** - hentikan pemrosesan dan kembalikan response itu seketika. -- **Kembalikan `undefined`** - diperlakukan sebagai pass-through jadi rantai lanjut seolah `next()` dipanggil. +- **Kembalikan `await next()`** - lanjut ke middleware atau route handler berikutnya, yang memungkinkan modifikasi dan inspeksi response +- **Kembalikan `Response`** - hentikan pemrosesan dan kembalikan response itu seketika +- **Kembalikan `undefined`** - diperlakukan sebagai pass-through jadi rantai lanjut seolah `next()` dipanggil -Middleware harus memanggil `next()` dan memakai hasilnya atau mengembalikan sebuah `Response`. Ketika tidak melakukan keduanya, misalnya tidak pernah memanggil `next()` dan tidak mengembalikan apa pun, request bisa menggantung, jadi `requestTimeoutMs` di `Router` membatasi durasi request dan mengembalikan 503. +Middleware harus memanggil `next()` dan memakai hasilnya atau mengembalikan sebuah `Response`. Ketika tidak melakukan keduanya, misalnya tidak pernah memanggil `next()` dan tidak mengembalikan apa pun, request bisa menggantung, jadi `timeoutMs` di `Router` membatasi durasi request dan mengembalikan 503. + +![Alur kontrol per-request Global Middleware: kembalikan await next() melanjutkan rantai, mengembalikan Response berhenti dan melewati handler, mengembalikan undefined adalah pass-through; melempar dialihkan ke router.catch atau 500, dan macet memicu penjaga 503 timeoutMs](/diagrams/middleware-global-flow.png) ## Pola Middleware Global Umum @@ -54,7 +56,7 @@ const router = new Router() // ---cut--- router.use(async (ctx, next) => { const start = Date.now() - console.log(`${ctx.request.method} ${ctx.url} - ${new Date().toISOString()}`) + console.log(`${ctx.get.method()} ${ctx.get.url().href} - ${new Date().toISOString()}`) const response = await next() const duration = Date.now() - start console.log(`Completed in ${duration}ms`) @@ -71,41 +73,34 @@ const router = new Router() declare function isValidToken(token: string): boolean // ---cut--- router.use(async (ctx, next) => { - const authHeader = ctx.header('authorization') + const authHeader = ctx.get.header('authorization') if (!authHeader) { - return ctx.send.text( - 'Unauthorized', - { - status: 401 - } - ) + return ctx.send.text('Unauthorized', { status: 401 }) } - // Validasi token di sini... + // Validasi token di sini const token = authHeader.replace('Bearer ', '') if (!isValidToken(token)) { - return ctx.send.text( - 'Invalid token', - { - status: 401 - } - ) + return ctx.send.text('Invalid token', { status: 401 }) } return await next() }) ``` +Untuk alur auth yang lebih bersih, lempar error dan biarkan [error handler terpusat](/id/error-handling/object-details) membentuk response alih-alih membangunnya inline. + ## Membungkus Middleware Dengan Penanganan Error -Middleware kustom yang melempar bisa dibungkus dengan `WrapMware`, jadi error ditangkap dan diteruskan ke `router.catch()` ketika didefinisikan: +Middleware kustom yang melempar bisa dibungkus dengan `Wrap.apply`, jadi error ditangkap dan diteruskan ke `router.catch()` ketika didefinisikan: ```typescript twoslash -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // Bungkus supaya lemparan sampai ke router.catch -const myAuth = WrapMware('Auth', async (ctx, next) => { - if (!ctx.header('x-api-key')) { +const myAuth = Wrap.apply('Auth', async (ctx, next) => { + // Baca API key dari header + if (!ctx.get.header('x-api-key')) { throw new Error('Missing API key') } return await next() @@ -113,48 +108,38 @@ const myAuth = WrapMware('Auth', async (ctx, next) => { // Terapkan middleware dan error handler router.use(myAuth) -router.catch((ctx, err) => { +router.catch((ctx, info) => { return ctx.send.json( - { - error: err.error?.message - }, - { - status: 500 - } + { error: info.error.message }, + { status: info.statusCode as HttpStatusCode } ) }) await router.serve(8000) ``` -**Signature:** `WrapMware(label: string, middleware: MiddlewareFn): MiddlewareFn`. Ketika middleware melempar, error berjalan lewat `ctx.handleError()` sehingga `router.catch()` dipanggil. +**Signature:** `Wrap.apply(label: string, middleware: MiddlewareFn): MiddlewareFn`. Ketika middleware melempar, error berjalan lewat `ctx.handleError()` sehingga `router.catch()` dipanggil. Setiap middleware bawaan di [Mware](/id/middleware/basic-auth) sudah dibungkus dengan cara ini, jadi lemparan di dalamnya membawa label middleware langsung ke error handler. ## Middleware Per Path -Middleware juga berlaku untuk path spesifik: +Middleware juga berlaku untuk path spesifik, dibahas lengkap di [Route-Specific Middleware](/id/middleware/route-specific): ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function isAuthenticated(ctx: Context): boolean // ---cut--- // Berjalan hanya untuk path /api router.use('/api', async (ctx, next) => { - console.log('API request:', ctx.url) + console.log('API request:', ctx.get.pathname()) return await next() }) // Jaga path /admin dengan cek auth router.use('/admin', async (ctx, next) => { if (!isAuthenticated(ctx)) { - return ctx.send.text( - 'Unauthorized', - { - status: 401 - } - ) + return ctx.send.text('Unauthorized', { status: 401 }) } return await next() }) diff --git a/docs/id/middleware/ip.md b/docs/id/middleware/ip.md index 4c6eed1..680e882 100644 --- a/docs/id/middleware/ip.md +++ b/docs/id/middleware/ip.md @@ -18,7 +18,10 @@ const router = new Router() // Izinkan hanya alamat terdaftar router.use( Mware.ip({ - whitelist: ['127.0.0.1', '192.168.1.0/24'] + whitelist: [ + '127.0.0.1', + '192.168.1.0/24' + ] }) ) @@ -37,7 +40,10 @@ const router = new Router() // Tolak alamat terdaftar, izinkan lainnya router.use( Mware.ip({ - blacklist: ['203.0.113.5', '198.51.100.0/24'] + blacklist: [ + '203.0.113.5', + '198.51.100.0/24' + ] }) ) ``` @@ -75,8 +81,8 @@ Aturan yang tidak valid melempar `Deno.errors.InvalidData` saat middleware dibua - **Blacklist ada** - IP yang cocok dengan blacklist ditolak, sisanya lolos. - **Tidak ada yang diatur** - setiap request lolos. -Middleware membaca IP klien yang diresolusi dari `ctx.ip`. Di balik proxy, atur [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien) supaya IP pengunjung asli yang dipakai. +Middleware membaca IP klien yang diresolusi dari `ctx.get.ip()`. Di balik proxy, atur [`trustProxy`](/id/getting-started/server-configuration#resolusi-ip-klien) supaya IP pengunjung asli yang dipakai. ## Penanganan Error -Ketika request ditolak, middleware menghasilkan **403** dan pesan `Access denied by IP restriction`. Kegagalan itu dialirkan ke [error handler terpusat](/id/error-handling/object-details), jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). +Ketika request ditolak, middleware menghasilkan **403** dan pesan `Access denied by IP restriction`. Kegagalan itu dialirkan ke [error handler terpusat](/id/error-handling/object-details), jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). Event `ip:denied` juga menyala dengan alamat IP yang ditolak, dibahas di [Referensi Event](/id/middleware/observability/events). diff --git a/docs/id/middleware/observability/errors.md b/docs/id/middleware/observability/errors.md index fc30c08..d7a6ccd 100644 --- a/docs/id/middleware/observability/errors.md +++ b/docs/id/middleware/observability/errors.md @@ -8,18 +8,18 @@ Error muncul di bus [`router.on()`](/id/middleware/observability/overview) yang ## Melaporkan Request Gagal -`request:error` menyala setiap kali status response `400` atau lebih tinggi, dan membawa error asli ketika ada: +`request:failed` menyala setiap kali status response `400` atau lebih tinggi, dan membawa error asli ketika ada: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Catat setiap request gagal router.on((event) => { - if (event.kind === 'request:error') { + if (event.kind === 'request:failed') { const { method, url, statusCode, error } = event.metadata as { method: string url: string @@ -35,19 +35,19 @@ await router.serve(8000) ## Menangkap Kesalahan Proses -`process:error` menyala untuk unhandled rejection, uncaught error, dan upaya terminasi yang diblokir. Router yang sedang melayani tetap berjalan dan melaporkan kesalahan alih-alih crash: +`process:failed` menyala untuk unhandled rejection, uncaught error, dan upaya terminasi yang diblokir. Router yang sedang melayani tetap berjalan dan melaporkan kesalahan alih-alih crash: -![Unhandled rejection, uncaught error, dan terminasi diri yang diblokir masing-masing jadi event process:error yang membawa origin dan error-nya, jadi proses tetap berjalan tanpa downtime dan kesalahan tertangkap di listener router.on yang sama alih-alih hilang karena crash](/diagrams/obs-process-fault.png) +![Unhandled rejection, uncaught error, dan terminasi diri yang diblokir masing-masing jadi event process:failed yang membawa origin dan error-nya, jadi proses tetap berjalan tanpa downtime dan kesalahan tertangkap di listener router.on yang sama alih-alih hilang karena crash](/diagrams/obs-process-fault.png) ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { - if (event.kind === 'process:error') { + if (event.kind === 'process:failed') { const { origin, error } = event.metadata as { origin: string; error: Error } // origin menunjuk sumber kesalahan console.error(`process fault [${origin}]`, error.message) @@ -57,18 +57,18 @@ router.on((event) => { ## Menangkap Kesalahan Subsistem -Listener yang sama menangkap kesalahan dari worker pool dan middleware bawaan. Task yang timeout, worker yang crash, dispatch yang ditolak di bawah beban, cookie session yang gagal didekode, dan aturan CSRF yang melempar masing-masing tiba sebagai event-nya sendiri. Saring berdasarkan kind yang terdaftar di [Referensi Event](/id/middleware/observability/events#worker) untuk merutekannya ke tempat log: +Listener yang sama menangkap kesalahan dari worker pool dan middleware bawaan. Task yang timeout, worker yang crash, dispatch yang ditolak di bawah beban, cookie session yang gagal didekode, dan aturan CSRF yang melempar masing-masing tiba sebagai event-nya sendiri. Saring berdasarkan kind yang terdaftar di [Worker](/id/middleware/observability/events#worker) dan [Middleware Keamanan](/id/middleware/observability/events#middleware-keamanan) untuk merutekannya ke tempat log: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Bereaksi pada kesalahan worker dan middleware - if (event.kind === 'worker:crash' || event.kind === 'session:invalid') { + if (event.kind === 'worker:crashed' || event.kind === 'session:invalid') { console.error(event.kind, event.metadata) } }) diff --git a/docs/id/middleware/observability/events.md b/docs/id/middleware/observability/events.md index 863df29..7d94914 100644 --- a/docs/id/middleware/observability/events.md +++ b/docs/id/middleware/observability/events.md @@ -1,81 +1,97 @@ --- -description: "Referensi semua event siklus hidup dan error yang dipancarkan router Deserve yang sedang melayani." +description: "Referensi semua event siklus hidup, keamanan, request, dan kesalahan yang dipancarkan router Deserve yang sedang melayani." --- # Referensi Event Setiap event dari [`router.on()`](/id/middleware/observability/overview) membawa diskriminan `kind` dan objek `metadata`. Halaman ini mendaftar setiap jenis dan field yang disediakannya. -![Event request bernilai external secara default tapi jadi internal ketika dipicu oleh timeout, error framework, atau context yang hilang, sementara setiap kind non-request selalu internal, jadi merutekan berdasarkan field type menjaga lalu lintas klien normal tetap di luar kanal alert kesalahan](/diagrams/obs-event-channel.png) +![Event request bernilai external secara default tapi jadi internal ketika dipicu oleh timeout, error framework, atau context yang hilang, sementara setiap kind non-request lain bersifat internal kecuali process:failed yang selalu tetap external, jadi merutekan berdasarkan field type menjaga lalu lintas klien normal tetap di luar kanal alert kesalahan](/diagrams/obs-event-channel.png) ## Server -| Kind | Metadata | -| ------------------- | ------------------------- | -| `server:listening` | `port`, `hostname` | -| `server:shutdown` | tidak ada | +| Kind | Metadata | +| ---------------- | ------------------ | +| `server:started` | `port`, `hostname` | +| `server:stopped` | tidak ada | -`server:listening` menyala saat server mengikat port. `server:shutdown` menyala setelah server selesai menuntaskan request berjalan. +`server:started` menyala setelah server mengikat port. `server:stopped` menyala setelah server selesai menuntaskan request berjalan. ## Rute -| Kind | Metadata | -| ---------------- | --------------------------------- | -| `route:loaded` | `routePath`, `pattern` | -| `route:reloaded` | `routePath`, `pattern` | -| `route:removed` | `routePath`, `pattern` | -| `route:skipped` | `routePath`, `reason` | -| `route:error` | `routePath`, `error` | -| `reload:error` | `routePath`, `error` | +| Kind | Metadata | +| --------------- | ----------------- | +| `route:added` | `path`, `pattern` | +| `route:updated` | `path`, `pattern` | +| `route:removed` | `path`, `pattern` | +| `route:ignored` | `path`, `reason` | +| `route:failed` | `path`, `error` | -Event reload datang dari hot reload saat berkas berubah di disk. +`route:added` menyala saat berkas rute dimuat, `route:updated` saat [hot reload](/id/core-concepts/hot-reload) menangkap perubahan, dan `route:removed` saat sebuah berkas hilang. `route:ignored` menyebut berkas yang dilewati dan alasannya, sementara `route:failed` membawa error saat sebuah rute gagal dimuat. ## View -| Kind | Metadata | -| ---------------- | ------------------------- | -| `view:compiled` | `path`, `durationMs` | -| `view:rendered` | `path`, `durationMs` | -| `view:refreshed` | `paths` | -| `view:error` | `path`, `error` | +| Kind | Metadata | +| ------------------ | -------------------- | +| `view:compiled` | `path`, `durationMs` | +| `view:rendered` | `path`, `durationMs` | +| `view:invalidated` | `paths` | +| `view:failed` | `path`, `error` | -Event view datang dari [mesin rendering DVE](/id/rendering/). +Event view datang dari [mesin rendering DVE](/id/rendering/). `view:invalidated` menyala saat perubahan template membersihkan output yang di-cache, membawa setiap path yang terpengaruh. ## Worker -| Kind | Metadata | -| ----------------- | ------------------------------------------------- | -| `worker:timeout` | `workerIndex`, `timeoutMs`, `error` | -| `worker:crash` | `workerIndex`, `error` | -| `worker:respawn` | `workerIndex` | -| `worker:rejected` | `reason` (`queue-depth`, `queue-wait`), `queueDepth`, `maxQueueDepth` | - -`worker:timeout` menyala saat sebuah task melewati tenggatnya, `worker:crash` saat worker mati di tengah task, dan `worker:respawn` saat slot yang dibebaskan diganti. `worker:rejected` menyala saat sebuah dispatch ditolak di bawah beban, dengan `reason` menyebut apakah kedalaman antrean atau proyeksi tunggu yang memicu batas. Ini datang dari [worker pool](/id/core-concepts/worker-pool). - -## Middleware - -| Kind | Metadata | -| ----------------- | ------------------------------------------------- | -| `session:invalid` | `cookieName`, `reason` (`tampered`, `expired`, `malformed`) | -| `csrf:rule-error` | `rule` (`origin`, `secFetchSite`), `error` | - -`session:invalid` menyala saat cookie bertanda tangan gagal didekode, dengan `reason` menyebut apakah nilainya dirusak, sudah lewat `maxAge`, atau malformed, sementara request lanjut tanpa session terpasang. Ini datang dari [middleware session](/id/middleware/session). `csrf:rule-error` menyala saat aturan CSRF kustom melempar, menyebut aturan mana yang rusak sementara pemeriksaan tetap jatuh aman ke penolakan. Ini datang dari [middleware CSRF](/id/middleware/csrf). +| Kind | Metadata | +| ------------------ | -------------------------------------------------------------------- | +| `worker:timeout` | `index`, `timeoutMs`, `error` | +| `worker:crashed` | `index`, `error` | +| `worker:respawned` | `index` | +| `worker:rejected` | `reason` (`queue-depth`, `queue-wait`), `queueDepth`, `maxQueueDepth` | + +`worker:timeout` menyala saat sebuah task melewati tenggatnya, `worker:crashed` saat worker mati di tengah task, dan `worker:respawned` saat slot yang dibebaskan diganti. `worker:rejected` menyala saat sebuah dispatch ditolak di bawah beban, dengan `reason` menyebut apakah kedalaman antrean atau proyeksi tunggu yang memicu batas. Ini datang dari [worker pool](/id/recipes/worker-pool). + +## Middleware Keamanan + +| Kind | Metadata | +| -------------------- | --------------------------------------------------------- | +| `session:invalid` | `cookieName`, `reason` (`tampered`, `expired`, `malformed`) | +| `csrf:failed` | `rule` (`origin`, `secFetchSite`), `error` | +| `cors:blocked` | `origin` | +| `auth:failed` | `reason` (`missing`, `malformed`, `invalid`) | +| `ip:denied` | `ip` | +| `validate:failed` | `source` (`body`, `cookies`, `headers`, `query`), `reasons` | +| `body:rejected` | `limit`, `declared` | +| `websocket:rejected` | `reason` (`origin`, `version`, `malformed`) | +| `static:missing` | `path` | + +Setiap event keamanan berpasangan dengan middleware-nya dan menyala saat pemeriksaan menolak request: + +- `session:invalid` menyala saat cookie bertanda tangan gagal didekode, dengan `reason` menyebut nilai yang dirusak, yang sudah lewat `maxAge`, atau yang malformed, sementara request lanjut tanpa session terpasang. Ini datang dari [middleware session](/id/middleware/session). +- `csrf:failed` menyala saat aturan CSRF melempar, menyebut aturan mana yang rusak sementara pemeriksaan tetap jatuh aman ke penolakan. Ini datang dari [middleware CSRF](/id/middleware/csrf). +- `cors:blocked` menyala saat sebuah origin ditolak, membawa origin itu. Ini datang dari [middleware CORS](/id/middleware/cors). +- `auth:failed` menyala pada login yang ditolak, dengan `reason` menyebut header yang hilang, yang malformed, atau kredensial yang salah. Ini datang dari [middleware basic auth](/id/middleware/basic-auth). +- `ip:denied` menyala saat sebuah alamat diblokir, membawa IP yang ditolak. Ini datang dari [middleware pembatasan IP](/id/middleware/ip). +- `validate:failed` menyala saat sebuah kontrak menolak input, menyebut `source` dan `reasons`. Ini datang dari [validasi](/id/middleware/validation/overview). +- `body:rejected` menyala saat body yang dideklarasikan melampaui batas, membawa `limit` dan ukuran `declared`. Ini datang dari [middleware body limit](/id/middleware/body-limit). +- `websocket:rejected` menyala pada handshake yang ditolak, dengan `reason` menyebut origin yang buruk, ketidakcocokan versi, atau upgrade yang malformed. Ini datang dari [middleware WebSocket](/id/middleware/websocket). +- `static:missing` menyala saat sebuah path statis tidak menemukan berkas, membawa path itu. ## Request -| Kind | Metadata | -| ------------------- | --------------------------------------------------- | -| `request:complete` | `method`, `statusCode`, `url`, `durationMs`, metrik | -| `request:error` | sama dengan `request:complete`, plus `error` opsional | +| Kind | Metadata | +| ------------------- | ---------------------------------------------------- | +| `request:completed` | `method`, `statusCode`, `url`, `durationMs`, metrik | +| `request:failed` | sama dengan `request:completed`, plus `error` opsional | -`request:complete` menyala untuk setiap request yang selesai. `request:error` menyala tambahan setiap kali status `400` atau lebih tinggi, dan membawa `error` hanya saat kegagalan dipicu oleh error framework. Keduanya membawa metrik selaras-OpenTelemetry opsional saat diketahui: `route`, `serverAddress`, `serverPort`, `userAgent`, `requestSize`, `responseSize`, dan `ip`. +`request:completed` menyala untuk setiap request yang selesai. `request:failed` menyala tambahan setiap kali status `400` atau lebih tinggi, dan membawa `error` hanya saat kegagalan dipicu oleh error framework. Keduanya membawa metrik selaras-OpenTelemetry opsional saat diketahui: `route`, `serverAddress`, `serverPort`, `userAgent`, `requestSize`, `responseSize`, dan `ip`. Ubah ini menjadi log di [Request Logging](/id/middleware/observability/logging). ## Process -| Kind | Metadata | -| --------------- | -------------------------------------------------------------------- | -| `process:error` | `error`, `origin` (`unhandledrejection`, `uncaughterror`, `process:exit`) | +| Kind | Metadata | +| ---------------- | --------------------------------------------------------------------------------------- | +| `process:failed` | `error`, `origin` (`unhandledrejection`, `uncaughterror`, `process:exit`, `process:signal`) | -Router yang sedang melayani menjebak unhandled rejection, uncaught error, dan upaya menghentikan proses. Setiap kesalahan menjadi event `process:error` alih-alih membuat server crash, jadi satu kegagalan tidak pernah menjatuhkan proses. Panggilan terminasi yang diblokir membawa `origin: 'process:exit'` dan menyebut panggilannya, contohnya `Blocked Deno.exit(0) — process termination is not permitted from application code`. Lihat [Proteksi Proses](/id/getting-started/server-configuration#proteksi-proses) untuk alasannya, dan tangkap ini di [Pelaporan Error](/id/middleware/observability/errors). +Router yang sedang melayani menjebak unhandled rejection, uncaught error, dan upaya menghentikan proses. Setiap kesalahan menjadi event `process:failed` alih-alih membuat server crash, jadi satu kegagalan tidak pernah menjatuhkan proses. Panggilan terminasi yang diblokir membawa `origin: 'process:exit'` dan menyebut panggilannya, contohnya `Blocked Deno.exit(0) process termination is not permitted from application code`. Lihat [Proteksi Proses](/id/getting-started/server-configuration#proteksi-proses) untuk alasannya, dan tangkap ini di [Pelaporan Error](/id/middleware/observability/errors). diff --git a/docs/id/middleware/observability/logging.md b/docs/id/middleware/observability/logging.md index d2b174d..e7d3dea 100644 --- a/docs/id/middleware/observability/logging.md +++ b/docs/id/middleware/observability/logging.md @@ -6,22 +6,22 @@ description: "Ubah event request Deserve menjadi log request terstruktur." Satu langganan [`router.on()`](/id/middleware/observability/overview) mengubah setiap request yang selesai menjadi access log terstruktur, tanpa kode logging di dalam handler. -![Setiap request yang selesai memancarkan request:complete dengan metrik selaras OpenTelemetry, dan request dengan status 400 atau lebih juga memancarkan request:error yang membawa error asli, jadi satu listener router.on menyebarkan amplop yang sama ke satu baris access log, peringatan request lambat yang disaring berdasarkan durasi, dan laporan error](/diagrams/obs-request-lifecycle.png) +![Setiap request yang selesai memancarkan request:completed dengan metrik selaras OpenTelemetry, dan request dengan status 400 atau lebih juga memancarkan request:failed yang membawa error asli, jadi satu listener router.on menyebarkan amplop yang sama ke satu baris access log, peringatan request lambat yang disaring berdasarkan durasi, dan laporan error](/diagrams/obs-request-lifecycle.png) ## Access Log Dasar -Dengarkan `request:complete` dan cetak satu baris per request: +Dengarkan `request:completed` dan cetak satu baris per request: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Satu baris log per request selesai router.on((event) => { - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { method, url, statusCode, durationMs } = event.metadata as { method: string url: string @@ -43,11 +43,11 @@ Pancarkan JSON ketika pipeline log mengharapkan rekaman terstruktur: import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { // Teruskan seluruh metadata sebagai JSON console.log(JSON.stringify({ at: event.timestamp, @@ -67,12 +67,12 @@ Saring berdasarkan durasi untuk memunculkan hanya lalu lintas lambat: import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Tandai request lebih lambat dari 500ms - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { url, durationMs } = event.metadata as { url: string; durationMs: number } if (durationMs > 500) { console.warn(`SLOW ${url} ${Math.round(durationMs)}ms`) diff --git a/docs/id/middleware/observability/overview.md b/docs/id/middleware/observability/overview.md index 199d616..a1066f2 100644 --- a/docs/id/middleware/observability/overview.md +++ b/docs/id/middleware/observability/overview.md @@ -18,7 +18,7 @@ Hook bergaya middleware ini duduk di samping router dan mengawasi semua yang ter import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Terima setiap event siklus hidup dan error @@ -41,13 +41,13 @@ Setiap event berbagi amplop yang sama: ```typescript { type: 'internal' | 'external', // kanal asal - kind: string, // nama event, seperti 'request:complete' + kind: string, // nama event, seperti 'request:completed' metadata: { ... }, // field spesifik per jenis timestamp: number // milidetik epoch } ``` -- **`type`** - `external` untuk lalu lintas klien normal, `internal` untuk kesalahan framework. Sebuah event request bernilai `internal` ketika kesalahan framework, timeout 503 sintetis, atau context request yang hilang yang memicunya, selain itu `external`. Setiap jenis lain selalu `internal`. +- **`type`** - `external` untuk lalu lintas klien normal, `internal` untuk kesalahan framework. Sebuah event request bernilai `internal` ketika kesalahan framework, timeout 503 sintetis, atau context request yang hilang yang memicunya, selain itu `external`. Jenis `process:failed` adalah satu-satunya pengecualian yang selalu tetap `external`, karena proses yang crash berada di luar kanal request. Setiap jenis lain selalu `internal`. - **`kind`** - diskriminan yang dipakai untuk membedakan event. - **`metadata`** - field readonly yang bergantung pada jenis. - **`timestamp`** - kapan event dibuat. @@ -60,27 +60,27 @@ Observability bus melaporkan aktivitas framework seperti request, rute, view, da ## Jejak Audit Bawaan -Setiap subsistem melapor di bus yang sama, dari sinyal [server](/id/middleware/observability/events#server) dan [rute](/id/middleware/observability/events#rute) sampai kesalahan [worker](/id/middleware/observability/events#worker), [middleware](/id/middleware/observability/events#middleware), dan [proses](/id/middleware/observability/events#process). Masing-masing tiba sebagai amplop `{ type, kind, metadata, timestamp }` yang sama, terstruktur dan bercap waktu pada saat ia menyala. Sebuah listener sederhana berubah menjadi jejak audit yang mencatat dirinya sendiri selama server berjalan, tanpa kabel tambahan. +Setiap subsistem melapor di bus yang sama, dari sinyal [server](/id/middleware/observability/events#server) dan [rute](/id/middleware/observability/events#rute) sampai kesalahan [worker](/id/middleware/observability/events#worker), [middleware keamanan](/id/middleware/observability/events#middleware-keamanan), dan [proses](/id/middleware/observability/events#process). Masing-masing tiba sebagai amplop `{ type, kind, metadata, timestamp }` yang sama, terstruktur dan bercap waktu pada saat ia menyala. Sebuah listener sederhana berubah menjadi jejak audit yang mencatat dirinya sendiri selama server berjalan, tanpa kabel tambahan. -Itu mencakup hal yang biasanya diminta oleh kerja kepatuhan dan keamanan: +Itu mencakup hal yang biasanya diminta oleh kerja kepatuhan dan keamanan, dan tiap kontrol memetakan ke perilaku yang sudah disediakan bus: -- **[SOC 2](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2)**, **[ISO/IEC 27001](https://www.iso.org/standard/27001)**, dan **[PCI DSS](https://www.pcisecuritystandards.org/document_library/)** meminta event relevan-keamanan tercatat. Cookie yang dirusak (`session:invalid`), panggilan terminasi yang diblokir (`process:error`), dan aturan CSRF yang gagal (`csrf:rule-error`) semua menyala sendiri. -- **Garis waktu** dapat direkonstruksi karena aliran event terurut dan setiap event membawa `timestamp` dalam milidetik epoch. -- **Setiap request** dapat dilacak asalnya lewat `method`, `url`, `statusCode`, `durationMs`, dan `ip` opsional pada `request:complete`. -- **[SIEM](https://csrc.nist.gov/glossary/term/security_information_and_event_management)** dapat menyerap aliran ini karena satu `router.on()` meneruskan seluruh permukaan ke kolektor mana pun. +- **[SOC 2](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2) (pemantauan CC7)** ingin event relevan-keamanan tertangkap. Cookie yang dirusak (`session:invalid`), panggilan terminasi yang diblokir (`process:failed`), dan aturan CSRF yang gagal (`csrf:failed`) semua menyala sendiri. +- **[ISO/IEC 27001](https://www.iso.org/standard/27001) (logging A.8.15)** ingin log event yang bertahan dari waktu ke waktu. Setiap event membawa `timestamp` dalam milidetik epoch dan tiba terurut, jadi garis waktu direkonstruksi dengan rapi. +- **[PCI DSS](https://www.pcisecuritystandards.org/document_library/) (jejak audit Persyaratan 10)** ingin tiap aksi terkait dengan sumbernya. `request:completed` melaporkan `method`, `url`, `statusCode`, `durationMs`, dan `ip` opsional ketika alamatnya diketahui. +- **[SIEM](https://csrc.nist.gov/glossary/term/security_information_and_event_management) dan alerting real-time** ingin aliran untuk diserap. Satu `router.on()` meneruskan seluruh permukaan ke mana pun log atau alert pergi. -Field `type` menjaga kanal kesalahan tetap bersih. Lalu lintas klien normal bernilai `external`, sementara kesalahan framework, timeout 503 sintetis, atau context request yang hilang menandai event `internal`. Pipeline alert kesalahan menyaring `internal` dan tidak pernah tenggelam dalam request rutin. +Field `type` menjaga kanal kesalahan tetap bersih. Lalu lintas klien normal bernilai `external`, sementara kesalahan framework, timeout 503 sintetis, atau context request yang hilang menandai event `internal`. Pipeline alert kesalahan menyaring `internal` untuk menangkap kesalahan framework tanpa tenggelam dalam request rutin, lalu menyertakan `process:failed` berdasarkan kind, karena kesalahan proses selalu menumpang kanal `external`: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { - // Teruskan hanya kesalahan framework - if (event.type === 'internal') { + // Teruskan kesalahan framework dan proses + if (event.type === 'internal' || event.kind === 'process:failed') { console.log(JSON.stringify({ at: event.timestamp, ...event })) } }) diff --git a/docs/id/middleware/route-specific.md b/docs/id/middleware/route-specific.md index c8a1717..82d629b 100644 --- a/docs/id/middleware/route-specific.md +++ b/docs/id/middleware/route-specific.md @@ -6,6 +6,10 @@ description: "Batasi cakupan middleware ke prefix path supaya hanya berjalan unt Middleware spesifik rute berlaku untuk pola rute tertentu, memungkinkan fungsi yang menyasar rute tertentu seperti autentikasi untuk rute API atau logging untuk rute admin. +Pencocokan sadar batas: `router.use('/api', fn)` berjalan untuk `/api` dan `/api/users`, tapi tidak untuk `/apiv2`, karena pathname harus sama dengan prefix atau dilanjut dengan `/`. + +![Pencocokan prefix Route-Specific: router.use('/api', fn) cocok dengan /api persis dan /api/users pada slash batas, tapi melewati /apiv2 dan /admin karena bukan kecocokan batas dari prefix](/diagrams/middleware-route-matching.png) + ## Penggunaan Dasar Terapkan middleware ke pola rute tertentu memakai method `use()` dengan path rute: @@ -17,7 +21,7 @@ const router = new Router() // Berjalan untuk path yang diawali /api router.use('/api', async (ctx, next) => { - console.log(`API request: ${ctx.request.method} ${ctx.url}`) + console.log(`API request: ${ctx.get.method()} ${ctx.get.pathname()}`) return await next() }) @@ -29,8 +33,7 @@ await router.serve(8000) Middleware berlaku untuk rute yang diawali pola yang ditentukan: ```typescript twoslash -import type { MiddlewareFn } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type MiddlewareFn } from '@neabyte/deserve' const router = new Router() declare const middleware: MiddlewareFn @@ -57,23 +60,13 @@ declare function isValidToken(token: string): boolean // ---cut--- // Wajibkan bearer token di bawah /api router.use('/api', async (ctx, next) => { - const authHeader = ctx.header('authorization') + const authHeader = ctx.get.header('authorization') if (!authHeader) { - return ctx.send.text( - 'API requires authentication', - { - status: 401 - } - ) + return ctx.send.text('API requires authentication', { status: 401 }) } const token = authHeader.replace('Bearer ', '') if (!isValidToken(token)) { - return ctx.send.text( - 'Invalid token', - { - status: 401 - } - ) + return ctx.send.text('Invalid token', { status: 401 }) } return await next() }) @@ -88,14 +81,9 @@ const router = new Router() // ---cut--- // Izinkan hanya peran admin di bawah /admin router.use('/admin', async (ctx, next) => { - const userRole = ctx.header('x-user-role') + const userRole = ctx.get.header('x-user-role') if (userRole !== 'admin') { - return ctx.send.text( - 'Admin access required', - { - status: 403 - } - ) + return ctx.send.text('Admin access required', { status: 403 }) } return await next() }) @@ -110,7 +98,7 @@ const router = new Router() // ---cut--- // Catat akses di bawah /public router.use('/public', async (ctx, next) => { - console.log(`Public access: ${ctx.request.method} ${ctx.url}`) + console.log(`Public access: ${ctx.get.method()} ${ctx.get.pathname()}`) return await next() }) ``` @@ -145,21 +133,16 @@ const router = new Router() // ---cut--- // Auth berjalan lebih dulu di bawah /api router.use('/api', async (ctx, next) => { - const authHeader = ctx.header('authorization') + const authHeader = ctx.get.header('authorization') if (!authHeader) { - return ctx.send.text( - 'Unauthorized', - { - status: 401 - } - ) + return ctx.send.text('Unauthorized', { status: 401 }) } return await next() }) // Logging berjalan setelah auth lolos router.use('/api', async (ctx, next) => { - console.log(`API: ${ctx.request.method} ${ctx.url}`) + console.log(`API: ${ctx.get.method()} ${ctx.get.pathname()}`) return await next() }) ``` @@ -187,14 +170,9 @@ router.use('/api/users', async (ctx, next) => { // Mempersempit lagi dan cek peran router.use('/api/users/admin', async (ctx, next) => { - const role = ctx.header('x-user-role') + const role = ctx.get.header('x-user-role') if (role !== 'admin') { - return ctx.send.text( - 'Admin access required', - { - status: 403 - } - ) + return ctx.send.text('Admin access required', { status: 403 }) } return await next() }) @@ -204,6 +182,8 @@ router.use('/api/users/admin', async (ctx, next) => { Middleware berjalan sesuai urutan penambahannya: +![Eksekusi Route-Specific untuk GET /api/users dalam satu rantai: logger global berjalan, auth /api berjalan pada kecocokan prefix, penjaga /admin dilewati karena tidak cocok tanpa memakai giliran, logger /api/users berjalan, lalu route handler dieksekusi, semua dalam urutan pendaftaran](/diagrams/middleware-route-chain.png) + ```typescript twoslash import { Router } from '@neabyte/deserve' diff --git a/docs/id/middleware/security-headers.md b/docs/id/middleware/security-headers.md index c236e3f..a929034 100644 --- a/docs/id/middleware/security-headers.md +++ b/docs/id/middleware/security-headers.md @@ -85,7 +85,7 @@ router.use( ## Opsi Konfigurasi -Setiap opsi header punya tiga bentuk. Nilai string mengatur header ke nilai itu. `false` menghilangkan header, bahkan yang punya default aman. Membiarkan opsi `undefined` mempertahankan default-nya ketika ada, atau melewatinya jika tidak. Empat header tanpa default - `contentSecurityPolicy`, `crossOriginEmbedderPolicy`, `strictTransportSecurity`, dan `xPoweredBy` - tidak aktif sampai sebuah nilai diberikan. +Setiap opsi header punya tiga bentuk. Nilai string mengatur header ke nilai itu. `false` menghilangkan header, bahkan yang punya default aman. Membiarkan opsi `undefined` mempertahankan default-nya ketika ada, atau melewatinya jika tidak. Tiga header tanpa default - `contentSecurityPolicy`, `crossOriginEmbedderPolicy`, dan `strictTransportSecurity` - tidak aktif sampai sebuah nilai diberikan. ### `contentSecurityPolicy` @@ -183,21 +183,13 @@ Kebijakan lintas-domain untuk Flash: xPermittedCrossDomainPolicies: 'none' // atau 'master-only', 'all' ``` -### `xPoweredBy` - -Mati secara bawaan. Atur string untuk mengiklankan nilai, atau biarkan untuk tanpa header: - -```typescript -xPoweredBy: 'Custom' // Tambah nilai khusus -``` - ## Contoh Lengkap ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Terapkan set header yang luas @@ -223,6 +215,5 @@ await router.serve(8000) - **Nilai string**: mengatur header ke nilai persis itu, menimpa default mana pun - **Diatur ke `false`**: menghilangkan header, bahkan yang punya default - **Undefined**: mempertahankan default ketika header punya satu, jika tidak melewatinya -- **X-Powered-By**: mati secara bawaan, atur string untuk menambahnya atau biarkan untuk tanpa header - **HSTS**: terapkan `strictTransportSecurity` hanya pada server HTTPS - **CSP**: Content Security Policy bisa jadi kompleks, jadi uji dengan teliti diff --git a/docs/id/middleware/session.md b/docs/id/middleware/session.md index 3565b45..0715e59 100644 --- a/docs/id/middleware/session.md +++ b/docs/id/middleware/session.md @@ -4,88 +4,74 @@ description: "Middleware session berbasis cookie yang ditandatangani dengan HMAC # Middleware Session -Middleware session menyimpan data session di cookie yang ditandatangani dan mengeksposnya lewat framework state, cocok untuk login, preferensi, atau state per-user tanpa database session. Payload cookie ditandatangani dengan HMAC-SHA256, dan **`cookieSecret` wajib serta minimal 32 karakter**. +Middleware session menyimpan data session di cookie yang ditandatangani dan mengeksposnya lewat context, cocok untuk login, preferensi, atau state per-user tanpa database session. Payload cookie ditandatangani dengan HMAC-SHA256, dan **`secret` wajib serta minimal 32 karakter**. ## Penggunaan Dasar -`Mware.session({ cookieSecret })` menambahkan session berbasis cookie: +`Mware.session({ secret })` menambahkan session berbasis cookie: ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' const router = new Router() -// cookieSecret menandatangani cookie session +// Secret menandatangani cookie session router.use( Mware.session({ - cookieSecret: Deno.env.get('SESSION_SECRET') ?? 'replace-with-secret-min-32-chars' + secret: Deno.env.get('SESSION_SECRET') ?? 'replace-with-secret-min-32-chars' }) ) await router.serve(8000) ``` -Middleware menyimpan tiga nilai di framework state, dibaca dengan `ctx.getState`: - -- **`session`** - data session, sebuah object atau `null` saat tidak ada atau signature invalid -- **`setSession`** - fungsi async yang menyimpan data dan mengatur cookie bertanda tangan -- **`clearSession`** - fungsi yang menghapus cookie session +Middleware memasang session controller di context, jadi handler membaca dan menulis data session lewat `ctx.get.session()` dan `ctx.set.session()`: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' +import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// Baca data session -const session = ctx.getState('session' as never) +// Baca data session saat ini +const session = ctx.get.session() // Simpan data session (async) -const setSession = ctx.getState<(data: DataRecord) => Promise>('setSession' as never) -await setSession?.({ - userId: '1' -}) +await ctx.set.session({ userId: '1' }) // Hapus session -const clearSession = ctx.getState<() => void>('clearSession' as never) -clearSession?.() +await ctx.set.session(null) ``` +`ctx.get.session()` mengembalikan objek data session atau `null` ketika tidak ada session. `ctx.set.session(data)` menandatangani data ke dalam cookie, dan `ctx.set.session(null)` menghapusnya. + ## Contoh: Login Dan Logout ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' +import type { Context } from '@neabyte/deserve' // POST: login, set session saat kredensial valid export async function POST(ctx: Context): Promise { - const body = await ctx.json() as DataRecord + // Baca body JSON yang sudah diparsing + const body = await ctx.get.json() as { username?: string; password?: string } // Simpan session saat kredensial cocok if (body?.username === 'admin' && body?.password === 'secret') { - const setSession = ctx.getState<(data: DataRecord) => Promise>('setSession' as never) - await setSession?.({ + await ctx.set.session({ userId: '1', username: 'admin' }) - return ctx.send.json({ - ok: true - }) + return ctx.send.json({ ok: true }) } return ctx.send.json( - { - error: 'Invalid credentials' - }, - { - status: 401 - } + { error: 'Invalid credentials' }, + { status: 401 } ) } // GET: cek status login export function GET(ctx: Context): Response { - // Baca session dari framework state - const session = ctx.getState('session' as never) + // Baca session dari context + const session = ctx.get.session() if (!session) { - return ctx.send.json({ - loggedIn: false - }) + return ctx.send.json({ loggedIn: false }) } return ctx.send.json({ loggedIn: true, @@ -94,19 +80,16 @@ export function GET(ctx: Context): Response { } // DELETE: logout, hapus session -export function DELETE(ctx: Context): Response { +export async function DELETE(ctx: Context): Promise { // Buang cookie session - const clearSession = ctx.getState<() => void>('clearSession' as never) - clearSession?.() - return ctx.send.json({ - ok: true - }) + await ctx.set.session(null) + return ctx.send.json({ ok: true }) } ``` ## Opsi Session -**`cookieSecret`** wajib, minimal 32 karakter, dan menandatangani cookie dengan HMAC-SHA256. Nama cookie, max age, path, dan atribut keamanan juga bisa dikonfigurasi: +**`secret`** wajib, minimal 32 karakter, dan menandatangani cookie dengan HMAC-SHA256. Nama cookie, max age, path, dan atribut keamanan juga bisa dikonfigurasi: ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' @@ -116,37 +99,38 @@ const router = new Router() // Timpa pengaturan cookie default router.use( Mware.session({ - cookieSecret: Deno.env.get('SESSION_SECRET') ?? 'fallback-secret-at-least-32-characters', - cookieName: 'sid', + secret: Deno.env.get('SESSION_SECRET') ?? 'fallback-secret-at-least-32-characters', + name: 'sid', maxAge: 3600, path: '/', sameSite: 'Lax', - httpOnly: true + httpOnly: true, + secure: false }) ) ``` -| Opsi | Default | Keterangan | -| -------------- | ----------- | ----------------------------------------------- | -| `cookieSecret` | - | **Wajib, min 32 karakter.** Menandatangani cookie. | -| `cookieName` | `'session'` | Nama cookie | -| `maxAge` | `86400` | Umur cookie dalam detik (24 jam) | -| `path` | `'/'` | Path cookie | -| `sameSite` | `'Lax'` | `'Strict' \| 'Lax' \| 'None'` | -| `httpOnly` | `true` | Cookie tidak diakses dari JavaScript | -| `secure` | `true` | Wajibkan HTTPS untuk cookie | +| Opsi | Default | Keterangan | +| ---------- | ----------- | --------------------------------------------------- | +| `secret` | - | **Wajib, min 32 karakter.** Menandatangani cookie. | +| `name` | `'session'` | Nama cookie | +| `maxAge` | `86400` | Umur cookie dalam detik (24 jam) | +| `path` | `'/'` | Path cookie | +| `sameSite` | `'Lax'` | `'Strict' \| 'Lax' \| 'None'` | +| `httpOnly` | `true` | Cookie tidak diakses dari JavaScript | +| `secure` | `false` | Wajibkan HTTPS untuk cookie | ### Validasi dan Kedaluwarsa Middleware memeriksa opsinya saat dibuat dan melempar `Deno.errors.InvalidData` saat ada yang tidak aman: -- `cookieSecret` lebih pendek dari 32 karakter +- `secret` lebih pendek dari 32 karakter - `sameSite: 'None'` tanpa `secure: true`, karena browser menolak kombinasi itu -- `maxAge` yang bukan angka positif, atau `path` kosong +- `maxAge` yang bukan angka bulat positif -Setiap cookie juga membawa waktu terbit bertanda tangan, jadi middleware memperlakukan session yang lebih tua dari `maxAge` sebagai tidak ada dan membacanya kembali sebagai `null`. Cookie yang dirusak gagal pemeriksaan signature dan dibaca sebagai `null` juga, sehingga session basi atau palsu tidak dipercaya. Setiap kali cookie gagal didekode, middleware memancarkan event [`session:invalid`](/id/middleware/observability/events#middleware) yang menyebut cookie dan apakah nilainya dirusak, kedaluwarsa, atau malformed, sementara request lanjut tanpa session terpasang. +Setiap cookie juga membawa waktu terbit bertanda tangan, jadi middleware memperlakukan session yang lebih tua dari `maxAge` sebagai tidak ada dan membacanya kembali sebagai `null`. Cookie yang dirusak gagal pemeriksaan signature dan dibaca sebagai `null` juga, sehingga session basi atau palsu tidak dipercaya. Setiap kali cookie gagal didekode, middleware memancarkan event [`session:invalid`](/id/middleware/observability/events) yang menyebut cookie dan apakah nilainya dirusak, kedaluwarsa, atau malformed, sementara request lanjut tanpa session terpasang. ## Batasan -- Data session ada di cookie dan ditandatangani dengan HMAC-SHA256, jadi sebaiknya hanya menyimpan identifier atau data kecil daripada nilai besar atau sangat sensitif. -- Session sisi server atau berbasis token butuh mekanisme lain seperti JWT atau Redis di luar middleware ini. +- Data session ada di cookie dan ditandatangani dengan HMAC-SHA256, jadi sebaiknya hanya menyimpan identifier atau data kecil daripada nilai besar atau sangat sensitif +- Session sisi server atau berbasis token butuh mekanisme lain seperti JWT atau Redis di luar middleware ini diff --git a/docs/id/middleware/validation/advanced-patterns.md b/docs/id/middleware/validation/advanced-patterns.md index eccca5c..3aba97a 100644 --- a/docs/id/middleware/validation/advanced-patterns.md +++ b/docs/id/middleware/validation/advanced-patterns.md @@ -12,8 +12,8 @@ Middleware berjalan sebelum router mencocokkan method atau path, jadi validator Saat validator itu gagal, 422-nya mencapai client lebih dulu dan menyembunyikan status yang akan diproduksi router: -- `POST /accounts` dengan header yang hilang mengembalikan **422**, bukan **405** yang akan diberikan handler POST yang tidak ada. -- `GET /accounts/missing` dengan header yang hilang mengembalikan **422**, bukan **404** untuk path tak dikenal. +- `POST /accounts` dengan header yang hilang mengembalikan **422**, bukan **405** yang akan diberikan handler POST yang tidak ada +- `GET /accounts/missing` dengan header yang hilang mengembalikan **422**, bukan **404** untuk path tak dikenal Membatasi validator berdasarkan method dan path menjaga validasi pada request yang menjadi haknya dan membiarkan router menjawab sisanya. Dengan batas yang tepat, `POST /transfers/tx_abc123` mengembalikan **405** yang bersih alih-alih 422 validasi body, karena validator melewati request yang memang bukan tugasnya. @@ -21,10 +21,10 @@ Membatasi validator berdasarkan method dan path menjaga validasi pada request ya `router.use('/transfers', ...)` mencocokkan `/transfers` dan setiap path yang berlanjut dengan garis miring, seperti `/transfers/tx_abc123`. Aturan pencocokan berasal dari [Middleware Spesifik Rute](/id/middleware/route-specific). Sebuah resource `transfers` biasanya membawa dua request berbeda di bawah satu prefix itu: -- `POST /transfers` mengirim body JSON yang butuh kontrak `json`. -- `GET /transfers/:id` tidak membawa body dan memvalidasi param-nya di dalam handler. +- `POST /transfers` mengirim body JSON yang butuh kontrak `body` +- `GET /transfers/:id` tidak membawa body dan memvalidasi param-nya di dalam handler -Mendaftarkan validator `json` pada seluruh prefix akan menjalankannya pada GET juga, dan membaca body yang tidak ada mengubah request valid menjadi kegagalan. Validator perlu menyala hanya untuk POST. +Mendaftarkan validator `body` pada seluruh prefix akan menjalankannya pada GET juga, dan membaca body yang tidak ada mengubah request valid menjadi kegagalan. Validator perlu menyala hanya untuk POST. ## Helper selectValidator @@ -33,7 +33,7 @@ Sebuah pembungkus kecil menyelesaikannya. Helper ini menerima sebuah pemilih yan ![Pola selectValidator: sebuah request pada prefix bersama mencapai pemilih yang membaca method dan pathname, mengembalikan schema membangun dan men-cache validator sekali sebelum handler, dan mengembalikan undefined memanggil next sehingga request mengalir lewat tanpa disentuh](/diagrams/validation-select-validator.png) ```typescript twoslash -import { type Context, type MiddlewareFn, Mware, type ValidationSchema } from '@neabyte/deserve' +import { type Context, type MiddlewareFn, Validator, type ValidationSchema } from '@neabyte/deserve' // Pilih sebuah schema atau lewati validasi function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): MiddlewareFn { @@ -46,7 +46,7 @@ function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): let validator = cache.get(schema) if (validator === undefined) { // Bangun sekali, pakai ulang nanti - validator = Mware.validator(schema) + validator = Validator.check(schema) cache.set(schema, validator) } return await validator(ctx, next) @@ -58,55 +58,55 @@ Mengembalikan `undefined` langsung memanggil `next`, jadi request mengalir lewat ## Menghubungkan Ke Sebuah Prefix -Pemilih membaca `ctx.pathname` dan method request untuk memutuskan. Di sini kontrak `json` berjalan hanya untuk POST koleksi, dan GET diteruskan untuk memvalidasi param-nya di handler: +Pemilih membaca `ctx.get.pathname()` dan method request untuk memutuskan. Di sini kontrak `body` berjalan hanya untuk POST koleksi, dan GET diteruskan untuk memvalidasi param-nya di handler: ```typescript twoslash -import { type Context, type MiddlewareFn, Define, Mware, Router, type ValidationSchema } from '@neabyte/deserve' +import { type Context, type MiddlewareFn, Router, Validator, type ValidationSchema } from '@neabyte/deserve' declare function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): MiddlewareFn -const router = new Router({ routesDir: './routes' }) +const router = new Router({ routes: { directory: './routes' } }) const createTransfer = { - json: Define((body: { amount: number }) => ({ amount: body.amount })) + body: Validator.define((body: { amount: number }) => ({ amount: body.amount })) } // ---cut--- // Validasi body hanya pada POST koleksi router.use( '/transfers', selectValidator((ctx) => - ctx.pathname === '/transfers' && ctx.request.method === 'POST' + ctx.get.pathname() === '/transfers' && ctx.get.method() === 'POST' ? createTransfer : undefined ) ) ``` -Handler `GET /transfers/:id` lalu memvalidasi param-nya sendiri dengan `Validator.check`, pendekatan dari [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#memeriksa-params-di-handler). Validasi body dan validasi param tetap terpisah, masing-masing menyala hanya di tempat yang sesuai. +Handler `GET /transfers/:id` lalu memvalidasi param-nya sendiri dengan panggilan kontrak langsung, pendekatan dari [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#memeriksa-param-di-handler). Validasi body dan validasi param tetap terpisah, masing-masing menyala hanya di tempat yang sesuai. ## Memilih Di Antara Beberapa Schema Pemilih yang sama menangani lebih dari satu cabang saat sebuah prefix menampung banyak method. Setiap cabang mengembalikan schema untuk kasus itu, dan apa pun yang tidak cocok mengembalikan `undefined`: ```typescript twoslash -import { type Context, type MiddlewareFn, Define, Router, type ValidationSchema } from '@neabyte/deserve' +import { type Context, type MiddlewareFn, Router, Validator, type ValidationSchema } from '@neabyte/deserve' declare function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): MiddlewareFn -const router = new Router({ routesDir: './routes' }) +const router = new Router({ routes: { directory: './routes' } }) -const listQuery = { query: Define((q: Record) => ({ page: Number(q['page'] ?? '1') })) } -const createBody = { json: Define((body: { name: string }) => ({ name: body.name.trim() })) } +const listQuery = { query: Validator.define((q: Record) => ({ page: Number(q['page'] ?? '1') })) } +const createBody = { body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- // Satu pemilih, satu schema per method router.use( '/users', selectValidator((ctx) => { - const isCollection = ctx.pathname === '/users' - if (isCollection && ctx.request.method === 'GET') { + const isCollection = ctx.get.pathname() === '/users' + if (isCollection && ctx.get.method() === 'GET') { return listQuery } - if (isCollection && ctx.request.method === 'POST') { + if (isCollection && ctx.get.method() === 'POST') { return createBody } return undefined @@ -125,13 +125,13 @@ Sebuah schema dengan beberapa sumber memvalidasinya dalam urutan kemunculan key, ![Urutan sumber lintas schema: kontrak query buruk melempar lebih dulu dan hanya melaporkan alasan query, sementara kontrak headers dan cookies yang datang setelahnya dalam urutan key tidak pernah berjalan](/diagrams/validation-source-order.png) ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Sumber divalidasi dalam urutan key const listAccounts = { - query: Define((q: Record) => q), - headers: Define((h: Record) => h), - cookies: Define((c: Record) => c) + query: Validator.define((q: Record) => q), + headers: Validator.define((h: Record) => h), + cookies: Validator.define((c: Record) => c) } ``` @@ -157,14 +157,14 @@ routes/ Barrel mengelompokkan kontrak tunggal menjadi schema per sumber yang dibaca sebuah rute, jadi perakitannya tetap di satu tempat: ```typescript twoslash -import { Define } from '@neabyte/deserve' -declare const Transfer: ReturnType -declare const AccountQuery: ReturnType -declare const ApiKeyHeader: ReturnType +import { Validator } from '@neabyte/deserve' +declare const Transfer: ReturnType +declare const AccountQuery: ReturnType +declare const ApiKeyHeader: ReturnType // ---cut--- // schemas/index.ts kelompokkan kontrak per sumber export const createTransferSchema = { - json: Transfer + body: Transfer } export const listAccountsSchema = { @@ -176,13 +176,13 @@ export const listAccountsSchema = { Sebuah rute mengimpor hanya tipe schema yang dibutuhkannya, sehingga handler tetap fokus pada respons alih-alih aturan: ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' -const createTransferSchema = { json: Define((body: { amount: number }) => ({ amount: body.amount })) } +import { type Context, Validator } from '@neabyte/deserve' +const createTransferSchema = { body: Validator.define((body: { amount: number }) => ({ amount: body.amount })) } // ---cut--- // routes/transfers.ts baca body tervalidasi export function POST(ctx: Context): Response { - const { json } = Validator.read(ctx) - return ctx.send.json({ amount: json.amount }, { status: 201 }) + const { body } = ctx.get.validated() + return ctx.send.json({ amount: body.amount }, { status: 201 }) } ``` @@ -190,7 +190,7 @@ Ini sebuah saran, bukan aturan. Aplikasi kecil menyimpan kontrak inline di sampi ## Langkah Berikutnya -- [Middleware Validator](/id/middleware/validation/validator-middleware) - pendaftaran per sumber yang dibungkus pola ini. -- [Membaca Data Tervalidasi](/id/middleware/validation/reading-data) - memvalidasi params di handler di samping pola ini. -- [Middleware Spesifik Rute](/id/middleware/route-specific) - aturan pencocokan prefix di baliknya. -- [Ringkasan Validasi](/id/middleware/validation/overview) - bagaimana semua bagian saling terhubung. +- [Middleware Validator](/id/middleware/validation/validator-middleware) - pendaftaran per sumber yang dibungkus pola ini +- [Membaca Data Tervalidasi](/id/middleware/validation/reading-data) - memvalidasi param di handler di samping pola ini +- [Middleware Spesifik Rute](/id/middleware/route-specific) - aturan pencocokan prefix di baliknya +- [Ringkasan Validasi](/id/middleware/validation/overview) - bagaimana semua bagian saling terhubung diff --git a/docs/id/middleware/validation/define-schema.md b/docs/id/middleware/validation/define-schema.md index ea73e44..1ed70dc 100644 --- a/docs/id/middleware/validation/define-schema.md +++ b/docs/id/middleware/validation/define-schema.md @@ -1,22 +1,22 @@ --- -description: "Bangun kontrak request dengan Define, sebuah transform yang dipasangkan dengan guard untuk menolak input buruk." +description: "Bangun kontrak request dengan Validator.define, sebuah transform yang dipasangkan dengan guard untuk menolak input buruk." --- # Define Schema > **Referensi**: [Repositori GitHub Typebox](https://github.com/NeaByteLab/Typebox) -Sebuah kontrak adalah fungsi yang menerima satu input dan mengembalikan nilai yang sudah bersih. `Define` membangunnya dari dua bagian, sebuah transform yang membentuk output dan guard opsional yang menolak input sebelum transform berjalan. +Sebuah kontrak adalah fungsi yang menerima satu input dan mengembalikan nilai yang sudah bersih. `Validator.define` membangunnya dari dua bagian, sebuah transform yang membentuk output dan guard opsional yang menolak input sebelum transform berjalan. -## Bentuk Define +## Bentuk Kontrak -`Define(transform, guard?)` mengembalikan sebuah kontrak: +`Validator.define(transform, guard?)` mengembalikan sebuah kontrak: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Hanya transform, tanpa guard -const Trim = Define((body: { name: string }) => ({ +const Trim = Validator.define((body: { name: string }) => ({ name: body.name.trim() })) ``` @@ -26,10 +26,10 @@ Transform menormalkan nilai, memangkas string, membuat email jadi huruf kecil, a Transform juga memiliki bentuk output. Guard yang lolos tidak membuang key tambahan, jadi field tak dikenal dari client tetap ada kecuali transform menghilangkannya. Mengembalikan object baru hanya dengan field yang diinginkan menjaga input kejutan keluar dari data tervalidasi: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Output hanya menyimpan field yang disebut -const NewUser = Define((body: { name: string; role: string }) => ({ +const NewUser = Validator.define((body: { name: string; role: string }) => ({ name: body.name.trim() })) ``` @@ -38,16 +38,16 @@ Di sini client yang mengirim `role: 'admin'` mendapati nilainya dibuang, karena ## Urutan Operasi -Memanggil sebuah kontrak menjalankan empat langkah dalam urutan tetap, dan transform hanya pernah melihat input yang sudah lolos setiap guard: +Sebuah kontrak dengan setidaknya satu guard menjalankan empat langkah dalam urutan tetap, dan transform hanya pernah melihat input yang sudah lolos setiap guard: 1. Input string yang lebih panjang dari 10000 karakter ditolak sebelum hal lain. 2. Input object dibekukan dalam (deep frozen) agar guard tidak bisa memutasinya. 3. Setiap guard berjalan berurutan, melempar pada kegagalan pertama. 4. Transform berjalan dan mengembalikan nilai yang sudah bersih. -Kontrak tanpa guard langsung lompat ke transform, jadi transform harus memercayai inputnya atau melakukan pemeriksaan sendiri. +Kontrak yang dibangun tanpa guard berbentuk sepenuhnya berbeda. `Validator.define(transform)` mengembalikan transform apa adanya, jadi batas string dan pembekuan tidak pernah berjalan dan input mentah langsung mencapai transform. Transform tanpa guard harus memercayai inputnya atau melakukan pemeriksaan sendiri. -![Urutan operasi Define: sebuah kontrak pertama membatasi input string pada 10000 karakter, lalu membekukan dalam sebuah object agar guard tidak bisa memutasinya, lalu menjalankan tiap guard berurutan dengan melempar pada kegagalan pertama, dan baru kemudian menjalankan transform pada input yang sudah lolos setiap guard](/diagrams/validation-contract-order.png) +![Urutan operasi Define: sebuah kontrak ber-guard pertama membatasi input string pada 10000 karakter, lalu membekukan dalam sebuah object agar guard tidak bisa memutasinya, lalu menjalankan tiap guard berurutan dengan melempar pada kegagalan pertama, dan baru kemudian menjalankan transform pada input yang sudah lolos setiap guard, sementara kontrak tanpa guard mengembalikan transform apa adanya jadi batas maupun pembekuan tidak berjalan](/diagrams/validation-contract-order.png) ## Guard Menentukan Lolos Atau Gagal @@ -60,10 +60,10 @@ Sebuah guard memeriksa input mentah dan mengembalikan keputusan: ![Keputusan guard: mengembalikan true mengirim input ke transform, sementara mengembalikan sebuah string atau array string membuat kontrak melempar dan menjadi 422 dengan alasan itu terjaga di error.cause](/diagrams/validation-guard-verdict.png) ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Guard menolak nama kosong -const NewUser = Define( +const NewUser = Validator.define( (body: { name: string }) => ({ name: body.name.trim() }), (body) => (body.name.trim().length > 0 ? true : 'name must not be empty') ) @@ -76,14 +76,14 @@ Guard yang mengembalikan alasan membuat kontrak melempar, dan validator mengubah Sebuah guard menerima input mentah, yang bisa `null`, sebuah array, atau nilai JSON apa pun yang dikirim client. Mengambil sebuah field pada bentuk yang salah akan melempar di dalam guard sebelum aturannya berjalan, jadi pemeriksaan bentuk datang lebih dulu: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Pastikan object sebelum membaca field function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } -const NewUser = Define( +const NewUser = Validator.define( (body: { name: string }) => ({ name: body.name.trim() }), (body) => { if (!isRecord(body)) { @@ -101,10 +101,10 @@ Lemparan di dalam guard tetap menjadi 422, tidak pernah 500, jadi pemeriksaan be Mengembalikan sebuah array melaporkan setiap field yang rusak dalam satu respons alih-alih satu per satu: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Kumpulkan tiap kegagalan ke satu array -const NewUser = Define( +const NewUser = Validator.define( (body: { name: string; age: number }) => body, (body) => { const reasons: string[] = [] @@ -124,7 +124,7 @@ const NewUser = Define( Argumen kedua juga menerima array guard. Guard berjalan berurutan dan kontrak melempar pada yang pertama gagal, jadi guard berikutnya tidak pernah melihat input yang sudah ditolak guard sebelumnya: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Cek bentuk dulu, aturan bisnis kedua function hasFields(body: { from: string; to: string }): true | string { @@ -135,7 +135,7 @@ function distinctAccounts(body: { from: string; to: string }): true | string { return body.from !== body.to ? true : 'from and to must differ' } -const Transfer = Define( +const Transfer = Validator.define( (body: { from: string; to: string }) => body, [hasFields, distinctAccounts] ) @@ -145,11 +145,11 @@ Memisahkan pemeriksaan bentuk dari aturan bisnis menjaga tiap guard tetap kecil ## Pengaman Bawaan -Batas string dan pembekuan dari [Urutan Operasi](#urutan-operasi) berjalan otomatis, jadi sebuah kontrak tidak pernah membuang waktu pada payload raksasa dan sebuah guard tidak pernah memutasi nilai yang diperiksanya. Satu aturan lagi menjaga model waktunya: +Batas string dan pembekuan dari [Urutan Operasi](#urutan-operasi) berjalan sebagai bagian dari langkah guard, jadi sebuah kontrak ber-guard tidak pernah membuang waktu pada string raksasa dan sebuah guard tidak pernah memutasi nilai yang diperiksanya. Satu aturan lagi menjaga model waktunya: - Guard async ditolak, karena validasi tetap sinkron dan dapat diprediksi. -Aturan ini berasal dari Typebox sendiri dan berlaku untuk setiap kontrak, baik yang berjalan lewat [Middleware Validator](/id/middleware/validation/validator-middleware) maupun panggilan `Validator.check` langsung. +Aturan ini berasal dari Typebox sendiri dan melindungi setiap kontrak yang membawa guard, baik yang berjalan lewat [Middleware Validator](/id/middleware/validation/validator-middleware) maupun panggilan kontrak langsung di sebuah handler. Transform tanpa guard memilih keluar dari ketiganya, jadi kontrak yang menangani input tak tepercaya sebaiknya selalu membawa setidaknya satu guard. ## Langkah Berikutnya diff --git a/docs/id/middleware/validation/overview.md b/docs/id/middleware/validation/overview.md index 9e8393e..3409246 100644 --- a/docs/id/middleware/validation/overview.md +++ b/docs/id/middleware/validation/overview.md @@ -14,75 +14,75 @@ Validasi berdiri di samping middleware lain dan mengawasi request sebelum mencap Validasi terbentuk dari tiga export, masing-masing dengan satu tugas: -- **`Define`** membangun kontrak dari sebuah transform dan guard opsional. Lihat [Define Schema](/id/middleware/validation/define-schema). -- **`Mware.validator`** mengubah schema menjadi middleware yang memvalidasi sumber request. Lihat [Middleware Validator](/id/middleware/validation/validator-middleware). -- **`Validator`** membaca data tervalidasi di dalam handler dan memeriksa nilai sesuai kebutuhan. Lihat [Membaca Data Tervalidasi](/id/middleware/validation/reading-data). +- **`Validator.define`** membangun kontrak dari sebuah transform dan guard opsional. Lihat [Define Schema](/id/middleware/validation/define-schema). +- **`Validator.check`** mengubah schema menjadi middleware yang memvalidasi sumber request. Lihat [Middleware Validator](/id/middleware/validation/validator-middleware). +- **`ctx.get.validated()`** membaca data tervalidasi di dalam handler. Lihat [Membaca Data Tervalidasi](/id/middleware/validation/reading-data). -![Validasi punya tiga bagian dengan satu tugas masing-masing: Define membangun kontrak, Mware.validator menjalankan kontrak sebagai middleware, dan Validator.read mengembalikan data tervalidasi bertipe di dalam handler](/diagrams/validation-three-pieces.png) +![Validasi punya tiga bagian dengan satu tugas masing-masing: Validator.define membangun kontrak, Validator.check menjalankan kontrak sebagai middleware, dan ctx.get.validated mengembalikan data tervalidasi bertipe di dalam handler](/diagrams/validation-three-pieces.png) ## Schema Memetakan Sumber Ke Kontrak Sebuah schema adalah object biasa yang memasangkan sumber request dengan kontrak: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Satu kontrak per sumber request const schema = { - json: Define((body: { name: string }) => body) + body: Validator.define((body: { name: string }) => body) } ``` -Ada enam sumber, dan masing-masing membaca dari bagian yang cocok di [Context](/id/core-concepts/context-object): +Ada empat sumber, dan masing-masing membaca dari bagian yang cocok di [Context](/id/core-concepts/context-object): -| Sumber | Membaca dari | Bentuk | -| --------- | --------------- | ------------------------- | -| `body` | `ctx.body()` | body mentah hasil parse | -| `cookies` | `ctx.cookie()` | `Record` | -| `headers` | `ctx.header()` | `Record` | -| `json` | `ctx.json()` | nilai JSON hasil parse | -| `params` | `ctx.params()` | `Record` | -| `query` | `ctx.query()` | `Record` | +| Sumber | Membaca dari | Bentuk | +| --------- | ------------------ | ------------------------- | +| `body` | `ctx.get.body()` | body mentah hasil parse | +| `cookies` | `ctx.get.cookie()` | `Record` | +| `headers` | `ctx.get.header()` | `Record` | +| `query` | `ctx.get.query()` | `Record` | + +Param rute bukan sumber validasi karena ia diresolusi setelah middleware berjalan. Validasi di dalam handler dengan panggilan kontrak langsung, dibahas di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#memeriksa-param-di-handler). ## Alur Request Sebuah request tervalidasi melewati empat langkah: -1. Middleware validator membaca setiap sumber yang disebut di schema. -2. Middleware menjalankan kontrak yang cocok pada nilai sumber itu. -3. Kontrak yang lolos menyimpan hasilnya di request state. -4. Handler membaca state itu dengan tipe penuh. +1. Middleware validator membaca setiap sumber yang disebut di schema +2. Middleware menjalankan kontrak yang cocok pada nilai sumber itu +3. Kontrak yang lolos menyimpan outputnya di context +4. Handler membaca output itu dengan tipe penuh lewat `ctx.get.validated()` -![Alur request validasi: middleware membaca setiap sumber dengan ctx.json atau ctx.query, menjalankan kontrak yang cocok, menyimpan hasil yang lolos di stateKeys.validated, dan handler membacanya kembali bertipe lewat Validator.read](/diagrams/validation-request-flow.png) +![Alur request validasi: middleware membaca setiap sumber dengan ctx.get.body atau ctx.get.query, menjalankan kontrak yang cocok, menyimpan hasil yang lolos di context, dan handler membacanya kembali bertipe lewat ctx.get.validated](/diagrams/validation-request-flow.png) ```typescript twoslash -import { type Context, Define, Mware, Router, Validator } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) const schema = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } -// Validasi body JSON sebelum handler -router.use('/users', Mware.validator(schema)) +// Validasi body sebelum handler +router.use('/users', Validator.check(schema)) await router.serve(8000) ``` ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' +import { type Context, Validator } from '@neabyte/deserve' const schema = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- export function POST(ctx: Context): Response { // Baca data bertipe yang sudah lolos - const { json } = Validator.read(ctx) - return ctx.send.json({ created: json.name }) + const { body } = ctx.get.validated() + return ctx.send.json({ created: body.name }) } ``` @@ -94,7 +94,7 @@ Lemparan dari input client tidak pernah menjadi 500. Aturan pemetaan itu ada di ## Langkah Berikutnya -- [Define Schema](/id/middleware/validation/define-schema) - menulis kontrak dengan transform dan guard. -- [Middleware Validator](/id/middleware/validation/validator-middleware) - mendaftarkan validasi per sumber dan per rute. -- [Membaca Data Tervalidasi](/id/middleware/validation/reading-data) - membaca output bertipe dan memeriksa params di handler. -- [Pola Lanjutan](/id/middleware/validation/advanced-patterns) - memilih schema per method pada satu prefix bersama. +- [Define Schema](/id/middleware/validation/define-schema) - menulis kontrak dengan transform dan guard +- [Middleware Validator](/id/middleware/validation/validator-middleware) - mendaftarkan validasi per sumber dan per rute +- [Membaca Data Tervalidasi](/id/middleware/validation/reading-data) - membaca output bertipe dan memeriksa param di handler +- [Pola Lanjutan](/id/middleware/validation/advanced-patterns) - memilih schema per method pada satu prefix bersama diff --git a/docs/id/middleware/validation/reading-data.md b/docs/id/middleware/validation/reading-data.md index a89199b..30e5e61 100644 --- a/docs/id/middleware/validation/reading-data.md +++ b/docs/id/middleware/validation/reading-data.md @@ -1,89 +1,75 @@ --- -description: "Baca data tervalidasi bertipe dengan Validator.read, periksa params di handler, dan lihat cara kegagalan dipetakan ke 422." +description: "Baca data tervalidasi bertipe dengan ctx.get.validated, periksa param di handler, dan lihat cara kegagalan dipetakan ke 422." --- # Membaca Data Tervalidasi -Handler membaca apa yang dihasilkan validator. `Validator.read` mengembalikan output tersimpan untuk sebuah schema, dan `Validator.check` memvalidasi sebuah nilai di tempat. +Handler membaca apa yang dihasilkan validator. `ctx.get.validated()` mengembalikan output tersimpan untuk sebuah schema, dan panggilan kontrak langsung memvalidasi sebuah nilai di tempat. ## Membaca Output Tersimpan -`Validator.read(ctx)` mengembalikan data tervalidasi yang dipetakan per sumber. Memberikan tipe schema memberi handler tipe penuh untuk setiap field: +`ctx.get.validated()` mengembalikan data tervalidasi yang dipetakan per sumber. Tipenya berasal dari definisi schema, jadi handler mendapat keamanan tipe penuh untuk setiap field: ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' +import { type Context, Validator } from '@neabyte/deserve' const createUser = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- export function POST(ctx: Context): Response { // Output bertipe, sudah tervalidasi - const { json } = Validator.read(ctx) - return ctx.send.json({ created: json.name }) + const { body } = ctx.get.validated() + return ctx.send.json({ created: body.name }) } ``` -Bentuknya mencerminkan schema, jadi schema dengan `query` dan `headers` mengembalikan kedua key dengan tipe output kontraknya sendiri. Middleware yang menyimpan state ini dibahas di [Middleware Validator](/id/middleware/validation/validator-middleware). +Bentuknya mencerminkan schema, jadi schema dengan `query` dan `headers` mengembalikan kedua key dengan tipe output kontraknya sendiri. Middleware yang menyimpan data ini dibahas di [Middleware Validator](/id/middleware/validation/validator-middleware). -## Membaca Tanpa Validator Melempar 500 +## Membaca Tanpa Validator Melempar -`Validator.read` mengharapkan [Middleware Validator](/id/middleware/validation/validator-middleware) sudah berjalan lebih dulu. Memanggilnya tanpa state tervalidasi melempar **500**, karena mencapai pembacaan tanpa apa pun tersimpan berarti middleware tidak pernah didaftarkan. Ini kesalahan perakitan di kode, bukan input buruk dari client. +`ctx.get.validated()` mengharapkan [Middleware Validator](/id/middleware/validation/validator-middleware) sudah berjalan lebih dulu. Memanggilnya tanpa data tervalidasi melempar `Deno.errors.NotSupported`, karena mencapai pembacaan tanpa apa pun tersimpan berarti middleware tidak pernah didaftarkan. Ini kesalahan perakitan di kode, bukan input buruk dari client, jadi framework memetakannya ke **501 Not Implemented** lewat jalur [penanganan error](/id/error-handling/object-details) yang sama yang memetakan setiap error yang dilempar. -```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' - -const createUser = { - json: Define((body: { name: string }) => body) -} -// ---cut--- -export function POST(ctx: Context): Response { - // Melempar 500 jika Mware.validator tak berjalan - const { json } = Validator.read(ctx) - return ctx.send.json(json) -} -``` - -## Memeriksa Params Di Handler +## Memeriksa Param Di Handler -Params rute baru tersedia setelah middleware berjalan, jadi [Middleware Validator](/id/middleware/validation/validator-middleware#params-ditolak-di-sini) menolak sumber `params`. Handler memvalidasinya langsung dengan `Validator.check(contract, value)`: +Param rute baru tersedia setelah middleware berjalan, jadi [Middleware Validator](/id/middleware/validation/validator-middleware) tidak menerima sumber `params`. Handler memvalidasinya langsung dengan memanggil fungsi kontrak: ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' +import { type Context, Validator } from '@neabyte/deserve' -const UserId = Define( +const UserId = Validator.define( (params: Record) => ({ id: Number(params['id']) }), (params) => (/^\d+$/.test(params['id'] ?? '') ? true : 'id must be numeric') ) // ---cut--- export function GET(ctx: Context): Response { // Validasi param rute yang cocok - const { id } = Validator.check(UserId, ctx.params()) + const { id } = UserId(ctx.get.param()) return ctx.send.json({ id }) } ``` -`Validator.check` mengembalikan output kontrak saat nilai lolos dan melempar saat gagal, lemparan yang sama dengan yang diproduksi middleware. Cara ini bekerja untuk nilai apa pun, tidak hanya params, sehingga berguna untuk memvalidasi sepotong data di tengah handler. +Memanggil kontrak langsung mengembalikan output transform saat nilai lolos dan melempar saat gagal, lemparan yang sama dengan yang diproduksi middleware. Cara ini bekerja untuk nilai apa pun, tidak hanya param, sehingga berguna untuk memvalidasi sepotong data di tengah handler. ## Cara Kegagalan Muncul Kontrak yang menolak inputnya akan melempar, dan framework memetakan lemparan itu ke sebuah status: -- Error yang sudah membawa status diteruskan tanpa diubah. -- Error yang membawa alasan kegagalan menjadi **422 Unprocessable Content**, dengan alasan terjaga di `error.cause` sebagai array string. -- Lemparan lain dari input client menjadi **422** umum. +- Error yang sudah membawa status diteruskan tanpa diubah +- Error yang membawa alasan kegagalan menjadi **422 Unprocessable Content**, dengan alasan terjaga di `error.cause` sebagai array string +- Lemparan lain dari input client menjadi **422** umum Input client tidak pernah berubah menjadi 500. Jaminan itu menjaga body malformed, query string buruk, atau guard yang melempar tetap di sisi client dari garis status tempat seharusnya. -![Cara lemparan validasi dipetakan ke status: error yang sudah membawa status diteruskan, error dengan alasan menjadi 422 yang menjaga alasan itu, lemparan client lain menjadi 422 umum, dan membaca tanpa validator terdaftar adalah satu-satunya 500 karena itu menandakan kesalahan perakitan alih-alih input buruk](/diagrams/validation-failure-status.png) +![Cara lemparan validasi dipetakan ke status: error yang sudah membawa status diteruskan, error dengan alasan menjadi 422 yang menjaga alasan itu, lemparan client lain menjadi 422 umum, dan membaca tanpa validator terdaftar melempar Deno.errors.NotSupported yang dipetakan ke 501 karena itu menandakan kesalahan perakitan alih-alih input buruk](/diagrams/validation-failure-status.png) Alasan menumpang di `error.cause`, jadi handler kustom membacanya dan membalas dengan detail tingkat field. Pembentukan respons error dipusatkan di [Detail Object](/id/error-handling/object-details), satu `router.catch` yang menangani validasi bersama setiap error lain: ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { type HttpStatusCode, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.catch((ctx, info) => { @@ -93,16 +79,16 @@ router.catch((ctx, info) => { : [] return ctx.send.json( { error: 'request_failed', status: info.statusCode, reasons }, - { status: info.statusCode } + { status: info.statusCode as HttpStatusCode } ) }) ``` -Untuk object `ErrorInfo` lengkap dan respons default saat tidak ada handler, lihat [Detail Object](/id/error-handling/object-details) dan [Perilaku Default](/id/error-handling/default-behavior). Kegagalan validasi juga mengalir lewat event bus observability, jadi sebuah listener bisa mencatatnya, dibahas di [Pelaporan Error](/id/middleware/observability/errors). +Untuk object `ErrorInfo` lengkap dan respons default saat tidak ada handler, lihat [Detail Object](/id/error-handling/object-details) dan [Perilaku Default](/id/error-handling/default-behavior). Kegagalan validasi juga mengalir lewat event bus observability sebagai event `validate:failed`, jadi sebuah listener bisa mencatatnya, dibahas di [Pelaporan Error](/id/middleware/observability/errors). ## Langkah Berikutnya -- [Define Schema](/id/middleware/validation/define-schema) - menulis kontrak di balik pembacaan. -- [Detail Object](/id/error-handling/object-details) - membentuk respons yang dihasilkan kegagalan. -- [Pola Lanjutan](/id/middleware/validation/advanced-patterns) - memvalidasi params di samping validator body. -- [Ringkasan Validasi](/id/middleware/validation/overview) - bagaimana semua bagian saling terhubung. +- [Define Schema](/id/middleware/validation/define-schema) - menulis kontrak di balik pembacaan +- [Detail Object](/id/error-handling/object-details) - membentuk respons yang dihasilkan kegagalan +- [Pola Lanjutan](/id/middleware/validation/advanced-patterns) - memvalidasi param di samping validator body +- [Ringkasan Validasi](/id/middleware/validation/overview) - bagaimana semua bagian saling terhubung diff --git a/docs/id/middleware/validation/validator-middleware.md b/docs/id/middleware/validation/validator-middleware.md index 9a38d96..223347f 100644 --- a/docs/id/middleware/validation/validator-middleware.md +++ b/docs/id/middleware/validation/validator-middleware.md @@ -1,28 +1,28 @@ --- -description: "Daftarkan middleware validasi dengan Mware.validator, batasi per rute, dan tumpuk beberapa sumber sekaligus." +description: "Daftarkan middleware validasi dengan Validator.check, batasi per rute, dan gabungkan beberapa sumber dalam satu schema." --- # Middleware Validator -`Mware.validator(schema)` mengubah sebuah schema menjadi middleware. Middleware ini membaca setiap sumber yang disebut di schema, menjalankan kontrak yang cocok, dan menyimpan hasilnya di request state untuk dibaca handler. +`Validator.check(schema)` mengubah sebuah schema menjadi middleware. Middleware ini membaca setiap sumber yang disebut di schema, menjalankan kontrak yang cocok, dan menyimpan hasilnya di context untuk dibaca handler. ## Mendaftarkan Middleware Berikan middleware ke `router.use`, panggilan yang sama yang mendaftarkan setiap middleware lain: ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) const createUser = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // Jalankan untuk setiap request -router.use(Mware.validator(createUser)) +router.use(Validator.check(createUser)) await router.serve(8000) ``` @@ -32,18 +32,18 @@ await router.serve(8000) Sebuah prefix path membatasi validator ke rute yang cocok, mengikuti aturan di [Middleware Spesifik Rute](/id/middleware/route-specific): ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) const createUser = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- // Validasi hanya di bawah /users -router.use('/users', Mware.validator(createUser)) +router.use('/users', Validator.check(createUser)) ``` Untuk pendaftaran global versus terbatas secara umum, lihat [Middleware Global](/id/middleware/global). @@ -53,85 +53,72 @@ Untuk pendaftaran global versus terbatas secara umum, lihat [Middleware Global]( Sebuah schema bisa menyebut lebih dari satu sumber, dan setiap kontrak memvalidasi bagiannya sendiri dari request: ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' -const router = new Router({ routesDir: './routes' }) +const router = new Router({ routes: { directory: './routes' } }) // ---cut--- const listUsers = { // Nomor halaman dari query string - query: Define( + query: Validator.define( (q: Record) => ({ page: Number(q['page'] ?? '1') }), (q) => (/^\d*$/.test(q['page'] ?? '') ? true : 'page must be numeric') ), // Kunci API dari header request - headers: Define( + headers: Validator.define( (h: Record) => ({ apiKey: h['x-api-key'] ?? '' }), (h) => (h['x-api-key'] ? true : 'x-api-key header is required') ) } // Validasi query dan headers bersamaan -router.use('/users', Mware.validator(listUsers)) +router.use('/users', Validator.check(listUsers)) ``` Beberapa sumber divalidasi dalam urutan kemunculan key-nya, dan sumber pertama yang gagal menghentikan sisanya. Aturan urutan itu ada di [Pola Lanjutan](/id/middleware/validation/advanced-patterns#urutan-validasi). ![Urutan sumber: sebuah schema memvalidasi sumbernya dalam urutan key, jadi kontrak query yang gagal melempar 422 yang hanya membawa alasan query sementara kontrak headers dan cookies setelahnya tidak pernah berjalan](/diagrams/validation-source-order.png) -## Menumpuk Validator +## Satu Schema Per Rute -Mendaftarkan lebih dari satu validator pada rute yang sama menggabungkan hasilnya. Setiap validator menambahkan sumbernya sendiri ke state bersama, jadi pembacaan kemudian melihat setiap sumber tervalidasi sekaligus: +Setiap validator menyimpan hasilnya sendiri di context, dan validator berikutnya menggantikan nilai tersimpan alih-alih menggabungkannya. Mendaftarkan dua validator pada rute yang sama menyisakan hanya yang terakhir bisa dibaca, jadi beberapa sumber sebaiknya berada dalam satu schema: ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' -const router = new Router({ routesDir: './routes' }) - -const queryRules = { - query: Define((q: Record) => ({ page: Number(q['page'] ?? '1') })) -} -const bodyRules = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) -} +const router = new Router({ routes: { directory: './routes' } }) // ---cut--- -// Dua validator mengisi satu state gabungan -router.use('/users', Mware.validator(queryRules)) -router.use('/users', Mware.validator(bodyRules)) -``` - -Handler membaca `query` dan `json` gabungan dalam satu panggilan, ditunjukkan di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data). - -## Params Ditolak Di Sini +// Gabungkan sumber dalam satu schema +const userRules = { + query: Validator.define((q: Record) => ({ page: Number(q['page'] ?? '1') })), + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) +} -Sebuah schema yang menyebut `params` melempar saat pendaftaran dengan [`Deno.errors.InvalidData`](https://docs.deno.com/api/deno/~/Deno.errors.InvalidData). Params rute baru tersedia setelah middleware berjalan, jadi middleware hanya akan melihat object kosong. Errornya menunjuk ke alat yang tepat: +// Satu validator membawa kedua sumber +router.use('/users', Validator.check(userRules)) +``` -```typescript twoslash -import { Define, Mware } from '@neabyte/deserve' +Handler membaca `query` dan `body` bersamaan dalam satu panggilan lewat `ctx.get.validated()`, ditunjukkan di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data). -// Melempar InvalidData saat pendaftaran -Mware.validator({ - params: Define((p: Record) => p) -}) -``` +## Sumber Tak Didukung Ditolak -Validasi params di dalam handler dengan `Validator.check`, dibahas di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#memeriksa-params-di-handler). +Sebuah schema yang menyebut sumber selain `body`, `cookies`, `headers`, atau `query` melempar `Deno.errors.InvalidData` saat pendaftaran. Param rute baru tersedia setelah middleware berjalan, jadi middleware hanya akan melihat object kosong. Validasi param di dalam handler dengan panggilan kontrak langsung, dibahas di [Membaca Data Tervalidasi](/id/middleware/validation/reading-data#memeriksa-param-di-handler). ## Schema Kosong Ditolak -Sebuah schema tanpa kontrak sumber juga melempar [`Deno.errors.InvalidData`](https://docs.deno.com/api/deno/~/Deno.errors.InvalidData) saat pendaftaran, karena validator tanpa apa pun untuk divalidasi adalah kesalahan perakitan yang layak ditangkap sejak awal: +Sebuah schema tanpa kontrak sumber juga melempar `Deno.errors.InvalidData` saat pendaftaran, karena validator tanpa apa pun untuk divalidasi adalah kesalahan perakitan yang layak ditangkap sejak awal: ```typescript twoslash -import { Mware } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Melempar InvalidData, tanpa sumber -Mware.validator({}) +Validator.check({}) ``` Kedua penolakan terjadi saat server menyala, bukan pada sebuah request, jadi schema yang rusak tidak pernah mencapai trafik produksi. ## Langkah Berikutnya -- [Membaca Data Tervalidasi](/id/middleware/validation/reading-data) - membaca output tersimpan di handler. -- [Define Schema](/id/middleware/validation/define-schema) - membentuk kontrak yang ditunjuk schema. -- [Pola Lanjutan](/id/middleware/validation/advanced-patterns) - memilih schema per method pada satu prefix bersama. -- [Ringkasan Validasi](/id/middleware/validation/overview) - bagaimana semua bagian saling terhubung. +- [Membaca Data Tervalidasi](/id/middleware/validation/reading-data) - membaca output tersimpan di handler +- [Define Schema](/id/middleware/validation/define-schema) - membentuk kontrak yang ditunjuk schema +- [Pola Lanjutan](/id/middleware/validation/advanced-patterns) - memilih schema per method pada satu prefix bersama +- [Ringkasan Validasi](/id/middleware/validation/overview) - bagaimana semua bagian saling terhubung diff --git a/docs/id/middleware/websocket.md b/docs/id/middleware/websocket.md index 8e9f63f..f6c69ea 100644 --- a/docs/id/middleware/websocket.md +++ b/docs/id/middleware/websocket.md @@ -22,7 +22,7 @@ router.use( Mware.websocket({ listener: '/ws', onConnect: (socket, event, ctx) => { - console.log('WebSocket connected:', ctx.url) + console.log('WebSocket connected:', ctx.get.url()) socket.send('Welcome') } }) @@ -69,7 +69,7 @@ Menangani koneksi WebSocket baru: ```typescript onConnect: (socket: WebSocket, event: Event, ctx: Context) => { - console.log('Client connected:', ctx.url) + console.log('Client connected:', ctx.get.url()) socket.send(JSON.stringify({ type: 'welcome', message: 'Connected' @@ -113,7 +113,7 @@ Menangani error WebSocket: ```typescript onError: (socket: WebSocket, event: Event, ctx: Context) => { - console.error('WebSocket error:', event, 'on', ctx.url) + console.error('WebSocket error:', event, 'on', ctx.get.url()) } ``` @@ -123,14 +123,14 @@ onError: (socket: WebSocket, event: Event, ctx: Context) => { import { Mware, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) router.use( Mware.websocket({ listener: '/ws', onConnect: (socket, event, ctx) => { - console.log(`WebSocket connected: ${ctx.url}`) + console.log(`WebSocket connected: ${ctx.get.url()}`) socket.send( JSON.stringify({ type: 'welcome', @@ -139,7 +139,7 @@ router.use( ) }, onMessage: (socket, event, ctx) => { - console.log(`Message from ${ctx.url}:`, event.data) + console.log(`Message from ${ctx.get.url()}:`, event.data) try { const data = JSON.parse(event.data as string) if (data.type === 'ping') { @@ -168,10 +168,10 @@ router.use( } }, onDisconnect: (socket, event, ctx) => { - console.log(`WebSocket disconnected: ${ctx.url} code=${event.code} reason=${event.reason}`) + console.log(`WebSocket disconnected: ${ctx.get.url()} code=${event.code} reason=${event.reason}`) }, onError: (socket, event, ctx) => { - console.error(`WebSocket error on ${ctx.url}:`, event) + console.error(`WebSocket error on ${ctx.get.url()}:`, event) } }) ) @@ -227,10 +227,12 @@ onConnect: (socket, event, ctx) => { Handshake yang ditolak diarahkan lewat error handler alih-alih melempar saat setup: -- **Origin tidak diizinkan** gagal dengan **403** dan pesan `WebSocket handshake rejected because the Origin is not allowed`. -- **Upgrade tidak valid** gagal dengan **400** dan pesan `WebSocket handshake is malformed because ...`. +- **Origin tidak diizinkan** gagal dengan **403** dan pesan `WebSocket handshake rejected because the Origin is not allowed` +- **Versi hilang** gagal dengan **400** dan pesan `WebSocket handshake requires Sec-WebSocket-Version 13` +- **Versi salah** mengembalikan **426 Upgrade Required** dengan header `Sec-WebSocket-Version: 13` dan `Upgrade: websocket` +- **Upgrade malformed** gagal dengan **400** dan pesan `WebSocket handshake is malformed because ...` -Keduanya dialirkan ke [error handler terpusat](/id/error-handling/object-details), jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). +Tiap penolakan juga memancarkan event `websocket:rejected` dengan alasannya, dibahas di [Referensi Event](/id/middleware/observability/events). Semua kegagalan dialirkan ke [error handler terpusat](/id/error-handling/object-details), jadi bentuk response di sana atau andalkan [perilaku default](/id/error-handling/default-behavior). ## Integrasi Dengan CORS diff --git a/docs/id/recipes/audit-compliance.md b/docs/id/recipes/audit-compliance.md index 4485670..1af4b54 100644 --- a/docs/id/recipes/audit-compliance.md +++ b/docs/id/recipes/audit-compliance.md @@ -1,12 +1,12 @@ --- -description: 'Ubah observability bus Deserve jadi jejak audit tingkat kepatuhan, lalu alirkan ke store milik sendiri, sebuah SIEM, atau sebuah WAF.' +description: 'Ubah observability bus Deserve menjadi jejak audit tingkat kepatuhan, lalu alirkan ke store milik sendiri, sebuah SIEM, atau sebuah WAF.' --- # Audit Kepatuhan Kerja kepatuhan mengajukan satu pertanyaan sulit ke setiap server: apa yang terjadi, kapan, dan bisakah dibuktikan nanti. Deserve menjawabnya di sumber. Setiap kesalahan subsistem, setiap request yang selesai, dan setiap terminasi diri yang diblokir tiba di satu [observability bus](/id/middleware/observability/overview), terstruktur dan bercap waktu pada saat ia menyala. -Pembingkaian ini penting, jadi layak dinyatakan terang. Deserve bukan [SIEM](https://csrc.nist.gov/glossary/term/security_information_and_event_management) dan tidak lebih tahan lama dari satu. Yang ia berikan adalah *input* SIEM paling rapi yang bisa diserahkan sebuah framework. Data yang meninggalkan bus lebih bersih dan lebih lengkap dari yang dipancarkan kebanyakan framework, karena ia membawa perilaku framework dan kesalahan aplikasi sekaligus, masing-masing di [kanal internal atau external](/id/middleware/observability/events) yang bersih sehingga jalur alert tidak pernah tenggelam dalam lalu lintas rutin. Penyimpanan tahan lama tetap tanggung jawab operator, tapi yang sampai ke penyimpanan itu berangkat jujur. +Pembingkaian ini penting, jadi layak dinyatakan terang. Deserve bukan [SIEM](https://csrc.nist.gov/glossary/term/security_information_and_event_management) dan tidak lebih tahan lama dari satu SIEM. Yang ia berikan adalah *input* SIEM paling rapi yang bisa diserahkan sebuah framework. Data yang meninggalkan bus lebih bersih dan lebih lengkap dari yang dipancarkan kebanyakan framework, karena ia membawa perilaku framework dan kesalahan aplikasi sekaligus, masing-masing di [kanal internal atau external](/id/middleware/observability/events) yang bersih sehingga jalur alert tidak pernah tenggelam dalam lalu lintas rutin. Penyimpanan tahan lama tetap tanggung jawab operator, tapi yang sampai ke penyimpanan itu berangkat jujur. ## Apa yang Sudah Ditangkap Bus @@ -14,9 +14,9 @@ Satu listener [`router.on()`](/id/middleware/observability/overview) melihat sel | Kebutuhan kepatuhan | Event yang menjawabnya | | ---------------------------- | ---------------------------------------------------------------------------------- | -| Siapa melakukan apa, kapan | [`request:complete`](/id/middleware/observability/events#request) dengan `method`, `url`, `statusCode`, `durationMs`, dan `ip` opsional | -| Event relevan-keamanan | [`session:invalid`](/id/middleware/observability/events#middleware), [`csrf:rule-error`](/id/middleware/observability/events#middleware), [`process:error`](/id/middleware/observability/events#process) | -| Kegagalan dan kesalahan | [`request:error`](/id/middleware/observability/events#request), [`worker:crash`](/id/middleware/observability/events#worker), [`view:error`](/id/middleware/observability/events#view) | +| Siapa melakukan apa, kapan | [`request:completed`](/id/middleware/observability/events#request) dengan `method`, `url`, `statusCode`, `durationMs`, dan `ip` opsional | +| Event relevan-keamanan | [`session:invalid`](/id/middleware/observability/events#middleware-keamanan), [`csrf:failed`](/id/middleware/observability/events#middleware-keamanan), [`process:failed`](/id/middleware/observability/events#process) | +| Kegagalan dan kesalahan | [`request:failed`](/id/middleware/observability/events#request), [`worker:crashed`](/id/middleware/observability/events#worker), [`view:failed`](/id/middleware/observability/events#view) | | Garis waktu yang dapat disusun | Setiap event membawa `timestamp` dalam milidetik epoch dan tiba terurut | Tidak ada yang perlu dikabelkan di dalam handler. Kesalahan menyala sendiri, itulah sebabnya cookie yang dirusak atau `Deno.exit` yang diblokir muncul tanpa satu baris logging pun di rute. Daftar lengkapnya ada di [Referensi Event](/id/middleware/observability/events). @@ -29,7 +29,7 @@ Listener audit punya satu tugas: menangkap setiap event sebagai rekaman terstruk import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Satu rekaman audit per event @@ -61,7 +61,7 @@ Sink tahan lama paling sederhana adalah yang dimiliki dari ujung ke ujung. Tamba import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- // Buka log audit sekali, tambah-saja @@ -91,7 +91,7 @@ Sebuah [SIEM](https://csrc.nist.gov/glossary/term/security_information_and_event import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- const endpoint = 'https://http-inputs-acme.splunkcloud.com/services/collector/event' @@ -119,18 +119,18 @@ Endpoint dan bentuk auth mengikuti vendor. Kolektor umum dengan API ingest HTTP ## Opsi 3 - Beri Makan Loop Keputusan WAF -Sebuah [Web Application Firewall](https://owasp.org/www-community/Web_Application_Firewall) memblokir lalu lintas buruk sebelum sampai ke aplikasi, dan bus memberinya sinyal untuk bertindak. Lonjakan event `request:error` dari satu `ip`, atau kesalahan `csrf:rule-error` berulang, persis pola yang diincar aturan WAF. Teruskan jenis yang relevan-keamanan ke API firewall untuk menggerakkan daftar blokir: +Sebuah [Web Application Firewall](https://owasp.org/www-community/Web_Application_Firewall) memblokir lalu lintas buruk sebelum sampai ke aplikasi, dan bus memberinya sinyal untuk bertindak. Lonjakan event `request:failed` dari satu `ip`, atau kesalahan `csrf:failed` berulang, persis pola yang diincar aturan WAF. Teruskan jenis yang relevan-keamanan ke API firewall untuk menggerakkan daftar blokir: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Hanya teruskan kesalahan relevan-keamanan - if (event.kind === 'csrf:rule-error' || event.kind === 'request:error') { + if (event.kind === 'csrf:failed' || event.kind === 'request:failed') { void fetch('https://waf.internal/signals', { method: 'POST', headers: { @@ -156,4 +156,3 @@ Menjaga klaim tetap lurus membuat resep ini tepercaya: - **Input, bukan analisis.** Bus menghasilkan rekaman bersih. Korelasi, retensi, dan alerting milik store, SIEM, atau WAF yang menerimanya. Yang dijamin Deserve adalah bagian yang biasanya gagal dibuat framework: data yang sampai ke penyimpanan itu terstruktur, bercap waktu di sumber, terpisah per kanal, dan lengkap di seluruh perilaku framework dan kesalahan aplikasi. Semua dapat diaudit karena semua memancar. - diff --git a/docs/id/recipes/file-upload.md b/docs/id/recipes/file-upload.md index 527154f..7d3bb19 100644 --- a/docs/id/recipes/file-upload.md +++ b/docs/id/recipes/file-upload.md @@ -21,14 +21,14 @@ Handler upload tinggal di [direktori routes](/id/core-concepts/file-based-routin ## Membaca Upload -Sebuah request multipart mengalir lewat `ctx.formData()`, dan tiap field bernama kembali dari `form.get()`. Field teks mengembalikan string sementara field file mengembalikan `File`: +Sebuah request multipart mengalir lewat `ctx.get.formData()`, dan tiap field bernama kembali dari `form.get()`. Field teks mengembalikan string sementara field file mengembalikan `File`: ```typescript twoslash import type { Context } from '@neabyte/deserve' // POST /api/upload dengan form multipart export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const title = form.get('title') // Field teks sebagai string const file = form.get('file') // File atau string atau null @@ -40,7 +40,7 @@ export async function POST(ctx: Context): Promise { } ``` -Memanggil `ctx.body()` mencapai parser yang sama, karena pembaca itu membaca header `Content-Type` dan mengarahkan baik `multipart/form-data` maupun `application/x-www-form-urlencoded` ke `FormData`. Memilih `ctx.formData()` membuat niat jelas di titik panggilan. +Memanggil `ctx.get.body()` mencapai parser yang sama, karena ia membaca header `Content-Type` dan mengarahkan baik `multipart/form-data` maupun `application/x-www-form-urlencoded` ke `FormData`. Memilih `ctx.get.formData()` membuat niat jelas di titik panggilan. ## Memastikan File Tiba @@ -50,7 +50,7 @@ Memanggil `ctx.body()` mencapai parser yang sama, karena pembaca itu membaca hea import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const file = form.get('file') // Tolak saat tidak ada file @@ -86,7 +86,7 @@ Bytes tetap di dalam `File` sampai `arrayBuffer()` mengeluarkannya, dan membungk import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const file = form.get('file') // Tolak saat tidak ada file @@ -120,13 +120,13 @@ Menulis ke disk butuh izin tulis Deno, jadi server berjalan dengan `--allow-writ ## Membaca Body Sekali -Tiap Context mengurai body-nya satu kali lalu men-cache hasilnya, jadi pembaca kedua dengan format berbeda pada request yang sama akan melempar error alih-alih mengembalikan data kosong. Memilih salah satu dari `formData()`, `json()`, `text()`, `arrayBuffer()`, atau `blob()` per request menjaga kontrak itu: +Tiap Context mengurai body-nya satu kali lalu men-cache hasilnya, jadi format yang dibaca lebih dulu adalah format yang terkunci untuk request itu. Memilih salah satu dari `formData()`, `json()`, `text()`, `bytes()`, atau `blob()` per request menjaga kontrak itu: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const form = await ctx.formData() // Body dikonsumsi sekali di sini + const form = await ctx.get.formData() // Body dikonsumsi sekali di sini // Baca form ter-cache, bukan body return ctx.send.json({ @@ -135,17 +135,17 @@ export async function POST(ctx: Context): Promise { } ``` -Pembaca kedua seperti `ctx.json()` pada request ini akan melempar error alih-alih mengembalikan data kosong, karena body sudah habis. Payload multipart yang rusak juga tidak pernah membuat pipeline crash, karena parser memetakan body rusak ke **400** yang mengalir lewat [penanganan error terpusat](/id/error-handling/object-details). Setiap pembaca dan tipe kembaliannya ada di [referensi penanganan request](/id/core-concepts/request-handling#referensi-method). +Pembaca kedua dengan format berbeda seperti `ctx.get.json()` pada request ini akan melempar **409** alih-alih mengembalikan data kosong, karena body sudah dikonsumsi sebagai form data. Membaca format yang sama dua kali aman, karena nilai terurai di-cache dan diberikan kembali tanpa menyentuh body lagi. Payload multipart yang rusak juga tidak pernah membuat pipeline crash, karena parser memetakan body rusak ke **400** yang mengalir lewat [penanganan error terpusat](/id/error-handling/object-details). Setiap pembaca dan tipe kembaliannya ada di [referensi penanganan request](/id/core-concepts/request-handling). ## Membatasi Ukuran Upload -`FormData` tidak membatasi berapa banyak bytes yang dikirim klien, jadi rute upload berpasangan dengan [middleware body limit](/id/middleware/body-limit) untuk menolak payload yang terlalu besar dengan **413** sebelum memenuhi memori. `Content-Length` yang diketahui melebihi batas ditolak sebelum body dibaca, sementara stream chunked dipotong begitu bytes berlebih tiba: +`FormData` tidak membatasi berapa banyak bytes yang dikirim klien, jadi rute upload berpasangan dengan [middleware body limit](/id/middleware/body-limit) untuk menolak payload yang terlalu besar dengan **413** sebelum memenuhi memori. Pemeriksaan ini membaca header `Content-Length`, jadi ukuran yang diumumkan melebihi batas ditolak sebelum body dibaca. Request tanpa `Content-Length`, seperti stream chunked, melewati pemeriksaan ini dan mencapai handler: ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Batasi rute upload pada 5MB @@ -176,7 +176,7 @@ router.static('/uploads', { }) ``` -Untuk unduhan sekali pakai yang digerakkan handler alih-alih prefix statis, [`ctx.send.file()`](/id/response/file) mengalirkan satu file langsung dari disk dengan `Content-Disposition` yang tepat terpasang. +Untuk unduhan sekali pakai yang digerakkan handler alih-alih prefix statis, [`ctx.send.download()`](/id/response/download) mengalirkan satu file langsung dari disk dengan `Content-Disposition` yang tepat terpasang. ## Alur Lengkap @@ -189,7 +189,7 @@ import { Mware, Router } from '@neabyte/deserve' // Titik masuk main.ts const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Jaga ukuran sebelum handler jalan @@ -217,7 +217,7 @@ import type { Context } from '@neabyte/deserve' // ---cut--- // Handler routes/api/upload.ts export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const file = form.get('file') // Tolak saat tidak ada file diff --git a/docs/id/recipes/graceful-shutdown.md b/docs/id/recipes/graceful-shutdown.md index 35d44c3..a8171d2 100644 --- a/docs/id/recipes/graceful-shutdown.md +++ b/docs/id/recipes/graceful-shutdown.md @@ -8,13 +8,13 @@ Graceful shutdown menghentikan server menerima koneksi baru sambil membiarkan re ## Penanganan Sinyal Bawaan -`router.serve()` polos sudah mendengarkan sinyal yang dikirim process manager saat berhenti. Pada `SIGINT` (`Ctrl+C` di terminal) atau `SIGTERM` (yang dikirim Docker dan kebanyakan orchestrator), server berhenti menerima request baru, menguras yang masih berjalan, lalu meresolusi promise `serve()`: +`router.serve()` polos sudah mendengarkan sinyal yang dikirim process manager saat berhenti. Pada `SIGHUP`, `SIGINT` (`Ctrl+C` di terminal), atau `SIGTERM` (yang dikirim Docker dan kebanyakan orchestrator), server berhenti menerima request baru, menguras yang masih berjalan, lalu meresolusi promise `serve()`: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Resolusi setelah pengurasan selesai @@ -24,7 +24,7 @@ await router.serve(8000) console.log('Server stopped') ``` -Windows mendengarkan `SIGINT` saja, karena `SIGTERM` tidak dikirim di sana. Tidak ada penyetelan yang diperlukan untuk jalur ini, jadi server dalam kontainer sudah keluar dengan bersih saat `docker stop`. +Windows menggantinya dengan `SIGBREAK` dan `SIGINT`, karena sinyal POSIX tidak dikirim di sana. Tidak ada penyetelan yang diperlukan untuk jalur ini, jadi server dalam kontainer sudah keluar dengan bersih saat `docker stop`. Tiap sinyal yang diterima juga memancarkan event [`process:failed`](/id/middleware/observability/events#process) dengan `origin: 'process:signal'` tepat sebelum pengurasan mulai, jadi alasan berhenti mendarat di bus yang sama dengan setiap kesalahan lain. ## Memicu Shutdown Dari Kode @@ -34,7 +34,7 @@ Meneruskan `AbortSignal` sebagai argumen ketiga menyerahkan pemicu ke aplikasi, import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- const controller = new AbortController() @@ -46,22 +46,22 @@ setTimeout(() => controller.abort(), 30_000) await router.serve(8000, '0.0.0.0', controller.signal) ``` -Menyerahkan `AbortSignal` mengambil alih pemicu berhenti, jadi listener bawaan `SIGINT` dan `SIGTERM` tetap mati dan controller menjadi satu-satunya cara berhenti. Saat keduanya diinginkan, sambungkan dengan membatalkan controller dari dalam listener sinyal. +Sebuah `AbortSignal` berjalan berdampingan dengan listener bawaan alih-alih menggantikannya, jadi `SIGTERM` dari host dan `abort()` dari kode keduanya mencapai pengurasan yang sama. Mana pun yang menyala lebih dulu menghentikan server, dan yang lain menjadi no-op begitu pengurasan berlangsung. Menyambungkan listener sinyal untuk memanggil `controller.abort()` adalah cara melipat kedua pemicu ke satu jalur saat itu tujuannya. ## Menjalankan Kerja Saat Shutdown -Pembersihan seperti menutup pool basis data atau membilas buffer berada setelah pengurasan, bukan di dalamnya. Event [`server:shutdown`](/id/middleware/observability/events#server) menyala setelah server selesai dikuras, jadi satu listener [observability](/id/middleware/observability/overview) menjaga kerja shutdown tetap di satu tempat: +Pembersihan seperti menutup pool basis data atau membilas buffer berada setelah pengurasan, bukan di dalamnya. Event [`server:stopped`](/id/middleware/observability/events#server) menyala setelah server selesai dikuras, jadi satu listener [observability](/id/middleware/observability/overview) menjaga kerja shutdown tetap di satu tempat: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Berjalan setelah pengurasan selesai - if (event.kind === 'server:shutdown') { + if (event.kind === 'server:stopped') { console.log('Closing resources') } }) @@ -69,8 +69,8 @@ router.on((event) => { await router.serve(8000) ``` -Event pasangannya [`server:listening`](/id/middleware/observability/events#server) menyala saat server mengikat port, jadi kait startup dan shutdown berdampingan di bus yang sama. +Event pasangannya [`server:started`](/id/middleware/observability/events#server) menyala saat server mengikat port, jadi kait startup dan shutdown berdampingan di bus yang sama. ## Arti Pengurasan Bagi Sebuah Request -Request yang sedang berjalan saat pengurasan mulai akan tuntas sampai selesai, dan response-nya tetap terkirim. Koneksi yang tiba setelah pengurasan dimulai ditolak, karena listener sudah berhenti menerima. Response berumur panjang adalah satu hal yang perlu diperhatikan, karena [stream](/id/recipes/streaming-data) atau [WebSocket](/id/middleware/websocket) yang terbuka menahan pengurasan sampai koneksi itu ditutup. Membatasi berapa lama satu request boleh berjalan dengan [`requestTimeoutMs`](/id/getting-started/routes-configuration#requesttimeoutms) menjaga pengurasan tidak menunggu selamanya pada handler yang lambat. +Request yang sedang berjalan saat pengurasan mulai akan tuntas sampai selesai, dan response-nya tetap terkirim. Koneksi yang tiba setelah pengurasan dimulai ditolak, karena listener sudah berhenti menerima. Response berumur panjang adalah satu hal yang perlu diperhatikan, karena [stream](/id/recipes/streaming-data) atau [WebSocket](/id/middleware/websocket) yang terbuka menahan pengurasan sampai koneksi itu ditutup. Membatasi berapa lama satu request boleh berjalan dengan [`timeoutMs`](/id/getting-started/routes-configuration#timeoutms) menjaga pengurasan tidak menunggu selamanya pada handler yang lambat. diff --git a/docs/id/recipes/object-storage.md b/docs/id/recipes/object-storage.md index 0ba320f..b25a536 100644 --- a/docs/id/recipes/object-storage.md +++ b/docs/id/recipes/object-storage.md @@ -1,97 +1,100 @@ --- -description: 'Sajikan berkas dari S3, R2, atau object storage apa pun di Deserve lewat hook staticHandler atau route handler.' +description: 'Sajikan file dari S3, R2, atau object storage apa pun di Deserve lewat route handler.' --- # Object Storage -[Static serving](/id/static-file/basic) bawaan membaca dari filesystem lokal, jadi `router.static()` saja tidak bisa menjangkau bucket di S3, Cloudflare R2, atau Google Cloud Storage. Jembatannya adalah opsi [`staticHandler`](/id/getting-started/routes-configuration#statichandler), sebuah hook yang mempertahankan route static yang familiar sambil menukar pembacaan berkas dengan fetch ke object storage. Route tetap terdaftar lewat `router.static()`, dan handler menjawab tiap request dari bucket alih-alih dari disk. +[Static serving](/id/static-file/basic) bawaan membaca dari filesystem lokal, jadi `router.static()` dengan opsi `path` saja tidak bisa menjangkau bucket di S3, Cloudflare R2, atau Google Cloud Storage. Jembatannya adalah [custom static handler](/id/static-file/basic#handler-kustom) yang sudah diterima `router.static()`, sebuah fungsi berbentuk `(ctx, urlPath) => Response` yang menukar pembacaan file dengan fetch ke object storage. Mount menjaga permukaan URL yang sama sementara bucket menjadi sumber kebenaran. -## Kenapa Hook dan Bukan Path +## Kenapa Custom Handler -Opsi `path` pada [static serving](/id/static-file/basic#path) memetakan prefix URL ke folder yang bisa diresolusi `Deno.stat` dan `Deno.realPath`, yang merupakan kontrak disk lokal. Object storage tidak punya path nyata di disk, jadi pemeriksaan traversal yang aman dan streaming lewat file handle tidak berlaku. Hook `staticHandler` menyerahkan seluruh langkah serve, jadi bucket menjadi sumber kebenaran sementara permukaan route tetap sama. +Opsi `path` pada [static serving](/id/static-file/basic#path) memetakan prefix URL ke folder yang bisa diresolusi `Deno.stat` dan `Deno.realPath`, yang merupakan kontrak disk lokal. Object storage tidak punya path nyata di disk, jadi pemeriksaan traversal yang aman dan streaming lewat file handle tidak berlaku. Meneruskan sebuah fungsi alih-alih objek `ServeOptions` menyerahkan seluruh langkah serve, jadi permukaan route tetap identik sementara sebuah `fetch` menjawab tiap request. Handler tetap berjalan hanya setelah route dinamis meleset, urutan [pencocokan](/id/static-file/basic#cara-kerja) yang sama dengan mount bawaan. ## Menyajikan Dari Bucket -Sebagian besar object store mengekspos endpoint HTTPS per objek, jadi `fetch` ke `${endpoint}/${key}` menarik byte-nya. Handler memotong prefix URL dari `ctx.pathname` untuk memulihkan kunci objek, lalu mengalirkan body response langsung lewat [`ctx.send.stream`](/id/response/stream): +Sebagian besar object store mengekspos endpoint HTTPS per objek, jadi `fetch` ke `${endpoint}/${key}` menarik byte-nya. Handler menerima `urlPath` dengan prefix mount sudah dilepas, jadi `/assets/logo.png` tiba sebagai `/logo.png`. Lepas garis miring depan untuk memulihkan kunci objek, lalu alirkan body response langsung lewat [`ctx.send.custom`](/id/response/custom): ```typescript twoslash -import { Router, type Context, type ServeOptions } from '@neabyte/deserve' +import { Router } from '@neabyte/deserve' // Endpoint dasar bucket const endpoint = 'https://my-bucket.s3.amazonaws.com' const router = new Router({ - routesDir: 'routes', - staticHandler: { - // Sajikan tiap objek dari bucket - async serve(ctx: Context, options: ServeOptions, urlPath: string) { - // Pulihkan kunci objek dari path - const key = ctx.pathname.slice(urlPath.length).replace(/^\//, '') - const object = await fetch(`${endpoint}/${key}`) - if (!object.ok || !object.body) { - return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) - } - // Alirkan body bucket ke klien - const contentType = object.headers.get('content-type') ?? 'application/octet-stream' - return ctx.send.stream(object.body, undefined, contentType) - } - } + routes: { directory: './routes' } }) -// Daftarkan route yang dipenuhi handler -router.static( - '/assets', - { - path: 's3' +// Custom handler menjembatani ke bucket +router.static('/assets', async (ctx, urlPath) => { + // Lepas garis miring depan untuk kunci + const key = urlPath.replace(/^\//, '') + const object = await fetch(`${endpoint}/${key}`) + if (object.status === 404) { + return await ctx.handleError(404, new Deno.errors.NotFound('Object not found')) } -) + if (!object.ok || !object.body) { + return await ctx.handleError(502, new Error('Object storage unavailable')) + } + // Alirkan body bucket langsung apa adanya + const contentType = object.headers.get('content-type') ?? 'application/octet-stream' + return ctx.send.custom(object.body, { + headers: { + 'Content-Type': contentType + } + }) +}) await router.serve(8000) ``` -Nilai `path` tetap harus diset pada [`router.static()`](/id/static-file/basic) karena wajib, namun handler mengabaikannya di sini karena bucket menggantikan folder lokal. Request ke `/assets/logo.png` menjadi fetch untuk kunci `logo.png`. +Request ke `/assets/logo.png` menjadi fetch untuk kunci `logo.png`, dan byte bucket mengalir balik tanpa pernah menyentuh disk. ## Meneruskan Byte Range -Static serving menjawab [byte range](/id/static-file/basic#permintaan-byte-range) sendiri, tapi handler kustom kini memegang tugas itu. Meneruskan header `Range` yang masuk ke bucket membiarkan store mengembalikan konten parsial, dan meneruskan kembali status serta header range menjaga penggeser video atau unduhan yang bisa dilanjutkan tetap bekerja: +Penyajian bawaan menjawab [byte range](/id/static-file/basic#permintaan-byte-range) sendiri, tapi custom handler kini memegang tugas itu. Meneruskan header `Range` yang masuk ke bucket membiarkan store mengembalikan konten parsial, dan meneruskan kembali status serta header range menjaga penggeser video atau unduhan yang bisa dilanjutkan tetap bekerja: ```typescript twoslash -import type { Context, ServeOptions } from '@neabyte/deserve' -declare const endpoint: string +import { Router, type HttpStatusCode } from '@neabyte/deserve' + +const endpoint = 'https://my-bucket.s3.amazonaws.com' + +const router = new Router({ + routes: { directory: './routes' } +}) // ---cut--- -async function serve(ctx: Context, options: ServeOptions, urlPath: string) { - const key = ctx.pathname.slice(urlPath.length).replace(/^\//, '') - const range = ctx.header('range') +router.static('/assets', async (ctx, urlPath) => { + const key = urlPath.replace(/^\//, '') + const range = ctx.get.header('range') // Teruskan header Range bila ada const object = await fetch(`${endpoint}/${key}`, { headers: range ? { Range: range } : {} }) if (!object.ok || !object.body) { - return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) + return await ctx.handleError(404, new Deno.errors.NotFound('Object not found')) } // Cerminkan header range ke klien const contentType = object.headers.get('content-type') ?? 'application/octet-stream' const contentRange = object.headers.get('content-range') if (contentRange) { - ctx.setHeader('Content-Range', contentRange) - ctx.setHeader('Accept-Ranges', 'bytes') + ctx.set.header('Content-Range', contentRange) + ctx.set.header('Accept-Ranges', 'bytes') } return ctx.send.custom(object.body, { - status: object.status, + status: object.status as HttpStatusCode, headers: { 'Content-Type': contentType } }) -} +}) ``` -Sebuah `206 Partial Content` dari bucket mengalir balik tanpa berubah, karena `ctx.send.custom` mempertahankan status yang dipilih bucket. +Sebuah `206 Partial Content` dari bucket mengalir balik tanpa berubah, karena meneruskan `object.status` ke `ctx.send.custom` mempertahankan status yang dipilih bucket. ## Memakai Route Handler Sebagai Ganti -Hook `staticHandler` mencakup satu prefix URL utuh, yang cocok untuk folder aset publik. Satu unduhan di balik auth atau logika bisnis lebih cocok dengan [route handler](/id/core-concepts/file-based-routing) biasa, tempat middleware berjalan lebih dulu dan kunci datang dari [route param](/id/core-concepts/route-patterns): +Route handler lebih cocok untuk satu unduhan di balik auth atau logika bisnis, tempat middleware berjalan lebih dulu dan kunci datang dari [route param](/id/core-concepts/route-patterns): ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -99,14 +102,18 @@ declare const endpoint: string // ---cut--- // routes/files/[key].ts export async function GET(ctx: Context): Promise { - const key = ctx.param('key') + const key = ctx.get.param('key') const object = await fetch(`${endpoint}/${key}`) if (!object.ok || !object.body) { - return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) + return await ctx.handleError(404, new Deno.errors.NotFound('Object not found')) } // Alirkan objek langsung apa adanya const contentType = object.headers.get('content-type') ?? 'application/octet-stream' - return ctx.send.stream(object.body, undefined, contentType) + return ctx.send.custom(object.body, { + headers: { + 'Content-Type': contentType + } + }) } ``` @@ -116,10 +123,10 @@ Jalur ini menjalankan rantai middleware penuh, jadi menjaganya dengan [basic aut Bucket privat butuh request yang ditandatangani, bukan `fetch` polos. Dua jalur cocok: -- **URL presigned** - SDK menandatangani URL berumur pendek, dan handler bisa mengalihkan dengan [`ctx.redirect`](/id/response/redirect) atau mengambilnya sisi server. +- **URL presigned** - SDK menandatangani URL berumur pendek, dan handler bisa mengalihkan dengan [`ctx.send.redirect`](/id/response/redirect) atau mengambilnya sisi server. - **SDK sisi server** - klien resmi menandatangani tiap request, misalnya [AWS SDK for JavaScript](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/welcome.html) untuk S3 atau binding Cloudflare R2 untuk [Workers](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/). -Jalur mana pun yang menandatangani request, body response tetap mengalir lewat `ctx.send.stream`, jadi bentuk penyajiannya tetap sama. +Jalur mana pun yang menandatangani request, body response tetap mengalir lewat `ctx.send.custom`, jadi bentuk penyajiannya tetap sama. ## Menangani Kegagalan @@ -130,7 +137,7 @@ import type { Context } from '@neabyte/deserve' declare const endpoint: string // ---cut--- export async function GET(ctx: Context): Promise { - const key = ctx.param('key') + const key = ctx.get.param('key') try { const object = await fetch(`${endpoint}/${key}`) if (object.status === 404) { @@ -140,7 +147,11 @@ export async function GET(ctx: Context): Promise { if (!object.ok || !object.body) { return await ctx.handleError(502, new Error('Object storage unavailable')) } - return ctx.send.stream(object.body, undefined, 'application/octet-stream') + return ctx.send.custom(object.body, { + headers: { + 'Content-Type': 'application/octet-stream' + } + }) } catch (error) { // Rutekan tiap gangguan jaringan ke error handling return await ctx.handleError(502, error as Error) diff --git a/docs/id/recipes/production-deploy.md b/docs/id/recipes/production-deploy.md index 304916a..8e7ba6e 100644 --- a/docs/id/recipes/production-deploy.md +++ b/docs/id/recipes/production-deploy.md @@ -52,7 +52,7 @@ Host biasanya menetapkan port lewat variabel `PORT`. Memanggil `serve()` tanpa p import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- // Baca env PORT, fallback ke 8000 @@ -79,4 +79,4 @@ Hasilnya berjalan langsung dari `./server` dengan flag sudah di dalam. Satu cata ## Mengawasinya Berjalan -Produksi butuh mata pada server tanpa membanjiri konsol dengan cetakan, yang merupakan tujuan [bus event observability](/id/middleware/observability/overview). Satu listener [`router.on()`](/id/middleware/observability/events) meneruskan event lifecycle, request, dan kesalahan ke apa pun yang mengumpulkan log, dan [pelaporan error](/id/middleware/observability/errors) merutekan kegagalan ke tempat yang sama. Berhenti bersih saat deploy dicakup oleh [Graceful Shutdown](/id/recipes/graceful-shutdown), dan memindahkan kerja berat tanpa memblokir server dicakup oleh [worker pool](/id/core-concepts/worker-pool). +Produksi butuh mata pada server tanpa membanjiri konsol dengan cetakan, yang merupakan tujuan [bus event observability](/id/middleware/observability/overview). Satu listener [`router.on()`](/id/middleware/observability/events) meneruskan event lifecycle, request, dan kesalahan ke apa pun yang mengumpulkan log, dan [pelaporan error](/id/middleware/observability/errors) merutekan kegagalan ke tempat yang sama. Berhenti bersih saat deploy dicakup oleh [Graceful Shutdown](/id/recipes/graceful-shutdown), dan memindahkan kerja berat tanpa memblokir server dicakup oleh [worker pool](/id/recipes/worker-pool). diff --git a/docs/id/recipes/streaming-data.md b/docs/id/recipes/streaming-data.md index 37f2006..b296826 100644 --- a/docs/id/recipes/streaming-data.md +++ b/docs/id/recipes/streaming-data.md @@ -4,9 +4,9 @@ description: 'Dorong data ke klien chunk demi chunk dengan Server-Sent Events da # Streaming Data -Sebuah respons streaming mengirim body-nya potongan demi potongan dari waktu ke waktu alih-alih satu blob jadi, jadi bytes pertama mencapai klien jauh sebelum pekerjaan selesai. Deserve meneruskan [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) langsung lewat `ctx.send.stream()` ke respons native, jadi tiap `controller.enqueue()` meninggalkan server sebagai chunk-nya sendiri. Resep ini mencakup dua format yang paling sering muncul di produksi - [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) untuk dorongan langsung dan [NDJSON](https://github.com/ndjson/ndjson-spec) untuk dataset besar yang dibaca baris demi baris. +Sebuah respons streaming mengirim body-nya potongan demi potongan dari waktu ke waktu alih-alih satu blob jadi, jadi bytes pertama mencapai klien jauh sebelum pekerjaan selesai. Deserve meneruskan [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) langsung lewat `ctx.send.custom()` ke respons native, jadi tiap `controller.enqueue()` meninggalkan server sebagai chunk-nya sendiri. Resep ini mencakup dua format yang paling sering muncul di produksi - [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) untuk dorongan langsung dan [NDJSON](https://github.com/ndjson/ndjson-spec) untuk dataset besar yang dibaca baris demi baris. -Untuk satu stream ter-buffer atau signature method-nya, lihat [respons stream](/id/response/stream). Untuk streaming HTML ter-render, lihat [streaming rendering](/id/rendering/streaming). +Untuk satu stream ter-buffer atau signature method-nya, lihat [respons stream](/id/response/custom). Untuk streaming HTML ter-render, lihat [streaming rendering](/id/rendering/streaming). ## Struktur Proyek @@ -42,19 +42,19 @@ export function GET(ctx: Context): Response { controller.close() } }) - return ctx.send.stream( + return ctx.send.custom( stream, { headers: { - 'Cache-Control': 'no-cache' + 'Cache-Control': 'no-cache', + 'Content-Type': 'text/event-stream' } - }, - 'text/event-stream' + } ) } ``` -Argumen ketiga menetapkan tipe konten sementara argumen kedua membawa header `Cache-Control: no-cache` yang menghentikan proxy mem-buffer feed. Sebuah `Content-Type` per panggilan yang diset begini menang atas header konteks generik mana pun, jadi stream event mempertahankan tipenya bahkan bersama header lain. +Header `Content-Type: text/event-stream` memberi tahu browser untuk memperlakukan respons sebagai sumber event, sementara `Cache-Control: no-cache` menghentikan proxy mem-buffer feed. ### Membaca Dari Browser @@ -91,11 +91,15 @@ export function GET(ctx: Context): Response { controller.close() } }) - return ctx.send.stream(stream, undefined, 'application/x-ndjson') + return ctx.send.custom(stream, { + headers: { + 'Content-Type': 'application/x-ndjson' + } + }) } ``` -Memberikan `undefined` untuk opsi mempertahankan default, sementara argumen ketiga melabeli body `application/x-ndjson` jadi klien tahu harus memecah pada newline. +Memberikan header `Content-Type: application/x-ndjson` memberi tahu klien untuk memecah pada newline. ### Membaca Dari Klien @@ -146,7 +150,11 @@ export function GET(ctx: Context): Response { } } }) - return ctx.send.stream(stream, undefined, 'text/event-stream') + return ctx.send.custom(stream, { + headers: { + 'Content-Type': 'text/event-stream' + } + }) } ``` diff --git a/docs/id/recipes/worker-pool.md b/docs/id/recipes/worker-pool.md new file mode 100644 index 0000000..d582aab --- /dev/null +++ b/docs/id/recipes/worker-pool.md @@ -0,0 +1,214 @@ +--- +description: "Mengalihkan kerja terikat-CPU ke pool worker Deno lewat API worker pool Deserve." +--- + +# Worker Pool + +> **Referensi**: [API Workers Deno](https://docs.deno.com/runtime/manual/workers/) + +Tugas terikat-CPU seperti matematika berat, parsing, atau kompresi memblokir event loop selama berjalan, jadi setiap request lain menunggu di belakangnya. Worker pool memindahkan kerja itu ke pool [Worker Deno](https://docs.deno.com/runtime/manual/workers/) yang berjalan di luar thread utama, jadi server tetap menjawab sementara komputasi terjadi di tempat lain. Kerja terikat-I/O seperti baca file atau panggilan jaringan sudah melepaskan loop, jadi tetap di thread utama tempatnya berada. + +Setelah pool dikonfigurasi pada router, sebuah route menjangkaunya lewat [`ctx.get.worker()`](/id/core-concepts/context-object#ctx-get-worker) dan menyerahkan tugas dengan `run(payload)`. + +## Mengonfigurasi Pool + +Pool menyala lewat opsi `worker`, yang butuh sebuah **script URL** yang meresolusi ke modul. Panggilan `import.meta.resolve()` menunjuk ke file di disk, sementara `URL.createObjectURL()` membungkus kode inline: + +```typescript twoslash +import { Router } from '@neabyte/deserve' + +// Resolusi script worker sebagai modul +const workerScriptUrl = import.meta.resolve('./worker.ts') + +// Aktifkan pool pada router +const router = new Router({ + routes: { directory: './routes' }, + worker: { + scriptURL: workerScriptUrl, + poolSize: 4 + } +}) + +await router.serve(8000) +``` + +## Menulis Script Worker + +Script worker mendengarkan `message` dan membalas dengan `postMessage`. Payload dan hasil keduanya melintasi batas thread lewat structured clone, jadi hanya data yang dapat diserialisasi yang lewat, yang menyingkirkan fungsi dan simbol: + +```typescript +// worker.ts +self.onmessage = (e: MessageEvent) => { + const data = e.data as { iterations?: number } + const n = Math.max(0, Number(data?.iterations) || 50_000) + let value = 0 + for (let i = 0; i < n; i++) { + value += Math.sqrt(i) + } + // Balas dengan hasil terhitung + self.postMessage({ + done: true, + value + }) +} +``` + +Sebuah worker melaporkan kegagalan dengan mengirim objek berisi `error: true` dan `message` opsional, yang muncul kembali di sisi pemanggil sebagai `run()` yang ditolak: + +```typescript +// Laporkan kegagalan ke pemanggil +self.postMessage({ + error: true, + message: 'Computation failed' +}) +``` + +## Mengirim Dari Sebuah Route + +Controller worker tinggal di `ctx.get.worker()`. Router yang dibuat tanpa opsi `worker` membiarkan controller tidak terpasang, jadi `ctx.get.worker()` melempar `NotSupported` saat sebuah route menjangkaunya. Membungkus dispatch dalam try membiarkan [error handler terpusat](/id/error-handling/object-details) membentuk balasan, di mana `NotSupported` dipetakan ke **501** dengan sendirinya: + +```typescript twoslash +// routes/heavy.ts +import type { Context } from '@neabyte/deserve' + +export async function GET(ctx: Context): Promise { + try { + // Melempar saat tanpa pool terkonfigurasi + const worker = ctx.get.worker() + // Kirim tugas ke worker pool + const result = await worker.run<{ done: boolean; value: number }>({ + iterations: 50_000 + }) + return ctx.send.json({ + value: result?.value + }) + } catch (error) { + // Rutekan kegagalan lewat error handling + return await ctx.handleError(500, error as Error) + } +} +``` + +Sebuah tugas dikirim round-robin lintas pool, jadi request beruntun menyebar ke worker yang tersedia alih-alih mengantre pada satu. + +## Menyetel Pool + +### `scriptURL` + +URL script worker, satu-satunya field wajib. Ia harus menunjuk ke modul, karena Deno menjalankan worker dengan `type: 'module'`. Dua sumber mencakup sebagian besar kasus: + +- **Path file:** `import.meta.resolve('./worker.ts')` +- **Script inline:** `URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))` + +### `poolSize` + +Jumlah worker dalam pool, default **4** dengan minimum 1. Sebuah tugas menyebar round-robin lintas worker ini, jadi pool yang lebih besar menyerap lebih banyak kerja paralel dengan biaya lebih banyak memori: + +```typescript +worker: { + scriptURL: workerScriptUrl, + poolSize: 8 +} +``` + +### `taskTimeoutMs` + +Batas waktu per tugas dalam milidetik, default **5000**. Tugas yang berjalan melewatinya ditolak dengan error timeout, slot direklaim, dan worker dijalankan ulang. Reklaim ini muncul sebagai event [`worker:timeout`](/id/middleware/observability/events#worker) lalu [`worker:respawned`](/id/middleware/observability/events#worker): + +```typescript +worker: { + scriptURL: workerScriptUrl, + taskTimeoutMs: 10_000 +} +``` + +### `maxQueueDepth` + +Batas tugas diterima-tapi-belum-selesai yang ditahan pool sebelum menolak pekerjaan baru, default jumlah worker dikali **8**, jadi pool 4 menahan hingga 32. Begitu batas tercapai, dispatch baru ditolak langsung alih-alih diantrekan, yang menjaga banjir pekerjaan tidak menumpuk tanpa batas: + +```typescript +worker: { + scriptURL: workerScriptUrl, + poolSize: 4, + maxQueueDepth: 64 +} +``` + +### `maxQueueWaitMs` + +Batas proyeksi tunggu, diukur sebagai jumlah pending pada slot terpilih dikali `taskTimeoutMs`, sebelum dispatch ditolak. Default adalah **2000**. Tugas yang seharusnya menunggu di belakang antrean panjang ditolak cepat alih-alih menunggu: + +```typescript +worker: { + scriptURL: workerScriptUrl, + maxQueueWaitMs: 5_000 +} +``` + +Dispatch yang ditolak langsung gagal dan muncul sebagai event [`worker:rejected`](/id/middleware/observability/events#worker), dengan `reason` berbunyi `queue-depth` saat `maxQueueDepth` memicunya atau `queue-wait` saat `maxQueueWaitMs` yang memicunya. + +## Script Worker Inline + +File `worker.ts` terpisah adalah tata letak paling jelas, tapi komputasi kecil cocok inline. Membungkus sumber dalam `Blob` dan menyerahkannya ke `URL.createObjectURL()` menghasilkan URL modul yang diterima pool, yang menjaga worker sekali pakai di file yang sama dengan router: + +```typescript twoslash +import { Router } from '@neabyte/deserve' + +const workerCode = ` +self.onmessage = (e) => { + const data = e.data || {} + const n = Math.max(0, Number(data.iterations) || 50000) + let value = 0 + for (let i = 0; i < n; i++) value += Math.sqrt(i) + self.postMessage({ + done: true, + value + }) +} +export {} +` + +const workerScriptUrl = URL.createObjectURL( + new Blob( + [workerCode], + { type: 'application/javascript' } + ) +) + +const router = new Router({ + routes: { directory: './routes' }, + worker: { + scriptURL: workerScriptUrl, + poolSize: 4 + } +}) + +await router.serve(8000) +``` + +## Bagaimana Kegagalan Muncul + +Sebuah dispatch bisa gagal dalam beberapa cara, dan masing-masing menolak `run()` dengan error spesifik agar penyebabnya tetap terbaca: + +- **Tanpa pool:** Router yang dibuat tanpa `worker` membiarkan `ctx.get.worker()` melempar `NotSupported`, yang dipetakan [error handler terpusat](/id/error-handling/object-details) ke **501**. Bungkus panggilan dalam try saat route harus membalas dengan pesan lebih jelas. +- **Error worker:** Ketika worker memanggil `postMessage({ error: true, message: '...' })`, `worker.run()` ditolak dengan `Error` yang membawa pesan itu. Tanpa pesan, error berbunyi `Worker returned an error with no message`. +- **Crash worker:** Ketika worker melempar atau crash, `run()` ditolak dengan `Worker task failed before responding`, dan slot pulih dengan sendirinya. +- **Timeout tugas:** Ketika tugas berjalan melewati `taskTimeoutMs` (default 5000), `run()` ditolak dengan `Worker task exceeded ms timeout`. +- **Ditolak di bawah beban:** Ketika pool mencapai `maxQueueDepth` atau proyeksi tunggu melewati `maxQueueWaitMs`, `run()` ditolak dengan error antrean-penuh atau slot-sibuk sebelum tugas sempat mulai. + +Setiap kesalahan ini juga mengalir lewat bus observability sebagai [event worker](/id/middleware/observability/events#worker), jadi stall, crash, pemulihan, atau penolakan tetap terlihat tanpa menyentuh jalur request. Menangkap tugas yang ditolak dan meneruskannya ke [error handler terpusat](/id/error-handling/object-details) menjaga pembentukan response tetap di satu tempat: + +```typescript +try { + // Kirim tugas ke worker pool + const result = await worker.run(payload) + return ctx.send.json(result) +} catch (err) { + // Rutekan kegagalan lewat error handling + return await ctx.handleError(500, err as Error) +} +``` + +## Hanya Structured Clone + +Payload dan hasil dikirim lewat `postMessage` / `onmessage`, jadi hanya data yang **dapat diserialisasi structured-clone** yang diizinkan, yang mencakup objek polos, array, primitif, `Date`, `RegExp`, `Map`, `Set`, dan nilai serupa. Fungsi, simbol, dan instance kelas yang tidak dapat diklon tidak bisa melewati batas itu. Lihat [algoritma structured clone](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) di MDN untuk daftar lengkapnya. diff --git a/docs/id/rendering/index.md b/docs/id/rendering/index.md index 55afc62..fd3a465 100644 --- a/docs/id/rendering/index.md +++ b/docs/id/rendering/index.md @@ -4,25 +4,27 @@ description: "Rendering template sisi server di Deserve memakai view engine DVE # Ringkasan Rendering -> Lihat dokumentasi [penyorotan sintaks DVE](https://github.com/NeaByteLab/Deserve/tree/main/editor). - -Deserve membawa mesin template bawaan bernama DVE (Deserve View Engine) untuk membangun HTML dinamis dari template polos dengan sintaks {{ }} yang ringkas. +Deserve membawa mesin template bawaan bernama DVE (Deserve View Engine). DVE mengubah template HTML polos menjadi halaman jadi dengan mengisi sintaks {{ }} yang ringkas memakai data rute. DVE berada di paketnya sendiri, jadi mesin yang sama bekerja di luar Deserve juga. Referensi lengkapnya ada di [JSR](https://jsr.io/@neabyte/dve) dan [npm](https://www.npmjs.com/package/@neabyte/dve), dengan kode sumbernya di [GitHub](https://github.com/NeaByteLab/DVE). ## Pengaturan -Arahkan `viewsDir` ke folder template saat membuat router: +View engine aktif begitu `views.directory` menunjuk ke folder template. Ketika dihilangkan, `ctx.render()` melempar `Deno.errors.NotSupported` karena tidak ada engine yang dikonfigurasi: ```typescript twoslash import { Router } from '@neabyte/deserve' -// Arahkan viewsDir ke folder template +// Arahkan views.directory ke folder template const router = new Router({ - viewsDir: './views' + views: { + directory: './views' + } }) await router.serve(8000) ``` +Batas render juga berada di bawah `views`, dibahas di [Performa dan Batas](/id/rendering/performance) dan [Konfigurasi Routes](/id/getting-started/routes-configuration#views). + ## Template Pertama Buat berkas `.dve` di dalam folder views: @@ -32,11 +34,11 @@ Buat berkas `.dve` di dalam folder views: - {{title}} + {{ title }} -

Hello {{name}}!

-

Today: {{date}}

+

Hello {{ name }}!

+

Today: {{ date }}

``` @@ -57,45 +59,39 @@ export async function GET(ctx: Context): Promise { } ``` -Ekstensi `.dve` opsional di dalam path, jadi `'welcome'` dan `'welcome.dve'` sama-sama menunjuk ke berkas yang sama. +Ekstensi `.dve` opsional di dalam path, jadi `'welcome'` dan `'welcome.dve'` sama-sama menunjuk ke berkas yang sama. Pencarian juga melepas garis miring di depan dan menormalkan backslash, jadi path gaya Windows tetap menemukan template-nya. + +## Caching dan Reload + +Render pertama sebuah template mengompilasinya dan menyimpan hasil parsing-nya, dan setiap render berikutnya memakai ulang cache itu. Mengedit berkas `.dve` membersihkan entri-nya lewat [hot reload](/id/core-concepts/hot-reload), jadi render berikutnya menangkap perubahannya tanpa restart. Angka di baliknya ada di [Performa dan Batas](/id/rendering/performance#caching). ## Penanganan Error -Template yang hilang melempar `Template "" not found in views directory`, dan kegagalan render juga melempar. Biarkan keduanya sampai ke [penanganan error terpusat](/id/error-handling/object-details), atau tangkap di handler untuk balasan yang presisi: +Berkas template yang hilang melempar `Deno.errors.NotFound`, dan kegagalan kompilasi atau render juga melempar. Keduanya sampai ke [error handler terpusat](/id/error-handling/object-details) yang diatur dengan `router.catch()`, yang membentuk satu balasan untuk seluruh aplikasi alih-alih try/catch di tiap rute. Berkas yang hilang dipetakan ke **404 Not Found**, dan kegagalan kompilasi atau render dipetakan ke **400 Bad Request**. + +Ketika satu rute butuh balasan presisi, tangkap throw-nya dan bercabang pada tipe error-nya: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare const data: DataRecord +import type { Context } from '@neabyte/deserve' +declare const data: Record // ---cut--- export async function GET(ctx: Context): Promise { try { return await ctx.render('template', data) } catch (error) { - const message = error instanceof Error ? error.message : '' - if (message.includes('not found in views directory')) { - return ctx.send.json( - { - error: 'Template missing' - }, - { - status: 404 - } - ) + // Berkas hilang melempar NotFound + if (error instanceof Deno.errors.NotFound) { + return ctx.send.json({ error: 'Template missing' }, { status: 404 }) } - return ctx.send.json( - { - error: 'Render failed' - }, - { - status: 500 - } - ) + return ctx.send.json({ error: 'Render failed' }, { status: 500 }) } } ``` +Kegagalan render juga muncul di [observability bus](/id/middleware/observability/overview) sebagai event [`view:failed`](/id/middleware/observability/events#view), jadi logging tinggal di satu tempat sementara error handler membentuk response-nya. + ## Langkah Berikutnya -- [Sintaks Template](/id/rendering/syntax) - variabel, kondisional, perulangan, include, dan ekspresi. -- [Performa dan Batas](/id/rendering/performance) - caching, batas iterasi, dan batas kedalaman include. +- [Sintaks Template](/id/rendering/syntax) - variabel, kondisional, perulangan, include, layout, dan ekspresi. +- [Performa dan Batas](/id/rendering/performance) - caching, batas iterasi, batas keluaran, dan kedalaman include. - [Streaming Rendering](/id/rendering/streaming) - kirim HTML potongan demi potongan untuk halaman besar. diff --git a/docs/id/rendering/performance.md b/docs/id/rendering/performance.md index 56dc0af..16c74ca 100644 --- a/docs/id/rendering/performance.md +++ b/docs/id/rendering/performance.md @@ -4,41 +4,43 @@ description: "Karakteristik performa dan perilaku caching mesin template Deserve # Performa dan Batas -Mesin DVE meng-cache template terkompilasi dan mengawal rendering dengan beberapa batas, jadi halaman besar tetap cepat dan template yang tak terkendali gagal dengan jelas alih-alih menggantung server. +Mesin DVE meng-cache template terkompilasi dan mengawal tiap render dengan sekumpulan batas, jadi halaman besar tetap cepat dan template yang tak terkendali gagal dengan jelas alih-alih menggantung server. Setiap batas dikonfigurasi di bawah `views` pada [opsi Router](/id/getting-started/routes-configuration#views). ## Caching -Template dikompilasi sekali, lalu AST hasil parse dipakai ulang pada tiap render berikutnya: +Template dikompilasi sekali, lalu hasil parsing-nya dipakai ulang pada tiap render berikutnya: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' +import type { Context } from '@neabyte/deserve' declare const ctx: Context -declare const data: DataRecord -declare const newData: DataRecord +declare const data: Record +declare const newData: Record // ---cut--- -// Render pertama kompilasi dan cache AST +// Render pertama kompilasi dan cache await ctx.render('template', data) -// Render berikutnya pakai cache AST +// Render berikutnya pakai cache await ctx.render('template', newData) ``` -Cache hanya mencakup kompilasi template, bukan data atau logika backend. Perubahan pada berkas membersihkan entri cache-nya lewat [hot reload](/id/core-concepts/hot-reload). +Cache hanya mencakup kompilasi, bukan data atau logika backend. Mengedit berkas membersihkan entri-nya lewat [hot reload](/id/core-concepts/hot-reload), jadi render berikutnya mengompilasi sumber yang baru. ## Batas Iterasi -Setiap blok {{#each}} dibatasi `100_000` iterasi secara default, yang mencegah event loop kelaparan akibat satu perulangan tak terbatas. Setel dengan `maxIterations`: +Setiap blok {{#each}} dibatasi `100_000` iterasi secara default, yang mencegah satu perulangan tak terbatas membuat event loop kelaparan. Mesin memeriksa panjang array sebelum memancarkan item apa pun, jadi perulangan berukuran berlebih gagal cepat. Setel dengan `views.maxIterations`: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - viewsDir: './views', - maxIterations: 200_000 + views: { + directory: './views', + maxIterations: 200_000 + } }) ``` -Ketika perulangan melewati batas, mesin melempar dan server membalas **400 Bad Request**. Untuk dataset sangat besar, gunakan [streaming rendering](/id/rendering/streaming). Untuk rendering berat CPU, alihkan ke [worker pool](/id/core-concepts/worker-pool). +Ketika perulangan melewati batas, mesin melempar dan server membalas **400 Bad Request**. Untuk dataset sangat besar, gunakan [streaming rendering](/id/rendering/streaming). Untuk rendering berat CPU, alihkan ke [worker pool](/id/recipes/worker-pool). ## Batas Anggaran Render @@ -48,14 +50,31 @@ Dua batas lain mengawal seluruh render, bukan hanya satu perulangan. `maxRenderI import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - viewsDir: './views', - maxRenderIterations: 500_000, - maxOutputSize: 2_000_000 + views: { + directory: './views', + maxRenderIterations: 500_000, + maxOutputSize: 2_000_000 + } }) ``` -Melewati salah satu batas membalas dengan **400 Bad Request**, status yang sama dengan batas per perulangan. Ketiganya diatur di [opsi Router](/id/getting-started/routes-configuration#opsi-konfigurasi). +Melewati salah satu batas membalas dengan **400 Bad Request**, status yang sama dengan batas per perulangan. Jaga `maxRenderIterations` di atas atau sama dengan `maxIterations`, jika tidak satu perulangan besar akan menyentuh batas total lebih dulu. + +## Batas Ukuran Template + +`maxTemplateSize` membatasi karakter dari satu sumber template, diperiksa saat kompilasi, dan default-nya `1_000_000`. Batas sama berlaku ke setiap berkas include atau layout yang diresolusi mesin. Sumber berukuran berlebih melempar sebelum parsing dimulai, yang membalas dengan **400 Bad Request**: + +```typescript twoslash +import { Router } from '@neabyte/deserve' +// ---cut--- +const router = new Router({ + views: { + directory: './views', + maxTemplateSize: 500_000 + } +}) +``` ## Batas Kedalaman Include -Include template dibatasi 64 tingkat bersarang, jadi rantai include melingkar atau tak terkendali melempar error alih-alih berputar selamanya. Melewati batas ini membalas dengan **400 Bad Request**. Selama struktur partial tidak terlalu dalam, render tetap jauh di dalam batas ini. +Include template dan rantai layout berbagi batas 64 tingkat bersarang, jadi rantai melingkar atau tak terkendali melempar alih-alih berputar selamanya. Melewati batas ini membalas dengan **400 Bad Request**. Menjaga partial dan layout tetap dangkal tetap jauh di dalam batas ini, yang dibahas bersama sintaks [include](/id/rendering/syntax#includes) dan [layout](/id/rendering/syntax#layouts). diff --git a/docs/id/rendering/streaming.md b/docs/id/rendering/streaming.md index de65197..9be83a7 100644 --- a/docs/id/rendering/streaming.md +++ b/docs/id/rendering/streaming.md @@ -4,97 +4,86 @@ description: "Streaming template rendering di Deserve untuk response time-to-fir # Streaming Template Rendering -Streaming template rendering mengirim HTML saat diproduksi, yang menurunkan time-to-first-byte (TTFB) dan membuat halaman besar terasa responsif. Ini pasangan progresif dari render biasa yang dibahas di [Ringkasan Rendering](/id/rendering/). +Streaming rendering mengirim HTML saat diproduksi, yang menurunkan time-to-first-byte (TTFB) dan membuat halaman besar terasa responsif. Ini pasangan progresif dari render ter-buffer yang dibahas di [Ringkasan Rendering](/id/rendering/), dan berjalan lewat panggilan `ctx.render()` yang sama. -## Konsep Dasar +## Buffered vs Streaming -Alih-alih menunggu seluruh template selesai, streaming mengirim HTML potongan demi potongan: +`ctx.render()` mem-buffer secara default, membangun seluruh halaman menjadi satu string sebelum mengirim. Memberi `{ stream: true }` sebagai argumen ketiga beralih ke `ReadableStream` yang menulis tiap node saat diproduksi: -```typescript -// Render biasa (blocking) - tunggu semua selesai -return await ctx.render('large-template', data) +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +declare const data: Record +// ---cut--- +// Buffered: tunggu seluruh halaman +await ctx.render('large-template', data) -// Streaming render (progresif) - kirim potongan demi potongan -return await ctx.streamRender('large-template', data) +// Streaming: kirim potongan demi potongan +await ctx.render('large-template', data, { stream: true }) ``` -![Berdampingan, ctx.render membangun seluruh HTML jadi satu string lalu mengirim semuanya sekaligus sehingga klien menunggu, sementara ctx.streamRender mengompilasi di awal, mengembalikan ReadableStream, dan menulis tiap node saat diproduksi sehingga byte pertama keluar lebih cepat](/diagrams/stream-render-vs-blocking.png) - -## Penggunaan Dasar +![Berdampingan, render ter-buffer membangun seluruh HTML jadi satu string lalu mengirim semuanya sekaligus sehingga klien menunggu, sementara render streaming mengompilasi di awal, mengembalikan ReadableStream, dan menulis tiap node saat diproduksi sehingga byte pertama keluar lebih cepat](/diagrams/stream-render-vs-blocking.png) -### 1. Di Context Handler +## Penggunaan -`ctx.streamRender()` mengembalikan response HTML streaming, jadi cukup di-await oleh rute: +Render streaming tetap satu `await`. Engine meresolusi dan mengompilasi template di awal, lalu mengembalikan response yang body-nya mengalir saat dirender, jadi rute tetap sekecil render ter-buffer: -![Rute meng-await ctx.streamRender, engine meresolusi dan mengompilasi template, membuat TransformStream, mengembalikan sisi readable seketika sehingga header response terkirim, lalu merender ke sisi writable di latar belakang di mana kegagalan muncul sebagai event view error](/diagrams/stream-render-pipeline.png) +![Rute meng-await ctx.render dengan stream true, engine meresolusi dan mengompilasi template, mengembalikan readable stream seketika sehingga header response terkirim, lalu merender tiap node ke stream di latar belakang di mana kegagalan muncul sebagai event view failed](/diagrams/stream-render-pipeline.png) ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getUser(): DataRecord -declare function getAnalytics(): DataRecord +import type { Context } from '@neabyte/deserve' +declare function getUser(): Record +declare function getAnalytics(): Record // ---cut--- // routes/dashboard.ts -// Streaming render dashboard kompleks +// Stream dashboard kompleks export async function GET(ctx: Context): Promise { - return await ctx.streamRender('dashboard', { + return await ctx.render('dashboard', { user: getUser(), analytics: getAnalytics() - }) + }, { stream: true }) } ``` -### 2. Header Response Khusus - -View engine ada di framework state, jadi `ctx.getState` menjangkaunya untuk kontrol penuh atas response yang di-stream: +Response membawa `Content-Type: text/html; charset=utf-8`, sama seperti render ter-buffer, dan status default ke `200`. Atur status berbeda lewat objek options yang sama bersama `stream`: ```typescript twoslash -import type { Context, DataRecord, ViewEngine } from '@neabyte/deserve' -declare const reportData: DataRecord +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +declare const data: Record // ---cut--- -// Akses view engine dari framework state -export async function GET(ctx: Context): Promise { - const view = ctx.getState('view' as never) - const stream = await view!.streamRender('report', reportData) - return ctx.send.stream( - stream, - { - headers: { - 'Cache-Control': 'no-cache' - } - }, - 'text/html; charset=utf-8' - ) -} +// Stream dengan status khusus +await ctx.render('report', data, { status: 201, stream: true }) ``` ## Dukungan Template -Semua fitur DVE dari [Sintaks Template](/id/rendering/syntax) bekerja dengan streaming: +Semua fitur DVE dari [Sintaks Template](/id/rendering/syntax) bekerja dengan streaming. Engine menelusuri node tingkat atas dan mem-flush tiap potongan yang diproduksi secara berurutan, jadi node teks polos keluar sendiri. Blok {{#each}} membangun semua barisnya dulu lalu mem-flush-nya sebagai satu potongan, artinya granularitasnya per node tingkat atas bukan per item perulangan: -![Streaming melooping node template tingkat atas dan menulis tiap potongan yang diproduksi secara berurutan, jadi node teks ter-flush sendiri, tetapi blok each membangun semua barisnya jadi satu string dulu lalu ter-flush sebagai satu potongan, artinya granularitas streaming per node tingkat atas bukan per item loop](/diagrams/stream-render-chunks.png) +![Streaming menelusuri node template tingkat atas dan menulis tiap potongan yang diproduksi secara berurutan, jadi node teks ter-flush sendiri, tetapi blok each membangun semua barisnya jadi satu string dulu lalu ter-flush sebagai satu potongan, artinya granularitas streaming per node tingkat atas bukan per item perulangan](/diagrams/stream-render-chunks.png) ```html - {{title}} + {{ title }} -
{{header}}
+
{{ header }}
- + {{#each items as item}}
-

{{item.name}}

-

{{item.description}}

+

{{ item.name }}

+

{{ item.description }}

{{/each}} {{#if showFooter}} -
{{footer}}
+
{{ footer }}
{{/if}} @@ -102,74 +91,63 @@ Semua fitur DVE dari [Sintaks Template](/id/rendering/syntax) bekerja dengan str ## Kasus Pakai Terbaik -### 1. Template Besar +Streaming menunjukkan nilainya saat halaman besar atau datanya menetes masuk. Sebuah report dengan ribuan baris mengirim byte pertamanya jauh sebelum baris terakhir siap: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getTransactions(): Promise -declare function calculateSummary(): DataRecord +import type { Context } from '@neabyte/deserve' +declare function getTransactions(): Promise[]> +declare function calculateSummary(): Record // ---cut--- -// Report dengan ribuan baris data +// Report dengan ribuan baris export async function GET(ctx: Context): Promise { - return await ctx.streamRender('financial-report', { - transactions: await getTransactions(), // 10,000+ items + return await ctx.render('financial-report', { + transactions: await getTransactions(), summary: calculateSummary() - }) -} -``` - -### 2. Data Real-time - -```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getLatestMetrics(): DataRecord -declare function getActiveAlerts(): DataRecord -// ---cut--- -// Dashboard dengan data live -export async function GET(ctx: Context): Promise { - return await ctx.streamRender('live-dashboard', { - metrics: getLatestMetrics(), - alerts: getActiveAlerts() - }) + }, { stream: true }) } ``` -### 3. Progressive Enhancement +Dashboard yang mencampur data cepat dan lambat mendapat manfaat sama, karena kerangkanya mencapai klien sementara bagian lambat masih diresolusi: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getLayoutData(): DataRecord -declare function getContent(): Promise -declare function getAnalytics(): Promise +import type { Context } from '@neabyte/deserve' +declare function getLayoutData(): Record +declare function getContent(): Promise> +declare function getAnalytics(): Promise> // ---cut--- -// Kirim skeleton dulu, data mengalir +// Kerangka cepat dulu, data lambat menyusul export async function GET(ctx: Context): Promise { - return await ctx.streamRender('progressive-app', { - layout: getLayoutData(), // Fast - content: await getContent(), // Slow - analytics: await getAnalytics() // Very slow - }) + return await ctx.render('progressive-app', { + layout: getLayoutData(), + content: await getContent(), + analytics: await getAnalytics() + }, { stream: true }) } ``` ## Penanganan Error -Streaming punya dua jendela kegagalan. Template yang hilang atau error kompilasi terlempar sebelum response mulai, jadi hal itu mencapai [error handler terpusat](/id/error-handling/object-details) seperti render biasa dan membentuk balasan status yang normal. Kegagalan saat memproduksi chunk terjadi setelah header sudah terkirim, jadi response tidak bisa berubah lagi. Kegagalan itu muncul sebagai event [`view:error`](/id/middleware/observability/events#view) di [bus observability](/id/middleware/observability/overview) dan stream ditutup, itulah kenapa validasi berat sebaiknya dilakukan sebelum stream, bukan di dalamnya. +Streaming punya dua jendela kegagalan. Template yang hilang atau error kompilasi terlempar sebelum response mulai, jadi hal itu mencapai [error handler terpusat](/id/error-handling/object-details) seperti render ter-buffer dan membentuk balasan status yang normal. Kegagalan saat memproduksi chunk terjadi setelah header sudah terkirim, jadi response tidak bisa berubah lagi. Kegagalan itu muncul sebagai event [`view:failed`](/id/middleware/observability/events#view) di [bus observability](/id/middleware/observability/overview) dan stream ditutup. Jendela itulah kenapa validasi berat sebaiknya sebelum stream, bukan di dalamnya. -## Migrasi dari Render Biasa +## Migrasi dari Render Ter-buffer -```typescript -// Sebelum (blocking) - tunggu semua selesai -export async function GET(ctx: Context): Promise { +Peralihannya satu argumen, karena panggilannya tetap sama: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const data: Record +// ---cut--- +// Sebelum: ter-buffer +export async function before(ctx: Context): Promise { return await ctx.render('large-template', data) } -// Sesudah (streaming) - kirim progresif -export async function GET(ctx: Context): Promise { - return await ctx.streamRender('large-template', data) +// Sesudah: streaming +export async function after(ctx: Context): Promise { + return await ctx.render('large-template', data, { stream: true }) } ``` -Streaming rendering mengangkat performa untuk template besar dan halaman real-time, dan API-nya tetap satu await yang sama seperti render biasa. +Streaming mengangkat performa untuk template besar dan halaman real-time sementara rute tetap satu await: -![Perbandingan time to first byte di mana render membuat klien menunggu selama seluruh halaman dibangun sehingga byte pertama datang terlambat, melawan streamRender yang mem-flush node pertama tepat setelah compile sehingga byte pertama datang lebih awal sementara potongan berikutnya terus berdatangan sampai stream ditutup](/diagrams/stream-render-ttfb.png) +![Perbandingan time to first byte di mana render ter-buffer membuat klien menunggu selama seluruh halaman dibangun sehingga byte pertama datang terlambat, melawan render streaming yang mem-flush node pertama tepat setelah compile sehingga byte pertama datang lebih awal sementara potongan berikutnya terus berdatangan sampai stream ditutup](/diagrams/stream-render-ttfb.png) diff --git a/docs/id/rendering/syntax.md b/docs/id/rendering/syntax.md index c641c36..d2aeac7 100644 --- a/docs/id/rendering/syntax.md +++ b/docs/id/rendering/syntax.md @@ -1,10 +1,10 @@ --- -description: "Referensi sintaks template DVE: variabel, kondisional, perulangan, dan include." +description: "Referensi sintaks template DVE: variabel, kondisional, perulangan, layout, dan ekspresi." --- # Sintaks Template -Template DVE adalah HTML polos dengan sekumpulan kecil tag {{ }} untuk data, kondisi, perulangan, dan include. Pengaturan dan render pertama ada di [Ringkasan Rendering](/id/rendering/). +Template DVE adalah HTML polos dengan sekumpulan kecil tag {{ }} untuk data, komentar, kondisi, perulangan, include, dan layout. Pengaturan dan render pertama ada di [Ringkasan Rendering](/id/rendering/), dan semua yang masuk ke dalam tag cetak dirinci di [Ekspresi](#ekspresi). ## Variabel @@ -21,11 +21,15 @@ Tag {{ }} mencetak sebuah nilai, dan akses anggota menjangkau

{{user.profile.email}}

``` -Pencarian hanya membaca properti milik objek itu sendiri, jadi `__proto__`, `constructor`, dan kunci warisan lain bernilai kosong. Itu memblokir prototype pollution lewat data pengguna. +Pencarian hanya membaca properti milik objek itu sendiri, jadi `__proto__`, `constructor`, dan kunci warisan lain bernilai kosong. Itu memblokir prototype pollution lewat data pengguna. Nilai yang hilang di mana pun sepanjang path bernilai string kosong. + +## Komentar + +Komentar DVE ditulis sebagai {{!-- teks --}} dan dibuang saat parsing, jadi tidak pernah mencapai keluaran. Komentar HTML biasa `` dibiarkan utuh dan tetap terkirim di response. Pakai bentuk DVE untuk menyembunyikan catatan dari klien. ## Kondisional -{{#if}} merender blok ketika nilainya truthy: +{{#if}} merender blok ketika nilainya truthy, dan setiap {{#if}} ditutup dengan {{/if}}: ```html @@ -34,7 +38,7 @@ Pencarian hanya membaca properti milik objek itu sendiri, jadi `__proto__`, `con {{/if}} ``` -Sebuah kondisi berpasangan dengan {{else}} untuk cabang cadangan, dan blok bisa bersarang bebas: +Cabang {{else if}} dan {{else}} bersifat opsional, dan blok bisa bersarang bebas: ```html {{#if posts.length > 0}} @@ -49,9 +53,11 @@ Sebuah kondisi berpasangan dengan {{else}} untuk cabang cadan {{/if}} ``` +Sebuah rantai menambahkan {{else if condition}} sebelum {{else}} terakhir, dan tiap cabang diuji berurutan sampai satu cocok. + ## Perulangan -{{#each}} menelusuri array dengan alias, lengkap dengan variabel metadata tiap putaran: +{{#each}} menelusuri array dengan alias, dan alias-nya default ke `item` ketika bagian `as name` dihilangkan: ```html {{#each users as u}} @@ -65,18 +71,28 @@ Sebuah kondisi berpasangan dengan {{else}} untuk cabang cadan {{/each}} ``` +Sebuah {{else}} opsional merender ketika array kosong atau hilang: + +```html +{{#each users as u}} +

{{u.name}}

+{{else}} +

No users yet.

+{{/each}} +``` + **Metadata each:** -- `@index` - Indeks item (mulai dari 0) -- `@first` - Boolean true bila item pertama -- `@last` - Boolean true bila item terakhir +- `@index` - Indeks item, mulai dari 0, dan menerima aritmetika seperti {{@index + 1}} +- `@first` - Boolean true pada item pertama +- `@last` - Boolean true pada item terakhir - `@length` - Total jumlah item -Setiap perulangan dibatasi oleh `maxIterations`, dibahas di [Performa dan Batas](/id/rendering/performance#batas-iterasi). +Setiap perulangan dibatasi oleh `maxIterations`, dan total berjalan di seluruh halaman dibatasi oleh `maxRenderIterations`. Keduanya ada di [Performa dan Batas](/id/rendering/performance#batas-iterasi). -## Include +## Includes -Operator `>` menarik template lain ke dalam template saat ini: +Operator `>` menarik template lain ke dalam template saat ini. Path-nya adalah teks persis di dalam tag, yang diresolusi Deserve terhadap direktori views: ```html @@ -91,31 +107,55 @@ Operator `>` menarik template lain ke dalam template saat ini: Include bersarang sampai kedalaman tetap, dibahas di [Performa dan Batas](/id/rendering/performance#batas-kedalaman-include). -## Ekspresi +## Layouts -DVE mendukung ekspresi mirip JavaScript untuk pencarian dan operator: +Layout adalah kerangka bersama tempat halaman menancap. Layout menandai placeholder bernama dengan tag slot, dan sebuah halaman memperluasnya lalu mengisi tiap placeholder dengan tag block. + +Definisikan kerangkanya, dengan tiap slot membawa konten default opsional: ```html - -

Hello {{ user?.name ?? 'Guest' }}.

+ + + + + {{#slot title}}Untitled{{/slot}} + + +
{{#slot body}}{{/slot}}
+ + +``` - -

Total: {{ 1 + 2 * 3 }}

+Lalu sebuah halaman memperluas layout dengan operator `<` dan mengisi slot berdasarkan nama. Tag extend dibuka dengan {{< layout/path}}, tiap {{#block name}} mengisi slot yang cocok, dan {{/}} kosong menutup layout: - -{{#if age >= 18}}Adult{{/if}} +
+ +```html +{{< layouts/main.dve}} + {{#block title}}Home{{/block}} + {{#block body}}

Welcome.

{{/block}} +{{/}} ``` -Tata bahasanya adalah subset yang aman, bukan JavaScript penuh. Bagian yang didukung: +
-- **Akses anggota** - `user.name`, `user.profile.email`, dan optional chaining `user?.name` -- **Matematika** - `+`, `-`, `*`, `/`, `%`, serta unary `+`, `-`, `!` -- **Perbandingan** - `===`, `!==`, `==`, `!=`, `>`, `<`, `>=`, `<=` -- **Logika** - `&&`, `||`, `??`, dan ternary `cond ? a : b` -- **Literal** - angka, string kutip tunggal atau ganda, `true`, `false`, `null`, `undefined` -- **Pengelompokan** - tanda kurung, misalnya `(a + b) * c` +Sebuah slot merender default-nya sendiri ketika tidak ada block yang cocok diberikan. Block yang menyebut slot yang tidak pernah dideklarasikan layout ditolak dengan error, yang menangkap salah ketik sebelum terkirim diam-diam. Rantai layout berbagi batas kedalaman include di [Performa dan Batas](/id/rendering/performance#batas-kedalaman-include). + +## Kontrol Spasi + +Sebuah `~` di sebelah kurung memangkas spasi yang menyentuh sisi tag itu, yang menjaga HTML hasil tetap rapi tanpa mengubah bentuk template: + +
+ +```html +{{#each items as item~}} + {{item}} +{{~/each}} +``` + +
-Demi menjaga template aman dan dapat ditebak, mesin menolak apa pun di luar subset itu dan melempar parse error. Pemanggilan fungsi seperti `format(price)`, pengindeksan bracket seperti `items[0]`, dan penugasan tidak diizinkan. +`~` bekerja pada tag mana pun, termasuk tag cetak, raw, dan block. ## Keluaran Mentah @@ -125,27 +165,34 @@ Nilai di-escape HTML secara default, dan kurung tiga melewati escape itu untuk m

{{userInput}}

- +

{{{trustedHtml}}}

``` -## Komposisi Layout +## Ekspresi -Layout dibangun dengan menyertakan template kecil dan menaruh data ke variabel polos, jadi satu kerangka bersama membungkus tiap halaman tanpa mekanisme slot khusus: +DVE mendukung ekspresi mirip JavaScript untuk pencarian dan operator: ```html - - - - - {{title}} - - -
{{> header.dve}}
-
{{{ content }}}
-
{{> footer.dve}}
- - + +

Hello {{ user?.name ?? 'Guest' }}.

+ + +

Total: {{ 1 + 2 * 3 }}

+ + +{{#if age >= 18}}Adult{{/if}} ``` -Nilai `content` datang dari data rute, dan kurung tiga merendernya sebagai HTML mentah ketika markup sudah tepercaya. +Tata bahasanya adalah subset yang aman, bukan JavaScript penuh. Bagian yang didukung: + +- **Akses anggota** - `user.name`, `user.profile.email`, dan optional chaining `user?.name`, keduanya null-safe +- **Matematika** - `+`, `-`, `*`, `/`, `%`, serta unary `+`, `-`, `!` +- **Perbandingan** - `===`, `!==`, `==`, `!=`, `>`, `<`, `>=`, `<=` +- **Logika** - `&&`, `||`, `??`, dan ternary `cond ? a : b` +- **Literal** - angka termasuk eksponen seperti `1e3`, string kutip tunggal atau ganda, `true`, `false`, `null`, `undefined` +- **Pengelompokan** - tanda kurung, misalnya `(a + b) * c` + +Literal string memahami escape sequence `\n`, `\t`, `\r`, `\b`, `\f`, `\v`, `\0`, `\\`, `\"`, `\'`, dan `\/`, plus escape code-point `\xNN`, `\uNNNN`, dan `\u{...}`. + +Demi menjaga template aman dan dapat ditebak, mesin menolak apa pun di luar subset itu dan melempar parse error. Pemanggilan fungsi seperti `format(price)`, pengindeksan bracket seperti `items[0]`, penugasan, dan regular expression tidak diizinkan. Apa pun yang butuh logika nyata berada di route handler, tempat nilai jadi dihitung dan dioper ke template lewat data render. diff --git a/docs/id/response/custom.md b/docs/id/response/custom.md index 7a1feea..9b28f04 100644 --- a/docs/id/response/custom.md +++ b/docs/id/response/custom.md @@ -4,7 +4,7 @@ description: "Bangun response sepenuhnya kustom dengan ctx.send.custom() saat he # Response Kustom -Method `ctx.send.custom()` membuat response kustom dengan kendali penuh atas body, status code, header, dan semua opsi konfigurasi response. Berbeda dengan helper bertipe, method ini tidak mengatur `Content-Type` sendiri, jadi tambahkan lewat header saat body membutuhkannya. +Method `ctx.send.custom()` membuat response dengan kendali penuh atas body. Berbeda dengan helper bertipe, method ini tidak mengatur `Content-Type` sendiri, jadi tambahkan lewat header saat body membutuhkannya. ## Penggunaan Dasar @@ -24,23 +24,20 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Atur status response ke 404 - return ctx.send.custom( - 'Not Found', - { - status: 404 - } - ) + return ctx.send.custom('Not Found', { status: 404 }) } ``` ## Dengan Header Kustom +Header yang diatur lewat `ctx.set.header()` digabung dengan header dari opsi. Header opsi diutamakan saat bentrok: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Header diatur pada context - ctx.setHeader('X-Custom', 'value') + ctx.set.header('X-Custom', 'value') // Opsi bisa menambah header lain return ctx.send.custom('Response body', { headers: { @@ -51,35 +48,46 @@ export function GET(ctx: Context): Response { } ``` -## Response Biner +## Response Streaming + +Sebuah `ReadableStream` yang diberikan sebagai body dialirkan ke client tanpa membuffer seluruh response. Ini cocok untuk data besar atau [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events): ```typescript twoslash import type { Context } from '@neabyte/deserve' -// ---cut--- + export function GET(ctx: Context): Response { - // Kirim byte mentah dengan tipe - const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]) - return ctx.send.custom(binaryData, { + // Dorong dua potongan teks lalu tutup + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('Hello\n')) + controller.enqueue(new TextEncoder().encode('World\n')) + controller.close() + } + }) + // Stream menjadi body response + return ctx.send.custom(stream, { headers: { - 'Content-Type': 'application/octet-stream' + 'Content-Type': 'text/plain' } }) } ``` -## Response Kosong (No Content) +Untuk streaming template, pakai [`ctx.render()`](/id/core-concepts/context-object#merender-template) dengan `stream: true` alih-alih, yang menangani mesin DVE dan content type untukmu. + +## Response Biner ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // 204 mengirim body null - return ctx.send.custom( - null, - { - status: 204 + // Kirim byte mentah dengan tipe + const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]) + return ctx.send.custom(binaryData, { + headers: { + 'Content-Type': 'application/octet-stream' } - ) + }) } ``` @@ -99,22 +107,11 @@ export function GET(ctx: Context): Response { } ``` -## Menggabungkan Header Context dan Opsi Kustom - -Header yang diatur lewat `ctx.setHeader()` digabung dengan header dari parameter opsi: +## Tanda Tangan Method -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - ctx.setHeader('X-Context-Header', 'from-context') - return ctx.send.custom('Body', { - headers: { - 'X-Options-Header': 'from-options' - } - }) - // Response membawa kedua header -} +```typescript +ctx.send.custom(body: BodyInit | null, options?: SendInit): Response ``` -Header opsi diutamakan di atas header context saat keduanya bentrok. +- **body** - nilai `BodyInit` apa pun (string, `Blob`, `BufferSource`, `ReadableStream`, dll.) atau `null` +- **options** - `status` dan `headers` opsional diff --git a/docs/id/response/data.md b/docs/id/response/data.md deleted file mode 100644 index 1bd732a..0000000 --- a/docs/id/response/data.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: "Kirim unduhan data biner dengan ctx.send.data()." ---- - -# Response Unduhan Data - -Method `ctx.send.data()` mengirim data in-memory (string atau `Uint8Array`) sebagai unduhan file. Berguna saat konten dibuat saat runtime (generate CSV, ekspor JSON, dll.) tanpa menulis ke disk dulu. - -## Penggunaan Dasar - -```typescript twoslash -import type { Context } from '@neabyte/deserve' - -export function GET(ctx: Context): Response { - // Body string dengan nama unduhan - const csvData = 'name,age\nAlice,30\nBob,25' - return ctx.send.data(csvData, 'users.csv') -} -``` - -## Data Biner - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - // Body Uint8Array dengan nama unduhan - const binaryData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) - return ctx.send.data(binaryData, 'image.png') -} -``` - -## Dengan Content Type Kustom - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - // Argumen keempat mengatur content type - const jsonData = JSON.stringify({ - data: 'value' - }) - return ctx.send.data( - jsonData, - 'data.json', - { status: 200 }, - 'application/json' - ) -} -``` - -## Pembuatan File Dinamis - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - // Bangun payload saat runtime - const data = { - timestamp: new Date().toISOString(), - version: '1.0.0' - } - const content = JSON.stringify(data, null, 2) - // Unduh tanpa menyentuh disk - return ctx.send.data(content, 'metadata.json') -} -``` diff --git a/docs/id/response/download.md b/docs/id/response/download.md new file mode 100644 index 0000000..b3f0d32 --- /dev/null +++ b/docs/id/response/download.md @@ -0,0 +1,131 @@ +--- +description: "Kirim response unduhan berkas dengan ctx.send.download(), termasuk Content-Disposition dan penanganan nama berkas." +--- + +# Response Download + +Method `ctx.send.download()` mengirim response yang memicu unduhan berkas di browser. Method ini mengatur `Content-Disposition: attachment` dengan nama berkas yang diberikan dan default `Content-Type` ke `application/octet-stream`. + +Ini menggantikan kebutuhan helper terpisah untuk berkas dan data in-memory. Body bisa berupa string, `BufferSource` (seperti `Uint8Array`), atau `ReadableStream` - apa pun yang sudah ada di tangan handler. + +## Penggunaan Dasar + +```typescript twoslash +import type { Context } from '@neabyte/deserve' + +export function GET(ctx: Context): Response { + // Body string dengan nama unduhan + const csv = 'name,age\nAlice,30\nBob,25' + return ctx.send.download(csv, 'users.csv') +} +``` + +## Data Biner + +Body `Uint8Array` bekerja dengan cara yang sama: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Body Uint8Array dengan nama unduhan + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) + return ctx.send.download(png, 'image.png') +} +``` + +## Streaming Dari Filesystem + +Untuk mengirim berkas dari disk, buka sebuah `ReadableStream` dan berikan sebagai body. Handler menjadi `async` karena `Deno.open` bersifat async: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare function createFileStream(): ReadableStream +// ---cut--- +export async function GET(ctx: Context): Promise { + // Buka berkas sebagai readable stream + const stream = createFileStream() + // Stream menjadi body unduhan + return ctx.send.download(stream, 'document.pdf') +} +``` + +Berkas yang hilang atau tidak terbaca melempar `Deno.errors.NotFound`. Tangkap dan teruskan ke [error handler terpusat](/id/error-handling/object-details) untuk balasan yang konsisten: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare function createFileStream(): ReadableStream +// ---cut--- +export async function GET(ctx: Context): Promise { + try { + const stream = createFileStream() + return ctx.send.download(stream, 'document.pdf') + } catch (error) { + // Alirkan kegagalan lewat penanganan error + return await ctx.handleError(404, error as Error) + } +} +``` + +## Dengan Content Type Kustom + +Default `Content-Type` adalah `application/octet-stream`. Timpa lewat header opsi saat browser butuh tipe tertentu: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + const json = JSON.stringify({ data: 'value' }) + return ctx.send.download( + json, + 'data.json', + { + headers: { + 'Content-Type': 'application/json' + } + } + ) +} +``` + +## Pembuatan Berkas Dinamis + +Bangun payload saat runtime dan kirim sebagai unduhan tanpa menyentuh disk: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Bangun payload saat runtime + const data = { + timestamp: new Date().toISOString(), + version: '1.0.0' + } + const content = JSON.stringify(data, null, 2) + // Unduh tanpa menyentuh disk + return ctx.send.download(content, 'metadata.json') +} +``` + +## Penanganan Nama Berkas + +Nama berkas dibersihkan sebelum mencapai header `Content-Disposition`: + +- Path direktori dipangkas, jadi `../secret.txt` menjadi `secret.txt` +- Karakter kontrol dihapus +- Karakter non-ASCII mendapat fallback `filename*=UTF-8''...` di samping nama ASCII +- Nama berkas kosong atau seluruhnya tidak valid jatuh ke `download` + +## Tanda Tangan Method + +```typescript +ctx.send.download( + body: ReadableStream | BufferSource | string, + filename: string, + options?: SendInit +): Response +``` + +- **body** - konten unduhan sebagai string, `BufferSource`, atau `ReadableStream` +- **filename** - nama berkas unduhan yang disarankan, dibersihkan otomatis +- **options** - `status` dan `headers` opsional diff --git a/docs/id/response/empty.md b/docs/id/response/empty.md new file mode 100644 index 0000000..17e9c8f --- /dev/null +++ b/docs/id/response/empty.md @@ -0,0 +1,58 @@ +--- +description: "Kirim response kosong dengan ctx.send.empty() untuk status code tanpa konten." +--- + +# Response Kosong + +Method `ctx.send.empty()` mengirim response tanpa body. Method ini cocok untuk status code seperti `204 No Content` di mana response tidak membawa apa pun selain status. + +## Penggunaan Dasar + +```typescript twoslash +import type { Context } from '@neabyte/deserve' + +export function DELETE(ctx: Context): Response { + // 204 No Content, body kosong + return ctx.send.empty(204) +} +``` + +## Tanpa Status + +Panggil `ctx.send.empty()` tanpa argumen untuk mengirim body kosong dengan status default: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Body kosong, status default + return ctx.send.empty() +} +``` + +## Dengan Header + +Header yang diatur lewat `ctx.set.header()` tetap digabung ke response kosong: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function DELETE(ctx: Context): Response { + // Atur header sebelum kirim + ctx.set.header('X-Deleted-Resource', 'true') + // 204 dengan header terpasang + return ctx.send.empty(204) +} +``` + +## Status Code Body Null + +Status code `101`, `204`, `205`, dan `304` selalu mengirim body null terlepas dari helper `ctx.send` mana yang dipakai. Memberikan salah satunya ke `ctx.send.json()` atau `ctx.send.text()` juga membuang body dan header `Content-Type`. `ctx.send.empty()` membuat maksud itu eksplisit. + +## Tanda Tangan Method + +```typescript +ctx.send.empty(status?: HttpStatusCode): Response +``` + +- **status** - status code HTTP opsional, default `200` diff --git a/docs/id/response/file.md b/docs/id/response/file.md deleted file mode 100644 index 01fd308..0000000 --- a/docs/id/response/file.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -description: "Sajikan unduhan file dari filesystem dengan ctx.send.file()." ---- - -# Response Unduhan File - -Method `ctx.send.file()` mengirim isi file dari filesystem sebagai response. Cocok untuk unduhan atau menyajikan file yang sudah ada di disk (path relatif atau absolut). - -## Penggunaan Dasar - -```typescript twoslash -import type { Context } from '@neabyte/deserve' - -export async function GET(ctx: Context): Promise { - // Stream file sebagai unduhan - return await ctx.send.file('./uploads/document.pdf') -} -``` - -## Dengan Nama File Kustom - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export async function GET(ctx: Context): Promise { - // Argumen kedua mengganti nama unduhan - return await ctx.send.file('./files/data.csv', 'report.csv') -} -``` - -## Penanganan Error - -File yang hilang atau tidak terbaca melempar `Deno.errors.NotFound`. Tangkap di handler untuk balasan presisi, atau biarkan naik ke [error handler terpusat](/id/error-handling/object-details): - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export async function GET(ctx: Context): Promise { - try { - return await ctx.send.file('./uploads/document.pdf') - } catch (error) { - // File hilang melempar, balas 404 - return ctx.send.json( - { - error: 'File not found' - }, - { - status: 404 - } - ) - } -} -``` diff --git a/docs/id/response/html.md b/docs/id/response/html.md index 2e32a94..3962faf 100644 --- a/docs/id/response/html.md +++ b/docs/id/response/html.md @@ -4,7 +4,7 @@ description: "Kirim response HTML dengan ctx.send.html()." # Response HTML -Method `ctx.send.html()` membuat response HTML. +Method `ctx.send.html()` membuat response HTML. Method ini mengatur `Content-Type: text/html; charset=utf-8` otomatis. ## Penggunaan Dasar @@ -20,6 +20,8 @@ export function GET(ctx: Context): Response { ## HTML Dinamis +Sebuah template literal membangun markup saat runtime: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- @@ -39,6 +41,8 @@ export function GET(ctx: Context): Response { } ``` +Untuk halaman lebih besar, render sebuah [template DVE](/id/rendering/) dengan `ctx.render()` alih-alih membangun HTML dengan tangan. + ## Dengan Status Code ```typescript twoslash @@ -47,23 +51,29 @@ import type { Context } from '@neabyte/deserve' export function GET(ctx: Context): Response { // Halaman Not Found dengan status 404 const html = '

Not Found

' - return ctx.send.html( - html, - { - status: 404 - } - ) + return ctx.send.html(html, { status: 404 }) } ``` -## Header Kustom +## Dengan Header Kustom + +Header yang diatur lewat `ctx.set.header()` digabung ke response: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Atur header sebelum kirim - ctx.setHeader('X-Frame-Options', 'DENY') + ctx.set.header('X-Frame-Options', 'DENY') return ctx.send.html('Content') } ``` + +## Tanda Tangan Method + +```typescript +ctx.send.html(html: string, options?: SendInit): Response +``` + +- **html** - body response string HTML +- **options** - `status` dan `headers` opsional diff --git a/docs/id/response/json.md b/docs/id/response/json.md index 4b396c0..ab33bf3 100644 --- a/docs/id/response/json.md +++ b/docs/id/response/json.md @@ -4,7 +4,7 @@ description: "Kirim response JSON dengan ctx.send.json(), termasuk status code d # Response JSON -Method `ctx.send.json()` membuat response JSON. +Method `ctx.send.json()` membuat response JSON. Method ini menserialisasi data dengan `JSON.stringify()` dan mengatur `Content-Type: application/json` otomatis. ## Penggunaan Dasar @@ -25,55 +25,66 @@ export function GET(ctx: Context): Response { import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const data = await ctx.body() + // Baca body request hasil parse + const data = await ctx.get.body() // Balas Created dengan status 201 return ctx.send.json( - { - message: 'Created successfully', - data - }, + { message: 'Created successfully', data }, { status: 201 } ) } ``` -Nilai `status` harus integer dalam rentang 200-599, atau salah satu kode tanpa body 101, 204, 205, dan 304 yang mengirim body kosong. Nilai lain melempar `Deno.errors.InvalidData`. Aturan ini berlaku untuk setiap helper `ctx.send`. +Nilai `status` harus integer dalam rentang 200-599, atau salah satu kode tanpa body `101`, `204`, `205`, dan `304` yang mengirim body kosong. Nilai lain melempar `Deno.errors.InvalidData`. Aturan ini berlaku untuk setiap helper `ctx.send`. -Di sini `ctx.body()` mengembalikan apa pun yang dikirim client, jadi handler yang bergantung pada bentuknya menjalankan kontrak [validasi](/id/middleware/validation/overview) lebih dulu dan membaca data bertipe yang sudah lolos. +Di sini `ctx.get.body()` mengembalikan apa pun yang dikirim client, jadi handler yang bergantung pada bentuknya menjalankan kontrak [validasi](/id/middleware/validation/overview) lebih dulu dan membaca data bertipe yang sudah lolos. ## Dengan Header Kustom +Header yang diatur lewat `ctx.set.header()` digabung ke response. Header opsi diutamakan saat bentrok: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Atur header sebelum kirim - ctx.setHeader('Cache-Control', 'no-cache') + ctx.set.header('Cache-Control', 'no-cache') return ctx.send.json({ data: 'sensitive' }) } ``` +Header juga bisa diberikan lewat opsi: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + return ctx.send.json( + { data: 'sensitive' }, + { + headers: { + 'Cache-Control': 'no-cache', + 'X-Request-ID': 'abc123' + } + } + ) +} +``` + ## Data Kompleks +Object dan array bersarang diserialisasi apa adanya: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // Object bersarang diserialisasi apa adanya const data = { users: [ - { - id: 1, - name: 'Alice', - email: 'alice@example.com' - }, - { - id: 2, - name: 'Bob', - email: 'bob@example.com' - } + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' } ], pagination: { page: 1, @@ -88,6 +99,8 @@ export function GET(ctx: Context): Response { ## Error Response +Sebuah handler bisa membentuk body error sekali pakai seperti ini: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- @@ -100,4 +113,13 @@ export function GET(ctx: Context): Response { } ``` -Sebuah handler bisa membentuk body error sekali pakai seperti ini, tetapi error yang dilempar mengalir lewat satu tempat alih-alih, dibahas di [Detail Objek Error](/id/error-handling/object-details). +Error yang dilempar mengalir lewat satu tempat alih-alih, dibahas di [Detail Objek Error](/id/error-handling/object-details). Untuk bentuk error yang konsisten di seluruh aplikasi, pakai [`ctx.handleError()`](/id/core-concepts/context-object#penanganan-error) alih-alih membangun setiap response dengan tangan. + +## Tanda Tangan Method + +```typescript +ctx.send.json(data: T, options?: SendInit): Response +``` + +- **data** - nilai untuk diserialisasi sebagai JSON +- **options** - `status` dan `headers` opsional diff --git a/docs/id/response/stream.md b/docs/id/response/stream.md deleted file mode 100644 index 174418d..0000000 --- a/docs/id/response/stream.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -description: "Kirim response streaming dari ReadableStream dengan ctx.send.stream()." ---- - -# Response Stream - -Method `ctx.send.stream()` mengembalikan body response dari `ReadableStream`, berguna untuk streaming data besar atau server-sent events tanpa buffering penuh. - -## Penggunaan Dasar - -```typescript twoslash -import type { Context } from '@neabyte/deserve' - -export function GET(ctx: Context): Response { - // Dorong dua chunk teks lalu tutup - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('Hello\n')) - controller.enqueue(new TextEncoder().encode('World\n')) - controller.close() - } - }) - // Stream menjadi body response - return ctx.send.stream(stream) -} -``` - -## Dengan Content-Type Kustom - -Parameter ketiga adalah content type dan defaultnya `application/octet-stream`: - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('Hello')) - controller.close() - } - }) - // Argumen ketiga mengatur content type - return ctx.send.stream(stream, undefined, 'text/plain') -} -``` - -## Dengan Status dan Header - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('{"ok":true}\n')) - controller.close() - } - }) - // Argumen kedua status, ketiga tipe - return ctx.send.stream(stream, { - status: 200, - headers: { - 'X-Custom': 'value' - } - }, 'application/x-ndjson') -} -``` - -## Method Signature - -```typescript -ctx.send.stream( - stream: ReadableStream, - options?: ResponseInit, - contentType?: string -): Response -``` - -- **stream** - ReadableStream yang dipakai sebagai body response -- **options** - status dan header opsional (ResponseInit) -- **contentType** - opsional, default `'application/octet-stream'` diff --git a/docs/id/response/text.md b/docs/id/response/text.md index 8a07627..21f15d0 100644 --- a/docs/id/response/text.md +++ b/docs/id/response/text.md @@ -4,7 +4,7 @@ description: "Kirim response teks biasa dengan ctx.send.text()." # Response Teks -Method `ctx.send.text()` membuat response teks biasa. +Method `ctx.send.text()` membuat response teks biasa. Method ini mengatur `Content-Type: text/plain; charset=utf-8` otomatis. ## Penggunaan Dasar @@ -24,43 +24,48 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export function POST(ctx: Context): Response { // Balas Not Implemented dengan 501 - return ctx.send.text( - 'Not Implemented', - { - status: 501 - } - ) + return ctx.send.text('Not Implemented', { status: 501 }) } ``` -## Pesan Error +Nilai `status` harus integer dalam rentang 200-599, atau salah satu kode tanpa body `101`, `204`, `205`, dan `304`. Nilai lain melempar `Deno.errors.InvalidData`. + +## Dengan Header Kustom + +Header yang diatur lewat `ctx.set.header()` digabung ke response. Header opsi diutamakan saat bentrok: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // Error teks biasa dengan status 500 - return ctx.send.text( - 'Internal Server Error', - { - status: 500 + // Atur header sebelum kirim + ctx.set.header('X-Custom', 'value') + return ctx.send.text('Hello World', { + headers: { + 'Content-Language': 'en' } - ) + }) } ``` -## Header Kustom +## Pesan Error + +Sebuah handler bisa mengembalikan body error teks biasa, tetapi error yang dilempar mengalir lewat satu tempat alih-alih, dibahas di [Detail Objek Error](/id/error-handling/object-details). Untuk bentuk error yang konsisten, pakai [`ctx.handleError()`](/id/core-concepts/context-object#penanganan-error). ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // Tambah header lewat opsi - return ctx.send.text('Hello World', { - headers: { - 'Content-Language': 'en', - 'X-Custom': 'value' - } - }) + // Error teks biasa dengan status 500 + return ctx.send.text('Internal Server Error', { status: 500 }) } ``` + +## Tanda Tangan Method + +```typescript +ctx.send.text(text: string, options?: SendInit): Response +``` + +- **text** - body response teks biasa +- **options** - `status` dan `headers` opsional diff --git a/docs/id/static-file/basic.md b/docs/id/static-file/basic.md index b3ea175..5ff7f54 100644 --- a/docs/id/static-file/basic.md +++ b/docs/id/static-file/basic.md @@ -4,20 +4,20 @@ description: "Sajikan file statis dari sebuah direktori dengan static handler De # Penyajian Static Dasar -Sajikan file statis (HTML, CSS, JS, images) menggunakan method `static()`. +Method `router.static()` menyajikan file dari sebuah folder di bawah prefix URL, dengan caching, byte range, dan keamanan path bawaan. Cara ini mencakup HTML, CSS, JavaScript, gambar, font, dan aset lain di disk. ## Penggunaan Dasar -Sajikan file statis dari direktori: +Pasang sebuah folder di bawah prefix URL: -![Memanggil router.static dengan prefix garis miring static dan path titik garis miring public mendaftarkan pola garis miring static garis miring bintang bintang, lalu tiap request prefix garis miring static-nya dipotong dari ctx.pathname dan sisanya digabung di bawah public, jadi garis miring static memetakan ke public garis miring index titik html, garis miring static garis miring css garis miring style titik css memetakan ke public garis miring css garis miring style titik css, dan garis miring static garis miring titik env ditolak dengan 404 sebelum pembacaan apa pun karena segmennya diawali titik](/diagrams/static-url-to-file.png) +![Sebuah request ke garis miring static garis miring css garis miring style titik css cocok dengan mount garis miring static, prefix garis miring static-nya dipotong menjadi css garis miring style titik css, dan disajikan dari folder public, sementara request ke garis miring static garis miring titik env ditolak dengan 404 sebelum pembacaan apa pun karena segmennya diawali titik](/diagrams/static-url-to-file.png) ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() -// Sajikan ./public di path /static +// Sajikan ./public di bawah prefix /static router.static('/static', { path: './public', etag: true, @@ -27,43 +27,38 @@ router.static('/static', { await router.serve(8000) ``` -Ini menyajikan file dari direktori `public/` di path URL `/static`: - -- `GET /static/index.html` → menyajikan `public/index.html` -- `GET /static/css/style.css` → menyajikan `public/css/style.css` -- `GET /static/.env` → ditolak dengan **404** sebelum pembacaan apa pun +Mount itu memetakan setiap URL di bawah `/static` ke sebuah file di `public/`: +- `GET /static/index.html` menyajikan `public/index.html` +- `GET /static/css/style.css` menyajikan `public/css/style.css` +- `GET /static/.env` ditolak dengan **404** sebelum pembacaan apa pun ## Cara Kerja -Deserve memakai implementasi penyajian file statis kustom: +Sebuah static mount bukan file route. Ia adalah registry terpisah yang diperiksa router hanya setelah dynamic route meleset, jadi urutan pencocokannya tetap: -1. **Route Matching**: Membuat route dengan pola `${urlPath}/**` untuk mencocokkan semua file -2. **Path Extraction**: Membaca `ctx.pathname` langsung untuk mendapat full request path, karena pola `/**` FastRouter hanya menangkap segment pertama -3. **File Resolution**: Memetakan path URL ke path file system memakai opsi `path` -4. **Priority**: Static route diregistrasi untuk semua HTTP method sebelum dynamic route +1. Entry middleware berjalan lebih dulu. +2. Sebuah dynamic route yang cocok menangani request dan static tidak pernah berjalan. +3. Saat path cocok dengan sebuah rute di bawah method lain, router membalas **405 Method Not Allowed** dengan header `Allow`, dan static tetap tidak pernah berjalan. +4. Tanpa kecocokan rute sama sekali, router menelusuri static mount dan menyajikan yang pertama yang prefix-nya mencakup path. -### Perilaku Pola Wildcard +Sebuah request mempertahankan prefix-nya sampai sebuah mount cocok, lalu prefix dipotong dan sisanya menjadi path file di bawah folder. Jadi `GET /static/css/style.css` memotong `/static` dan meresolusi `css/style.css` di dalam `public/`. -Ketika `urlPath` adalah `/`, Deserve membuat pola `/**`. Untuk path resolution, Deserve memakai `ctx.pathname` daripada mengandalkan parameter wildcard, karena: +### Pencocokan Prefix -- Pola `/**` FastRouter hanya menangkap **segment pertama** dari request path alih-alih full path (misalnya `"styles"` untuk `/styles/ui.css`) -- Untuk menyajikan file bersarang dengan benar, Deserve mengekstrak full path dari `ctx.pathname` dan menghapus `/` awal untuk mendapat path file relatif +Mount diurutkan prefix terpanjang dulu, jadi yang paling spesifik menang. Mount pada `/admin/assets` dicoba sebelum mount pada `/admin`, yang membuat fallback luas dan folder fokus hidup berdampingan. Mount pada `/` bertindak sebagai catch-all yang mencakup setiap path tersisa. Beberapa mount dan urutan dispatch-nya ada di [Banyak Direktori](/id/static-file/multiple). -**Contoh:** +### Method yang Didukung -- Request: `GET /styles/ui.css` -- Pattern: `/**` cocok dari path yang dikonfigurasi -- File path: Diekstrak dari `ctx.pathname` → `"styles/ui.css"` -- Resolved: `static/styles/ui.css` +Sebuah static mount menjawab `GET` dan `HEAD` saja. Method lain apa pun pada path yang dicakup mount mengembalikan **405 Method Not Allowed** dengan `Allow: GET, HEAD`. Sebuah request `HEAD` menjalankan jalur yang sama dengan `GET` dan mengembalikan header dengan body kosong. ## Opsi Static File -Method `static()` menerima object `ServeOptions`: +Argumen kedua adalah object `ServeOptions`. Hanya `path` yang wajib: ### `path` -Path direktori file system untuk menyajikan file: +Direktori filesystem untuk menyajikan, relatif ke current working directory atau absolut. Path kosong melempar saat mount: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -71,17 +66,17 @@ import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.static('/static', { - path: './public' // Sajikan file dari folder public/ + path: './public' // Sajikan file dari folder public }) router.static('/assets', { - path: '/absolute/path/to/assets' // Path absolut juga didukung + path: '/absolute/path/to/assets' // Path absolut juga bisa }) ``` ### `etag` -Aktifkan pembuatan ETag untuk caching. Tag adalah hash SHA-256 dari ukuran file dan waktu modifikasi, bukan isi file penuh, jadi tetap murah dihitung: +Mengaktifkan pembuatan ETag, dan default aktif saat dihilangkan. Tag adalah validator lemah yang dibangun dari hash SHA-256 ukuran file dan waktu modifikasi, bukan isi file, jadi tetap murah dihitung: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -90,15 +85,15 @@ const router = new Router() // ---cut--- router.static('/static', { path: './public', - etag: true // Generate ETag dari size dan mtime + etag: true // Bangun ETag dari size dan mtime }) ``` -Saat aktif, client yang mengirim header `If-None-Match` yang cocok menerima response `304 Not Modified` tanpa body. +Saat client mengirim `If-None-Match` yang cocok, response-nya **304 Not Modified** tanpa body. Client yang mengirim `If-Modified-Since` mendapat 304 yang sama saat file tidak lebih baru dari tanggal itu. ### `cacheControl` -Atur Cache-Control max-age dalam detik. Deserve mengirimnya sebagai `public, max-age=`, hanya berlaku saat nilainya `0` atau lebih: +Mengatur `Cache-Control` max-age dalam detik, dikirim sebagai `public, max-age=`. Berlaku hanya saat nilainya `0` atau lebih, dan dihilangkan selain itu: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -107,54 +102,61 @@ const router = new Router() // ---cut--- router.static('/static', { path: './public', - cacheControl: 86400 // Cache 1 hari (86400 detik) + cacheControl: 86400 // Cache selama satu hari }) router.static('/assets', { path: './assets', - cacheControl: 31536000 // Cache 1 tahun + cacheControl: 31536000 // Cache selama satu tahun }) ``` -## Permintaan Byte-Range +## Handler Kustom -Response statis mendukung satu [byte range](https://www.rfc-editor.org/rfc/rfc7233) sehingga klien bisa mengambil sebagian berkas, yang diandalkan oleh penggeser video atau unduhan yang bisa dilanjutkan. Setiap response statis mengumumkan `Accept-Ranges: bytes`, dan request yang membawa satu header `Range` kontigu dijawab dengan jendela yang cocok: +Sebagai ganti opsi, `static()` menerima sebuah fungsi berbentuk `(ctx, urlPath) => Response`. Fungsi itu menerima [context](/id/core-concepts/context-object) dan path dengan prefix mount yang sudah dipotong, yang cocok untuk peta aset in-memory atau file yang dihasilkan: -- **Satu range valid** mengembalikan **206 Partial Content** dengan header `Content-Range: bytes start-end/size` dan hanya byte itu yang dialirkan dari disk. -- **Range tak terpenuhi** yang menyebut jendela melewati ukuran berkas mengembalikan **416 Range Not Satisfiable** dengan `Content-Range: bytes */size`. -- **Range yang tidak ada, multi-bagian, atau tidak valid** kembali menyajikan berkas penuh seperti sebelumnya. +```typescript twoslash +import { Router, type Context } from '@neabyte/deserve' -Hanya byte di dalam jendela yang diminta yang dibaca, dan handle berkas dilepas begitu jendela terkirim, error, atau dibatalkan. +const router = new Router() +// ---cut--- +// Sajikan aset dari peta berdasarkan path terpotong +router.static('/cdn', (ctx: Context, urlPath: string) => { + const assets: Record = { 'logo.svg': '' } + const body = assets[urlPath] + if (body === undefined) { + return ctx.send.empty(404) + } + return ctx.send.custom(body, { headers: { 'Content-Type': 'image/svg+xml' } }) +}) +``` -## Resolusi File dan Keamanan +## Permintaan Byte-Range -Penyajian static memetakan path URL ke file di bawah direktori yang dikonfigurasi, dengan beberapa aturan bawaan: +Response statis mendukung satu [byte range](https://www.rfc-editor.org/rfc/rfc7233) sehingga klien bisa mengambil sebagian berkas, yang diandalkan oleh penggeser video atau unduhan yang bisa dilanjutkan. Setiap response statis mengumumkan `Accept-Ranges: bytes`: -- **Index fallback** - request ke root route menyajikan `index.html` dari direktori. -- **Content type** - tipe dipilih dari ekstensi file. Aset web umum seperti HTML, CSS, JavaScript, JSON, images, fonts, dan dokumen sudah dipetakan langsung, dan ekstensi tidak dikenal memakai `application/octet-stream`. -- **Dotfiles diblokir** - segment path apa pun yang namanya diawali `.` ditolak dengan **404**, jadi file seperti `.env`, `.git/config`, atau `..` di awal tidak pernah disajikan. Aturan melihat nama segment, bukan ekstensi, jadi file biasa seperti `report.env` tetap disajikan. -- **Directory traversal diblokir** - real path hasil resolusi harus tetap di dalam direktori dasar. Path yang lolos keluar, misalnya dibangun dari `..`, ditolak dengan **404**. +- Satu range valid mengembalikan **206 Partial Content** dengan `Content-Range: bytes start-end/size`, mengalirkan hanya byte itu dari disk. +- Range melewati ukuran berkas mengembalikan **416 Range Not Satisfiable** dengan `Content-Range: bytes */size`. +- Range yang tidak ada, multi-bagian, atau malformed kembali menyajikan berkas penuh. -File yang hilang atau diblokir mengembalikan 404 lewat [error handler terpusat](/id/error-handling/object-details). +Header `If-Range` yang membawa tanggal mempertahankan range hanya saat berkas tidak berubah, selain itu berkas penuh dikirim. `If-Range` yang membawa entity tag diperlakukan sebagai basi, jadi berkas penuh dikirim. Handle berkas dilepas begitu jendela terkirim, error, atau dibatalkan. -## Pemecahan Masalah +## Resolusi File dan Keamanan -### File Tidak Ditemukan +Sebuah mount memetakan URL ke file di bawah folder-nya dengan beberapa aturan tetap: -- Periksa `path` benar (relatif ke current working directory atau absolut) -- Verifikasi permission file -- Pastikan file ada di direktori -- Periksa path URL cocok dengan pola route (`/static/file.css` untuk `router.static('/static', ...)`) +- **Index fallback** - request ke root mount menyajikan `index.html` dari folder. +- **Content type** - tipe berasal dari ekstensi file. Aset web umum seperti HTML, CSS, JavaScript, JSON, gambar, font, dan dokumen sudah dipetakan langsung, dan ekstensi tidak dikenal memakai `application/octet-stream`. +- **Dotfiles diblokir** - segmen path apa pun yang namanya diawali `.` ditolak dengan **404**, jadi `.env`, `.git/config`, atau `..` di awal tidak pernah disajikan. Aturan membaca nama segmen, bukan ekstensi, jadi file biasa seperti `report.env` tetap disajikan. +- **Traversal diblokir** - real path hasil resolusi harus tetap di dalam folder. Path yang lolos keluar lewat `..` atau symlink ditolak dengan **404**. -### Error 404 +Sebuah miss atau path yang diblokir memancarkan event `static:missing` di [bus observability](/id/middleware/observability/overview) dan mengembalikan **404** lewat [error handler terpusat](/id/error-handling/object-details), handler yang sama yang diatur dengan `router.catch()` yang membentuk setiap error lain. Tidak ada hook error per-mount, jadi satu handler mencakup static, rute, dan middleware sekaligus. -- Verifikasi static route diregistrasi sebelum memanggil `router.serve()` -- Periksa path file cocok dengan struktur URL -- Pastikan file ada di path hasil resolusi +## Pemecahan Masalah -### Masalah Caching +Beberapa miss umum dan apa yang perlu diperiksa: -- Verifikasi `etag` dan `cacheControl` diatur dengan benar -- Periksa tab Network di DevTools browser untuk header ETag dan Cache-Control -- Bersihkan cache browser untuk testing -- Pakai response `304 Not Modified` (terlihat saat ETag cocok) +- **404 pada file yang ada** - pastikan `path` menunjuk folder yang benar dan URL mempertahankan prefix mount, jadi `/static/app.css` untuk mount pada `/static`. +- **404 pada dotfile** - ini disengaja, karena segmen apa pun yang diawali `.` diblokir. +- **Sebuah rute menang atas file statis** - dynamic route pada path yang sama diprioritaskan, jadi ganti nama salah satu atau pindahkan static mount ke prefix yang berbeda. +- **Caching tidak diterapkan** - periksa header `ETag` dan `Cache-Control` di panel network browser, dan pastikan `etag` serta `cacheControl` diatur. diff --git a/docs/id/static-file/multiple.md b/docs/id/static-file/multiple.md index a0c22dc..fb623db 100644 --- a/docs/id/static-file/multiple.md +++ b/docs/id/static-file/multiple.md @@ -4,11 +4,11 @@ description: "Sajikan aset statis dari beberapa direktori di bawah prefix URL be # Beberapa Direktori -Sajikan file statis dari beberapa direktori dengan konfigurasi berbeda per path. Setiap pemanggilan berbagi opsi dan aturan resolusi yang sama seperti di [Penyajian Static Dasar](/id/static-file/basic). +Beberapa panggilan `router.static()` bisa berjalan berdampingan, masing-masing mengikat satu prefix URL ke foldernya sendiri dengan kebijakan cache sendiri. Opsi dan aturan resolusi per mount dibahas di [Penyajian Static Dasar](/id/static-file/basic), dan halaman ini fokus pada bagaimana banyak mount berbagi satu router. ## Penggunaan Dasar -Konfigurasi beberapa direktori static: +Pasang tiap prefix dengan folder dan cache-nya sendiri: ![Tiga panggilan static masing-masing mengikat satu prefix url ke foldernya sendiri dengan kebijakan cache sendiri, di mana garis miring admin menyajikan folder admin garis miring dist dengan etag aktif dan cache satu hari, garis miring uploads menyajikan folder uploads dengan etag nonaktif dan tanpa cache, dan garis miring docs menyajikan folder docs garis miring build dengan etag aktif dan cache satu jam](/diagrams/static-multiple-dirs.png) @@ -17,7 +17,7 @@ import { Router } from '@neabyte/deserve' const router = new Router() -// Tiap path punya folder dan cache sendiri +// Tiap prefix punya folder dan cache sendiri router.static('/admin', { path: './admin/dist', etag: true, @@ -37,79 +37,67 @@ router.static('/docs', { await router.serve(8000) ``` +## Bagaimana Mount Dipilih + +Setiap mount masuk ke satu registry yang diurutkan prefix terpanjang dulu. Sebuah request menelusuri daftar itu dan prefix pertama yang mencakup path menang, jadi mount paling spesifik selalu diutamakan atas yang lebih luas: + +![Satu request memilih prefix static yang diawalinya, jadi request di bawah garis miring uploads cocok dengan mount garis miring uploads dan disajikan dari folder uploads dengan prefix itu etag nonaktif dan tanpa cache, sementara tail yang sama di bawah garis miring docs malah cocok dengan mount garis miring docs dan disajikan dari docs garis miring build dengan etag aktif dan cache satu jam, membuktikan prefix yang cocok menentukan folder sekaligus kebijakan cache](/diagrams/static-prefix-dispatch.png) + +Prefix yang cocok menentukan folder sekaligus kebijakan cache, jadi dua mount bisa berbagi tail path dan tetap meresolusi ke file berbeda. Mount pada `/` duduk di akhir sebagai catch-all yang mencakup apa pun yang tidak dicakup prefix sebelumnya. + ## Pola Umum -![Satu request memilih prefix static yang diawalinya, jadi GET garis miring uploads garis miring img garis miring a titik png cocok dengan pola garis miring uploads, prefiksnya dipotong, dan disajikan dari folder uploads dengan etag nonaktif dan tanpa cache, sementara tail yang sama pada GET garis miring docs garis miring img garis miring a titik png malah cocok dengan pola garis miring docs dan disajikan dari docs garis miring build dengan etag aktif dan cache satu jam, membuktikan prefix yang cocok menentukan folder sekaligus kebijakan cache](/diagrams/static-prefix-dispatch.png) +### Situs Dengan Root Catch-All -### Website + Panel Admin +Mount `/` yang luas dan mount `/admin` yang fokus hidup berdampingan karena prefix yang lebih panjang dicocokkan lebih dulu. Request ke `/admin/index.html` meresolusi lewat mount admin, sementara `/style.css` jatuh ke mount root: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- -// Website utama -router.static('/', { - path: './public', +// Panel admin, dicocokkan lebih dulu +router.static('/admin', { + path: './admin/dist', etag: true, cacheControl: 86400 }) -// Panel admin -router.static('/admin', { - path: './admin/dist', +// Root catch-all, dicocokkan terakhir +router.static('/', { + path: './public', etag: true, cacheControl: 86400 }) ``` -### Aset + Uploads +### Aset Berumur Panjang dan Upload Segar + +Folder aset ber-fingerprint di-cache selama setahun, sementara folder upload pengguna mematikan cache agar file yang diganti selalu diambil segar: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- -// Aset statis dengan cache jangka panjang +// Aset ber-fingerprint di-cache setahun router.static('/assets', { path: './public/assets', etag: true, - cacheControl: 31536000 // 1 tahun + cacheControl: 31536000 }) -// Upload pengguna tanpa cache +// Upload pengguna tetap tanpa cache router.static('/uploads', { path: './uploads', etag: false, - cacheControl: 0 // Tanpa cache -}) -``` - -### Development + Production - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const router = new Router() -// ---cut--- -// File development - cache pendek -router.static('/dev', { - path: './dev', - etag: true, - cacheControl: 0 // Tanpa cache untuk dev -}) - -// Build production - cache panjang -router.static('/', { - path: './dist', - etag: true, - cacheControl: 31536000 // 1 tahun + cacheControl: 0 }) ``` -## Contoh Struktur Direktori +## Struktur Direktori -### Aplikasi Full-Stack +Sebuah tata letak yang cocok dengan mount di atas: ``` . @@ -122,118 +110,31 @@ router.static('/', { │ └── dist/ │ ├── index.html │ └── assets/ -├── uploads/ -│ ├── images/ -│ └── documents/ -└── docs/ - └── build/ - ├── index.html - └── assets/ -``` - -### Frontend Microservices - -``` -. -├── main.ts -├── web/ -│ └── dist/ -├── api/ -│ └── docs/ -├── admin/ -│ └── build/ -└── mobile/ - └── public/ +└── uploads/ + ├── images/ + └── documents/ ``` -## Contoh Konfigurasi +## Rute Diprioritaskan -### Strategi Caching Berbeda +Static mount berjalan hanya setelah dynamic route meleset, jadi sebuah rute selalu menang pada path bersama. Sebuah file route di `/admin` menangani `GET /admin` sebelum static mount `/admin` melihatnya, yang merupakan urutan pencocokan yang dirinci di [Penyajian Static Dasar](/id/static-file/basic#cara-kerja). Jaga API dan folder statis pada prefix berbeda untuk menghindari kejutan: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- -// Aset cache jangka panjang (1 tahun) -router.static('/assets', { - path: './public/assets', - etag: true, - cacheControl: 31536000 -}) - -// Cache jangka menengah (1 hari) -router.static('/images', { - path: './public/images', - etag: true, - cacheControl: 86400 -}) - -// Tanpa cache untuk upload dinamis -router.static('/uploads', { - path: './uploads', - etag: false, - cacheControl: 0 -}) -``` - -### Pengaturan ETag Berbeda - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const router = new Router() -// ---cut--- -// Aktifkan ETag untuk caching efisien +// API di bawah /api, aset di bawah /static router.static('/static', { - path: './public', - etag: true, - cacheControl: 86400 + path: './public' }) - -// Matikan ETag untuk file yang sering berubah -router.static('/reports', { - path: './reports', - etag: false, - cacheControl: 3600 +router.static('/admin', { + path: './admin/dist' }) ``` ## Pemecahan Masalah -### Konflik Route - -Route diregistrasi untuk semua HTTP method (`GET`, `POST`, dll.). Pastikan static route tidak bentrok dengan dynamic route: - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const router = new Router() -// ---cut--- -router.static( - '/', - { - path: './public' - } -) -router.static( - '/admin', - { - path: './admin/dist' - } -) -``` - -### File Tidak Ditemukan - -- Periksa nilai `path` benar (relatif ke cwd atau absolut) -- Verifikasi struktur direktori cocok dengan konfigurasi -- Pastikan file ada di direktori yang ditentukan -- Periksa path URL cocok dengan pola route - -### Masalah Performa - -- Aktifkan `etag: true` untuk caching efisien -- Atur nilai `cacheControl` sesuai tipe konten -- Aset statis: cache panjang (31536000 = 1 tahun) -- Konten dinamis: cache pendek atau tanpa cache (0 atau 3600) +- **Folder salah disajikan** - prefix yang lebih luas cocok lebih dulu hanya saat ia memang lebih panjang, jadi pastikan mount spesifik punya prefix yang lebih panjang. +- **Sebuah rute membayangi file** - dynamic route pada path yang sama disajikan sebelum static mount, jadi pindahkan salah satu ke prefix berbeda. +- **404 lintas sebuah mount** - periksa path folder dan bahwa URL mempertahankan prefix mount, karena setiap miss mengembalikan 404 lewat [error handler terpusat](/id/error-handling/object-details). diff --git a/docs/middleware/basic-auth.md b/docs/middleware/basic-auth.md index bda4cc0..1fde53d 100644 --- a/docs/middleware/basic-auth.md +++ b/docs/middleware/basic-auth.md @@ -101,9 +101,34 @@ router.use( ) ``` +## Custom Realm + +The `realm` names the protected area in the browser prompt and defaults to `'Secure Area'`: + +```typescript twoslash +import { Mware, Router } from '@neabyte/deserve' + +const router = new Router() +// ---cut--- +// Name the area shown in the prompt +router.use( + Mware.basicAuth({ + realm: 'Admin Panel', + users: [ + { + username: 'admin', + password: 'secret' + } + ] + }) +) +``` + ## Error Handling -A failed login fails with **401 Unauthorized** and a `WWW-Authenticate: Basic realm="Secure Area"` header, which is what makes browsers show the login prompt. Credentials are checked in constant time to avoid timing leaks, and an empty `users` array throws `Deno.errors.InvalidData` when the middleware is created. The 401 routes through the [central error handler](/error-handling/object-details), so shape the response there or rely on the [default behavior](/error-handling/default-behavior). +A failed login fails with **401 Unauthorized** and a `WWW-Authenticate: Basic realm="..."` header, which is what makes browsers show the login prompt. The realm defaults to `'Secure Area'` and can be overridden through the `realm` option. Credentials are checked in constant time to avoid timing leaks, and an empty `users` array throws `Deno.errors.InvalidData` when the middleware is created. + +Each rejection emits an `auth:failed` event with the reason - `missing`, `malformed`, or `invalid` - covered in [Event Reference](/middleware/observability/events). The 401 routes through the [central error handler](/error-handling/object-details), so shape the response there or rely on the [default behavior](/error-handling/default-behavior). ## Browser Authentication diff --git a/docs/middleware/body-limit.md b/docs/middleware/body-limit.md index 79d831d..7b5b62e 100644 --- a/docs/middleware/body-limit.md +++ b/docs/middleware/body-limit.md @@ -6,7 +6,7 @@ description: "Limit incoming request body size to guard against oversized payloa > **Reference**: [RFC 7230 HTTP/1.1 Message Syntax and Routing](https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.1) -Body Limit middleware enforces a maximum request body size. When a body is present on a method that allows one, the body stream is wrapped with a limiter so the size is enforced as bytes arrive, not only from headers, which keeps large payloads from overwhelming the server. +Body Limit middleware enforces a maximum request body size by checking the `Content-Length` header. When a request carries a body on a method that allows one, the middleware rejects oversized payloads before the body is ever read, which keeps large payloads from overwhelming the server. ## Basic Usage @@ -56,7 +56,7 @@ router.use('/api', Mware.bodyLimit({ ### `limit` -Maximum body size in bytes: +Maximum body size in bytes. Must be a positive finite number, otherwise the middleware throws `Deno.errors.InvalidData` when created: ```typescript // 1MB (1,048,576 bytes) @@ -71,44 +71,14 @@ limit: 10 * 1024 * 1024 ## How It Works -When a request can carry a body, the middleware checks the declared size first, then wraps the body stream with a byte limiter so the size is enforced as the body is read, not only from headers: +The middleware checks the declared size from the `Content-Length` header before the body is read: -1. **GET or HEAD** - nothing is wrapped and the request passes through. -2. **Content-Length** - when present without `Transfer-Encoding`, the request is rejected before the body is read if the value is missing a number, negative, or above `limit`. -3. **Body present** - on a method that allows a body, the stream is wrapped with the limiter. When the client sends more bytes than `limit`, reading stops and the middleware responds with **413**. +1. **GET or HEAD** - the request passes through without a check, since these methods carry no body +2. **Content-Length present** - when the value is missing a number, negative, or above `limit`, the request is rejected with **413** before the body is read +3. **No Content-Length** - the request passes through, and the body is read normally by the handler -### RFC 7230 - -- When both `Transfer-Encoding` and `Content-Length` are present, `Transfer-Encoding` takes precedence. -- Chunked or unknown-length bodies are still limited by the wrapped stream, and only the bytes read count toward the limit. - -This middleware caps how many bytes a body may carry. Checking the shape of those bytes is a separate step that a [validation](/middleware/validation/overview) contract runs once the body is within the limit. - -## Complete Example - -```typescript twoslash -import { Mware, Router } from '@neabyte/deserve' - -const router = new Router({ - routesDir: './routes' -}) - -// Global 1MB limit -router.use(Mware.bodyLimit({ - limit: 1024 * 1024 -})) - -// Larger limits for uploads and API -router.use('/uploads', Mware.bodyLimit({ - limit: 5 * 1024 * 1024 -})) -router.use('/api', Mware.bodyLimit({ - limit: 10 * 1024 * 1024 -})) - -await router.serve(8000) -``` +This caps how many bytes a body may declare. Checking the shape of those bytes is a separate step that a [validation](/middleware/validation/overview) contract runs once the body is within the limit. ## Error Handling -When the limit is exceeded, the middleware fails with status **413** and message `Request body exceeds bytes limit`, whether a declared `Content-Length` trips it before the body is read or an oversized stream trips it as the extra bytes arrive. That failure routes through the [central error handler](/error-handling/object-details) like any other, so shape the response there or rely on the [default behavior](/error-handling/default-behavior). +When the limit is exceeded, the middleware fails with status **413** and message `Request body exceeds bytes limit`. That failure routes through the [central error handler](/error-handling/object-details) like any other, so shape the response there or rely on the [default behavior](/error-handling/default-behavior). A `body:rejected` observability event also fires with the limit and the declared size, covered in [Event Reference](/middleware/observability/events). diff --git a/docs/middleware/cors.md b/docs/middleware/cors.md index a1bf32d..47aa770 100644 --- a/docs/middleware/cors.md +++ b/docs/middleware/cors.md @@ -80,7 +80,7 @@ origin: '*' ### `methods` -Specify allowed HTTP methods: +Specify allowed HTTP methods. Defaults to all seven supported methods: ```typescript methods: [ @@ -95,7 +95,7 @@ methods: [ ### `allowedHeaders` -Specify allowed headers: +Specify allowed headers. Defaults to `Content-Type`, `Authorization`, and `X-Requested-With`: ```typescript allowedHeaders: [ @@ -126,7 +126,7 @@ credentials: true // Allow cookies and authorization headers ### `maxAge` -Set preflight cache duration in seconds: +Set preflight cache duration in seconds. Defaults to `86400` (24 hours): ```typescript maxAge: 3600 // Cache preflight requests for 1 hour @@ -147,10 +147,10 @@ Every option has a default, so `Mware.cors()` with no arguments allows any origi ## How It Works -- **No Origin header** - the request passes through untouched, since it is not cross-origin. -- **Preflight OPTIONS** - a matching origin gets a **204 No Content** with the CORS headers, and a non-matching origin gets a **403 Forbidden**. -- **Actual request** - a matching origin receives `Access-Control-Allow-Origin` plus credentials and exposed headers when configured. -- **Vary header** - `Vary: Origin` is added whenever `origin` is not the `'*'` wildcard, so caches stay correct. +- **No Origin header** - the request passes through untouched, since it is not cross-origin +- **Preflight OPTIONS** - a matching origin gets a **204 No Content** with the CORS headers. A non-matching origin also gets a **204** but without any CORS headers, so the browser blocks the actual request. A `cors:blocked` event fires for the non-matching origin +- **Actual request** - a matching origin receives `Access-Control-Allow-Origin` plus credentials and exposed headers when configured. A non-matching origin gets no CORS headers and a `cors:blocked` event fires +- **Vary header** - `Vary: Origin` is added whenever `origin` is not the `'*'` wildcard, so caches stay correct ## Credentials and Wildcard @@ -170,62 +170,19 @@ router.use( ) ``` -## Complete Example - -```typescript twoslash -import { Mware, Router } from '@neabyte/deserve' - -const router = new Router({ - routesDir: './routes' -}) - -// Production CORS with full options -router.use( - Mware.cors({ - origin: [ - 'http://localhost:3000', - 'http://localhost:5173', - 'https://yourdomain.com' - ], - methods: [ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'PATCH', - 'OPTIONS' - ], - allowedHeaders: [ - 'Content-Type', - 'Authorization', - 'X-Requested-With', - 'X-Custom-Header' - ], - exposedHeaders: [ - 'X-Total-Count', - 'X-Page-Count' - ], - credentials: true, - maxAge: 3600 - }) -) - -await router.serve(8000) -``` - ## Common CORS Headers ### Request Headers -- `Origin` - The origin making the request -- `Access-Control-Request-Method` - Method for preflight requests -- `Access-Control-Request-Headers` - Headers for preflight requests +- `Origin` - the origin making the request +- `Access-Control-Request-Method` - method for preflight requests +- `Access-Control-Request-Headers` - headers for preflight requests ### Response Headers -- `Access-Control-Allow-Origin` - Allowed origins -- `Access-Control-Allow-Methods` - Allowed HTTP methods -- `Access-Control-Allow-Headers` - Allowed request headers -- `Access-Control-Allow-Credentials` - Allow credentials -- `Access-Control-Max-Age` - Preflight cache duration -- `Access-Control-Expose-Headers` - Headers exposed to client +- `Access-Control-Allow-Origin` - allowed origins +- `Access-Control-Allow-Methods` - allowed HTTP methods +- `Access-Control-Allow-Headers` - allowed request headers +- `Access-Control-Allow-Credentials` - allow credentials +- `Access-Control-Max-Age` - preflight cache duration +- `Access-Control-Expose-Headers` - headers exposed to client diff --git a/docs/middleware/csrf.md b/docs/middleware/csrf.md index 6d9a8a7..c51f42f 100644 --- a/docs/middleware/csrf.md +++ b/docs/middleware/csrf.md @@ -102,4 +102,4 @@ type CsrfRulePredicate = (value: string, ctx: Context) => boolean When a request is blocked, the middleware fails with **403** and message `Request blocked by CSRF protection`. That failure routes through the [central error handler](/error-handling/object-details), so shape the response there or rely on the [default behavior](/error-handling/default-behavior). -A custom `origin` or `secFetchSite` rule that throws fails its own check and falls safe to a refusal, and the fault surfaces as a [`csrf:rule-error`](/middleware/observability/events#middleware) event naming which rule broke instead of staying hidden. +A custom `origin` or `secFetchSite` rule that throws fails its own check and falls safe to a refusal, and the fault surfaces as a [`csrf:failed`](/middleware/observability/events) event naming which rule broke instead of staying hidden. diff --git a/docs/middleware/global.md b/docs/middleware/global.md index 019197a..067f312 100644 --- a/docs/middleware/global.md +++ b/docs/middleware/global.md @@ -6,7 +6,7 @@ description: "Register global middleware that runs for every request with router Global middleware executes for every request before route handlers, providing cross-cutting functionality like authentication, logging, and CORS. -Each `router.use(fn)` call appends an entry with an empty path, so it matches every request and runs in the exact order you registered it, before any route matching happens. +Each `router.use(fn)` call appends an entry with an empty path, so it matches every request and runs in the exact order it was registered, before any route matching happens. ![Global Middleware registration and position: each router.use(fn) appends a path-empty entry that matches every request and runs before route matching, in registration order](/diagrams/middleware-global-registration.png) @@ -21,7 +21,7 @@ const router = new Router() // Log every request, then continue router.use(async (ctx, next) => { - console.log(`${ctx.request.method} ${ctx.url}`) + console.log(`${ctx.get.method()} ${ctx.get.url().href}`) return await next() }) @@ -34,16 +34,16 @@ await router.serve(8000) type MiddlewareFn = ( ctx: Context, next: () => Promise -) => Response | undefined | Promise +) => Promise ``` -- **Return `await next()`** - continue to the next middleware or route handler, which allows response modification and inspection. -- **Return `Response`** - stop processing and return that response immediately. -- **Return `undefined`** - treated as pass-through so the chain continues as if `next()` were called. +- **Return `await next()`** - continue to the next middleware or route handler, which allows response modification and inspection +- **Return `Response`** - stop processing and return that response immediately +- **Return `undefined`** - treated as pass-through so the chain continues as if `next()` were called -Middleware must either call `next()` and use its result or return a `Response`. When it does neither, for example never calling `next()` and returning nothing, the request can hang, so `requestTimeoutMs` in `Router` caps the request duration and returns a 503 instead. +Middleware must either call `next()` and use its result or return a `Response`. When it does neither, for example never calling `next()` and returning nothing, the request can hang, so `timeoutMs` in `Router` caps the request duration and returns a 503 instead. -![Global Middleware per-request control flow: return await next() continues the chain, returning a Response stops and skips the handler, returning undefined is pass-through; throwing routes to router.catch or 500, and stalling triggers the requestTimeoutMs 503 guard](/diagrams/middleware-global-flow.png) +![Global Middleware per-request control flow: return await next() continues the chain, returning a Response stops and skips the handler, returning undefined is pass-through; throwing routes to router.catch or 500, and stalling triggers the timeoutMs 503 guard](/diagrams/middleware-global-flow.png) ## Common Global Middleware Patterns @@ -56,7 +56,7 @@ const router = new Router() // ---cut--- router.use(async (ctx, next) => { const start = Date.now() - console.log(`${ctx.request.method} ${ctx.url} - ${new Date().toISOString()}`) + console.log(`${ctx.get.method()} ${ctx.get.url().href} - ${new Date().toISOString()}`) const response = await next() const duration = Date.now() - start console.log(`Completed in ${duration}ms`) @@ -73,41 +73,34 @@ const router = new Router() declare function isValidToken(token: string): boolean // ---cut--- router.use(async (ctx, next) => { - const authHeader = ctx.header('authorization') + const authHeader = ctx.get.header('authorization') if (!authHeader) { - return ctx.send.text( - 'Unauthorized', - { - status: 401 - } - ) + return ctx.send.text('Unauthorized', { status: 401 }) } - // Validate token here... + // Validate token here const token = authHeader.replace('Bearer ', '') if (!isValidToken(token)) { - return ctx.send.text( - 'Invalid token', - { - status: 401 - } - ) + return ctx.send.text('Invalid token', { status: 401 }) } return await next() }) ``` +For a cleaner auth flow, throw an error and let the [centralized error handler](/error-handling/object-details) shape the response instead of building it inline. + ## Wrapping Middleware With Error Handling -Custom middleware that throws can be wrapped with `WrapMware`, so errors are caught and passed to `router.catch()` when it is defined: +Custom middleware that throws can be wrapped with `Wrap.apply`, so errors are caught and passed to `router.catch()` when it is defined: ```typescript twoslash -import { Router, WrapMware } from '@neabyte/deserve' +import { Router, Wrap, type HttpStatusCode } from '@neabyte/deserve' const router = new Router() // Wrap so throws reach router.catch -const myAuth = WrapMware('Auth', async (ctx, next) => { - if (!ctx.header('x-api-key')) { +const myAuth = Wrap.apply('Auth', async (ctx, next) => { + // Read API key from header + if (!ctx.get.header('x-api-key')) { throw new Error('Missing API key') } return await next() @@ -115,48 +108,38 @@ const myAuth = WrapMware('Auth', async (ctx, next) => { // Apply middleware and the error handler router.use(myAuth) -router.catch((ctx, err) => { +router.catch((ctx, info) => { return ctx.send.json( - { - error: err.error?.message - }, - { - status: 500 - } + { error: info.error.message }, + { status: info.statusCode as HttpStatusCode } ) }) await router.serve(8000) ``` -**Signature:** `WrapMware(label: string, middleware: MiddlewareFn): MiddlewareFn`. When the middleware throws, the error runs through `ctx.handleError()` so `router.catch()` is invoked. +**Signature:** `Wrap.apply(label: string, middleware: MiddlewareFn): MiddlewareFn`. When the middleware throws, the error runs through `ctx.handleError()` so `router.catch()` is invoked. Every built-in middleware in [Mware](/middleware/basic-auth) is already wrapped this way, so a throw inside them carries the middleware label straight to the error handler. ## Path-Specific Middleware -Middleware also applies to specific paths: +Middleware also applies to specific paths, covered in detail in [Route-Specific Middleware](/middleware/route-specific): ```typescript twoslash -import type { Context } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type Context } from '@neabyte/deserve' const router = new Router() declare function isAuthenticated(ctx: Context): boolean // ---cut--- // Runs only for /api paths router.use('/api', async (ctx, next) => { - console.log('API request:', ctx.url) + console.log('API request:', ctx.get.pathname()) return await next() }) // Guard /admin paths with an auth check router.use('/admin', async (ctx, next) => { if (!isAuthenticated(ctx)) { - return ctx.send.text( - 'Unauthorized', - { - status: 401 - } - ) + return ctx.send.text('Unauthorized', { status: 401 }) } return await next() }) diff --git a/docs/middleware/ip.md b/docs/middleware/ip.md index 528881e..31772d6 100644 --- a/docs/middleware/ip.md +++ b/docs/middleware/ip.md @@ -81,8 +81,8 @@ A malformed rule throws `Deno.errors.InvalidData` when the middleware is created - **Blacklist present** - IPs that match the blacklist are denied, the rest pass. - **Neither set** - every request passes. -The middleware reads the resolved client IP from `ctx.ip`. Behind a proxy, configure [`trustProxy`](/getting-started/server-configuration#client-ip-resolution) so the real visitor IP is used. +The middleware reads the resolved client IP from `ctx.get.ip()`. Behind a proxy, configure [`trustProxy`](/getting-started/server-configuration#client-ip-resolution) so the real visitor IP is used. ## Error Handling -When a request is denied, the middleware fails with **403** and message `Access denied by IP restriction`. That failure routes through the [central error handler](/error-handling/object-details), so shape the response there or rely on the [default behavior](/error-handling/default-behavior). +When a request is denied, the middleware fails with **403** and message `Access denied by IP restriction`. That failure routes through the [central error handler](/error-handling/object-details), so shape the response there or rely on the [default behavior](/error-handling/default-behavior). An `ip:denied` event also fires with the denied IP address, covered in [Event Reference](/middleware/observability/events). diff --git a/docs/middleware/observability/errors.md b/docs/middleware/observability/errors.md index fbdd027..0b147cc 100644 --- a/docs/middleware/observability/errors.md +++ b/docs/middleware/observability/errors.md @@ -8,18 +8,18 @@ Errors surface on the same [`router.on()`](/middleware/observability/overview) b ## Reporting Failed Requests -`request:error` fires whenever a response status is `400` or higher, and carries the original error when one exists: +`request:failed` fires whenever a response status is `400` or higher, and carries the original error when one exists: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Record every failed request router.on((event) => { - if (event.kind === 'request:error') { + if (event.kind === 'request:failed') { const { method, url, statusCode, error } = event.metadata as { method: string url: string @@ -35,19 +35,19 @@ await router.serve(8000) ## Capturing Process Faults -`process:error` fires for unhandled rejections, uncaught errors, and blocked termination attempts. A serving router keeps running and reports the fault instead of crashing: +`process:failed` fires for unhandled rejections, uncaught errors, and blocked termination attempts. A serving router keeps running and reports the fault instead of crashing: -![Unhandled rejections, uncaught errors, and blocked self-termination each become a process:error event carrying its origin and error, so the process keeps running with no downtime and the fault is captured in the same router.on listener instead of being lost to a crash](/diagrams/obs-process-fault.png) +![Unhandled rejections, uncaught errors, and blocked self-termination each become a process:failed event carrying its origin and error, so the process keeps running with no downtime and the fault is captured in the same router.on listener instead of being lost to a crash](/diagrams/obs-process-fault.png) ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { - if (event.kind === 'process:error') { + if (event.kind === 'process:failed') { const { origin, error } = event.metadata as { origin: string; error: Error } // origin tells the fault source console.error(`process fault [${origin}]`, error.message) @@ -57,18 +57,18 @@ router.on((event) => { ## Capturing Subsystem Faults -The same listener catches faults from the worker pool and the built-in middleware. A task that times out, a worker that crashes, a dispatch refused under load, a session cookie that fails to decode, and a CSRF rule that throws each arrive as their own event. Filter on the kinds listed in the [Event Reference](/middleware/observability/events#workers) to route them wherever logs go: +The same listener catches faults from the worker pool and the built-in middleware. A task that times out, a worker that crashes, a dispatch refused under load, a session cookie that fails to decode, and a CSRF rule that throws each arrive as their own event. Filter on the kinds listed under [Workers](/middleware/observability/events#workers) and [Security Middleware](/middleware/observability/events#security-middleware) to route them wherever logs go: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // React to worker and middleware faults - if (event.kind === 'worker:crash' || event.kind === 'session:invalid') { + if (event.kind === 'worker:crashed' || event.kind === 'session:invalid') { console.error(event.kind, event.metadata) } }) diff --git a/docs/middleware/observability/events.md b/docs/middleware/observability/events.md index 718ac84..f079604 100644 --- a/docs/middleware/observability/events.md +++ b/docs/middleware/observability/events.md @@ -1,81 +1,97 @@ --- -description: "Reference of all lifecycle and error events emitted by a serving Deserve router." +description: "Reference of every lifecycle, security, request, and fault event emitted by a serving Deserve router." --- # Event Reference Every event from [`router.on()`](/middleware/observability/overview) carries a `kind` discriminant and a `metadata` object. This page lists each kind and the fields it provides. -![A request event is external by default but becomes internal when a timeout, a framework error, or a missing context produced it, while every non-request kind is always internal, so routing on the type field keeps normal client traffic out of the fault alert channel](/diagrams/obs-event-channel.png) +![A request event is external by default but becomes internal when a timeout, a framework error, or a missing context produced it, while every other non-request kind is internal except process:failed which always stays external, so routing on the type field keeps normal client traffic out of the fault alert channel](/diagrams/obs-event-channel.png) ## Server -| Kind | Metadata | -| ------------------- | ------------------------- | -| `server:listening` | `port`, `hostname` | -| `server:shutdown` | none | +| Kind | Metadata | +| ---------------- | ------------------ | +| `server:started` | `port`, `hostname` | +| `server:stopped` | none | -`server:listening` fires once the server binds. `server:shutdown` fires after the server stops draining. +`server:started` fires once the server binds. `server:stopped` fires after the server stops draining. ## Routes -| Kind | Metadata | -| ---------------- | --------------------------------- | -| `route:loaded` | `routePath`, `pattern` | -| `route:reloaded` | `routePath`, `pattern` | -| `route:removed` | `routePath`, `pattern` | -| `route:skipped` | `routePath`, `reason` | -| `route:error` | `routePath`, `error` | -| `reload:error` | `routePath`, `error` | +| Kind | Metadata | +| --------------- | ----------------- | +| `route:added` | `path`, `pattern` | +| `route:updated` | `path`, `pattern` | +| `route:removed` | `path`, `pattern` | +| `route:ignored` | `path`, `reason` | +| `route:failed` | `path`, `error` | -Reload events come from hot reload as files change on disk. +`route:added` fires when a route file loads, `route:updated` when [hot reload](/core-concepts/hot-reload) picks up a change, and `route:removed` when a file disappears. `route:ignored` names a file that was skipped and why, while `route:failed` carries the error when a route fails to load. ## Views -| Kind | Metadata | -| ---------------- | ------------------------- | -| `view:compiled` | `path`, `durationMs` | -| `view:rendered` | `path`, `durationMs` | -| `view:refreshed` | `paths` | -| `view:error` | `path`, `error` | +| Kind | Metadata | +| ------------------ | -------------------- | +| `view:compiled` | `path`, `durationMs` | +| `view:rendered` | `path`, `durationMs` | +| `view:invalidated` | `paths` | +| `view:failed` | `path`, `error` | -View events come from the [DVE rendering engine](/rendering/). +View events come from the [DVE rendering engine](/rendering/). `view:invalidated` fires when a template change clears cached output, carrying every affected path. ## Workers -| Kind | Metadata | -| ----------------- | ------------------------------------------------- | -| `worker:timeout` | `workerIndex`, `timeoutMs`, `error` | -| `worker:crash` | `workerIndex`, `error` | -| `worker:respawn` | `workerIndex` | -| `worker:rejected` | `reason` (`queue-depth`, `queue-wait`), `queueDepth`, `maxQueueDepth` | - -`worker:timeout` fires when a task passes its deadline, `worker:crash` when a worker dies mid-task, and `worker:respawn` when the freed slot is replaced. `worker:rejected` fires when a dispatch is turned away under load, with `reason` saying whether the queue depth or the projected wait tripped the limit. These come from the [worker pool](/core-concepts/worker-pool). - -## Middleware - -| Kind | Metadata | -| ----------------- | ------------------------------------------------- | -| `session:invalid` | `cookieName`, `reason` (`tampered`, `expired`, `malformed`) | -| `csrf:rule-error` | `rule` (`origin`, `secFetchSite`), `error` | - -`session:invalid` fires when a signed cookie fails to decode, with `reason` naming whether the value was tampered with, aged past `maxAge`, or was malformed, while the request continues with no session attached. It comes from the [session middleware](/middleware/session). `csrf:rule-error` fires when a custom CSRF rule throws, naming which rule broke while the check still falls safe to a refusal. It comes from the [CSRF middleware](/middleware/csrf). +| Kind | Metadata | +| ------------------ | -------------------------------------------------------------------- | +| `worker:timeout` | `index`, `timeoutMs`, `error` | +| `worker:crashed` | `index`, `error` | +| `worker:respawned` | `index` | +| `worker:rejected` | `reason` (`queue-depth`, `queue-wait`), `queueDepth`, `maxQueueDepth` | + +`worker:timeout` fires when a task passes its deadline, `worker:crashed` when a worker dies mid-task, and `worker:respawned` when the freed slot is replaced. `worker:rejected` fires when a dispatch is turned away under load, with `reason` saying whether the queue depth or the projected wait tripped the limit. These come from the [worker pool](/recipes/worker-pool). + +## Security Middleware + +| Kind | Metadata | +| -------------------- | --------------------------------------------------------- | +| `session:invalid` | `cookieName`, `reason` (`tampered`, `expired`, `malformed`) | +| `csrf:failed` | `rule` (`origin`, `secFetchSite`), `error` | +| `cors:blocked` | `origin` | +| `auth:failed` | `reason` (`missing`, `malformed`, `invalid`) | +| `ip:denied` | `ip` | +| `validate:failed` | `source` (`body`, `cookies`, `headers`, `query`), `reasons` | +| `body:rejected` | `limit`, `declared` | +| `websocket:rejected` | `reason` (`origin`, `version`, `malformed`) | +| `static:missing` | `path` | + +Each security event pairs with its middleware and fires the moment the check refuses a request: + +- `session:invalid` fires when a signed cookie fails to decode, with `reason` naming a tampered value, one aged past `maxAge`, or a malformed one, while the request continues with no session attached. It comes from the [session middleware](/middleware/session). +- `csrf:failed` fires when a CSRF rule throws, naming which rule broke while the check still falls safe to a refusal. It comes from the [CSRF middleware](/middleware/csrf). +- `cors:blocked` fires when an origin is refused, carrying that origin. It comes from the [CORS middleware](/middleware/cors). +- `auth:failed` fires on a rejected login, with `reason` naming a missing header, a malformed one, or wrong credentials. It comes from the [basic auth middleware](/middleware/basic-auth). +- `ip:denied` fires when an address is blocked, carrying the denied IP. It comes from the [IP restriction middleware](/middleware/ip). +- `validate:failed` fires when a contract rejects input, naming the `source` and the `reasons`. It comes from [validation](/middleware/validation/overview). +- `body:rejected` fires when a declared body exceeds the cap, carrying the `limit` and the `declared` size. It comes from the [body limit middleware](/middleware/body-limit). +- `websocket:rejected` fires on a refused handshake, with `reason` naming a bad origin, a version mismatch, or a malformed upgrade. It comes from the [WebSocket middleware](/middleware/websocket). +- `static:missing` fires when a static path resolves to no file, carrying that path. ## Requests -| Kind | Metadata | -| ------------------- | --------------------------------------------------- | -| `request:complete` | `method`, `statusCode`, `url`, `durationMs`, metrics | -| `request:error` | same as `request:complete`, plus an optional `error` | +| Kind | Metadata | +| ------------------- | ---------------------------------------------------- | +| `request:completed` | `method`, `statusCode`, `url`, `durationMs`, metrics | +| `request:failed` | same as `request:completed`, plus an optional `error` | -`request:complete` fires for every finished request. `request:error` fires in addition whenever the status is `400` or higher, and carries `error` only when a framework error produced the failure. Both carry optional OpenTelemetry-aligned metrics when known: `route`, `serverAddress`, `serverPort`, `userAgent`, `requestSize`, `responseSize`, and `ip`. +`request:completed` fires for every finished request. `request:failed` fires in addition whenever the status is `400` or higher, and carries `error` only when a framework error produced the failure. Both carry optional OpenTelemetry-aligned metrics when known: `route`, `serverAddress`, `serverPort`, `userAgent`, `requestSize`, `responseSize`, and `ip`. Turn these into a log in [Request Logging](/middleware/observability/logging). ## Process -| Kind | Metadata | -| --------------- | -------------------------------------------------------------------- | -| `process:error` | `error`, `origin` (`unhandledrejection`, `uncaughterror`, `process:exit`) | +| Kind | Metadata | +| ---------------- | --------------------------------------------------------------------------------------- | +| `process:failed` | `error`, `origin` (`unhandledrejection`, `uncaughterror`, `process:exit`, `process:signal`) | -A serving router traps unhandled rejections, uncaught errors, and attempts to terminate the process. Each fault becomes a `process:error` event rather than crashing the server, so a single failure never takes the process down. A blocked termination call carries `origin: 'process:exit'` and names the call, for example `Blocked Deno.exit(0) — process termination is not permitted from application code`. See [Process Protection](/getting-started/server-configuration#process-protection) for the reasoning, and capture these in [Error Reporting](/middleware/observability/errors). +A serving router traps unhandled rejections, uncaught errors, and attempts to terminate the process. Each fault becomes a `process:failed` event rather than crashing the server, so a single failure never takes the process down. A blocked termination call carries `origin: 'process:exit'` and names the call, for example `Blocked Deno.exit(0) process termination is not permitted from application code`. See [Process Protection](/getting-started/server-configuration#process-protection) for the reasoning, and capture these in [Error Reporting](/middleware/observability/errors). diff --git a/docs/middleware/observability/logging.md b/docs/middleware/observability/logging.md index 20ef029..fe90be5 100644 --- a/docs/middleware/observability/logging.md +++ b/docs/middleware/observability/logging.md @@ -6,22 +6,22 @@ description: "Turn Deserve request events into structured request logs." A single [`router.on()`](/middleware/observability/overview) subscription turns every finished request into a structured access log, with no logging code inside handlers. -![Every finished request emits request:complete with OpenTelemetry-aligned metrics, and a request with status 400 or higher also emits request:error carrying the original error, so one router.on listener fans the same envelope into an access log line, a slow request warning filtered by duration, and an error report](/diagrams/obs-request-lifecycle.png) +![Every finished request emits request:completed with OpenTelemetry-aligned metrics, and a request with status 400 or higher also emits request:failed carrying the original error, so one router.on listener fans the same envelope into an access log line, a slow request warning filtered by duration, and an error report](/diagrams/obs-request-lifecycle.png) ## Basic Access Log -Listen for `request:complete` and print one line per request: +Listen for `request:completed` and print one line per request: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // One log line per finished request router.on((event) => { - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { method, url, statusCode, durationMs } = event.metadata as { method: string url: string @@ -43,11 +43,11 @@ Emit JSON when a log pipeline expects structured records: import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { // Forward the full metadata as JSON console.log(JSON.stringify({ at: event.timestamp, @@ -67,12 +67,12 @@ Filter by duration to surface only slow traffic: import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Flag requests slower than 500ms - if (event.kind === 'request:complete') { + if (event.kind === 'request:completed') { const { url, durationMs } = event.metadata as { url: string; durationMs: number } if (durationMs > 500) { console.warn(`SLOW ${url} ${Math.round(durationMs)}ms`) diff --git a/docs/middleware/observability/overview.md b/docs/middleware/observability/overview.md index 3d0601a..680ebfb 100644 --- a/docs/middleware/observability/overview.md +++ b/docs/middleware/observability/overview.md @@ -18,7 +18,7 @@ This middleware-style hook sits beside the router and watches everything that ha import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Receive every lifecycle and error event @@ -41,13 +41,13 @@ Every event shares the same envelope: ```typescript { type: 'internal' | 'external', // origin channel - kind: string, // event name, such as 'request:complete' + kind: string, // event name, such as 'request:completed' metadata: { ... }, // fields specific to the kind timestamp: number // epoch milliseconds } ``` -- **`type`** - `external` for normal client traffic, `internal` for framework faults. A request event is `internal` when a framework error, the synthetic 503 timeout, or a missing request context produced it, otherwise `external`. Every other kind is always `internal`. +- **`type`** - `external` for normal client traffic, `internal` for framework faults. A request event is `internal` when a framework error, the synthetic 503 timeout, or a missing request context produced it, otherwise `external`. The `process:failed` kind is the one exception that always stays `external`, since a crashed process sits outside the request channel. Every other kind is always `internal`. - **`kind`** - the discriminant used to tell events apart. - **`metadata`** - readonly fields that depend on the kind. - **`timestamp`** - when the event was created. @@ -60,27 +60,27 @@ The observability bus reports framework activity such as requests, routes, views ## A Built-In Audit Trail -Every subsystem reports on the same bus, from [server](/middleware/observability/events#server) and [route](/middleware/observability/events#routes) signals to [worker](/middleware/observability/events#workers), [middleware](/middleware/observability/events#middleware), and [process](/middleware/observability/events#process) faults. Each one arrives as the same `{ type, kind, metadata, timestamp }` envelope, structured and stamped at the instant it fires. A plain listener becomes an audit trail that records itself as the server runs, with no extra wiring. +Every subsystem reports on the same bus, from [server](/middleware/observability/events#server) and [route](/middleware/observability/events#routes) signals to [worker](/middleware/observability/events#workers), [security middleware](/middleware/observability/events#security-middleware), and [process](/middleware/observability/events#process) faults. Each one arrives as the same `{ type, kind, metadata, timestamp }` envelope, structured and stamped at the instant it fires. A plain listener becomes an audit trail that records itself as the server runs, with no extra wiring. That covers what compliance and security work usually ask for, and each control maps to a behaviour the bus already provides: -- **[SOC 2](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2) (CC7 monitoring)** wants security-relevant events captured. Tampered cookies (`session:invalid`), blocked termination calls (`process:error`), and failed CSRF rules (`csrf:rule-error`) all emit on their own. +- **[SOC 2](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2) (CC7 monitoring)** wants security-relevant events captured. Tampered cookies (`session:invalid`), blocked termination calls (`process:failed`), and failed CSRF rules (`csrf:failed`) all emit on their own. - **[ISO/IEC 27001](https://www.iso.org/standard/27001) (A.8.15 logging)** wants an event log that holds up over time. Every event carries a `timestamp` in epoch milliseconds and arrives in order, so a timeline reconstructs cleanly. -- **[PCI DSS](https://www.pcisecuritystandards.org/document_library/) (Requirement 10 audit trails)** wants each action tied to its source. `request:complete` reports `method`, `url`, `statusCode`, `durationMs`, and an optional `ip` when the address is known. +- **[PCI DSS](https://www.pcisecuritystandards.org/document_library/) (Requirement 10 audit trails)** wants each action tied to its source. `request:completed` reports `method`, `url`, `statusCode`, `durationMs`, and an optional `ip` when the address is known. - **[SIEM](https://csrc.nist.gov/glossary/term/security_information_and_event_management) and real-time alerting** want a feed to ingest. A single `router.on()` forwards the whole surface to wherever logs or alerts go. -The `type` field keeps the fault channel clean. Normal client traffic is `external`, while a framework error, the synthetic 503 timeout, or a missing request context marks the event `internal`. A fault alert pipeline filters on `internal` and never drowns in routine requests. +The `type` field keeps the fault channel clean. Normal client traffic is `external`, while a framework error, the synthetic 503 timeout, or a missing request context marks the event `internal`. A fault alert pipeline filters on `internal` to catch framework faults without drowning in routine requests, then folds in `process:failed` by kind, since a process fault always rides the `external` channel: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { - // Forward framework faults only - if (event.type === 'internal') { + // Forward framework and process faults + if (event.type === 'internal' || event.kind === 'process:failed') { console.log(JSON.stringify({ at: event.timestamp, ...event })) } }) diff --git a/docs/middleware/route-specific.md b/docs/middleware/route-specific.md index 0d559d7..a8591eb 100644 --- a/docs/middleware/route-specific.md +++ b/docs/middleware/route-specific.md @@ -21,7 +21,7 @@ const router = new Router() // Runs for paths starting with /api router.use('/api', async (ctx, next) => { - console.log(`API request: ${ctx.request.method} ${ctx.url}`) + console.log(`API request: ${ctx.get.method()} ${ctx.get.pathname()}`) return await next() }) @@ -33,8 +33,7 @@ await router.serve(8000) Middleware applies to routes that start with the specified pattern: ```typescript twoslash -import type { MiddlewareFn } from '@neabyte/deserve' -import { Router } from '@neabyte/deserve' +import { Router, type MiddlewareFn } from '@neabyte/deserve' const router = new Router() declare const middleware: MiddlewareFn @@ -61,23 +60,13 @@ declare function isValidToken(token: string): boolean // ---cut--- // Require a bearer token under /api router.use('/api', async (ctx, next) => { - const authHeader = ctx.header('authorization') + const authHeader = ctx.get.header('authorization') if (!authHeader) { - return ctx.send.text( - 'API requires authentication', - { - status: 401 - } - ) + return ctx.send.text('API requires authentication', { status: 401 }) } const token = authHeader.replace('Bearer ', '') if (!isValidToken(token)) { - return ctx.send.text( - 'Invalid token', - { - status: 401 - } - ) + return ctx.send.text('Invalid token', { status: 401 }) } return await next() }) @@ -92,14 +81,9 @@ const router = new Router() // ---cut--- // Allow only the admin role under /admin router.use('/admin', async (ctx, next) => { - const userRole = ctx.header('x-user-role') + const userRole = ctx.get.header('x-user-role') if (userRole !== 'admin') { - return ctx.send.text( - 'Admin access required', - { - status: 403 - } - ) + return ctx.send.text('Admin access required', { status: 403 }) } return await next() }) @@ -114,7 +98,7 @@ const router = new Router() // ---cut--- // Log access under /public router.use('/public', async (ctx, next) => { - console.log(`Public access: ${ctx.request.method} ${ctx.url}`) + console.log(`Public access: ${ctx.get.method()} ${ctx.get.pathname()}`) return await next() }) ``` @@ -149,21 +133,16 @@ const router = new Router() // ---cut--- // Auth runs first under /api router.use('/api', async (ctx, next) => { - const authHeader = ctx.header('authorization') + const authHeader = ctx.get.header('authorization') if (!authHeader) { - return ctx.send.text( - 'Unauthorized', - { - status: 401 - } - ) + return ctx.send.text('Unauthorized', { status: 401 }) } return await next() }) // Logging runs after auth passes router.use('/api', async (ctx, next) => { - console.log(`API: ${ctx.request.method} ${ctx.url}`) + console.log(`API: ${ctx.get.method()} ${ctx.get.pathname()}`) return await next() }) ``` @@ -191,14 +170,9 @@ router.use('/api/users', async (ctx, next) => { // Narrows further and checks role router.use('/api/users/admin', async (ctx, next) => { - const role = ctx.header('x-user-role') + const role = ctx.get.header('x-user-role') if (role !== 'admin') { - return ctx.send.text( - 'Admin access required', - { - status: 403 - } - ) + return ctx.send.text('Admin access required', { status: 403 }) } return await next() }) diff --git a/docs/middleware/security-headers.md b/docs/middleware/security-headers.md index b0e0967..e530e18 100644 --- a/docs/middleware/security-headers.md +++ b/docs/middleware/security-headers.md @@ -85,7 +85,7 @@ router.use( ## Configuration Options -Each header option takes three forms. A string value sets the header to that value. `false` omits the header, even one that has a secure default. Leaving an option `undefined` keeps its default when it has one, or skips it otherwise. The four headers without a default - `contentSecurityPolicy`, `crossOriginEmbedderPolicy`, `strictTransportSecurity`, and `xPoweredBy` - stay off until a value is given. +Each header option takes three forms. A string value sets the header to that value. `false` omits the header, even one that has a secure default. Leaving an option `undefined` keeps its default when it has one, or skips it otherwise. The three headers without a default - `contentSecurityPolicy`, `crossOriginEmbedderPolicy`, and `strictTransportSecurity` - stay off until a value is given. ### `contentSecurityPolicy` @@ -183,21 +183,13 @@ Cross-domain policy for Flash: xPermittedCrossDomainPolicies: 'none' // or 'master-only', 'all' ``` -### `xPoweredBy` - -Off by default. Set a string to advertise a value, or leave it for no header: - -```typescript -xPoweredBy: 'Custom' // Add a custom value -``` - ## Complete Example ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Apply a broad set of headers @@ -223,6 +215,5 @@ await router.serve(8000) - **String value**: sets the header to that exact value, overriding any default - **Set to `false`**: omits the header, even one that has a default - **Undefined**: keeps the default when the header has one, otherwise skips it -- **X-Powered-By**: off by default, set a string to add it or leave it for no header - **HSTS**: apply `strictTransportSecurity` only on HTTPS servers - **CSP**: Content Security Policy can grow complex, so test it thoroughly diff --git a/docs/middleware/session.md b/docs/middleware/session.md index 6666dd4..6b7d11c 100644 --- a/docs/middleware/session.md +++ b/docs/middleware/session.md @@ -4,88 +4,74 @@ description: "Cookie-based session middleware signed with HMAC-SHA256 for per-us # Session Middleware -Session middleware stores session data in a signed cookie and exposes it through framework state, which suits login, preferences, or per-user state without a session database. The cookie payload is signed with HMAC-SHA256, and **`cookieSecret` is required and must be at least 32 characters**. +Session middleware stores session data in a signed cookie and exposes it through the context, which suits login, preferences, or per-user state without a session database. The cookie payload is signed with HMAC-SHA256, and **`secret` is required and must be at least 32 characters**. ## Basic Usage -`Mware.session({ cookieSecret })` adds a cookie-based session: +`Mware.session({ secret })` adds a cookie-based session: ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' const router = new Router() -// cookieSecret signs the session cookie +// Secret signs the session cookie router.use( Mware.session({ - cookieSecret: Deno.env.get('SESSION_SECRET') ?? 'replace-with-secret-min-32-chars' + secret: Deno.env.get('SESSION_SECRET') ?? 'replace-with-secret-min-32-chars' }) ) await router.serve(8000) ``` -The middleware stores three values in framework state, read with `ctx.getState`: - -- **`session`** - session data, an object or `null` when absent or signature invalid -- **`setSession`** - async function that saves data and sets the signed cookie -- **`clearSession`** - function that clears the session cookie +The middleware installs a session controller on the context, so a handler reads and writes session data through `ctx.get.session()` and `ctx.set.session()`: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' +import type { Context } from '@neabyte/deserve' declare const ctx: Context // ---cut--- -// Read session data -const session = ctx.getState('session' as never) +// Read current session data +const session = ctx.get.session() // Save session data (async) -const setSession = ctx.getState<(data: DataRecord) => Promise>('setSession' as never) -await setSession?.({ - userId: '1' -}) +await ctx.set.session({ userId: '1' }) // Clear session -const clearSession = ctx.getState<() => void>('clearSession' as never) -clearSession?.() +await ctx.set.session(null) ``` +`ctx.get.session()` returns the session data object or `null` when no session exists. `ctx.set.session(data)` signs the data into a cookie, and `ctx.set.session(null)` clears it. + ## Example: Login And Logout ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' +import type { Context } from '@neabyte/deserve' // POST: login, set session when credentials valid export async function POST(ctx: Context): Promise { - const body = await ctx.json() as DataRecord + // Read parsed JSON body + const body = await ctx.get.json() as { username?: string; password?: string } // Save a session on matching credentials if (body?.username === 'admin' && body?.password === 'secret') { - const setSession = ctx.getState<(data: DataRecord) => Promise>('setSession' as never) - await setSession?.({ + await ctx.set.session({ userId: '1', username: 'admin' }) - return ctx.send.json({ - ok: true - }) + return ctx.send.json({ ok: true }) } return ctx.send.json( - { - error: 'Invalid credentials' - }, - { - status: 401 - } + { error: 'Invalid credentials' }, + { status: 401 } ) } // GET: check login status export function GET(ctx: Context): Response { - // Read session from framework state - const session = ctx.getState('session' as never) + // Read session from context + const session = ctx.get.session() if (!session) { - return ctx.send.json({ - loggedIn: false - }) + return ctx.send.json({ loggedIn: false }) } return ctx.send.json({ loggedIn: true, @@ -94,19 +80,16 @@ export function GET(ctx: Context): Response { } // DELETE: logout, clear session -export function DELETE(ctx: Context): Response { +export async function DELETE(ctx: Context): Promise { // Drop the session cookie - const clearSession = ctx.getState<() => void>('clearSession' as never) - clearSession?.() - return ctx.send.json({ - ok: true - }) + await ctx.set.session(null) + return ctx.send.json({ ok: true }) } ``` ## Session Options -**`cookieSecret`** is required, must be at least 32 characters, and signs the cookie with HMAC-SHA256. The cookie name, max age, path, and security attributes are also configurable: +**`secret`** is required, must be at least 32 characters, and signs the cookie with HMAC-SHA256. The cookie name, max age, path, and security attributes are also configurable: ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' @@ -116,37 +99,38 @@ const router = new Router() // Override the default cookie settings router.use( Mware.session({ - cookieSecret: Deno.env.get('SESSION_SECRET') ?? 'fallback-secret-at-least-32-characters', - cookieName: 'sid', + secret: Deno.env.get('SESSION_SECRET') ?? 'fallback-secret-at-least-32-characters', + name: 'sid', maxAge: 3600, path: '/', sameSite: 'Lax', - httpOnly: true + httpOnly: true, + secure: false }) ) ``` -| Option | Default | Description | -| -------------- | ----------- | -------------------------------------------- | -| `cookieSecret` | - | **Required, min 32 characters.** Signs the cookie. | -| `cookieName` | `'session'` | Cookie name | -| `maxAge` | `86400` | Cookie age in seconds (24 hours) | -| `path` | `'/'` | Cookie path | -| `sameSite` | `'Lax'` | `'Strict' \| 'Lax' \| 'None'` | -| `httpOnly` | `true` | Cookie not accessible from JavaScript | -| `secure` | `true` | Require HTTPS for the cookie | +| Option | Default | Description | +| ---------- | ----------- | ---------------------------------------------------- | +| `secret` | - | **Required, min 32 characters.** Signs the cookie. | +| `name` | `'session'` | Cookie name | +| `maxAge` | `86400` | Cookie age in seconds (24 hours) | +| `path` | `'/'` | Cookie path | +| `sameSite` | `'Lax'` | `'Strict' \| 'Lax' \| 'None'` | +| `httpOnly` | `true` | Cookie not accessible from JavaScript | +| `secure` | `false` | Require HTTPS for the cookie | ### Validation and Expiry The middleware checks its options when created and throws `Deno.errors.InvalidData` when something is unsafe: -- `cookieSecret` shorter than 32 characters +- `secret` shorter than 32 characters - `sameSite: 'None'` without `secure: true`, since browsers reject that combination -- `maxAge` that is not a positive number, or an empty `path` +- `maxAge` that is not a positive whole number -Each cookie also carries a signed issue time, so the middleware treats a session older than `maxAge` as absent and reads it back as `null`. A tampered cookie fails the signature check and reads as `null` too, which keeps stale or forged sessions from being trusted. Whenever a cookie fails to decode, the middleware emits a [`session:invalid`](/middleware/observability/events#middleware) event naming the cookie and whether the value was tampered with, expired, or malformed, while the request continues with no session attached. +Each cookie also carries a signed issue time, so the middleware treats a session older than `maxAge` as absent and reads it back as `null`. A tampered cookie fails the signature check and reads as `null` too, which keeps stale or forged sessions from being trusted. Whenever a cookie fails to decode, the middleware emits a [`session:invalid`](/middleware/observability/events) event naming the cookie and whether the value was tampered with, expired, or malformed, while the request continues with no session attached. ## Limitations -- Session data lives in the cookie and is signed with HMAC-SHA256, so it should hold only identifiers or small data rather than large or highly sensitive values. -- A server-side or token-based session needs another mechanism such as JWT or Redis outside this middleware. +- Session data lives in the cookie and is signed with HMAC-SHA256, so it should hold only identifiers or small data rather than large or highly sensitive values +- A server-side or token-based session needs another mechanism such as JWT or Redis outside this middleware diff --git a/docs/middleware/validation/advanced-patterns.md b/docs/middleware/validation/advanced-patterns.md index 7499d04..cdc879f 100644 --- a/docs/middleware/validation/advanced-patterns.md +++ b/docs/middleware/validation/advanced-patterns.md @@ -12,8 +12,8 @@ Middleware runs before the router matches a method or a path, so a prefix valida When that validator fails, its 422 reaches the client first and hides the status the router would have produced: -- `POST /accounts` with a missing header returns **422**, not the **405** the missing POST handler would give. -- `GET /accounts/missing` with a missing header returns **422**, not the **404** for an unknown path. +- `POST /accounts` with a missing header returns **422**, not the **405** the missing POST handler would give +- `GET /accounts/missing` with a missing header returns **422**, not the **404** for an unknown path Gating the validator by method and path keeps validation on the requests it belongs to and lets the router answer the rest. With the right gate, `POST /transfers/tx_abc123` returns a clean **405** instead of a body-validation 422, because the validator skips a request it was never meant to check. @@ -21,10 +21,10 @@ Gating the validator by method and path keeps validation on the requests it belo `router.use('/transfers', ...)` matches `/transfers` and every path that continues with a slash, such as `/transfers/tx_abc123`. The matching rule comes from [Route-Specific Middleware](/middleware/route-specific). A `transfers` resource usually carries two different requests under that one prefix: -- `POST /transfers` sends a JSON body that needs a `json` contract. -- `GET /transfers/:id` carries no body and validates its param inside the handler. +- `POST /transfers` sends a JSON body that needs a `body` contract +- `GET /transfers/:id` carries no body and validates its param inside the handler -Registering a `json` validator on the whole prefix would run it on the GET too, and reading a body that is not there turns a valid request into a failure. The validator needs to fire only for the POST. +Registering a `body` validator on the whole prefix would run it on the GET too, and reading a body that is not there turns a valid request into a failure. The validator needs to fire only for the POST. ## The selectValidator Helper @@ -33,7 +33,7 @@ A small wrapper solves it. It takes a picker that returns a schema for the curre ![The selectValidator pattern: a request on a shared prefix reaches a picker that reads the method and pathname, returning a schema builds and caches the validator once before the handler, and returning undefined calls next so the request flows through untouched](/diagrams/validation-select-validator.png) ```typescript twoslash -import { type Context, type MiddlewareFn, Mware, type ValidationSchema } from '@neabyte/deserve' +import { type Context, type MiddlewareFn, Validator, type ValidationSchema } from '@neabyte/deserve' // Pick a schema or skip validation function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): MiddlewareFn { @@ -46,7 +46,7 @@ function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): let validator = cache.get(schema) if (validator === undefined) { // Build once, reuse on later requests - validator = Mware.validator(schema) + validator = Validator.check(schema) cache.set(schema, validator) } return await validator(ctx, next) @@ -58,55 +58,55 @@ Returning `undefined` calls `next` straight away, so the request flows through u ## Wiring It To A Prefix -The picker reads `ctx.pathname` and the request method to decide. Here the `json` contract runs only for the collection POST, and the GET passes through to validate its param in the handler: +The picker reads `ctx.get.pathname()` and the request method to decide. Here the `body` contract runs only for the collection POST, and the GET passes through to validate its param in the handler: ```typescript twoslash -import { type Context, type MiddlewareFn, Define, Mware, Router, type ValidationSchema } from '@neabyte/deserve' +import { type Context, type MiddlewareFn, Router, Validator, type ValidationSchema } from '@neabyte/deserve' declare function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): MiddlewareFn -const router = new Router({ routesDir: './routes' }) +const router = new Router({ routes: { directory: './routes' } }) const createTransfer = { - json: Define((body: { amount: number }) => ({ amount: body.amount })) + body: Validator.define((body: { amount: number }) => ({ amount: body.amount })) } // ---cut--- // Validate body only on collection POST router.use( '/transfers', selectValidator((ctx) => - ctx.pathname === '/transfers' && ctx.request.method === 'POST' + ctx.get.pathname() === '/transfers' && ctx.get.method() === 'POST' ? createTransfer : undefined ) ) ``` -The `GET /transfers/:id` handler then validates its own param with `Validator.check`, the approach from [Reading Validated Data](/middleware/validation/reading-data#checking-params-in-a-handler). Body validation and param validation stay separate, each firing only where it belongs. +The `GET /transfers/:id` handler then validates its own param with a direct contract call, the approach from [Reading Validated Data](/middleware/validation/reading-data#checking-params-in-a-handler). Body validation and param validation stay separate, each firing only where it belongs. ## Picking Between Several Schemas The same picker handles more than one branch when a prefix hosts many methods. Each branch returns the schema for that case, and anything unmatched returns `undefined`: ```typescript twoslash -import { type Context, type MiddlewareFn, Define, Router, type ValidationSchema } from '@neabyte/deserve' +import { type Context, type MiddlewareFn, Router, Validator, type ValidationSchema } from '@neabyte/deserve' declare function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): MiddlewareFn -const router = new Router({ routesDir: './routes' }) +const router = new Router({ routes: { directory: './routes' } }) -const listQuery = { query: Define((q: Record) => ({ page: Number(q['page'] ?? '1') })) } -const createBody = { json: Define((body: { name: string }) => ({ name: body.name.trim() })) } +const listQuery = { query: Validator.define((q: Record) => ({ page: Number(q['page'] ?? '1') })) } +const createBody = { body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- // One picker, one schema per method router.use( '/users', selectValidator((ctx) => { - const isCollection = ctx.pathname === '/users' - if (isCollection && ctx.request.method === 'GET') { + const isCollection = ctx.get.pathname() === '/users' + if (isCollection && ctx.get.method() === 'GET') { return listQuery } - if (isCollection && ctx.request.method === 'POST') { + if (isCollection && ctx.get.method() === 'POST') { return createBody } return undefined @@ -125,13 +125,13 @@ A schema with several sources validates them in the order the keys appear, and t ![Source order across a schema: a bad query contract throws first and reports only the query reason, while the headers and cookies contracts that come after it in key order never run](/diagrams/validation-source-order.png) ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Sources validate in key order const listAccounts = { - query: Define((q: Record) => q), - headers: Define((h: Record) => h), - cookies: Define((c: Record) => c) + query: Validator.define((q: Record) => q), + headers: Validator.define((h: Record) => h), + cookies: Validator.define((c: Record) => c) } ``` @@ -157,14 +157,14 @@ routes/ The barrel groups single contracts into the per-source schemas a route reads, so the wiring stays in one place: ```typescript twoslash -import { Define } from '@neabyte/deserve' -declare const Transfer: ReturnType -declare const AccountQuery: ReturnType -declare const ApiKeyHeader: ReturnType +import { Validator } from '@neabyte/deserve' +declare const Transfer: ReturnType +declare const AccountQuery: ReturnType +declare const ApiKeyHeader: ReturnType // ---cut--- // schemas/index.ts groups contracts per source export const createTransferSchema = { - json: Transfer + body: Transfer } export const listAccountsSchema = { @@ -176,13 +176,13 @@ export const listAccountsSchema = { A route imports only the schema type it needs, which keeps the handler focused on the response rather than the rules: ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' -const createTransferSchema = { json: Define((body: { amount: number }) => ({ amount: body.amount })) } +import { type Context, Validator } from '@neabyte/deserve' +const createTransferSchema = { body: Validator.define((body: { amount: number }) => ({ amount: body.amount })) } // ---cut--- // routes/transfers.ts reads the validated body export function POST(ctx: Context): Response { - const { json } = Validator.read(ctx) - return ctx.send.json({ amount: json.amount }, { status: 201 }) + const { body } = ctx.get.validated() + return ctx.send.json({ amount: body.amount }, { status: 201 }) } ``` @@ -190,7 +190,7 @@ This is a suggestion, not a rule. A tiny app keeps contracts inline beside the r ## Where To Go Next -- [Validator Middleware](/middleware/validation/validator-middleware) - the per-source registration this pattern wraps. -- [Reading Validated Data](/middleware/validation/reading-data) - validate params in the handler beside this pattern. -- [Route-Specific Middleware](/middleware/route-specific) - the prefix matching rule behind it. -- [Validation Overview](/middleware/validation/overview) - how the pieces fit together. +- [Validator Middleware](/middleware/validation/validator-middleware) - the per-source registration this pattern wraps +- [Reading Validated Data](/middleware/validation/reading-data) - validate params in the handler beside this pattern +- [Route-Specific Middleware](/middleware/route-specific) - the prefix matching rule behind it +- [Validation Overview](/middleware/validation/overview) - how the pieces fit together diff --git a/docs/middleware/validation/define-schema.md b/docs/middleware/validation/define-schema.md index 12f1e68..fa6fd36 100644 --- a/docs/middleware/validation/define-schema.md +++ b/docs/middleware/validation/define-schema.md @@ -1,22 +1,22 @@ --- -description: "Build request contracts with Define, a transform paired with guards that reject bad input." +description: "Build request contracts with Validator.define, a transform paired with guards that reject bad input." --- # Define Schema > **Reference**: [Typebox GitHub Repository](https://github.com/NeaByteLab/Typebox) -A contract is a function that takes one input and returns a cleaned value. `Define` builds one from two parts, a transform that shapes the output and optional guards that reject input before the transform runs. +A contract is a function that takes one input and returns a cleaned value. `Validator.define` builds one from two parts, a transform that shapes the output and optional guards that reject input before the transform runs. -## The Shape Of Define +## The Shape Of A Contract -`Define(transform, guard?)` returns a contract: +`Validator.define(transform, guard?)` returns a contract: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Transform only, no guard -const Trim = Define((body: { name: string }) => ({ +const Trim = Validator.define((body: { name: string }) => ({ name: body.name.trim() })) ``` @@ -26,10 +26,10 @@ The transform normalizes the value, trimming strings, lowercasing an email, or c The transform also owns the output shape. A guard that passes does not strip extra keys, so unknown fields from the client survive unless the transform leaves them out. Returning a fresh object with only the wanted fields keeps surprise input out of the validated data: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Output holds only the named fields -const NewUser = Define((body: { name: string; role: string }) => ({ +const NewUser = Validator.define((body: { name: string; role: string }) => ({ name: body.name.trim() })) ``` @@ -38,16 +38,16 @@ Here a client sending `role: 'admin'` finds it dropped, since the transform neve ## Order Of Operations -Calling a contract runs four steps in a fixed order, and the transform only ever sees input that cleared every guard: +A contract with at least one guard runs four steps in a fixed order, and the transform only ever sees input that cleared every guard: 1. A string input longer than 10000 characters is rejected before anything else. 2. An object input is deep frozen so a guard cannot mutate it. 3. Each guard runs in order, throwing on the first failure. 4. The transform runs and returns the cleaned value. -A contract with no guard skips straight to the transform, so the transform must trust its input or do its own checks. +A contract built with no guard is a different shape entirely. `Validator.define(transform)` returns the transform untouched, so the string cap and the freeze never run and the raw input reaches the transform directly. A guardless transform must trust its input or do its own checks. -![Define order of operations: a contract first caps string input at 10000 characters, then deep freezes an object so a guard cannot mutate it, then runs each guard in order throwing on the first failure, and only then runs the transform on input that cleared every guard](/diagrams/validation-contract-order.png) +![Define order of operations: a guarded contract first caps string input at 10000 characters, then deep freezes an object so a guard cannot mutate it, then runs each guard in order throwing on the first failure, and only then runs the transform on input that cleared every guard, while a guardless contract returns the transform untouched so neither the cap nor the freeze runs](/diagrams/validation-contract-order.png) ## Guards Decide Pass Or Fail @@ -60,10 +60,10 @@ A guard inspects the raw input and returns a verdict: ![Guard verdicts: returning true sends the input on to the transform, while returning a string or a string array makes the contract throw and become a 422 with those reasons preserved on error.cause](/diagrams/validation-guard-verdict.png) ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Guard rejects an empty name -const NewUser = Define( +const NewUser = Validator.define( (body: { name: string }) => ({ name: body.name.trim() }), (body) => (body.name.trim().length > 0 ? true : 'name must not be empty') ) @@ -76,14 +76,14 @@ A guard that returns reasons makes the contract throw, and the validator turns t A guard receives the raw input, which can be `null`, an array, or any JSON value a client sends. Reaching for a field on the wrong shape throws inside the guard before the rule even runs, so a shape check comes first: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Confirm an object before reading fields function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } -const NewUser = Define( +const NewUser = Validator.define( (body: { name: string }) => ({ name: body.name.trim() }), (body) => { if (!isRecord(body)) { @@ -101,10 +101,10 @@ A throw inside a guard still becomes a 422, never a 500, so a missed shape check Returning an array reports every broken field in a single response instead of one at a time: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Collect each failure into one array -const NewUser = Define( +const NewUser = Validator.define( (body: { name: string; age: number }) => body, (body) => { const reasons: string[] = [] @@ -124,7 +124,7 @@ const NewUser = Define( The second argument also takes an array of guards. They run in order and the contract throws on the first one that fails, so later guards never see input that an earlier guard already rejected: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Shape check first, business rule second function hasFields(body: { from: string; to: string }): true | string { @@ -135,7 +135,7 @@ function distinctAccounts(body: { from: string; to: string }): true | string { return body.from !== body.to ? true : 'from and to must differ' } -const Transfer = Define( +const Transfer = Validator.define( (body: { from: string; to: string }) => body, [hasFields, distinctAccounts] ) @@ -145,11 +145,11 @@ Splitting a shape check from a business rule keeps each guard small and lets the ## Built-In Safety -The string cap and the freeze from [Order Of Operations](#order-of-operations) run automatically, so a contract never burns time on a huge payload and a guard never mutates the value it inspects. One more rule guards the timing model: +The string cap and the freeze from [Order Of Operations](#order-of-operations) run as part of the guard step, so a guarded contract never burns time on a huge string and a guard never mutates the value it inspects. One more rule guards the timing model: - An async guard is rejected, since validation stays synchronous and predictable. -These rules come from Typebox itself and apply to every contract, whether it runs through the [Validator Middleware](/middleware/validation/validator-middleware) or a direct `Validator.check` call. +These rules come from Typebox itself and protect any contract that carries a guard, whether it runs through the [Validator Middleware](/middleware/validation/validator-middleware) or a direct contract call in a handler. A guardless transform opts out of all three, so a contract that handles untrusted input should always carry at least one guard. ## Where To Go Next diff --git a/docs/middleware/validation/overview.md b/docs/middleware/validation/overview.md index 39b0a7a..4b225fe 100644 --- a/docs/middleware/validation/overview.md +++ b/docs/middleware/validation/overview.md @@ -14,75 +14,75 @@ This sits beside the other middleware and watches the request before it reaches Validation comes together from three exports, each with one job: -- **`Define`** builds a contract from a transform and optional guards. See [Define Schema](/middleware/validation/define-schema). -- **`Mware.validator`** turns a schema into middleware that validates request sources. See [Validator Middleware](/middleware/validation/validator-middleware). -- **`Validator`** reads validated data inside a handler and checks values on demand. See [Reading Validated Data](/middleware/validation/reading-data). +- **`Validator.define`** builds a contract from a transform and optional guards. See [Define Schema](/middleware/validation/define-schema). +- **`Validator.check`** turns a schema into middleware that validates request sources. See [Validator Middleware](/middleware/validation/validator-middleware). +- **`ctx.get.validated()`** reads validated data inside a handler. See [Reading Validated Data](/middleware/validation/reading-data). -![Validation has three pieces with one job each: Define builds a contract, Mware.validator runs the contracts as middleware, and Validator.read returns the typed validated data inside a handler](/diagrams/validation-three-pieces.png) +![Validation has three pieces with one job each: Validator.define builds a contract, Validator.check runs the contracts as middleware, and ctx.get.validated returns the typed validated data inside a handler](/diagrams/validation-three-pieces.png) ## A Schema Maps Sources To Contracts A schema is a plain object that pairs a request source with a contract: ```typescript twoslash -import { Define } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // One contract per request source const schema = { - json: Define((body: { name: string }) => body) + body: Validator.define((body: { name: string }) => body) } ``` -Six sources exist, and each one reads from a matching part of the [Context](/core-concepts/context-object): +Four sources exist, and each one reads from a matching part of the [Context](/core-concepts/context-object): -| Source | Reads from | Shape | -| --------- | --------------- | ------------------------- | -| `body` | `ctx.body()` | raw parsed body | -| `cookies` | `ctx.cookie()` | `Record` | -| `headers` | `ctx.header()` | `Record` | -| `json` | `ctx.json()` | parsed JSON value | -| `params` | `ctx.params()` | `Record` | -| `query` | `ctx.query()` | `Record` | +| Source | Reads from | Shape | +| --------- | ----------------- | ------------------------- | +| `body` | `ctx.get.body()` | raw parsed body | +| `cookies` | `ctx.get.cookie()`| `Record` | +| `headers` | `ctx.get.header()`| `Record` | +| `query` | `ctx.get.query()` | `Record` | + +Route params are not a validation source because they resolve after middleware runs. Validate them inside the handler with a direct contract call, covered in [Reading Validated Data](/middleware/validation/reading-data#checking-params-in-a-handler). ## The Request Flow A validated request moves through four steps: -1. The validator middleware reads each source named in the schema. -2. It runs the matching contract on that source value. -3. A passing contract stores its output on request state. -4. The handler reads that state with full types. +1. The validator middleware reads each source named in the schema +2. It runs the matching contract on that source value +3. A passing contract stores its output on the context +4. The handler reads that output with full types through `ctx.get.validated()` -![The validation request flow: the middleware reads each source with ctx.json or ctx.query, runs the matching contract, stores a passing result on stateKeys.validated, and the handler reads it back typed through Validator.read](/diagrams/validation-request-flow.png) +![The validation request flow: the middleware reads each source with ctx.get.body or ctx.get.query, runs the matching contract, stores a passing result on the context, and the handler reads it back typed through ctx.get.validated](/diagrams/validation-request-flow.png) ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) const schema = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } -// Validate the JSON body before the handler -router.use('/users', Mware.validator(schema)) +// Validate the body before the handler +router.use('/users', Validator.check(schema)) await router.serve(8000) ``` ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' +import { type Context, Validator } from '@neabyte/deserve' const schema = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- export function POST(ctx: Context): Response { // Read typed data that already passed - const { json } = Validator.read(ctx) - return ctx.send.json({ created: json.name }) + const { body } = ctx.get.validated() + return ctx.send.json({ created: body.name }) } ``` @@ -94,7 +94,7 @@ A throw from client input never becomes a 500. That mapping rule lives in [Readi ## Where To Go Next -- [Define Schema](/middleware/validation/define-schema) - write a contract with a transform and guards. -- [Validator Middleware](/middleware/validation/validator-middleware) - register validation per source and per route. -- [Reading Validated Data](/middleware/validation/reading-data) - read typed output and check params in a handler. -- [Advanced Patterns](/middleware/validation/advanced-patterns) - pick a schema per method on a shared prefix. +- [Define Schema](/middleware/validation/define-schema) - write a contract with a transform and guards +- [Validator Middleware](/middleware/validation/validator-middleware) - register validation per source and per route +- [Reading Validated Data](/middleware/validation/reading-data) - read typed output and check params in a handler +- [Advanced Patterns](/middleware/validation/advanced-patterns) - pick a schema per method on a shared prefix diff --git a/docs/middleware/validation/reading-data.md b/docs/middleware/validation/reading-data.md index f048eb2..d184212 100644 --- a/docs/middleware/validation/reading-data.md +++ b/docs/middleware/validation/reading-data.md @@ -1,89 +1,75 @@ --- -description: "Read typed validated data with Validator.read, check params in a handler, and see how failures map to 422." +description: "Read typed validated data with ctx.get.validated, check params in a handler, and see how failures map to 422." --- # Reading Validated Data -The handler reads what the validator produced. `Validator.read` returns the stored output for a schema, and `Validator.check` validates a value on the spot. +The handler reads what the validator produced. `ctx.get.validated()` returns the stored output for a schema, and a direct contract call validates a value on the spot. ## Reading Stored Output -`Validator.read(ctx)` returns the validated data keyed by source. Passing the schema type gives the handler full types for every field: +`ctx.get.validated()` returns the validated data keyed by source. The types come from the schema definition, so the handler gets full type safety for every field: ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' +import { type Context, Validator } from '@neabyte/deserve' const createUser = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- export function POST(ctx: Context): Response { // Typed output, already validated - const { json } = Validator.read(ctx) - return ctx.send.json({ created: json.name }) + const { body } = ctx.get.validated() + return ctx.send.json({ created: body.name }) } ``` -The shape mirrors the schema, so a schema with `query` and `headers` returns both keys with their own contract output types. The middleware that stored this state is covered in [Validator Middleware](/middleware/validation/validator-middleware). +The shape mirrors the schema, so a schema with `query` and `headers` returns both keys with their own contract output types. The middleware that stored this data is covered in [Validator Middleware](/middleware/validation/validator-middleware). -## Reading Without A Validator Throws 500 +## Reading Without A Validator Throws -`Validator.read` expects the [Validator Middleware](/middleware/validation/validator-middleware) to have run first. Calling it with no validated state throws a **500**, since reaching a read with nothing stored means the middleware was never registered. This is a wiring mistake in the code, not bad input from a client. - -```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' - -const createUser = { - json: Define((body: { name: string }) => body) -} -// ---cut--- -export function POST(ctx: Context): Response { - // Throws 500 if Mware.validator never ran - const { json } = Validator.read(ctx) - return ctx.send.json(json) -} -``` +`ctx.get.validated()` expects the [Validator Middleware](/middleware/validation/validator-middleware) to have run first. Calling it with no validated data throws `Deno.errors.NotSupported`, since reaching a read with nothing stored means the middleware was never registered. This is a wiring mistake in the code, not bad input from a client, so the framework maps it to a **501 Not Implemented** through the same [error handling](/error-handling/object-details) path that maps every other thrown error. ## Checking Params In A Handler -Route params resolve after middleware runs, so the [Validator Middleware](/middleware/validation/validator-middleware#params-are-rejected-here) rejects a `params` source. The handler validates them directly with `Validator.check(contract, value)`: +Route params resolve after middleware runs, so the [Validator Middleware](/middleware/validation/validator-middleware) does not accept a `params` source. The handler validates them directly by calling the contract function: ```typescript twoslash -import { type Context, Define, Validator } from '@neabyte/deserve' +import { type Context, Validator } from '@neabyte/deserve' -const UserId = Define( +const UserId = Validator.define( (params: Record) => ({ id: Number(params['id']) }), (params) => (/^\d+$/.test(params['id'] ?? '') ? true : 'id must be numeric') ) // ---cut--- export function GET(ctx: Context): Response { // Validate the matched route param - const { id } = Validator.check(UserId, ctx.params()) + const { id } = UserId(ctx.get.param()) return ctx.send.json({ id }) } ``` -`Validator.check` returns the contract output when the value passes and throws when it fails, the same throw the middleware produces. It works for any value, not only params, which makes it handy for validating a slice of data mid-handler. +Calling a contract directly returns the transform output when the value passes and throws when it fails, the same throw the middleware produces. It works for any value, not only params, which makes it handy for validating a slice of data mid-handler. ## How Failures Surface A contract that rejects its input throws, and the framework maps that throw to a status: -- An error that already carries a status passes through unchanged. -- An error carrying failure reasons becomes a **422 Unprocessable Content**, with the reasons preserved on `error.cause` as a string array. -- Any other throw from client input becomes a generic **422**. +- An error that already carries a status passes through unchanged +- An error carrying failure reasons becomes a **422 Unprocessable Content**, with the reasons preserved on `error.cause` as a string array +- Any other throw from client input becomes a generic **422** Client input never turns into a 500. That guarantee keeps a malformed body, a bad query string, or a thrown guard on the client side of the status line where it belongs. -![How a validation throw maps to a status: an error that already carries a status passes through, an error with reasons becomes a 422 keeping those reasons, any other client throw becomes a generic 422, and reading with no validator registered is the one 500 because that signals a wiring mistake rather than bad input](/diagrams/validation-failure-status.png) +![How a validation throw maps to a status: an error that already carries a status passes through, an error with reasons becomes a 422 keeping those reasons, any other client throw becomes a generic 422, and reading with no validator registered throws Deno.errors.NotSupported that maps to a 501 because that signals a wiring mistake rather than bad input](/diagrams/validation-failure-status.png) The reasons ride on `error.cause`, so a custom handler reads them and replies with field-level detail. Error shaping is centralized in [Object Details](/error-handling/object-details), the single `router.catch` that handles validation alongside every other error: ```typescript twoslash -import { Router } from '@neabyte/deserve' +import { type HttpStatusCode, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.catch((ctx, info) => { @@ -93,16 +79,16 @@ router.catch((ctx, info) => { : [] return ctx.send.json( { error: 'request_failed', status: info.statusCode, reasons }, - { status: info.statusCode } + { status: info.statusCode as HttpStatusCode } ) }) ``` -For the full `ErrorInfo` object and the default response when no handler is set, see [Object Details](/error-handling/object-details) and [Default Behavior](/error-handling/default-behavior). Validation faults also flow through the observability bus, so a listener can record them, covered in [Error Reporting](/middleware/observability/errors). +For the full `ErrorInfo` object and the default response when no handler is set, see [Object Details](/error-handling/object-details) and [Default Behavior](/error-handling/default-behavior). Validation faults also flow through the observability bus as `validate:failed` events, so a listener can record them, covered in [Error Reporting](/middleware/observability/errors). ## Where To Go Next -- [Define Schema](/middleware/validation/define-schema) - write the contracts behind the reads. -- [Object Details](/error-handling/object-details) - shape the response a failure produces. -- [Advanced Patterns](/middleware/validation/advanced-patterns) - validate params beside a body validator. -- [Validation Overview](/middleware/validation/overview) - how the pieces fit together. +- [Define Schema](/middleware/validation/define-schema) - write the contracts behind the reads +- [Object Details](/error-handling/object-details) - shape the response a failure produces +- [Advanced Patterns](/middleware/validation/advanced-patterns) - validate params beside a body validator +- [Validation Overview](/middleware/validation/overview) - how the pieces fit together diff --git a/docs/middleware/validation/validator-middleware.md b/docs/middleware/validation/validator-middleware.md index 13612be..1df84a5 100644 --- a/docs/middleware/validation/validator-middleware.md +++ b/docs/middleware/validation/validator-middleware.md @@ -1,28 +1,28 @@ --- -description: "Register validation middleware with Mware.validator, scope it per route, and stack sources together." +description: "Register validation middleware with Validator.check, scope it per route, and stack sources together." --- # Validator Middleware -`Mware.validator(schema)` turns a schema into middleware. It reads each source named in the schema, runs the matching contract, and stores the result on request state for the handler to read. +`Validator.check(schema)` turns a schema into middleware. It reads each source named in the schema, runs the matching contract, and stores the result on the context for the handler to read. ## Registering The Middleware Pass the middleware to `router.use`, the same call that registers every other middleware: ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) const createUser = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // Run for every request -router.use(Mware.validator(createUser)) +router.use(Validator.check(createUser)) await router.serve(8000) ``` @@ -32,18 +32,18 @@ await router.serve(8000) A path prefix limits the validator to matching routes, which follows the rules in [Route-Specific Middleware](/middleware/route-specific): ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) const createUser = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) } // ---cut--- // Validate only under /users -router.use('/users', Mware.validator(createUser)) +router.use('/users', Validator.check(createUser)) ``` For global versus scoped registration in general, see [Global Middleware](/middleware/global). @@ -53,85 +53,72 @@ For global versus scoped registration in general, see [Global Middleware](/middl A schema can name more than one source, and each contract validates its own slice of the request: ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' -const router = new Router({ routesDir: './routes' }) +const router = new Router({ routes: { directory: './routes' } }) // ---cut--- const listUsers = { // Page number from the query string - query: Define( + query: Validator.define( (q: Record) => ({ page: Number(q['page'] ?? '1') }), (q) => (/^\d*$/.test(q['page'] ?? '') ? true : 'page must be numeric') ), // API key from the request headers - headers: Define( + headers: Validator.define( (h: Record) => ({ apiKey: h['x-api-key'] ?? '' }), (h) => (h['x-api-key'] ? true : 'x-api-key header is required') ) } // Validate query and headers together -router.use('/users', Mware.validator(listUsers)) +router.use('/users', Validator.check(listUsers)) ``` Several sources validate in the order their keys appear, and the first failing source stops the rest. That order rule lives in [Advanced Patterns](/middleware/validation/advanced-patterns#order-of-validation). ![Source order: a schema validates its sources in key order, so a failing query contract throws a 422 carrying only the query reason while the headers and cookies contracts after it never run](/diagrams/validation-source-order.png) -## Stacking Validators +## One Schema Per Route -Registering more than one validator on the same route merges their results. Each validator adds its own sources to the shared state, so a later read sees every validated source at once: +Each validator stores its own result on the context, and a later validator replaces the stored value rather than merging into it. Registering two validators on the same route leaves only the last one readable, so several sources belong in a single schema: ```typescript twoslash -import { Define, Mware, Router } from '@neabyte/deserve' +import { Router, Validator } from '@neabyte/deserve' -const router = new Router({ routesDir: './routes' }) - -const queryRules = { - query: Define((q: Record) => ({ page: Number(q['page'] ?? '1') })) -} -const bodyRules = { - json: Define((body: { name: string }) => ({ name: body.name.trim() })) -} +const router = new Router({ routes: { directory: './routes' } }) // ---cut--- -// Two validators feed one merged state -router.use('/users', Mware.validator(queryRules)) -router.use('/users', Mware.validator(bodyRules)) -``` - -The handler reads the merged `query` and `json` in one call, shown in [Reading Validated Data](/middleware/validation/reading-data). - -## Params Are Rejected Here +// Combine sources in one schema +const userRules = { + query: Validator.define((q: Record) => ({ page: Number(q['page'] ?? '1') })), + body: Validator.define((body: { name: string }) => ({ name: body.name.trim() })) +} -A schema that names `params` throws at registration with [`Deno.errors.InvalidData`](https://docs.deno.com/api/deno/~/Deno.errors.InvalidData). Route params resolve after middleware runs, so the middleware would only ever see an empty object. The error points to the right tool: +// One validator carries both sources +router.use('/users', Validator.check(userRules)) +``` -```typescript twoslash -import { Define, Mware } from '@neabyte/deserve' +The handler reads `query` and `body` together in one call through `ctx.get.validated()`, shown in [Reading Validated Data](/middleware/validation/reading-data). -// Throws InvalidData at registration -Mware.validator({ - params: Define((p: Record) => p) -}) -``` +## Unsupported Sources Are Rejected -Validate params inside the handler with `Validator.check` instead, covered in [Reading Validated Data](/middleware/validation/reading-data#checking-params-in-a-handler). +A schema that names a source other than `body`, `cookies`, `headers`, or `query` throws `Deno.errors.InvalidData` at registration. Route params resolve after middleware runs, so the middleware would only ever see an empty object. Validate params inside the handler with a direct contract call instead, covered in [Reading Validated Data](/middleware/validation/reading-data#checking-params-in-a-handler). ## An Empty Schema Is Rejected -A schema with no source contract also throws [`Deno.errors.InvalidData`](https://docs.deno.com/api/deno/~/Deno.errors.InvalidData) at registration, since a validator with nothing to validate is a wiring mistake worth catching early: +A schema with no source contract also throws `Deno.errors.InvalidData` at registration, since a validator with nothing to validate is a wiring mistake worth catching early: ```typescript twoslash -import { Mware } from '@neabyte/deserve' +import { Validator } from '@neabyte/deserve' // Throws InvalidData, no sources given -Mware.validator({}) +Validator.check({}) ``` Both rejections fire when the server starts, not on a request, so a broken schema never reaches production traffic. ## Where To Go Next -- [Reading Validated Data](/middleware/validation/reading-data) - read the stored output in a handler. -- [Define Schema](/middleware/validation/define-schema) - shape the contracts a schema points to. -- [Advanced Patterns](/middleware/validation/advanced-patterns) - pick a schema per method on a shared prefix. -- [Validation Overview](/middleware/validation/overview) - how the pieces fit together. +- [Reading Validated Data](/middleware/validation/reading-data) - read the stored output in a handler +- [Define Schema](/middleware/validation/define-schema) - shape the contracts a schema points to +- [Advanced Patterns](/middleware/validation/advanced-patterns) - pick a schema per method on a shared prefix +- [Validation Overview](/middleware/validation/overview) - how the pieces fit together diff --git a/docs/middleware/websocket.md b/docs/middleware/websocket.md index 17ca489..3ecba4e 100644 --- a/docs/middleware/websocket.md +++ b/docs/middleware/websocket.md @@ -22,7 +22,7 @@ router.use( Mware.websocket({ listener: '/ws', onConnect: (socket, event, ctx) => { - console.log('WebSocket connected:', ctx.url) + console.log('WebSocket connected:', ctx.get.url()) socket.send('Welcome') } }) @@ -69,7 +69,7 @@ Handle new WebSocket connections: ```typescript onConnect: (socket: WebSocket, event: Event, ctx: Context) => { - console.log('Client connected:', ctx.url) + console.log('Client connected:', ctx.get.url()) socket.send(JSON.stringify({ type: 'welcome', message: 'Connected' @@ -113,7 +113,7 @@ Handle WebSocket errors: ```typescript onError: (socket: WebSocket, event: Event, ctx: Context) => { - console.error('WebSocket error:', event, 'on', ctx.url) + console.error('WebSocket error:', event, 'on', ctx.get.url()) } ``` @@ -123,14 +123,14 @@ onError: (socket: WebSocket, event: Event, ctx: Context) => { import { Mware, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) router.use( Mware.websocket({ listener: '/ws', onConnect: (socket, event, ctx) => { - console.log(`WebSocket connected: ${ctx.url}`) + console.log(`WebSocket connected: ${ctx.get.url()}`) socket.send( JSON.stringify({ type: 'welcome', @@ -139,7 +139,7 @@ router.use( ) }, onMessage: (socket, event, ctx) => { - console.log(`Message from ${ctx.url}:`, event.data) + console.log(`Message from ${ctx.get.url()}:`, event.data) try { const data = JSON.parse(event.data as string) if (data.type === 'ping') { @@ -168,10 +168,10 @@ router.use( } }, onDisconnect: (socket, event, ctx) => { - console.log(`WebSocket disconnected: ${ctx.url} code=${event.code} reason=${event.reason}`) + console.log(`WebSocket disconnected: ${ctx.get.url()} code=${event.code} reason=${event.reason}`) }, onError: (socket, event, ctx) => { - console.error(`WebSocket error on ${ctx.url}:`, event) + console.error(`WebSocket error on ${ctx.get.url()}:`, event) } }) ) @@ -227,10 +227,12 @@ onConnect: (socket, event, ctx) => { A rejected handshake routes through the error handler instead of throwing at setup: -- **Disallowed origin** fails with **403** and message `WebSocket handshake rejected because the Origin is not allowed`. -- **Malformed upgrade** fails with **400** and message `WebSocket handshake is malformed because ...`. +- **Disallowed origin** fails with **403** and message `WebSocket handshake rejected because the Origin is not allowed` +- **Missing version** fails with **400** and message `WebSocket handshake requires Sec-WebSocket-Version 13` +- **Wrong version** returns **426 Upgrade Required** with `Sec-WebSocket-Version: 13` and `Upgrade: websocket` headers +- **Malformed upgrade** fails with **400** and message `WebSocket handshake is malformed because ...` -Both route through the [central error handler](/error-handling/object-details), so shape the response there or rely on the [default behavior](/error-handling/default-behavior). +Each rejection also emits a `websocket:rejected` event with the reason, covered in [Event Reference](/middleware/observability/events). All failures route through the [central error handler](/error-handling/object-details), so shape the response there or rely on the [default behavior](/error-handling/default-behavior). ## Integration with CORS diff --git a/docs/public/diagrams/default-error-behavior.png b/docs/public/diagrams/default-error-behavior.png index 3428aa2..0a4b67d 100644 Binary files a/docs/public/diagrams/default-error-behavior.png and b/docs/public/diagrams/default-error-behavior.png differ diff --git a/docs/public/diagrams/defense-in-depth.png b/docs/public/diagrams/defense-in-depth.png index e25db87..ff0a2ad 100644 Binary files a/docs/public/diagrams/defense-in-depth.png and b/docs/public/diagrams/defense-in-depth.png differ diff --git a/docs/public/diagrams/hot-reload-route-sequence.png b/docs/public/diagrams/hot-reload-route-sequence.png index 9a35310..3f93d92 100644 Binary files a/docs/public/diagrams/hot-reload-route-sequence.png and b/docs/public/diagrams/hot-reload-route-sequence.png differ diff --git a/docs/public/diagrams/middleware-global-flow.png b/docs/public/diagrams/middleware-global-flow.png index cf634f4..18e2892 100644 Binary files a/docs/public/diagrams/middleware-global-flow.png and b/docs/public/diagrams/middleware-global-flow.png differ diff --git a/docs/public/diagrams/obs-catch-vs-on.png b/docs/public/diagrams/obs-catch-vs-on.png index 84dd353..bd8d94c 100644 Binary files a/docs/public/diagrams/obs-catch-vs-on.png and b/docs/public/diagrams/obs-catch-vs-on.png differ diff --git a/docs/public/diagrams/obs-event-channel.png b/docs/public/diagrams/obs-event-channel.png index cfa4729..c4b0652 100644 Binary files a/docs/public/diagrams/obs-event-channel.png and b/docs/public/diagrams/obs-event-channel.png differ diff --git a/docs/public/diagrams/obs-process-fault.png b/docs/public/diagrams/obs-process-fault.png index 8ae3f51..663875b 100644 Binary files a/docs/public/diagrams/obs-process-fault.png and b/docs/public/diagrams/obs-process-fault.png differ diff --git a/docs/public/diagrams/obs-request-lifecycle.png b/docs/public/diagrams/obs-request-lifecycle.png index 692ee3f..92af918 100644 Binary files a/docs/public/diagrams/obs-request-lifecycle.png and b/docs/public/diagrams/obs-request-lifecycle.png differ diff --git a/docs/public/diagrams/obs-single-bus.png b/docs/public/diagrams/obs-single-bus.png index faf0f52..d905504 100644 Binary files a/docs/public/diagrams/obs-single-bus.png and b/docs/public/diagrams/obs-single-bus.png differ diff --git a/docs/public/diagrams/router-isolation.png b/docs/public/diagrams/router-isolation.png index 3f6dfdc..8a8df71 100644 Binary files a/docs/public/diagrams/router-isolation.png and b/docs/public/diagrams/router-isolation.png differ diff --git a/docs/public/diagrams/stream-render-chunks.png b/docs/public/diagrams/stream-render-chunks.png index f9bc2a1..d2a2f54 100644 Binary files a/docs/public/diagrams/stream-render-chunks.png and b/docs/public/diagrams/stream-render-chunks.png differ diff --git a/docs/public/diagrams/stream-render-pipeline.png b/docs/public/diagrams/stream-render-pipeline.png index 1b768d4..7279b35 100644 Binary files a/docs/public/diagrams/stream-render-pipeline.png and b/docs/public/diagrams/stream-render-pipeline.png differ diff --git a/docs/public/diagrams/stream-render-ttfb.png b/docs/public/diagrams/stream-render-ttfb.png index ad50d34..026c85a 100644 Binary files a/docs/public/diagrams/stream-render-ttfb.png and b/docs/public/diagrams/stream-render-ttfb.png differ diff --git a/docs/public/diagrams/stream-render-vs-blocking.png b/docs/public/diagrams/stream-render-vs-blocking.png index 4f01cc4..40148f7 100644 Binary files a/docs/public/diagrams/stream-render-vs-blocking.png and b/docs/public/diagrams/stream-render-vs-blocking.png differ diff --git a/docs/public/diagrams/team-many-hands.png b/docs/public/diagrams/team-many-hands.png index c554e53..0a72cf8 100644 Binary files a/docs/public/diagrams/team-many-hands.png and b/docs/public/diagrams/team-many-hands.png differ diff --git a/docs/public/diagrams/validation-failure-status.png b/docs/public/diagrams/validation-failure-status.png index b8b9264..c6be13a 100644 Binary files a/docs/public/diagrams/validation-failure-status.png and b/docs/public/diagrams/validation-failure-status.png differ diff --git a/docs/public/diagrams/validation-guard-verdict.png b/docs/public/diagrams/validation-guard-verdict.png index 29dd34c..4e35040 100644 Binary files a/docs/public/diagrams/validation-guard-verdict.png and b/docs/public/diagrams/validation-guard-verdict.png differ diff --git a/docs/public/diagrams/validation-request-flow.png b/docs/public/diagrams/validation-request-flow.png index eee706f..cb3b26c 100644 Binary files a/docs/public/diagrams/validation-request-flow.png and b/docs/public/diagrams/validation-request-flow.png differ diff --git a/docs/public/diagrams/validation-select-validator.png b/docs/public/diagrams/validation-select-validator.png index f2cf4dc..699b5d0 100644 Binary files a/docs/public/diagrams/validation-select-validator.png and b/docs/public/diagrams/validation-select-validator.png differ diff --git a/docs/public/diagrams/validation-three-pieces.png b/docs/public/diagrams/validation-three-pieces.png index 80e1299..1d9547f 100644 Binary files a/docs/public/diagrams/validation-three-pieces.png and b/docs/public/diagrams/validation-three-pieces.png differ diff --git a/docs/public/diagrams/zero-dep-process-guard.png b/docs/public/diagrams/zero-dep-process-guard.png index bb33834..fad8ce3 100644 Binary files a/docs/public/diagrams/zero-dep-process-guard.png and b/docs/public/diagrams/zero-dep-process-guard.png differ diff --git a/docs/recipes/audit-compliance.md b/docs/recipes/audit-compliance.md index 8e4325e..e8bb3af 100644 --- a/docs/recipes/audit-compliance.md +++ b/docs/recipes/audit-compliance.md @@ -1,5 +1,5 @@ --- -description: 'Turn the Deserve observability bus into a compliance-grade audit trail, then pipe it to your own store, a SIEM, or a WAF.' +description: 'Turn the Deserve observability bus into a compliance-grade audit trail, then pipe it to a custom store, a SIEM, or a WAF.' --- # Audit Compliance @@ -14,9 +14,9 @@ A single [`router.on()`](/middleware/observability/overview) listener sees the w | Compliance need | Events that answer it | | ---------------------------- | ---------------------------------------------------------------------------------- | -| Who did what, when | [`request:complete`](/middleware/observability/events#requests) with `method`, `url`, `statusCode`, `durationMs`, and optional `ip` | -| Security-relevant events | [`session:invalid`](/middleware/observability/events#middleware), [`csrf:rule-error`](/middleware/observability/events#middleware), [`process:error`](/middleware/observability/events#process) | -| Failures and faults | [`request:error`](/middleware/observability/events#requests), [`worker:crash`](/middleware/observability/events#workers), [`view:error`](/middleware/observability/events#views) | +| Who did what, when | [`request:completed`](/middleware/observability/events#requests) with `method`, `url`, `statusCode`, `durationMs`, and optional `ip` | +| Security-relevant events | [`session:invalid`](/middleware/observability/events#security-middleware), [`csrf:failed`](/middleware/observability/events#security-middleware), [`process:failed`](/middleware/observability/events#process) | +| Failures and faults | [`request:failed`](/middleware/observability/events#requests), [`worker:crashed`](/middleware/observability/events#workers), [`view:failed`](/middleware/observability/events#views) | | Reconstructable timeline | Every event carries a `timestamp` in epoch milliseconds and arrives in order | Nothing here needs wiring inside handlers. The faults emit on their own, which is why a tampered cookie or a blocked `Deno.exit` shows up without a single line of logging in the route. The full list lives in the [Event Reference](/middleware/observability/events). @@ -29,7 +29,7 @@ The audit listener has one job: capture every event as a structured record and h import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // One audit record per event @@ -53,7 +53,7 @@ await router.serve(8000) Each record is already JSON, already timestamped, and already labelled by `channel`. That is the shape every downstream below expects, so the same listener feeds all three options without change. -## Option 1 - Build Your Own Store +## Option 1 - Build a Custom Store The simplest durable sink is one owned end to end. Append each record to a write-only file, ship it to object storage, or insert it into a database. A file appender keeps the audit log on disk and out of the request path: @@ -61,7 +61,7 @@ The simplest durable sink is one owned end to end. Append each record to a write import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- // Open the audit log once, append-only @@ -91,7 +91,7 @@ A [SIEM](https://csrc.nist.gov/glossary/term/security_information_and_event_mana import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- const endpoint = 'https://http-inputs-acme.splunkcloud.com/services/collector/event' @@ -119,18 +119,18 @@ The endpoint and auth shape follow the vendor. Common collectors with public HTT ## Option 3 - Feed a WAF Decision Loop -A [Web Application Firewall](https://owasp.org/www-community/Web_Application_Firewall) blocks bad traffic before it reaches the app, and the bus gives it signal to act on. A burst of `request:error` events from one `ip`, or repeated `csrf:rule-error` faults, is exactly the pattern a WAF rule wants. Forward the security-relevant kinds to the firewall's API to drive a block list: +A [Web Application Firewall](https://owasp.org/www-community/Web_Application_Firewall) blocks bad traffic before it reaches the app, and the bus gives it signal to act on. A burst of `request:failed` events from one `ip`, or repeated `csrf:failed` faults, is exactly the pattern a WAF rule wants. Forward the security-relevant kinds to the firewall's API to drive a block list: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Only forward security-relevant faults - if (event.kind === 'csrf:rule-error' || event.kind === 'request:error') { + if (event.kind === 'csrf:failed' || event.kind === 'request:failed') { void fetch('https://waf.internal/signals', { method: 'POST', headers: { diff --git a/docs/recipes/file-upload.md b/docs/recipes/file-upload.md index cee91fa..9268d59 100644 --- a/docs/recipes/file-upload.md +++ b/docs/recipes/file-upload.md @@ -21,14 +21,14 @@ The upload handler lives in the [routes directory](/core-concepts/file-based-rou ## Reading the Upload -A multipart request flows through `ctx.formData()`, and each named field comes back from `form.get()`. A text field returns a string while a file field returns a `File`: +A multipart request flows through `ctx.get.formData()`, and each named field comes back from `form.get()`. A text field returns a string while a file field returns a `File`: ```typescript twoslash import type { Context } from '@neabyte/deserve' // POST /api/upload with multipart form export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const title = form.get('title') // Text field as string const file = form.get('file') // File or string or null @@ -40,7 +40,7 @@ export async function POST(ctx: Context): Promise { } ``` -Calling `ctx.body()` reaches the same parser, since it reads the `Content-Type` header and routes both `multipart/form-data` and `application/x-www-form-urlencoded` into `FormData`. Reaching for `ctx.formData()` keeps the intent clear at the call site. +Calling `ctx.get.body()` reaches the same parser, since it reads the `Content-Type` header and routes both `multipart/form-data` and `application/x-www-form-urlencoded` into `FormData`. Reaching for `ctx.get.formData()` keeps the intent clear at the call site. ## Confirming a File Arrived @@ -50,7 +50,7 @@ Calling `ctx.body()` reaches the same parser, since it reads the `Content-Type` import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const file = form.get('file') // Reject when no file was sent @@ -86,7 +86,7 @@ The bytes stay inside the `File` until `arrayBuffer()` pulls them out, and wrapp import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const file = form.get('file') // Reject when no file was sent @@ -120,13 +120,13 @@ Writing to disk needs Deno's write permission, so the server runs with `--allow- ## Reading the Body Once -Each Context parses its body a single time then caches the result, so a second reader in a different format on the same request throws instead of handing back empty data. Picking one of `formData()`, `json()`, `text()`, `arrayBuffer()`, or `blob()` per request keeps that contract: +Each Context parses its body a single time then caches the result, so the format read first is the one locked in for that request. Picking one of `formData()`, `json()`, `text()`, `bytes()`, or `blob()` per request keeps that contract: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const form = await ctx.formData() // Body consumed once here + const form = await ctx.get.formData() // Body consumed once here // Reads cached form, not body return ctx.send.json({ @@ -135,17 +135,17 @@ export async function POST(ctx: Context): Promise { } ``` -A second reader such as `ctx.json()` on this request would throw instead of returning empty data, since the body is already gone. A malformed multipart payload never crashes the pipeline either, since the parser maps a broken body to a **400** that flows through the [centralized error handler](/error-handling/object-details). Every reader and its return type lives in the [request handling reference](/core-concepts/request-handling#method-reference). +A second reader in a different format such as `ctx.get.json()` on this request throws a **409** instead of returning empty data, since the body was already consumed as form data. Reading the same format twice is safe, since the parsed value is cached and handed back without touching the body again. A malformed multipart payload never crashes the pipeline either, since the parser maps a broken body to a **400** that flows through the [centralized error handler](/error-handling/object-details). Every reader and its return type lives in the [request handling reference](/core-concepts/request-handling). ## Capping Upload Size -`FormData` puts no ceiling on how many bytes a client sends, so an upload route pairs with [body limit middleware](/middleware/body-limit) to reject oversized payloads with a **413** before they fill memory. A known `Content-Length` over the cap is rejected before the body is read, while a chunked stream is cut off as soon as the extra bytes arrive: +`FormData` puts no ceiling on how many bytes a client sends, so an upload route pairs with [body limit middleware](/middleware/body-limit) to reject oversized payloads with a **413** before they fill memory. The check reads the `Content-Length` header, so a declared size over the cap is rejected before the body is ever read. A request with no `Content-Length`, such as a chunked stream, skips the check and reaches the handler: ```typescript twoslash import { Mware, Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Cap the upload route at 5MB @@ -176,7 +176,7 @@ router.static('/uploads', { }) ``` -For a one-off download driven by a handler instead of a static prefix, [`ctx.send.file()`](/response/file) streams a single file straight off disk with the right `Content-Disposition` attached. +For a one-off download driven by a handler instead of a static prefix, [`ctx.send.download()`](/response/download) streams a single file straight off disk with the right `Content-Disposition` attached. ## Full Round Trip @@ -189,7 +189,7 @@ import { Mware, Router } from '@neabyte/deserve' // main.ts entry point const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Guard size before the handler runs @@ -217,7 +217,7 @@ import type { Context } from '@neabyte/deserve' // ---cut--- // routes/api/upload.ts handler export async function POST(ctx: Context): Promise { - const form = await ctx.formData() + const form = await ctx.get.formData() const file = form.get('file') // Reject when no file was sent diff --git a/docs/recipes/graceful-shutdown.md b/docs/recipes/graceful-shutdown.md index 6ab2530..4e2d1c2 100644 --- a/docs/recipes/graceful-shutdown.md +++ b/docs/recipes/graceful-shutdown.md @@ -8,13 +8,13 @@ A graceful shutdown stops a server from accepting new connections while letting ## Built-In Signal Handling -A plain `router.serve()` already listens for the signals a process manager sends on stop. On `SIGINT` (a `Ctrl+C` in the terminal) or `SIGTERM` (what Docker and most orchestrators send), the server stops taking new requests, drains the ones still running, then resolves the `serve()` promise: +A plain `router.serve()` already listens for the signals a process manager sends on stop. On `SIGHUP`, `SIGINT` (a `Ctrl+C` in the terminal), or `SIGTERM` (what Docker and most orchestrators send), the server stops taking new requests, drains the ones still running, then resolves the `serve()` promise: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // Resolves once the drain completes @@ -24,7 +24,7 @@ await router.serve(8000) console.log('Server stopped') ``` -Windows listens for `SIGINT` only, since `SIGTERM` is not delivered there. No setup is needed for this path, so a containerized server already exits cleanly on `docker stop`. +Windows swaps that set for `SIGBREAK` and `SIGINT`, since the POSIX signals are not delivered there. No setup is needed for this path, so a containerized server already exits cleanly on `docker stop`. Each received signal also emits a [`process:failed`](/middleware/observability/events#process) event with `origin: 'process:signal'` right before the drain begins, so the stop reason lands on the same bus as every other fault. ## Triggering Shutdown From Code @@ -34,7 +34,7 @@ Passing an `AbortSignal` as the third argument hands the trigger to the applicat import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- const controller = new AbortController() @@ -46,22 +46,22 @@ setTimeout(() => controller.abort(), 30_000) await router.serve(8000, '0.0.0.0', controller.signal) ``` -Handing over an `AbortSignal` takes over the stop trigger, so the built-in `SIGINT` and `SIGTERM` listeners stay off and the controller becomes the single way to stop. Wiring both is a matter of aborting the controller from inside a signal listener when that is the goal. +An `AbortSignal` runs alongside the built-in listeners rather than replacing them, so a `SIGTERM` from the host and an `abort()` from code both reach the same drain. Whichever fires first stops the server, and the other becomes a no-op once the drain is underway. Wiring a signal listener to call `controller.abort()` is a way to fold both triggers into one path when that is the goal. ## Running Work on Shutdown -Cleanup like closing a database pool or flushing a buffer belongs after the drain, not inside it. The [`server:shutdown`](/middleware/observability/events#server) event fires once the server stops draining, so a single [observability](/middleware/observability/overview) listener keeps shutdown work in one place: +Cleanup like closing a database pool or flushing a buffer belongs after the drain, not inside it. The [`server:stopped`](/middleware/observability/events#server) event fires once the server stops draining, so a single [observability](/middleware/observability/overview) listener keeps shutdown work in one place: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- router.on((event) => { // Runs after the drain completes - if (event.kind === 'server:shutdown') { + if (event.kind === 'server:stopped') { console.log('Closing resources') } }) @@ -69,8 +69,8 @@ router.on((event) => { await router.serve(8000) ``` -The matching [`server:listening`](/middleware/observability/events#server) event fires when the server binds, so startup and shutdown hooks live side by side on the same bus. +The matching [`server:started`](/middleware/observability/events#server) event fires when the server binds, so startup and shutdown hooks live side by side on the same bus. ## What Drain Means for a Request -A request that is mid-flight when the drain starts runs to completion, and its response still ships. A connection that arrives after the drain begins is refused, since the listener has already stopped accepting. Long-lived responses are the one thing to watch, because an open [stream](/recipes/streaming-data) or [WebSocket](/middleware/websocket) holds the drain until it closes. Capping how long a single request may run with [`requestTimeoutMs`](/getting-started/routes-configuration#requesttimeoutms) keeps the drain from waiting forever on a slow handler. +A request that is mid-flight when the drain starts runs to completion, and its response still ships. A connection that arrives after the drain begins is refused, since the listener has already stopped accepting. Long-lived responses are the one thing to watch, because an open [stream](/recipes/streaming-data) or [WebSocket](/middleware/websocket) holds the drain until it closes. Capping how long a single request may run with [`timeoutMs`](/getting-started/routes-configuration#timeoutms) keeps the drain from waiting forever on a slow handler. diff --git a/docs/recipes/object-storage.md b/docs/recipes/object-storage.md index efdd4f8..f9aab1b 100644 --- a/docs/recipes/object-storage.md +++ b/docs/recipes/object-storage.md @@ -1,97 +1,100 @@ --- -description: 'Serve files from S3, R2, or any object storage in Deserve through the staticHandler hook or a route handler.' +description: 'Serve files from S3, R2, or any object storage in Deserve through a route handler.' --- # Object Storage -Built-in [static serving](/static-file/basic) reads from the local filesystem, so `router.static()` alone cannot reach a bucket on S3, Cloudflare R2, or Google Cloud Storage. The bridge is the [`staticHandler`](/getting-started/routes-configuration#statichandler) option, a hook that keeps the familiar static route while swapping the file read for a fetch against object storage. A route still registers with `router.static()`, and the handler answers each request from the bucket instead of the disk. +Built-in [static serving](/static-file/basic) reads from the local filesystem, so `router.static()` with a `path` option alone cannot reach a bucket on S3, Cloudflare R2, or Google Cloud Storage. The bridge is the [custom static handler](/static-file/basic#custom-handler) that `router.static()` already accepts, a function of the shape `(ctx, urlPath) => Response` that swaps the file read for a fetch against object storage. The mount keeps the same URL surface while the bucket becomes the source of truth. -## Why a Hook Instead of a Path +## Why a Custom Handler -The `path` option on [static serving](/static-file/basic#path) maps a URL prefix to a folder that `Deno.stat` and `Deno.realPath` can resolve, which is a local-disk contract. Object storage has no real path on disk, so the safe traversal checks and the file handle streaming never apply. The `staticHandler` hook hands the whole serve step over, so the bucket becomes the source of truth while the route surface stays the same. +The `path` option on [static serving](/static-file/basic#path) maps a URL prefix to a folder that `Deno.stat` and `Deno.realPath` can resolve, which is a local-disk contract. Object storage has no real path on disk, so the safe traversal checks and the file-handle streaming never apply. Passing a function instead of a `ServeOptions` object hands the whole serve step over, so the route surface stays identical while a `fetch` answers each request. The handler still runs only after dynamic routes miss, the same [matching order](/static-file/basic#how-it-works) as the built-in mount. ## Serving From a Bucket -Most object stores expose an HTTPS endpoint per object, so a `fetch` against `${endpoint}/${key}` pulls the bytes. The handler slices the URL prefix off `ctx.pathname` to recover the object key, then streams the response body straight through with [`ctx.send.stream`](/response/stream): +Most object stores expose an HTTPS endpoint per object, so a `fetch` against `${endpoint}/${key}` pulls the bytes. The handler receives `urlPath` with the mount prefix already stripped, so `/assets/logo.png` arrives as `/logo.png`. Strip the leading slash to recover the object key, then stream the response body straight through with [`ctx.send.custom`](/response/custom): ```typescript twoslash -import { Router, type Context, type ServeOptions } from '@neabyte/deserve' +import { Router } from '@neabyte/deserve' // Bucket base endpoint const endpoint = 'https://my-bucket.s3.amazonaws.com' const router = new Router({ - routesDir: 'routes', - staticHandler: { - // Serve each object from the bucket - async serve(ctx: Context, options: ServeOptions, urlPath: string) { - // Recover object key from the path - const key = ctx.pathname.slice(urlPath.length).replace(/^\//, '') - const object = await fetch(`${endpoint}/${key}`) - if (!object.ok || !object.body) { - return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) - } - // Stream bucket body to the client - const contentType = object.headers.get('content-type') ?? 'application/octet-stream' - return ctx.send.stream(object.body, undefined, contentType) - } - } + routes: { directory: './routes' } }) -// Register the route the handler fulfills -router.static( - '/assets', - { - path: 's3' +// Custom handler bridges to a bucket +router.static('/assets', async (ctx, urlPath) => { + // Drop the leading slash for key + const key = urlPath.replace(/^\//, '') + const object = await fetch(`${endpoint}/${key}`) + if (object.status === 404) { + return await ctx.handleError(404, new Deno.errors.NotFound('Object not found')) } -) + if (!object.ok || !object.body) { + return await ctx.handleError(502, new Error('Object storage unavailable')) + } + // Stream the bucket body straight through + const contentType = object.headers.get('content-type') ?? 'application/octet-stream' + return ctx.send.custom(object.body, { + headers: { + 'Content-Type': contentType + } + }) +}) await router.serve(8000) ``` -The `path` value still has to be set on [`router.static()`](/static-file/basic) since it is required, yet the handler ignores it here because the bucket replaces the local folder. A request to `/assets/logo.png` becomes a fetch for the `logo.png` key. +A request to `/assets/logo.png` becomes a fetch for the `logo.png` key, and the bucket's bytes flow back without ever touching the disk. ## Forwarding a Byte Range -Static serving answers a [byte range](/static-file/basic#byte-range-requests) on its own, but a custom handler owns that job now. Passing the incoming `Range` header through to the bucket lets the store return the partial content, and forwarding the status and range headers back keeps a video scrubber or resumable download working: +Built-in serving answers a [byte range](/static-file/basic#byte-range-requests) on its own, but a custom handler owns that job now. Passing the incoming `Range` header through to the bucket lets the store return the partial content, and forwarding the status and range headers back keeps a video scrubber or resumable download working: ```typescript twoslash -import type { Context, ServeOptions } from '@neabyte/deserve' -declare const endpoint: string +import { Router, type HttpStatusCode } from '@neabyte/deserve' + +const endpoint = 'https://my-bucket.s3.amazonaws.com' + +const router = new Router({ + routes: { directory: './routes' } +}) // ---cut--- -async function serve(ctx: Context, options: ServeOptions, urlPath: string) { - const key = ctx.pathname.slice(urlPath.length).replace(/^\//, '') - const range = ctx.header('range') +router.static('/assets', async (ctx, urlPath) => { + const key = urlPath.replace(/^\//, '') + const range = ctx.get.header('range') // Forward the Range header when present const object = await fetch(`${endpoint}/${key}`, { headers: range ? { Range: range } : {} }) if (!object.ok || !object.body) { - return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) + return await ctx.handleError(404, new Deno.errors.NotFound('Object not found')) } // Mirror range headers back to the client const contentType = object.headers.get('content-type') ?? 'application/octet-stream' const contentRange = object.headers.get('content-range') if (contentRange) { - ctx.setHeader('Content-Range', contentRange) - ctx.setHeader('Accept-Ranges', 'bytes') + ctx.set.header('Content-Range', contentRange) + ctx.set.header('Accept-Ranges', 'bytes') } return ctx.send.custom(object.body, { - status: object.status, + status: object.status as HttpStatusCode, headers: { 'Content-Type': contentType } }) -} +}) ``` -A `206 Partial Content` from the bucket flows back unchanged, since `ctx.send.custom` keeps the status the bucket chose. +A `206 Partial Content` from the bucket flows back unchanged, since passing `object.status` to `ctx.send.custom` keeps the status the bucket chose. ## Using a Route Handler Instead -The `staticHandler` hook covers a whole URL prefix, which fits a public asset folder. A single download behind auth or business logic fits a normal [route handler](/core-concepts/file-based-routing) better, where middleware runs first and the key comes from a [route param](/core-concepts/route-patterns): +A route handler fits a single download behind auth or business logic better, where middleware runs first and the key comes from a [route param](/core-concepts/route-patterns): ```typescript twoslash import type { Context } from '@neabyte/deserve' @@ -99,14 +102,18 @@ declare const endpoint: string // ---cut--- // routes/files/[key].ts export async function GET(ctx: Context): Promise { - const key = ctx.param('key') + const key = ctx.get.param('key') const object = await fetch(`${endpoint}/${key}`) if (!object.ok || !object.body) { - return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) + return await ctx.handleError(404, new Deno.errors.NotFound('Object not found')) } // Stream the object straight through const contentType = object.headers.get('content-type') ?? 'application/octet-stream' - return ctx.send.stream(object.body, undefined, contentType) + return ctx.send.custom(object.body, { + headers: { + 'Content-Type': contentType + } + }) } ``` @@ -116,10 +123,10 @@ This path runs the full middleware chain, so guarding it with [basic auth](/midd A private bucket needs signed requests rather than a plain `fetch`. Two routes work well: -- **Presigned URL** - the SDK signs a short-lived URL, and the handler either redirects with [`ctx.redirect`](/response/redirect) or fetches it server-side. +- **Presigned URL** - the SDK signs a short-lived URL, and the handler either redirects with [`ctx.send.redirect`](/response/redirect) or fetches it server-side. - **Server-side SDK** - the official client signs each request, for example the [AWS SDK for JavaScript](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/welcome.html) for S3 or the Cloudflare R2 binding for [Workers](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/). -Whichever route signs the request, the response body still streams through `ctx.send.stream`, so the serving shape stays the same. +Whichever route signs the request, the response body still streams through `ctx.send.custom`, so the serving shape stays the same. ## Handling Failures @@ -130,7 +137,7 @@ import type { Context } from '@neabyte/deserve' declare const endpoint: string // ---cut--- export async function GET(ctx: Context): Promise { - const key = ctx.param('key') + const key = ctx.get.param('key') try { const object = await fetch(`${endpoint}/${key}`) if (object.status === 404) { @@ -140,7 +147,11 @@ export async function GET(ctx: Context): Promise { if (!object.ok || !object.body) { return await ctx.handleError(502, new Error('Object storage unavailable')) } - return ctx.send.stream(object.body, undefined, 'application/octet-stream') + return ctx.send.custom(object.body, { + headers: { + 'Content-Type': 'application/octet-stream' + } + }) } catch (error) { // Route any network fault through error handling return await ctx.handleError(502, error as Error) diff --git a/docs/recipes/production-deploy.md b/docs/recipes/production-deploy.md index 311221e..ae668a8 100644 --- a/docs/recipes/production-deploy.md +++ b/docs/recipes/production-deploy.md @@ -52,10 +52,10 @@ A host usually assigns the port through a `PORT` variable. Calling `serve()` wit import { Router } from '@neabyte/deserve' const router = new Router({ - routesDir: './routes' + routes: { directory: './routes' } }) // ---cut--- -// Reads PORT env, falls back to 8000 +// Reads PORT env, defaults to 8000 await router.serve() ``` @@ -79,4 +79,4 @@ The result runs straight from `./server` with the flags already inside. One cave ## Watching It Run -Production needs eyes on the server without a console full of prints, which is what the [observability event bus](/middleware/observability/overview) is for. A single [`router.on()`](/middleware/observability/events) listener forwards lifecycle, request, and fault events to whatever collects logs, and [error reporting](/middleware/observability/errors) routes failures to the same place. A clean stop on deploy is covered by [Graceful Shutdown](/recipes/graceful-shutdown), and offloading heavy work without blocking the server is covered by the [worker pool](/core-concepts/worker-pool). +Production needs eyes on the server without a console full of prints, which is what the [observability event bus](/middleware/observability/overview) is for. A single [`router.on()`](/middleware/observability/events) listener forwards lifecycle, request, and fault events to whatever collects logs, and [error reporting](/middleware/observability/errors) routes failures to the same place. A clean stop on deploy is covered by [Graceful Shutdown](/recipes/graceful-shutdown), and offloading heavy work without blocking the server is covered by the [worker pool](/recipes/worker-pool). diff --git a/docs/recipes/streaming-data.md b/docs/recipes/streaming-data.md index e7d0d69..759ba2b 100644 --- a/docs/recipes/streaming-data.md +++ b/docs/recipes/streaming-data.md @@ -4,9 +4,9 @@ description: 'Push data to the client chunk by chunk with Server-Sent Events and # Streaming Data -A streaming response sends its body in pieces over time instead of one finished blob, so the first bytes reach the client long before the work is done. Deserve passes a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) straight through `ctx.send.stream()` to the native response, so each `controller.enqueue()` leaves the server as its own chunk. This recipe covers the two formats that show up most in production - [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) for live push and [NDJSON](https://github.com/ndjson/ndjson-spec) for large datasets read line by line. +A streaming response sends its body in pieces over time instead of one finished blob, so the first bytes reach the client long before the work is done. Deserve passes a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) straight through `ctx.send.custom()` to the native response, so each `controller.enqueue()` leaves the server as its own chunk. This recipe covers the two formats that show up most in production - [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) for live push and [NDJSON](https://github.com/ndjson/ndjson-spec) for large datasets read line by line. -For a single buffered stream or the method signature, see [stream responses](/response/stream). For streaming rendered HTML, see [streaming rendering](/rendering/streaming). +For a single buffered stream or the method signature, see [stream responses](/response/custom). For streaming rendered HTML, see [streaming rendering](/rendering/streaming). ## Project Structure @@ -42,19 +42,19 @@ export function GET(ctx: Context): Response { controller.close() } }) - return ctx.send.stream( + return ctx.send.custom( stream, { headers: { - 'Cache-Control': 'no-cache' + 'Cache-Control': 'no-cache', + 'Content-Type': 'text/event-stream' } - }, - 'text/event-stream' + } ) } ``` -The third argument sets the content type while the second carries the `Cache-Control: no-cache` header that stops a proxy from buffering the feed. A per-call `Content-Type` set this way wins over any generic context header, so the event stream keeps its type even alongside other headers. +The `Content-Type: text/event-stream` header tells the browser to treat the response as an event source, while `Cache-Control: no-cache` stops a proxy from buffering the feed. ### Reading From the Browser @@ -91,11 +91,15 @@ export function GET(ctx: Context): Response { controller.close() } }) - return ctx.send.stream(stream, undefined, 'application/x-ndjson') + return ctx.send.custom(stream, { + headers: { + 'Content-Type': 'application/x-ndjson' + } + }) } ``` -Passing `undefined` for the options keeps the defaults, while the third argument labels the body `application/x-ndjson` so the client knows to split on newlines. +Passing the `Content-Type: application/x-ndjson` header tells the client to split on newlines. ### Reading From the Client @@ -146,7 +150,11 @@ export function GET(ctx: Context): Response { } } }) - return ctx.send.stream(stream, undefined, 'text/event-stream') + return ctx.send.custom(stream, { + headers: { + 'Content-Type': 'text/event-stream' + } + }) } ``` diff --git a/docs/recipes/worker-pool.md b/docs/recipes/worker-pool.md new file mode 100644 index 0000000..26389fa --- /dev/null +++ b/docs/recipes/worker-pool.md @@ -0,0 +1,214 @@ +--- +description: "Offloading CPU-bound work to a pool of Deno workers via the Deserve worker pool API." +--- + +# Worker Pool + +> **Reference**: [Deno Workers API](https://docs.deno.com/runtime/manual/workers/) + +A CPU-bound task like heavy math, parsing, or compression blocks the event loop while it runs, so every other request waits behind it. The worker pool moves that work onto a pool of [Deno Workers](https://docs.deno.com/runtime/manual/workers/) running off the main thread, so the server keeps answering while the computation happens elsewhere. I/O-bound work like a file read or a network call already yields the loop, so it stays on the main thread where it belongs. + +Once a pool is configured on the router, a route reaches it through [`ctx.get.worker()`](/core-concepts/context-object#ctx-get-worker) and hands off a task with `run(payload)`. + +## Configuring the Pool + +The pool turns on through the `worker` option, which needs a **script URL** that resolves to a module. An `import.meta.resolve()` call points at a file on disk, while `URL.createObjectURL()` wraps inline code: + +```typescript twoslash +import { Router } from '@neabyte/deserve' + +// Resolve worker script as a module +const workerScriptUrl = import.meta.resolve('./worker.ts') + +// Enable the pool on the router +const router = new Router({ + routes: { directory: './routes' }, + worker: { + scriptURL: workerScriptUrl, + poolSize: 4 + } +}) + +await router.serve(8000) +``` + +## Writing the Worker Script + +The worker script listens for `message` and replies with `postMessage`. The payload and result both cross the thread boundary through structured clone, so only serializable data passes, which rules out functions and symbols: + +```typescript +// worker.ts +self.onmessage = (e: MessageEvent) => { + const data = e.data as { iterations?: number } + const n = Math.max(0, Number(data?.iterations) || 50_000) + let value = 0 + for (let i = 0; i < n; i++) { + value += Math.sqrt(i) + } + // Reply with the computed result + self.postMessage({ + done: true, + value + }) +} +``` + +A worker reports a failure by sending an object with `error: true` and an optional `message`, which surfaces back on the calling side as a rejected `run()`: + +```typescript +// Report a failure to the caller +self.postMessage({ + error: true, + message: 'Computation failed' +}) +``` + +## Dispatching From a Route + +The worker controller lives on `ctx.get.worker()`. A router created without a `worker` option leaves the controller unset, so `ctx.get.worker()` throws `NotSupported` the moment a route reaches for it. Wrapping the dispatch in a try lets the [centralized error handler](/error-handling/object-details) shape the reply, where `NotSupported` maps to a **501** on its own: + +```typescript twoslash +// routes/heavy.ts +import type { Context } from '@neabyte/deserve' + +export async function GET(ctx: Context): Promise { + try { + // Throws when no pool configured + const worker = ctx.get.worker() + // Dispatch task to worker pool + const result = await worker.run<{ done: boolean; value: number }>({ + iterations: 50_000 + }) + return ctx.send.json({ + value: result?.value + }) + } catch (error) { + // Route the failure through error handling + return await ctx.handleError(500, error as Error) + } +} +``` + +A task is dispatched round-robin across the pool, so back-to-back requests spread over the available workers rather than queuing on one. + +## Tuning the Pool + +### `scriptURL` + +The worker script URL, the one required field. It must point to a module, since Deno runs workers with `type: 'module'`. Two sources cover most cases: + +- **File path:** `import.meta.resolve('./worker.ts')` +- **Inline script:** `URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))` + +### `poolSize` + +The number of workers in the pool, defaulting to **4** with a floor of 1. A task spreads round-robin across these workers, so a larger pool absorbs more parallel work at the cost of more memory: + +```typescript +worker: { + scriptURL: workerScriptUrl, + poolSize: 8 +} +``` + +### `taskTimeoutMs` + +The per-task deadline in milliseconds, defaulting to **5000**. A task that runs past it rejects with a timeout error, the slot is reclaimed, and the worker is respawned. The reclaim surfaces as a [`worker:timeout`](/middleware/observability/events#workers) event followed by [`worker:respawned`](/middleware/observability/events#workers): + +```typescript +worker: { + scriptURL: workerScriptUrl, + taskTimeoutMs: 10_000 +} +``` + +### `maxQueueDepth` + +The ceiling on accepted-but-unsettled tasks the pool holds before turning new work away, defaulting to the worker count times **8**, so a pool of 4 holds up to 32. Once the ceiling is hit a new dispatch is refused right away rather than queued, which keeps a flood of work from piling up without bound: + +```typescript +worker: { + scriptURL: workerScriptUrl, + poolSize: 4, + maxQueueDepth: 64 +} +``` + +### `maxQueueWaitMs` + +The cap on projected wait, measured as the chosen slot's pending count times `taskTimeoutMs`, before a dispatch is refused. The default is **2000**. A task that would otherwise sit behind a long backlog is turned away fast instead of waiting: + +```typescript +worker: { + scriptURL: workerScriptUrl, + maxQueueWaitMs: 5_000 +} +``` + +A refused dispatch rejects right away and surfaces as a [`worker:rejected`](/middleware/observability/events#workers) event, with `reason` reading `queue-depth` when `maxQueueDepth` tripped it or `queue-wait` when `maxQueueWaitMs` did. + +## Inline Worker Script + +A separate `worker.ts` file is the clearest layout, but small compute fits inline. Wrapping the source in a `Blob` and handing it to `URL.createObjectURL()` produces a module URL the pool accepts, which keeps a one-off worker in the same file as the router: + +```typescript twoslash +import { Router } from '@neabyte/deserve' + +const workerCode = ` +self.onmessage = (e) => { + const data = e.data || {} + const n = Math.max(0, Number(data.iterations) || 50000) + let value = 0 + for (let i = 0; i < n; i++) value += Math.sqrt(i) + self.postMessage({ + done: true, + value + }) +} +export {} +` + +const workerScriptUrl = URL.createObjectURL( + new Blob( + [workerCode], + { type: 'application/javascript' } + ) +) + +const router = new Router({ + routes: { directory: './routes' }, + worker: { + scriptURL: workerScriptUrl, + poolSize: 4 + } +}) + +await router.serve(8000) +``` + +## How Failures Surface + +A dispatch can fail in a handful of ways, and each one rejects `run()` with a specific error so the cause stays readable: + +- **No pool:** A router created without `worker` leaves `ctx.get.worker()` throwing `NotSupported`, which the [centralized error handler](/error-handling/object-details) maps to a **501**. Wrap the call in a try when the route should reply with a clearer message. +- **Worker error:** When the worker calls `postMessage({ error: true, message: '...' })`, `worker.run()` rejects with an `Error` carrying that message. Without a message, the error reads `Worker returned an error with no message`. +- **Worker crash:** When the worker throws or crashes, `run()` rejects with `Worker task failed before responding`, and the slot recovers on its own. +- **Task timeout:** When a task runs past `taskTimeoutMs` (default 5000), `run()` rejects with `Worker task exceeded ms timeout`. +- **Refused under load:** When the pool is at `maxQueueDepth` or the projected wait passes `maxQueueWaitMs`, `run()` rejects with a queue-full or slot-busy error before the task ever starts. + +Every one of these faults also streams through the observability bus as a [worker event](/middleware/observability/events#workers), so a stall, crash, recovery, or refusal stays visible without touching the request path. Catching a rejected task and forwarding it to the [centralized error handler](/error-handling/object-details) keeps the response shaping in one place: + +```typescript +try { + // Dispatch task to worker pool + const result = await worker.run(payload) + return ctx.send.json(result) +} catch (err) { + // Route the failure through error handling + return await ctx.handleError(500, err as Error) +} +``` + +## Structured Clone Only + +Payload and result are sent via `postMessage` / `onmessage`, so only **structured-clone serializable** data is allowed, which covers plain objects, arrays, primitives, `Date`, `RegExp`, `Map`, `Set`, and similar values. Functions, symbols, and non-cloneable class instances cannot cross that boundary. See the [structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) on MDN for the full list. diff --git a/docs/rendering/index.md b/docs/rendering/index.md index 2b2ba77..51aba2a 100644 --- a/docs/rendering/index.md +++ b/docs/rendering/index.md @@ -4,25 +4,27 @@ description: "Server-side template rendering in Deserve using the built-in DVE v # Rendering Overview -> See [DVE syntax highlighting](https://github.com/NeaByteLab/Deserve/tree/main/editor) documentation. - -Deserve ships a built-in template engine called DVE (Deserve View Engine) for building dynamic HTML from plain templates with a small {{ }} syntax. +Deserve ships with a built-in template engine called DVE (Deserve View Engine). It turns plain HTML templates into finished pages by filling a small {{ }} syntax with route data. DVE lives in its own package, so the same engine works outside Deserve too. The full reference sits on [JSR](https://jsr.io/@neabyte/dve) and [npm](https://www.npmjs.com/package/@neabyte/dve), with the source on [GitHub](https://github.com/NeaByteLab/DVE). ## Setup -Point `viewsDir` at the templates folder when creating the router: +The view engine activates the moment `views.directory` points at a templates folder. With it omitted, `ctx.render()` throws `Deno.errors.NotSupported` because no engine is configured: ```typescript twoslash import { Router } from '@neabyte/deserve' -// Point viewsDir at the templates folder +// Point views.directory at the templates folder const router = new Router({ - viewsDir: './views' + views: { + directory: './views' + } }) await router.serve(8000) ``` +The render limits also live under `views`, covered in [Performance and Limits](/rendering/performance) and [Routes Configuration](/getting-started/routes-configuration#views). + ## First Template Create a `.dve` file inside the views folder: @@ -32,11 +34,11 @@ Create a `.dve` file inside the views folder: - {{title}} + {{ title }} -

Hello {{name}}!

-

Today: {{date}}

+

Hello {{ name }}!

+

Today: {{ date }}

``` @@ -57,45 +59,39 @@ export async function GET(ctx: Context): Promise { } ``` -The `.dve` extension is optional in the path, so `'welcome'` and `'welcome.dve'` both resolve to the same file. +The `.dve` extension is optional in the path, so `'welcome'` and `'welcome.dve'` both resolve to the same file. The lookup also strips a leading slash and normalizes backslashes, so a Windows-style path still finds the template. + +## Caching and Reload + +The first render of a template compiles it and caches the parsed result, and every later render reuses that cache. Editing a `.dve` file clears its entry through [hot reload](/core-concepts/hot-reload), so the next render picks up the change with no restart. The numbers behind this live in [Performance and Limits](/rendering/performance#caching). ## Error Handling -A missing template throws `Template "" not found in views directory`, and a render fault throws too. Let either reach the [centralized error handler](/error-handling/object-details), or catch it in the handler for a precise reply: +A missing template file throws `Deno.errors.NotFound`, and a compile or render fault throws as well. Both reach the [centralized error handler](/error-handling/object-details) set with `router.catch()`, which shapes a single reply for the whole app instead of a try/catch in every route. A missing file maps to **404 Not Found**, and a compile or render fault maps to **400 Bad Request**. + +When one route needs a precise reply, catch the throw and branch on the error type: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare const data: DataRecord +import type { Context } from '@neabyte/deserve' +declare const data: Record // ---cut--- export async function GET(ctx: Context): Promise { try { return await ctx.render('template', data) } catch (error) { - const message = error instanceof Error ? error.message : '' - if (message.includes('not found in views directory')) { - return ctx.send.json( - { - error: 'Template missing' - }, - { - status: 404 - } - ) + // Missing file throws NotFound + if (error instanceof Deno.errors.NotFound) { + return ctx.send.json({ error: 'Template missing' }, { status: 404 }) } - return ctx.send.json( - { - error: 'Render failed' - }, - { - status: 500 - } - ) + return ctx.send.json({ error: 'Render failed' }, { status: 500 }) } } ``` +A render fault also surfaces on the [observability bus](/middleware/observability/overview) as a [`view:failed`](/middleware/observability/events#views) event, so logging stays in one place while the error handler shapes the response. + ## Where to Go Next -- [Template Syntax](/rendering/syntax) - variables, conditionals, loops, includes, and expressions. -- [Performance and Limits](/rendering/performance) - caching, the iteration limit, and the include depth limit. +- [Template Syntax](/rendering/syntax) - variables, conditionals, loops, includes, layouts, and expressions. +- [Performance and Limits](/rendering/performance) - caching, iteration caps, the output cap, and include depth. - [Streaming Rendering](/rendering/streaming) - send HTML chunk by chunk for large pages. diff --git a/docs/rendering/performance.md b/docs/rendering/performance.md index 5256e11..907c59d 100644 --- a/docs/rendering/performance.md +++ b/docs/rendering/performance.md @@ -4,58 +4,77 @@ description: "Performance characteristics and caching behavior of the Deserve te # Performance and Limits -The DVE engine caches compiled templates and guards rendering with a set of limits, so large pages stay fast and a runaway template fails loudly instead of hanging the server. +The DVE engine caches compiled templates and guards each render with a set of limits, so large pages stay fast and a runaway template fails loudly instead of hanging the server. Every limit is configured under `views` on the [Router options](/getting-started/routes-configuration#views). ## Caching -Templates are compiled once, then the parsed AST is reused on every later render: +A template is compiled once, then the parsed result is reused on every later render: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' +import type { Context } from '@neabyte/deserve' declare const ctx: Context -declare const data: DataRecord -declare const newData: DataRecord +declare const data: Record +declare const newData: Record // ---cut--- -// First render compiles and caches AST +// First render compiles and caches await ctx.render('template', data) -// Later renders reuse cached AST +// Later renders reuse the cache await ctx.render('template', newData) ``` -The cache covers template compilation only, not data or backend logic. A change to the file clears its cache entry through [hot reload](/core-concepts/hot-reload). +The cache covers compilation only, not data or backend logic. Editing the file clears its entry through [hot reload](/core-concepts/hot-reload), so the next render compiles the new source. ## Iteration Limit -Each {{#each}} block is capped at `100_000` iterations by default, which prevents event loop starvation from one unbounded loop. Tune it with `maxIterations`: +Each {{#each}} block is capped at `100_000` iterations by default, which keeps one unbounded loop from starving the event loop. The engine checks the array length before emitting any item, so an oversized loop fails fast. Tune it with `views.maxIterations`: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - viewsDir: './views', - maxIterations: 200_000 + views: { + directory: './views', + maxIterations: 200_000 + } }) ``` -When a loop exceeds the limit, the engine throws and the server responds with **400 Bad Request**. For very large datasets, reach for [streaming rendering](/rendering/streaming). For CPU-heavy rendering, offload to a [worker pool](/core-concepts/worker-pool). +When a loop exceeds the cap, the engine throws and the server responds with **400 Bad Request**. For very large datasets, reach for [streaming rendering](/rendering/streaming). For CPU-heavy rendering, offload to a [worker pool](/recipes/worker-pool). ## Render Budget Limits -Two more caps guard the whole render, not just one loop. `maxRenderIterations` sums every {{#each}} body execution across the page, including nested loops, and defaults to `1_000_000`. `maxOutputSize` caps the total characters a render may produce and defaults to `5_000_000`: +Two more caps guard the whole render rather than a single loop. `maxRenderIterations` sums every {{#each}} body execution across the page, including nested loops, and defaults to `1_000_000`. `maxOutputSize` caps the total characters one render may produce and defaults to `5_000_000`: ```typescript twoslash import { Router } from '@neabyte/deserve' // ---cut--- const router = new Router({ - viewsDir: './views', - maxRenderIterations: 500_000, - maxOutputSize: 2_000_000 + views: { + directory: './views', + maxRenderIterations: 500_000, + maxOutputSize: 2_000_000 + } }) ``` -Crossing either cap responds with **400 Bad Request**, the same status as the per-loop limit. All three are set on the [Router options](/getting-started/routes-configuration#configuration-options). +Crossing either cap responds with **400 Bad Request**, the same status as the per-loop limit. Keep `maxRenderIterations` at or above `maxIterations`, otherwise a single large loop trips the total cap first. + +## Template Size Limit + +`maxTemplateSize` caps the characters of a single template source, checked at compile time, and defaults to `1_000_000`. The same cap applies to every included or layout file the engine resolves. An oversized source throws before parsing begins, which responds with **400 Bad Request**: + +```typescript twoslash +import { Router } from '@neabyte/deserve' +// ---cut--- +const router = new Router({ + views: { + directory: './views', + maxTemplateSize: 500_000 + } +}) +``` ## Include Depth Limit -Template includes are capped at 64 levels of nesting, so a circular or runaway include chain throws an error instead of looping forever. Crossing the cap responds with **400 Bad Request**. Keeping partials shallow stays well within this limit. +Template includes and layout chains share a cap of 64 levels of nesting, so a circular or runaway chain throws instead of looping forever. Crossing the cap responds with **400 Bad Request**. Keeping partials and layouts shallow stays well within this limit, which is covered alongside the [include](/rendering/syntax#includes) and [layout](/rendering/syntax#layouts) syntax. diff --git a/docs/rendering/streaming.md b/docs/rendering/streaming.md index 7d0ef60..04e2032 100644 --- a/docs/rendering/streaming.md +++ b/docs/rendering/streaming.md @@ -4,73 +4,62 @@ description: "Streaming template rendering in Deserve for faster time-to-first-b # Streaming Template Rendering -Streaming template rendering sends HTML as it is produced, which lowers time-to-first-byte (TTFB) and keeps large pages feeling responsive. It is the progressive counterpart to the regular render covered in [Rendering Overview](/rendering/). +Streaming rendering sends HTML as it is produced, which lowers time-to-first-byte (TTFB) and keeps large pages feeling responsive. It is the progressive counterpart to the buffered render covered in [Rendering Overview](/rendering/), and it runs through the same `ctx.render()` call. -## Basic Concept +## Buffered vs Streaming -Instead of waiting for the whole template to finish, streaming sends the HTML chunk by chunk: +`ctx.render()` buffers by default, building the whole page into one string before it sends. Passing `{ stream: true }` as the third argument switches to a `ReadableStream` that writes each node as it is produced: -```typescript -// Regular render (blocking) - wait for everything to complete -return await ctx.render('large-template', data) +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +declare const data: Record +// ---cut--- +// Buffered: wait for the whole page +await ctx.render('large-template', data) -// Streaming render (progressive) - send chunk by chunk -return await ctx.streamRender('large-template', data) +// Streaming: send chunk by chunk +await ctx.render('large-template', data, { stream: true }) ``` -![Side by side, ctx.render builds the whole HTML into one string and sends it all at once so the client waits, while ctx.streamRender compiles up front, returns a ReadableStream, and writes each node as produced so the first bytes leave early](/diagrams/stream-render-vs-blocking.png) - -## Basic Usage +![Side by side, the buffered render builds the whole HTML into one string and sends it all at once so the client waits, while the streaming render compiles up front, returns a ReadableStream, and writes each node as produced so the first bytes leave early](/diagrams/stream-render-vs-blocking.png) -### 1. In Context Handler +## Usage -`ctx.streamRender()` returns a streaming HTML response, so awaiting it is all that a route needs: +A streaming render is still a single `await`. The engine resolves and compiles the template up front, then returns a response whose body streams as it renders, so the route stays as small as a buffered one: -![The route awaits ctx.streamRender, the engine resolves and compiles the template, creates a TransformStream, returns the readable side at once so response headers go out, then renders into the writable side in the background where a failure surfaces as a view error event](/diagrams/stream-render-pipeline.png) +![The route awaits ctx.render with stream true, the engine resolves and compiles the template, returns the readable stream at once so response headers go out, then renders each node into the stream in the background where a failure surfaces as a view failed event](/diagrams/stream-render-pipeline.png) ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getUser(): DataRecord -declare function getAnalytics(): DataRecord +import type { Context } from '@neabyte/deserve' +declare function getUser(): Record +declare function getAnalytics(): Record // ---cut--- // routes/dashboard.ts -// Streaming render complex dashboard +// Stream a complex dashboard export async function GET(ctx: Context): Promise { - return await ctx.streamRender('dashboard', { + return await ctx.render('dashboard', { user: getUser(), analytics: getAnalytics() - }) + }, { stream: true }) } ``` -### 2. Custom Response Headers - -The view engine lives in framework state, so `ctx.getState` reaches it for full control over the streamed response: +The response carries `Content-Type: text/html; charset=utf-8`, the same as a buffered render, and the status defaults to `200`. Set a different status through the same options object alongside `stream`: ```typescript twoslash -import type { Context, DataRecord, ViewEngine } from '@neabyte/deserve' -declare const reportData: DataRecord +import type { Context } from '@neabyte/deserve' +declare const ctx: Context +declare const data: Record // ---cut--- -// Access view engine from framework state -export async function GET(ctx: Context): Promise { - const view = ctx.getState('view' as never) - const stream = await view!.streamRender('report', reportData) - return ctx.send.stream( - stream, - { - headers: { - 'Cache-Control': 'no-cache' - } - }, - 'text/html; charset=utf-8' - ) -} +// Stream with a custom status +await ctx.render('report', data, { status: 201, stream: true }) ``` ## Template Support -All DVE features from [Template Syntax](/rendering/syntax) work with streaming: +Every DVE feature from [Template Syntax](/rendering/syntax) works with streaming. The engine walks the top-level nodes and flushes each produced chunk in order, so a plain text node leaves on its own. An {{#each}} block builds all its rows first and flushes them as one chunk, which means the granularity is per top-level node rather than per loop item: ![Streaming loops the top-level template nodes and writes each produced chunk in order, so a text node flushes on its own, but an each block builds all its rows into one string first and then flushes as a single chunk, meaning the streaming granularity is per top-level node rather than per loop item](/diagrams/stream-render-chunks.png) @@ -79,22 +68,22 @@ All DVE features from [Template Syntax](/rendering/syntax) work with streaming: - {{title}} + {{ title }} -
{{header}}
+
{{ header }}
{{#each items as item}}
-

{{item.name}}

-

{{item.description}}

+

{{ item.name }}

+

{{ item.description }}

{{/each}} {{#if showFooter}} -
{{footer}}
+
{{ footer }}
{{/if}} @@ -102,74 +91,63 @@ All DVE features from [Template Syntax](/rendering/syntax) work with streaming: ## Best Use Cases -### 1. Large Templates +Streaming pays off when the page is large or the data trickles in. A report with thousands of rows ships its first bytes long before the last row is ready: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getTransactions(): Promise -declare function calculateSummary(): DataRecord +import type { Context } from '@neabyte/deserve' +declare function getTransactions(): Promise[]> +declare function calculateSummary(): Record // ---cut--- -// Report with thousands of data rows +// Report with thousands of rows export async function GET(ctx: Context): Promise { - return await ctx.streamRender('financial-report', { - transactions: await getTransactions(), // 10,000+ items + return await ctx.render('financial-report', { + transactions: await getTransactions(), summary: calculateSummary() - }) -} -``` - -### 2. Real-time Data - -```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getLatestMetrics(): DataRecord -declare function getActiveAlerts(): DataRecord -// ---cut--- -// Dashboard with live data -export async function GET(ctx: Context): Promise { - return await ctx.streamRender('live-dashboard', { - metrics: getLatestMetrics(), - alerts: getActiveAlerts() - }) + }, { stream: true }) } ``` -### 3. Progressive Enhancement +A dashboard that mixes fast and slow data benefits the same way, since the shell reaches the client while the slow parts resolve: ```typescript twoslash -import type { Context, DataRecord } from '@neabyte/deserve' -declare function getLayoutData(): DataRecord -declare function getContent(): Promise -declare function getAnalytics(): Promise +import type { Context } from '@neabyte/deserve' +declare function getLayoutData(): Record +declare function getContent(): Promise> +declare function getAnalytics(): Promise> // ---cut--- -// Send skeleton first, data streams in +// Fast shell first, slow data after export async function GET(ctx: Context): Promise { - return await ctx.streamRender('progressive-app', { - layout: getLayoutData(), // Fast - content: await getContent(), // Slow - analytics: await getAnalytics() // Very slow - }) + return await ctx.render('progressive-app', { + layout: getLayoutData(), + content: await getContent(), + analytics: await getAnalytics() + }, { stream: true }) } ``` ## Error Handling -Streaming has two failure windows. A missing template or a compile error throws before the response starts, so it reaches the [centralized error handler](/error-handling/object-details) like a regular render and shapes a normal status reply. A fault while producing chunks happens after the headers are already sent, so the response cannot change. That fault surfaces as a [`view:error`](/middleware/observability/events#views) event on the [observability bus](/middleware/observability/overview) and the stream closes, which is why heavy validation belongs before the stream rather than inside it. +Streaming has two failure windows. A missing template or a compile error throws before the response starts, so it reaches the [centralized error handler](/error-handling/object-details) like a buffered render and shapes a normal status reply. A fault while producing chunks happens after the headers are already sent, so the response cannot change. That fault surfaces as a [`view:failed`](/middleware/observability/events#views) event on the [observability bus](/middleware/observability/overview) and the stream closes. That window is why heavy validation belongs before the stream rather than inside it. -## Migration from Regular Render +## Migration from a Buffered Render -```typescript -// Before (blocking) - wait for everything to complete -export async function GET(ctx: Context): Promise { +The switch is one argument, since the call stays the same: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare const data: Record +// ---cut--- +// Before: buffered +export async function before(ctx: Context): Promise { return await ctx.render('large-template', data) } -// After (streaming) - send progressively -export async function GET(ctx: Context): Promise { - return await ctx.streamRender('large-template', data) +// After: streaming +export async function after(ctx: Context): Promise { + return await ctx.render('large-template', data, { stream: true }) } ``` -Streaming rendering lifts performance for large templates and real-time pages, and the API stays the same single await that a regular render uses. +Streaming lifts performance for large templates and real-time pages while the route stays a single await: -![A time to first byte comparison where render makes the client wait while the whole page is built so the first byte lands late, against streamRender which flushes the first node right after compile so the first byte lands early while later chunks keep arriving until the stream closes](/diagrams/stream-render-ttfb.png) +![A time to first byte comparison where the buffered render makes the client wait while the whole page is built so the first byte lands late, against the streaming render which flushes the first node right after compile so the first byte lands early while later chunks keep arriving until the stream closes](/diagrams/stream-render-ttfb.png) diff --git a/docs/rendering/syntax.md b/docs/rendering/syntax.md index 7008a5e..ffaed11 100644 --- a/docs/rendering/syntax.md +++ b/docs/rendering/syntax.md @@ -1,10 +1,10 @@ --- -description: "DVE template syntax reference: variables, conditionals, loops, and includes." +description: "DVE template syntax reference: variables, conditionals, loops, layouts, and expressions." --- # Template Syntax -DVE templates are plain HTML with a small set of {{ }} tags for data, conditions, loops, and includes. Setup and the first render live in [Rendering Overview](/rendering/). +DVE templates are plain HTML with a small set of {{ }} tags for data, comments, conditions, loops, includes, and layouts. Setup and the first render live in [Rendering Overview](/rendering/), and everything that goes inside a printing tag is detailed in [Expressions](#expressions). ## Variables @@ -21,11 +21,15 @@ A {{ }} tag prints a value, and member access reaches nested

{{user.profile.email}}

``` -Lookups read only an object's own properties, so `__proto__`, `constructor`, and other inherited keys resolve to nothing. That blocks prototype pollution through user data. +Lookups read only an object's own properties, so `__proto__`, `constructor`, and other inherited keys resolve to nothing. That blocks prototype pollution through user data. A missing value anywhere along the path resolves to an empty string. + +## Comments + +A DVE comment is written as {{!-- text --}} and is stripped during parsing, so it never reaches the output. A normal `` HTML comment is left untouched and still ships in the response. Reach for the DVE form to hide notes from the client. ## Conditionals -{{#if}} renders a block when the value is truthy: +{{#if}} renders a block when the value is truthy, and every {{#if}} closes with {{/if}}: ```html @@ -34,7 +38,7 @@ Lookups read only an object's own properties, so `__proto__`, `constructor`, and {{/if}} ``` -A condition pairs with {{else}} for the fallback branch, and blocks nest freely: +The {{else if}} and {{else}} branches are optional, and blocks nest freely: ```html {{#if posts.length > 0}} @@ -49,9 +53,11 @@ A condition pairs with {{else}} for the fallback branch, and {{/if}} ``` +A chain adds {{else if condition}} before the final {{else}}, and each branch is tested in order until one matches. + ## Loops -{{#each}} walks an array under an alias, with metadata variables for each pass: +{{#each}} walks an array under an alias, and the alias defaults to `item` when the `as name` part is omitted: ```html {{#each users as u}} @@ -65,18 +71,28 @@ A condition pairs with {{else}} for the fallback branch, and {{/each}} ``` +An optional {{else}} renders when the array is empty or missing: + +```html +{{#each users as u}} +

{{u.name}}

+{{else}} +

No users yet.

+{{/each}} +``` + **Each metadata:** -- `@index` - Item index (0-based) -- `@first` - Boolean true if first item -- `@last` - Boolean true if last item +- `@index` - Item index, starting at 0, and it accepts arithmetic such as {{@index + 1}} +- `@first` - Boolean true on the first item +- `@last` - Boolean true on the last item - `@length` - Total number of items -Each loop is capped by `maxIterations`, covered in [Performance and Limits](/rendering/performance#iteration-limit). +Each loop is capped by `maxIterations`, and the running total across the page is capped by `maxRenderIterations`. Both live in [Performance and Limits](/rendering/performance#iteration-limit). ## Includes -The `>` operator pulls another template into the current one: +The `>` operator pulls another template into the current one. The path is the exact text in the tag, which Deserve resolves against the views directory: ```html @@ -91,31 +107,55 @@ The `>` operator pulls another template into the current one: Includes nest up to a fixed depth, covered in [Performance and Limits](/rendering/performance#include-depth-limit). -## Expressions +## Layouts -DVE supports JavaScript-like expressions for lookups and operators: +A layout is a shared shell that pages plug into. The layout marks named placeholders with a slot tag, and a page extends it and fills each placeholder with a block tag. + +Define the shell, with each slot carrying optional default content: ```html - -

Hello {{ user?.name ?? 'Guest' }}.

+ + + + + {{#slot title}}Untitled{{/slot}} + + +
{{#slot body}}{{/slot}}
+ + +``` - -

Total: {{ 1 + 2 * 3 }}

+Then a page extends the layout with the `<` operator and fills the slots by name. The extend tag opens with {{< layout/path}}, each {{#block name}} fills a matching slot, and a bare {{/}} closes the layout: - -{{#if age >= 18}}Adult{{/if}} +
+ +```html +{{< layouts/main.dve}} + {{#block title}}Home{{/block}} + {{#block body}}

Welcome.

{{/block}} +{{/}} ``` -The grammar is a safe subset, not full JavaScript. These pieces are supported: +
-- **Member access** - `user.name`, `user.profile.email`, and optional chaining `user?.name` -- **Math** - `+`, `-`, `*`, `/`, `%`, and unary `+`, `-`, `!` -- **Comparison** - `===`, `!==`, `==`, `!=`, `>`, `<`, `>=`, `<=` -- **Logic** - `&&`, `||`, `??`, and the ternary `cond ? a : b` -- **Literals** - numbers, single or double quoted strings, `true`, `false`, `null`, `undefined` -- **Grouping** - parentheses, for example `(a + b) * c` +A slot renders its own default when no matching block is provided. A block that names a slot the layout never declared is rejected with an error, which catches a typo before it ships silently. The layout chain shares the include depth cap in [Performance and Limits](/rendering/performance#include-depth-limit). + +## Whitespace Control + +A `~` next to the braces trims the whitespace touching that side of the tag, which keeps generated HTML tidy without reshaping the template: + +
+ +```html +{{#each items as item~}} + {{item}} +{{~/each}} +``` + +
-To keep templates safe and predictable, the engine rejects anything outside that subset and throws a parse error. Function calls like `format(price)`, bracket indexing like `items[0]`, and assignment are not allowed. +The `~` works on any tag, including the printing, raw, and block tags. ## Raw Output @@ -125,27 +165,34 @@ Values are HTML escaped by default, and triple braces opt out for trusted markup

{{userInput}}

- +

{{{trustedHtml}}}

``` -## Layout Composition +## Expressions -A layout is built by including smaller templates and dropping data into plain variables, so a shared shell wraps each page without any special slot mechanism: +DVE supports JavaScript-like expressions for lookups and operators: ```html - - - - - {{title}} - - -
{{> header.dve}}
-
{{{ content }}}
-
{{> footer.dve}}
- - + +

Hello {{ user?.name ?? 'Guest' }}.

+ + +

Total: {{ 1 + 2 * 3 }}

+ + +{{#if age >= 18}}Adult{{/if}} ``` -The `content` value comes from the route data, and triple braces render it as raw HTML when the markup is already trusted. +The grammar is a safe subset, not full JavaScript. These pieces are supported: + +- **Member access** - `user.name`, `user.profile.email`, and optional chaining `user?.name`, both null-safe +- **Math** - `+`, `-`, `*`, `/`, `%`, and unary `+`, `-`, `!` +- **Comparison** - `===`, `!==`, `==`, `!=`, `>`, `<`, `>=`, `<=` +- **Logic** - `&&`, `||`, `??`, and the ternary `cond ? a : b` +- **Literals** - numbers including exponents like `1e3`, single or double quoted strings, `true`, `false`, `null`, `undefined` +- **Grouping** - parentheses, for example `(a + b) * c` + +String literals understand escape sequences `\n`, `\t`, `\r`, `\b`, `\f`, `\v`, `\0`, `\\`, `\"`, `\'`, and `\/`, plus code-point escapes `\xNN`, `\uNNNN`, and `\u{...}`. + +To keep templates safe and predictable, the engine rejects anything outside that subset and throws a parse error. Function calls like `format(price)`, bracket indexing like `items[0]`, assignment, and regular expressions are not allowed. Anything that needs real logic belongs in the route handler, where the finished value is computed and passed into the template through the render data. diff --git a/docs/response/custom.md b/docs/response/custom.md index 138b5dc..3b1ee62 100644 --- a/docs/response/custom.md +++ b/docs/response/custom.md @@ -4,7 +4,7 @@ description: "Build fully custom responses with ctx.send.custom() when the helpe # Custom Responses -The `ctx.send.custom()` method creates custom responses with full control over body, status code, headers, and all response configuration options. Unlike the typed helpers, it sets no `Content-Type` on its own, so add one through the headers when the body needs it. +The `ctx.send.custom()` method creates responses with full control over the body. Unlike the typed helpers, it sets no `Content-Type` on its own, so add one through the headers when the body needs it. ## Basic Usage @@ -24,23 +24,20 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Set the response status to 404 - return ctx.send.custom( - 'Not Found', - { - status: 404 - } - ) + return ctx.send.custom('Not Found', { status: 404 }) } ``` ## With Custom Headers +Headers set through `ctx.set.header()` merge with headers from the options. Options headers take precedence when they conflict: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Header set on the context - ctx.setHeader('X-Custom', 'value') + ctx.set.header('X-Custom', 'value') // Options can add more headers return ctx.send.custom('Response body', { headers: { @@ -51,35 +48,46 @@ export function GET(ctx: Context): Response { } ``` -## Binary Responses +## Streaming Responses + +A `ReadableStream` passed as the body streams to the client without buffering the whole response. This suits large data or [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events): ```typescript twoslash import type { Context } from '@neabyte/deserve' -// ---cut--- + export function GET(ctx: Context): Response { - // Send raw bytes with a type - const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]) - return ctx.send.custom(binaryData, { + // Push two text chunks then close + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('Hello\n')) + controller.enqueue(new TextEncoder().encode('World\n')) + controller.close() + } + }) + // Stream becomes the response body + return ctx.send.custom(stream, { headers: { - 'Content-Type': 'application/octet-stream' + 'Content-Type': 'text/plain' } }) } ``` -## Empty Response (No Content) +For template streaming, use [`ctx.render()`](/core-concepts/context-object#rendering-templates) with `stream: true` instead, which handles the DVE engine and content type for you. + +## Binary Responses ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // 204 sends a null body - return ctx.send.custom( - null, - { - status: 204 + // Send raw bytes with a type + const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]) + return ctx.send.custom(binaryData, { + headers: { + 'Content-Type': 'application/octet-stream' } - ) + }) } ``` @@ -99,22 +107,11 @@ export function GET(ctx: Context): Response { } ``` -## Combining Context Headers and Custom Options - -Headers set via `ctx.setHeader()` are merged with headers from the options parameter: +## Method Signature -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - ctx.setHeader('X-Context-Header', 'from-context') - return ctx.send.custom('Body', { - headers: { - 'X-Options-Header': 'from-options' - } - }) - // Response carries both headers -} +```typescript +ctx.send.custom(body: BodyInit | null, options?: SendInit): Response ``` -Options headers take precedence over context headers when they conflict. +- **body** - any `BodyInit` value (string, `Blob`, `BufferSource`, `ReadableStream`, etc.) or `null` +- **options** - optional `status` and `headers` diff --git a/docs/response/data.md b/docs/response/data.md deleted file mode 100644 index c0d52bd..0000000 --- a/docs/response/data.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: "Send binary data downloads with ctx.send.data()." ---- - -# Data Download Responses - -The `ctx.send.data()` method sends in-memory data (string or `Uint8Array`) as a file download. Useful when content is created at runtime (generate CSV, JSON export, etc.) without writing to disk first. - -## Basic Usage - -```typescript twoslash -import type { Context } from '@neabyte/deserve' - -export function GET(ctx: Context): Response { - // String body with a download name - const csvData = 'name,age\nAlice,30\nBob,25' - return ctx.send.data(csvData, 'users.csv') -} -``` - -## Binary Data - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - // Uint8Array body with a download name - const binaryData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) - return ctx.send.data(binaryData, 'image.png') -} -``` - -## With Custom Content Type - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - // Fourth arg sets the content type - const jsonData = JSON.stringify({ - data: 'value' - }) - return ctx.send.data( - jsonData, - 'data.json', - { status: 200 }, - 'application/json' - ) -} -``` - -## Dynamic File Generation - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - // Build the payload at runtime - const data = { - timestamp: new Date().toISOString(), - version: '1.0.0' - } - const content = JSON.stringify(data, null, 2) - // Download without touching disk - return ctx.send.data(content, 'metadata.json') -} -``` diff --git a/docs/response/download.md b/docs/response/download.md new file mode 100644 index 0000000..8c62e0e --- /dev/null +++ b/docs/response/download.md @@ -0,0 +1,131 @@ +--- +description: "Send file download responses with ctx.send.download(), including Content-Disposition and filename handling." +--- + +# Download Responses + +The `ctx.send.download()` method sends a response that triggers a file download in the browser. It sets `Content-Disposition: attachment` with the given filename and defaults the `Content-Type` to `application/octet-stream`. + +This replaces having separate helpers for files and in-memory data. The body can be a string, a `BufferSource` (like `Uint8Array`), or a `ReadableStream` - whatever the handler already has in hand. + +## Basic Usage + +```typescript twoslash +import type { Context } from '@neabyte/deserve' + +export function GET(ctx: Context): Response { + // String body with a download name + const csv = 'name,age\nAlice,30\nBob,25' + return ctx.send.download(csv, 'users.csv') +} +``` + +## Binary Data + +A `Uint8Array` body works the same way: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Uint8Array body with a download name + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) + return ctx.send.download(png, 'image.png') +} +``` + +## Streaming From the Filesystem + +To send a file from disk, open a `ReadableStream` and pass it as the body. The handler becomes `async` because `Deno.open` is async: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare function createFileStream(): ReadableStream +// ---cut--- +export async function GET(ctx: Context): Promise { + // Open file as a readable stream + const stream = createFileStream() + // Stream becomes the download body + return ctx.send.download(stream, 'document.pdf') +} +``` + +A missing or unreadable file throws `Deno.errors.NotFound`. Catch it and forward to the [centralized error handler](/error-handling/object-details) for a consistent reply: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +declare function createFileStream(): ReadableStream +// ---cut--- +export async function GET(ctx: Context): Promise { + try { + const stream = createFileStream() + return ctx.send.download(stream, 'document.pdf') + } catch (error) { + // Route the failure through error handling + return await ctx.handleError(404, error as Error) + } +} +``` + +## With Custom Content Type + +The default `Content-Type` is `application/octet-stream`. Override it through the options headers when the browser needs a specific type: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + const json = JSON.stringify({ data: 'value' }) + return ctx.send.download( + json, + 'data.json', + { + headers: { + 'Content-Type': 'application/json' + } + } + ) +} +``` + +## Dynamic File Generation + +Build the payload at runtime and send it as a download without touching disk: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Build the payload at runtime + const data = { + timestamp: new Date().toISOString(), + version: '1.0.0' + } + const content = JSON.stringify(data, null, 2) + // Download without touching disk + return ctx.send.download(content, 'metadata.json') +} +``` + +## Filename Handling + +The filename is sanitized before it reaches the `Content-Disposition` header: + +- Directory paths are stripped, so `../secret.txt` becomes `secret.txt` +- Control characters are removed +- Non-ASCII characters get a `filename*=UTF-8''...` fallback alongside the ASCII name +- An empty or all-invalid filename falls back to `download` + +## Method Signature + +```typescript +ctx.send.download( + body: ReadableStream | BufferSource | string, + filename: string, + options?: SendInit +): Response +``` + +- **body** - download content as a string, `BufferSource`, or `ReadableStream` +- **filename** - suggested download filename, sanitized automatically +- **options** - optional `status` and `headers` diff --git a/docs/response/empty.md b/docs/response/empty.md new file mode 100644 index 0000000..b720caf --- /dev/null +++ b/docs/response/empty.md @@ -0,0 +1,58 @@ +--- +description: "Send empty responses with ctx.send.empty() for no-content status codes." +--- + +# Empty Responses + +The `ctx.send.empty()` method sends a response with no body. It suits status codes like `204 No Content` where the response carries nothing but a status. + +## Basic Usage + +```typescript twoslash +import type { Context } from '@neabyte/deserve' + +export function DELETE(ctx: Context): Response { + // 204 No Content, empty body + return ctx.send.empty(204) +} +``` + +## Without Status + +Call `ctx.send.empty()` with no argument to send an empty body with the default status: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + // Empty body, default status + return ctx.send.empty() +} +``` + +## With Headers + +Headers set through `ctx.set.header()` still merge into an empty response: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function DELETE(ctx: Context): Response { + // Set a header before sending + ctx.set.header('X-Deleted-Resource', 'true') + // 204 with the header attached + return ctx.send.empty(204) +} +``` + +## Null Body Status Codes + +The status codes `101`, `204`, `205`, and `304` always send a null body regardless of which `ctx.send` helper is used. Passing one of these to `ctx.send.json()` or `ctx.send.text()` also strips the body and the `Content-Type` header. `ctx.send.empty()` makes that intent explicit. + +## Method Signature + +```typescript +ctx.send.empty(status?: HttpStatusCode): Response +``` + +- **status** - optional HTTP status code, defaults to `200` diff --git a/docs/response/file.md b/docs/response/file.md deleted file mode 100644 index c4f3fe9..0000000 --- a/docs/response/file.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -description: "Serve file downloads from the filesystem with ctx.send.file()." ---- - -# File Download Responses - -The `ctx.send.file()` method sends file contents from the filesystem as the response. Suitable for downloads or serving files already on disk (relative or absolute path). - -## Basic Usage - -```typescript twoslash -import type { Context } from '@neabyte/deserve' - -export async function GET(ctx: Context): Promise { - // Stream the file as a download - return await ctx.send.file('./uploads/document.pdf') -} -``` - -## With Custom Filename - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export async function GET(ctx: Context): Promise { - // Second arg renames the download - return await ctx.send.file('./files/data.csv', 'report.csv') -} -``` - -## Error Handling - -A missing or unreadable file throws `Deno.errors.NotFound`. Catch it in the handler for a precise reply, or let it bubble to the [centralized error handler](/error-handling/object-details): - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export async function GET(ctx: Context): Promise { - try { - return await ctx.send.file('./uploads/document.pdf') - } catch (error) { - // Missing file throws, reply 404 - return ctx.send.json( - { - error: 'File not found' - }, - { - status: 404 - } - ) - } -} -``` diff --git a/docs/response/html.md b/docs/response/html.md index 5f97822..0a89e7c 100644 --- a/docs/response/html.md +++ b/docs/response/html.md @@ -4,7 +4,7 @@ description: "Send HTML responses with ctx.send.html()." # HTML Responses -The `ctx.send.html()` method creates HTML responses. +The `ctx.send.html()` method creates HTML responses. It sets `Content-Type: text/html; charset=utf-8` automatically. ## Basic Usage @@ -20,6 +20,8 @@ export function GET(ctx: Context): Response { ## Dynamic HTML +A template literal builds markup at runtime: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- @@ -39,6 +41,8 @@ export function GET(ctx: Context): Response { } ``` +For larger pages, render a [DVE template](/rendering/) with `ctx.render()` instead of building HTML by hand. + ## With Status Codes ```typescript twoslash @@ -47,23 +51,29 @@ import type { Context } from '@neabyte/deserve' export function GET(ctx: Context): Response { // Not Found page with status 404 const html = '

Not Found

' - return ctx.send.html( - html, - { - status: 404 - } - ) + return ctx.send.html(html, { status: 404 }) } ``` -## Custom Headers +## With Custom Headers + +Headers set through `ctx.set.header()` merge into the response: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Set a header before sending - ctx.setHeader('X-Frame-Options', 'DENY') + ctx.set.header('X-Frame-Options', 'DENY') return ctx.send.html('Content') } ``` + +## Method Signature + +```typescript +ctx.send.html(html: string, options?: SendInit): Response +``` + +- **html** - HTML string response body +- **options** - optional `status` and `headers` diff --git a/docs/response/json.md b/docs/response/json.md index e030301..05f57de 100644 --- a/docs/response/json.md +++ b/docs/response/json.md @@ -4,7 +4,7 @@ description: "Send JSON responses with ctx.send.json(), including status codes a # JSON Responses -The `ctx.send.json()` method creates JSON responses. +The `ctx.send.json()` method creates JSON responses. It serializes the data with `JSON.stringify()` and sets `Content-Type: application/json` automatically. ## Basic Usage @@ -25,55 +25,66 @@ export function GET(ctx: Context): Response { import type { Context } from '@neabyte/deserve' // ---cut--- export async function POST(ctx: Context): Promise { - const data = await ctx.body() + // Read parsed request body + const data = await ctx.get.body() // Reply Created with status 201 return ctx.send.json( - { - message: 'Created successfully', - data - }, + { message: 'Created successfully', data }, { status: 201 } ) } ``` -The `status` value must be an integer in the 200-599 range, or one of the body-less codes 101, 204, 205, and 304 which send an empty body. Any other value throws `Deno.errors.InvalidData`. This rule is shared by every `ctx.send` helper. +The `status` value must be an integer in the 200-599 range, or one of the body-less codes `101`, `204`, `205`, and `304` which send an empty body. Any other value throws `Deno.errors.InvalidData`. This rule is shared by every `ctx.send` helper. -Here `ctx.body()` returns whatever the client sent, so a handler that depends on its shape runs a [validation](/middleware/validation/overview) contract first and reads typed data that already passed. +Here `ctx.get.body()` returns whatever the client sent, so a handler that depends on its shape runs a [validation](/middleware/validation/overview) contract first and reads typed data that already passed. ## With Custom Headers +Headers set through `ctx.set.header()` merge into the response. Options headers take precedence when they conflict: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { // Set a header before sending - ctx.setHeader('Cache-Control', 'no-cache') + ctx.set.header('Cache-Control', 'no-cache') return ctx.send.json({ data: 'sensitive' }) } ``` +Headers can also be passed through the options: + +```typescript twoslash +import type { Context } from '@neabyte/deserve' +// ---cut--- +export function GET(ctx: Context): Response { + return ctx.send.json( + { data: 'sensitive' }, + { + headers: { + 'Cache-Control': 'no-cache', + 'X-Request-ID': 'abc123' + } + } + ) +} +``` + ## Complex Data +Nested objects and arrays serialize as-is: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // Nested objects serialize as-is const data = { users: [ - { - id: 1, - name: 'Alice', - email: 'alice@example.com' - }, - { - id: 2, - name: 'Bob', - email: 'bob@example.com' - } + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' } ], pagination: { page: 1, @@ -88,6 +99,8 @@ export function GET(ctx: Context): Response { ## Error Responses +A handler can shape a one-off error body like this: + ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- @@ -100,4 +113,13 @@ export function GET(ctx: Context): Response { } ``` -A handler can shape a one-off error body like this, but a thrown error routes through one place instead, covered in [Error Object Details](/error-handling/object-details). +A thrown error routes through one place instead, covered in [Error Object Details](/error-handling/object-details). For consistent error shapes across the app, use [`ctx.handleError()`](/core-concepts/context-object#error-handling) rather than building each response by hand. + +## Method Signature + +```typescript +ctx.send.json(data: T, options?: SendInit): Response +``` + +- **data** - value to serialize as JSON +- **options** - optional `status` and `headers` diff --git a/docs/response/redirect.md b/docs/response/redirect.md index d48ade2..9df119f 100644 --- a/docs/response/redirect.md +++ b/docs/response/redirect.md @@ -4,7 +4,7 @@ description: "Create redirect responses with ctx.send.redirect(), including allo # Redirect Responses -The `ctx.send.redirect()` method creates a redirect response to another URL. The default status is 302 (temporary redirect) and the accepted statuses are 301 (permanent), 302, 303 (see other), 307 (temporary) and 308 (permanent), so any other status throws `Deno.errors.InvalidData`. +The `ctx.send.redirect()` method creates a redirect response to another URL. The default status is `302` (temporary redirect). The accepted statuses are `301`, `302`, `303`, `307`, and `308`, so any other status throws `Deno.errors.InvalidData`. ## Basic Usage @@ -67,7 +67,7 @@ export function GET(ctx: Context): Response { ## URL Resolution -A relative target resolves against the current request URL and must stay on the same origin, which guards against open redirects. To send a visitor to another site, pass a full `https://` URL on purpose: +A relative target resolves against the current request URL and must stay on the same origin, which guards against [open redirects](https://cwe.mitre.org/data/definitions/601.html). To send a visitor to another site, pass a full `https://` URL on purpose: ```typescript twoslash import type { Context } from '@neabyte/deserve' diff --git a/docs/response/stream.md b/docs/response/stream.md deleted file mode 100644 index 537ac59..0000000 --- a/docs/response/stream.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -description: "Send streaming responses from a ReadableStream with ctx.send.stream()." ---- - -# Stream Responses - -The `ctx.send.stream()` method returns a response body from a `ReadableStream`, useful for streaming large data or server-sent events without full buffering. - -## Basic Usage - -```typescript twoslash -import type { Context } from '@neabyte/deserve' - -export function GET(ctx: Context): Response { - // Push two text chunks then close - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('Hello\n')) - controller.enqueue(new TextEncoder().encode('World\n')) - controller.close() - } - }) - // Stream becomes the response body - return ctx.send.stream(stream) -} -``` - -## With Custom Content-Type - -The third parameter is the content type and it defaults to `application/octet-stream`: - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('Hello')) - controller.close() - } - }) - // Third arg sets the content type - return ctx.send.stream(stream, undefined, 'text/plain') -} -``` - -## With Status And Headers - -```typescript twoslash -import type { Context } from '@neabyte/deserve' -// ---cut--- -export function GET(ctx: Context): Response { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('{"ok":true}\n')) - controller.close() - } - }) - // Second arg status, third arg type - return ctx.send.stream(stream, { - status: 200, - headers: { - 'X-Custom': 'value' - } - }, 'application/x-ndjson') -} -``` - -## Method Signature - -```typescript -ctx.send.stream( - stream: ReadableStream, - options?: ResponseInit, - contentType?: string -): Response -``` - -- **stream** - ReadableStream used as response body -- **options** - optional status and headers (ResponseInit) -- **contentType** - optional, defaults to `'application/octet-stream'` diff --git a/docs/response/text.md b/docs/response/text.md index a88013e..4ac9d1c 100644 --- a/docs/response/text.md +++ b/docs/response/text.md @@ -4,7 +4,7 @@ description: "Send plain text responses with ctx.send.text()." # Text Responses -The `ctx.send.text()` method creates plain text responses. +The `ctx.send.text()` method creates plain text responses. It sets `Content-Type: text/plain; charset=utf-8` automatically. ## Basic Usage @@ -24,43 +24,48 @@ import type { Context } from '@neabyte/deserve' // ---cut--- export function POST(ctx: Context): Response { // Reply Not Implemented with 501 - return ctx.send.text( - 'Not Implemented', - { - status: 501 - } - ) + return ctx.send.text('Not Implemented', { status: 501 }) } ``` -## Error Messages +The `status` value must be an integer in the 200-599 range, or one of the body-less codes `101`, `204`, `205`, and `304`. Any other value throws `Deno.errors.InvalidData`. + +## With Custom Headers + +Headers set through `ctx.set.header()` merge into the response. Options headers take precedence when they conflict: ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // Plain text error with status 500 - return ctx.send.text( - 'Internal Server Error', - { - status: 500 + // Set a header before sending + ctx.set.header('X-Custom', 'value') + return ctx.send.text('Hello World', { + headers: { + 'Content-Language': 'en' } - ) + }) } ``` -## Custom Headers +## Error Messages + +A handler can return a plain text error body, but a thrown error routes through one place instead, covered in [Error Object Details](/error-handling/object-details). For consistent error shapes, use [`ctx.handleError()`](/core-concepts/context-object#error-handling). ```typescript twoslash import type { Context } from '@neabyte/deserve' // ---cut--- export function GET(ctx: Context): Response { - // Add headers through the options - return ctx.send.text('Hello World', { - headers: { - 'Content-Language': 'en', - 'X-Custom': 'value' - } - }) + // Plain text error with status 500 + return ctx.send.text('Internal Server Error', { status: 500 }) } ``` + +## Method Signature + +```typescript +ctx.send.text(text: string, options?: SendInit): Response +``` + +- **text** - plain text response body +- **options** - optional `status` and `headers` diff --git a/docs/static-file/basic.md b/docs/static-file/basic.md index cddfeff..c7c0d6e 100644 --- a/docs/static-file/basic.md +++ b/docs/static-file/basic.md @@ -4,20 +4,20 @@ description: "Serve static files from a directory with the Deserve static handle # Basic Static Serving -Serve static files (HTML, CSS, JS, images) using the `static()` method. +The `router.static()` method serves files from a folder under a URL prefix, with caching, byte ranges, and path safety built in. It covers HTML, CSS, JavaScript, images, fonts, and any other asset on disk. ## Basic Usage -Serve static files from a directory: +Mount a folder under a URL prefix: -![Calling router.static with the prefix slash static and path dot slash public registers the pattern slash static slash star star, then each request has its slash static prefix sliced off ctx.pathname and the remainder joined under public, so slash static maps to public slash index dot html, slash static slash css slash style dot css maps to public slash css slash style dot css, and slash static slash dot env is rejected with 404 before any read because the segment starts with a dot](/diagrams/static-url-to-file.png) +![A request to slash static slash css slash style dot css matches the slash static mount, has its slash static prefix stripped to css slash style dot css, and is served from the public folder, while a request to slash static slash dot env is rejected with 404 before any read because the segment starts with a dot](/diagrams/static-url-to-file.png) ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() -// Serve ./public under the /static path +// Serve ./public under the /static prefix router.static('/static', { path: './public', etag: true, @@ -27,43 +27,38 @@ router.static('/static', { await router.serve(8000) ``` -This serves files from the `public/` directory at the `/static` URL path: - -- `GET /static/index.html` → serves `public/index.html` -- `GET /static/css/style.css` → serves `public/css/style.css` -- `GET /static/.env` → rejected with **404** before any read +That mount maps each URL under `/static` to a file in `public/`: +- `GET /static/index.html` serves `public/index.html` +- `GET /static/css/style.css` serves `public/css/style.css` +- `GET /static/.env` is rejected with **404** before any read ## How It Works -Deserve uses a custom static file serving implementation: +A static mount is not a file route. It is a separate registry that the router checks only after dynamic routes miss, so the matching order is fixed: -1. **Route Matching**: Creates routes with pattern `${urlPath}/**` to match all files -2. **Path Extraction**: reads `ctx.pathname` directly to get the full request path, since FastRouter's `/**` pattern only captures the first segment -3. **File Resolution**: Maps URL paths to file system paths using the `path` option -4. **Priority**: Static routes are registered for all HTTP methods before dynamic routes +1. Entry middleware runs first. +2. A matching dynamic route handles the request and static never runs. +3. When the path matches a route under a different method, the router replies **405 Method Not Allowed** with an `Allow` header, and static still never runs. +4. With no route match at all, the router walks the static mounts and serves the first one whose prefix covers the path. -### Wildcard Pattern Behavior +A request keeps its prefix until a mount matches, then the prefix is stripped and the remainder becomes the file path under the folder. So `GET /static/css/style.css` strips `/static` and resolves `css/style.css` inside `public/`. -When `urlPath` is `/`, Deserve creates a `/**` pattern. For path resolution, Deserve uses `ctx.pathname` instead of relying on wildcard parameter, because: +### Prefix Matching -- FastRouter's `/**` pattern only captures the **first segment** of the request path instead of the full path (e.g., `"styles"` for `/styles/ui.css`) -- To serve nested files correctly, Deserve extracts the full path from `ctx.pathname` and removes the leading `/` to get the relative file path +Mounts are sorted longest prefix first, so the most specific one wins. A mount on `/admin/assets` is tried before a mount on `/admin`, which lets a broad fallback and a focused folder live together. A mount on `/` acts as a catch-all that covers every remaining path. Multiple mounts and their dispatch order live in [Multiple Directories](/static-file/multiple). -**Example:** +### Supported Methods -- Request: `GET /styles/ui.css` -- Pattern: `/**` matches from configurable path -- File path: Extracted from `ctx.pathname` → `"styles/ui.css"` -- Resolved: `static/styles/ui.css` +A static mount answers `GET` and `HEAD` only. Any other method on a path the mount covers returns **405 Method Not Allowed** with `Allow: GET, HEAD`. A `HEAD` request runs the same path as `GET` and returns the headers with an empty body. ## Static File Options -The `static()` method accepts a `ServeOptions` object: +The second argument is a `ServeOptions` object. Only `path` is required: ### `path` -File system directory path to serve files from: +The filesystem directory to serve from, relative to the current working directory or absolute. An empty path throws at mount time: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -71,17 +66,17 @@ import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- router.static('/static', { - path: './public' // Serve files from public/ directory + path: './public' // Serve files from public folder }) router.static('/assets', { - path: '/absolute/path/to/assets' // Absolute path also supported + path: '/absolute/path/to/assets' // Absolute path also works }) ``` ### `etag` -Enable ETag generation for caching. The tag is a SHA-256 hash of the file size and modification time, not the full file content, so it stays cheap to compute: +Turns on ETag generation, and it defaults to on when omitted. The tag is a weak validator built from a SHA-256 hash of the file size and modification time, not the file contents, so it stays cheap to compute: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -90,15 +85,15 @@ const router = new Router() // ---cut--- router.static('/static', { path: './public', - etag: true // Generate ETag from size and mtime + etag: true // Build ETag from size and mtime }) ``` -When enabled, a client that sends a matching `If-None-Match` header receives a `304 Not Modified` response with no body. +When a client sends a matching `If-None-Match`, the response is **304 Not Modified** with no body. A client sending `If-Modified-Since` gets the same 304 when the file is no newer than that date. ### `cacheControl` -Set the Cache-Control max-age in seconds. Deserve sends it as `public, max-age=`, applied only when the value is `0` or higher: +Sets the `Cache-Control` max-age in seconds, sent as `public, max-age=`. It applies only when the value is `0` or higher, and is omitted otherwise: ```typescript twoslash import { Router } from '@neabyte/deserve' @@ -107,54 +102,61 @@ const router = new Router() // ---cut--- router.static('/static', { path: './public', - cacheControl: 86400 // Cache for 1 day (86400 seconds) + cacheControl: 86400 // Cache for one day }) router.static('/assets', { path: './assets', - cacheControl: 31536000 // Cache for 1 year + cacheControl: 31536000 // Cache for one year }) ``` -## Byte-Range Requests +## Custom Handler -Static responses support a single [byte range](https://www.rfc-editor.org/rfc/rfc7233) so a client can fetch part of a file, which is what a video scrubber or a resumable download relies on. Every static response advertises `Accept-Ranges: bytes`, and a request carrying one contiguous `Range` header is answered with the matched window: +In place of options, `static()` accepts a function of the shape `(ctx, urlPath) => Response`. It receives the [context](/core-concepts/context-object) and the path with the mount prefix already stripped, which suits an in-memory asset map or a generated file: -- **One valid range** returns **206 Partial Content** with a `Content-Range: bytes start-end/size` header and only those bytes streamed off disk. -- **An unsatisfiable range** that names a window past the file size returns **416 Range Not Satisfiable** with `Content-Range: bytes */size`. -- **An absent, multi-part, or malformed range** falls back to the full file as before. +```typescript twoslash +import { Router, type Context } from '@neabyte/deserve' -Only the bytes inside the requested window are read, and the file handle is released once the window is sent, errors, or is cancelled. +const router = new Router() +// ---cut--- +// Serve assets from a map by stripped path +router.static('/cdn', (ctx: Context, urlPath: string) => { + const assets: Record = { 'logo.svg': '' } + const body = assets[urlPath] + if (body === undefined) { + return ctx.send.empty(404) + } + return ctx.send.custom(body, { headers: { 'Content-Type': 'image/svg+xml' } }) +}) +``` -## File Resolution and Security +## Byte-Range Requests -Static serving maps a URL path to a file under the configured directory, with a few built-in rules: +Static responses support a single [byte range](https://www.rfc-editor.org/rfc/rfc7233) so a client can fetch part of a file, which is what a video scrubber or a resumable download relies on. Every static response advertises `Accept-Ranges: bytes`: -- **Index fallback** - a request to the route root serves `index.html` from the directory. -- **Content type** - the type is picked from the file extension. Common web assets like HTML, CSS, JavaScript, JSON, images, fonts, and documents are mapped out of the box, and an unknown extension falls back to `application/octet-stream`. -- **Dotfiles blocked** - any path segment whose name starts with `.` is rejected with **404**, so files like `.env`, `.git/config`, or a leading `..` never get served. The rule looks at the segment name, not the extension, so a normal file such as `report.env` is still served. -- **Directory traversal blocked** - the resolved real path must stay inside the base directory. A path that escapes it, such as one built from `..`, is rejected with **404**. +- A single valid range returns **206 Partial Content** with `Content-Range: bytes start-end/size`, streaming only those bytes off disk. +- A range past the file size returns **416 Range Not Satisfiable** with `Content-Range: bytes */size`. +- An absent, multi-part, or malformed range falls back to the full file. -A missing or blocked file returns 404 through the [centralized error handler](/error-handling/object-details). +An `If-Range` header carrying a date keeps the range only when the file is unchanged, otherwise the full file is sent. An `If-Range` carrying an entity tag is treated as stale, so the full file is sent. The file handle is released once the window is sent, errors, or is cancelled. -## Troubleshooting +## File Resolution and Security -### Files Not Found +A mount maps a URL to a file under its folder with a few fixed rules: -- Check `path` is correct (relative to current working directory or absolute) -- Verify file permissions -- Ensure files exist in the directory -- Check that the URL path matches the route pattern (`/static/file.css` for `router.static('/static', ...)`) +- **Index fallback** - a request to the mount root serves `index.html` from the folder. +- **Content type** - the type comes from the file extension. Common web assets such as HTML, CSS, JavaScript, JSON, images, fonts, and documents are mapped out of the box, and an unknown extension falls back to `application/octet-stream`. +- **Dotfiles blocked** - any path segment whose name starts with `.` is rejected with **404**, so `.env`, `.git/config`, or a leading `..` never get served. The rule reads the segment name, not the extension, so a normal file like `report.env` is still served. +- **Traversal blocked** - the resolved real path must stay inside the folder. A path that escapes through `..` or a symlink is rejected with **404**. -### 404 Errors +A miss or a blocked path emits a `static:missing` event on the [observability bus](/middleware/observability/overview) and returns **404** through the [centralized error handler](/error-handling/object-details), the same handler set with `router.catch()` that shapes every other error. There is no per-mount error hook, so one handler covers static, routes, and middleware alike. -- Verify the static route is registered before calling `router.serve()` -- Check that file paths match the URL structure -- Ensure the file exists at the resolved path +## Troubleshooting -### Caching Issues +A few common misses and what to check: -- Verify `etag` and `cacheControl` are set correctly -- Check browser DevTools Network tab for ETag and Cache-Control headers -- Clear browser cache for testing -- Use `304 Not Modified` responses (visible when ETag matches) +- **404 on a file that exists** - confirm `path` points at the right folder and the URL keeps the mount prefix, so `/static/app.css` for a mount on `/static`. +- **404 on a dotfile** - this is intentional, since any segment starting with `.` is blocked. +- **A route wins over a static file** - a dynamic route on the same path takes priority, so rename one or move the static mount under a distinct prefix. +- **Caching not applied** - check the `ETag` and `Cache-Control` headers in the browser network panel, and confirm `etag` and `cacheControl` are set. diff --git a/docs/static-file/multiple.md b/docs/static-file/multiple.md index 84ba9f6..ee92227 100644 --- a/docs/static-file/multiple.md +++ b/docs/static-file/multiple.md @@ -4,11 +4,11 @@ description: "Serve static assets from multiple directories under different URL # Multiple Directories -Serve static files from multiple directories with different configurations per path. Each call shares the same options and resolution rules covered in [Basic Static Serving](/static-file/basic). +Several `router.static()` calls can run side by side, each binding one URL prefix to its own folder with its own cache policy. The options and resolution rules per mount are covered in [Basic Static Serving](/static-file/basic), and this page focuses on how many mounts share one router. ## Basic Usage -Configure multiple static directories: +Mount each prefix with its own folder and cache: ![Three static calls each bind one url prefix to its own folder with its own cache policy, where slash admin serves the admin slash dist folder with etag on and a one day cache, slash uploads serves the uploads folder with etag off and no cache, and slash docs serves the docs slash build folder with etag on and a one hour cache](/diagrams/static-multiple-dirs.png) @@ -17,7 +17,7 @@ import { Router } from '@neabyte/deserve' const router = new Router() -// Each path gets its own folder and cache +// Each prefix gets its own folder and cache router.static('/admin', { path: './admin/dist', etag: true, @@ -37,79 +37,67 @@ router.static('/docs', { await router.serve(8000) ``` +## How Mounts Are Picked + +Every mount lands in one registry sorted longest prefix first. A request walks that list and the first prefix that covers the path wins, so the most specific mount always takes precedence over a broader one: + +![One request picks the static prefix it starts with, so a request under slash uploads matches the slash uploads mount and is served from the uploads folder with that prefix etag off and no cache, while the same tail under slash docs matches the slash docs mount instead and is served from docs slash build with etag on and a one hour cache, proving the matched prefix decides both folder and cache policy](/diagrams/static-prefix-dispatch.png) + +The matched prefix decides both the folder and the cache policy, so two mounts can share a tail path and still resolve to different files. A mount on `/` sits at the end as a catch-all that covers anything the earlier prefixes did not. + ## Common Patterns -![One request picks the static prefix it starts with, so GET slash uploads slash img slash a dot png matches the slash uploads pattern, has its prefix sliced off, and is served from the uploads folder with that prefix etag off and no cache, while the same tail under GET slash docs slash img slash a dot png matches the slash docs pattern instead and is served from docs slash build with etag on and a one hour cache, proving the matched prefix decides both folder and cache policy](/diagrams/static-prefix-dispatch.png) +### Site With a Catch-All Root -### Website + Admin Panel +A broad `/` mount and a focused `/admin` mount coexist because the longer prefix is matched first. A request to `/admin/index.html` resolves through the admin mount, while `/style.css` falls to the root mount: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- -// Main website -router.static('/', { - path: './public', +// Admin panel, matched first +router.static('/admin', { + path: './admin/dist', etag: true, cacheControl: 86400 }) -// Admin panel -router.static('/admin', { - path: './admin/dist', +// Catch-all root, matched last +router.static('/', { + path: './public', etag: true, cacheControl: 86400 }) ``` -### Assets + Uploads +### Long-Lived Assets and Fresh Uploads + +A fingerprinted asset folder caches for a year, while a user upload folder turns caching off so a replaced file is always fetched fresh: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- -// Static assets with long-term caching +// Fingerprinted assets cache for a year router.static('/assets', { path: './public/assets', etag: true, - cacheControl: 31536000 // 1 year + cacheControl: 31536000 }) -// User uploads without caching +// User uploads stay uncached router.static('/uploads', { path: './uploads', etag: false, - cacheControl: 0 // No cache -}) -``` - -### Development + Production - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const router = new Router() -// ---cut--- -// Development files - short cache -router.static('/dev', { - path: './dev', - etag: true, - cacheControl: 0 // No cache for dev -}) - -// Production build - long cache -router.static('/', { - path: './dist', - etag: true, - cacheControl: 31536000 // 1 year + cacheControl: 0 }) ``` -## Directory Structure Examples +## Directory Structure -### Full-Stack Application +A layout that fits the mounts above: ``` . @@ -122,118 +110,31 @@ router.static('/', { │ └── dist/ │ ├── index.html │ └── assets/ -├── uploads/ -│ ├── images/ -│ └── documents/ -└── docs/ - └── build/ - ├── index.html - └── assets/ -``` - -### Microservices Frontend - -``` -. -├── main.ts -├── web/ -│ └── dist/ -├── api/ -│ └── docs/ -├── admin/ -│ └── build/ -└── mobile/ - └── public/ +└── uploads/ + ├── images/ + └── documents/ ``` -## Configuration Examples +## Routes Take Priority -### Different Caching Strategies +Static mounts run only after dynamic routes miss, so a route always wins on a shared path. A file route at `/admin` handles `GET /admin` before the `/admin` static mount ever sees it, which is the matching order detailed in [Basic Static Serving](/static-file/basic#how-it-works). Keep an API and a static folder on distinct prefixes to avoid a surprise: ```typescript twoslash import { Router } from '@neabyte/deserve' const router = new Router() // ---cut--- -// Long-term cached assets (1 year) -router.static('/assets', { - path: './public/assets', - etag: true, - cacheControl: 31536000 -}) - -// Medium-term cache (1 day) -router.static('/images', { - path: './public/images', - etag: true, - cacheControl: 86400 -}) - -// No caching for dynamic uploads -router.static('/uploads', { - path: './uploads', - etag: false, - cacheControl: 0 -}) -``` - -### Different ETag Settings - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const router = new Router() -// ---cut--- -// Enable ETag for efficient caching +// API under /api, assets under /static router.static('/static', { - path: './public', - etag: true, - cacheControl: 86400 + path: './public' }) - -// Disable ETag for frequently changing files -router.static('/reports', { - path: './reports', - etag: false, - cacheControl: 3600 +router.static('/admin', { + path: './admin/dist' }) ``` ## Troubleshooting -### Route Conflicts - -Routes are registered for all HTTP methods (`GET`, `POST`, etc.). Make sure static routes don't conflict with dynamic routes: - -```typescript twoslash -import { Router } from '@neabyte/deserve' - -const router = new Router() -// ---cut--- -router.static( - '/', - { - path: './public' - } -) -router.static( - '/admin', - { - path: './admin/dist' - } -) -``` - -### File Not Found - -- Check `path` values are correct (relative to cwd or absolute) -- Verify directory structure matches configuration -- Ensure files exist in the specified directories -- Check URL paths match the route pattern - -### Performance Issues - -- Enable `etag: true` for efficient caching -- Set appropriate `cacheControl` values based on content type -- Static assets: long cache (31536000 = 1 year) -- Dynamic content: short or no cache (0 or 3600) +- **Wrong folder served** - a broader prefix is matching first only when it is actually longer, so confirm the specific mount has the longer prefix. +- **A route shadows a file** - a dynamic route on the same path is served before the static mount, so move one to a distinct prefix. +- **404 across a mount** - check the folder path and that the URL keeps the mount prefix, since each miss returns 404 through the [centralized error handler](/error-handling/object-details). diff --git a/editor/README.md b/editor/README.md deleted file mode 100644 index 600d266..0000000 --- a/editor/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# Editor Tooling - -Editor support for Deserve, including syntax highlighting for Deserve View Engine (DVE) templates. - -## Table of Contents - -- [DVE (Deserve View Engine)](#dve-deserve-view-engine) -- [Example: Use DVE in Deserve](#example-use-dve-in-deserve) - - [Project Structure](#project-structure) - - [1) Add Templates](#1-add-templates) - - [2) Configure Router](#2-configure-router) - - [3) Render in a Route](#3-render-in-a-route) -- [Syntax Highlighting (Cursor / VS Code / Trae)](#syntax-highlighting-cursor--vs-code--trae) - -## DVE (Deserve View Engine) - -DVE is Deserve's built-in view engine for rendering `.dve` templates. - -## Example: Use DVE in Deserve - -### Project Structure - -``` -. -├── main.ts -├── routes/ -│ └── index.ts -└── views/ - ├── index.dve - └── partials/ - └── header.dve -``` - -### 1) Add Templates - -Create `views/index.dve`: - -```txt -{{> partials/header.dve}} -Hello {{ user?.name ?? 'Guest' }}. -``` - -Create `views/partials/header.dve`: - -```txt -

Welcome

-``` - -### 2) Configure Router - -Enable DVE by setting `viewsDir` when the router is created. - -```ts -import { Router } from '@neabyte/deserve' - -const router = new Router({ - routesDir: './routes', - viewsDir: './views' -}) - -await router.serve(8000) -``` - -### 3) Render in a Route - -Create `routes/index.ts`: - -```ts -import type { Context } from '@neabyte/deserve' - -export async function GET(ctx: Context) { - return await ctx.render('index', { user: { name: 'Nea' } }) -} -``` - -Run the server and open `http://localhost:8000` to see the rendered page. - -## Syntax Highlighting (Cursor / VS Code / Trae) - -Deserve ships a local DVE extension package at `editor/dve/dve-language-0.1.0.vsix`. - -Install it with an editor CLI: - -```bash -# Trae -trae --install-extension ./dve/dve-language-0.1.0.vsix --force - -# VS Code -code --install-extension ./dve/dve-language-0.1.0.vsix --force - -# Cursor -cursor --install-extension ./dve/dve-language-0.1.0.vsix --force -``` - -After installing, reload the editor window and open any `.dve` file, where HTML stays the base syntax with the embedded DVE tags highlighted on top. - -- **DVE syntax reference**: See [`editor/dve/README.md`](dve/README.md) diff --git a/editor/dve/README.md b/editor/dve/README.md deleted file mode 100644 index 9b0bd66..0000000 --- a/editor/dve/README.md +++ /dev/null @@ -1,250 +0,0 @@ -# DVE Grammar - -A short, friendly tour of the Deserve `.dve` template syntax that reads from top to bottom, so the first open of a `.dve` file feels easy instead of intimidating. - -- **Editor tooling overview**: See [`editor/README.md`](../README.md) - -## Table of Contents - -- [Install Local VSIX](#install-local-vsix) -- [Start Here](#start-here) -- [Variables](#variables) -- [Raw Output (Unescaped)](#raw-output-unescaped) -- [Include](#include) -- [If / Else](#if--else) -- [Each](#each) -- [Each Metadata](#each-metadata) -- [Expressions](#expressions) -- [Operator Reference](#operator-reference) -- [Snippets](#snippets) -- [Advanced Examples](#advanced-examples) -- [What DVE Does Not Do](#what-dve-does-not-do) -- [Editor Scope Mapping](#editor-scope-mapping) - -## Install Local VSIX - -This folder ships a prebuilt VSIX package, so nothing needs to be built first: - -```txt -dve-language-0.1.0.vsix -``` - -Install it from this directory with an editor CLI: - -```bash -# VS Code -code --install-extension ./dve-language-0.1.0.vsix --force - -# Cursor -cursor --install-extension ./dve-language-0.1.0.vsix --force - -# Trae -trae --install-extension ./dve-language-0.1.0.vsix --force -``` - -Reload the editor after installing, and since DVE builds on HTML syntax the `.dve` files keep full HTML highlighting with the template tags layered on top. - -## Start Here - -The whole language comes down to two tags, and once those land the rest is just small variations. - -A `{{ ... }}` tag **shows a value** while a `{{#... }} ... {{/... }}` tag wraps a **block** like an if or a loop, and everything further down builds on those two shapes. - -```txt -Hello {{ user?.name ?? 'Guest' }}. -{{#if user?.isAdmin}}ADMIN{{else}}USER{{/if}} -``` - -## Variables - -A value wrapped in double braces is printed onto the page, and DVE escapes HTML by default so user input can never sneak in markup or open an injection hole. - -```txt -Hello {{ name }}. -``` - -## Raw Output (Unescaped) - -Triple braces print the value as-is with no escaping, which is meant only for HTML that is already known to be safe. - -```txt -{{{ trustedHtml }}} -``` - -## Include - -A repeated piece of markup can live in its own file and get pulled in with an include, where the path is resolved relative to the configured `viewsDir`. - -```txt -{{> partials/header.dve}} -``` - -## If / Else - -An `#if` block renders its body only when the condition is truthy and an optional `else` covers the other case, and every `#if` must be closed with a matching `/if` or DVE reports the block as unclosed. - -```txt -{{#if ok}}YES{{else}}NO{{/if}} -``` - -## Each - -An `#each` block walks an array and `as` names the current item, and leaving the name out falls back to `item`. - -```txt -{{#each items as item}}{{ item }},{{/each}} -``` - -## Each Metadata - -Inside an `#each` block four helpers are available for free, so the loop position never has to be tracked by hand: - -- `@index` — current position, starting at 0 -- `@first` — true on the first item -- `@last` — true on the last item -- `@length` — total number of items - -```txt -{{#each items as item}}({{ @index }}/{{ @length }} {{#if @first}}F{{else}}-{{/if}}{{#if @last}}L{{else}}-{{/if}}={{ item }});{{/each}} -``` - -## Expressions - -Any `{{ ... }}` tag accepts a small JavaScript-like expression, so a value can be read, given a fallback, compared, or run through a little math all in one place. - -```txt -Hello {{ user?.name ?? 'Guest' }}. -Total {{ price * quantity }} -{{#if age >= 18}}Adult{{else}}Minor{{/if}} -``` - -A few behaviours worth knowing: - -- A dotted path like `user.profile.name` reads nested values, and missing data along the way resolves to `undefined` -- Both `.` and `?.` return `undefined` when the object is missing, so a deep lookup never throws -- Strings use `"double"` or `'single'` quotes and understand the `\n`, `\t`, `\r` escapes -- Numbers can be decimals or exponents like `2.5` or `1e3` - -## Operator Reference - -Everything DVE understands, lowest precedence at the top down to highest at the bottom, and anything not on this list is rejected by the parser on purpose. - -| Group | Operators | Example | -| -------------- | --------------------------------------------------- | -------------------------- | -| Ternary | `? :` | `{{ ok ? 'yes' : 'no' }}` | -| Nullish | `??` | `{{ name ?? 'Guest' }}` | -| Logical OR | `\|\|` | `{{ a \|\| b }}` | -| Logical AND | `&&` | `{{ a && b }}` | -| Equality | `===` `!==` `==` `!=` | `{{ role === 'admin' }}` | -| Relational | `>` `<` `>=` `<=` | `{{ age >= 18 }}` | -| Additive | `+` `-` | `{{ a + b }}` | -| Multiplicative | `*` `/` `%` | `{{ total % 2 }}` | -| Unary | `!` `+` `-` | `{{ !done }}` | -| Member | `.` `?.` | `{{ user?.profile.name }}` | -| Grouping | `( )` | `{{ (a + b) * c }}` | -| Literals | numbers, strings, `true` `false` `null` `undefined` | `{{ 1 + 2 * 3 }}` | - -## Snippets - -Type a prefix and press Tab to drop in the syntax that is easiest to forget. - -| Prefix | Inserts | -| ---------- | ------------------------------------- | -| `dve` | `{{ value }}` | -| `dveraw` | `{{{ html }}}` | -| `dveinc` | `{{> partials/header.dve }}` | -| `dveif` | `{{#if}} ... {{else}} ... {{/if}}` | -| `dveifn` | multi-line `{{#if}}` block | -| `dveeach` | `{{#each items as item}}` block | -| `dveeachm` | `#each` block with `@index`/`@length` | -| `dvetern` | `{{ cond ? yes : no }}` | -| `dvedef` | `{{ value ?? 'fallback' }}` | -| `dveopt` | `{{ user?.name ?? 'Guest' }}` | -| `dvecmt` | `` | - -## Advanced Examples - -### Layout + Partial Composition - -`views/layout.dve`: - -```txt - - - {{> partials/header.dve}} -
- {{{ bodyHtml }}} -
- {{> partials/footer.dve}} - - -``` - -`views/partials/header.dve`: - -```txt -
-

{{ title ?? 'Untitled' }}

- {{#if user?.name}}

Hello {{ user.name }}.

{{else}}

Hello Guest.

{{/if}} -
-``` - -### Lists With Conditional Blocks - -```txt -{{#if items?.length ?? 0}} -
    - {{#each items as item}} -
  • - {{#if item?.isPinned}}[PIN] {{/if}} - ({{ @index + 1 }}/{{ @length }}) {{ item?.label ?? 'No label' }} -
  • - {{/each}} -
-{{else}} -

No items.

-{{/if}} -``` - -### Nested Each (Matrix-Style) - -```txt - - {{#each rows as row}} - - {{#each row as cell}} - - {{/each}} - - {{/each}} -
{{ cell }}
-``` - -## What DVE Does Not Do - -DVE stays small on purpose so a template can never run arbitrary code, and these limits are the safety boundary rather than missing features: - -- No function or method calls -- No array indexing like `items[0]` -- No assignment or variable declarations -- No regular expressions or arbitrary JavaScript - -Two guardrails also stop runaway templates, where include nesting is capped at 64 levels deep and a single `#each` is capped at 100,000 iterations, and crossing either one raises a clear error instead of hanging. - -Anything that needs real logic belongs in the route handler, where the finished value gets computed and then passed into the template. - -## Editor Scope Mapping - -| Syntax | Scope | -| ---------------------------------- | ------------------------------------------------------- | -| `{{` `}}` `{{{` `}}}` | `meta.tag.output.dve` / `meta.tag.raw.dve` | -| `#if` `#each` `else` `/if` `/each` | `keyword.control.dve` | -| `>` (include) | `keyword.control.include.dve` | -| `as` (in #each) | `keyword.operator.as.dve` | -| Include path | `string.unquoted.path.dve` | -| `true` `false` `null` `undefined` | `constant.language.dve` | -| Numbers (incl. `1e3`) | `constant.numeric.dve` | -| `"..."` `'...'` | `string.quoted.double.dve` / `string.quoted.single.dve` | -| Operators `?.` `===` `??` etc. | `keyword.operator.dve` | -| Identifiers / variables | `variable.other.dve` | -| Item name in #each | `variable.parameter.dve` | diff --git a/editor/dve/dve-language-0.1.0.vsix b/editor/dve/dve-language-0.1.0.vsix deleted file mode 100644 index a729a0d..0000000 Binary files a/editor/dve/dve-language-0.1.0.vsix and /dev/null differ diff --git a/editor/dve/language-configuration.json b/editor/dve/language-configuration.json deleted file mode 100644 index ae56a0e..0000000 --- a/editor/dve/language-configuration.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "comments": {}, - "brackets": [ - ["{{", "}}"], - ["{{{", "}}}"] - ], - "autoClosingPairs": [ - { "open": "{{", "close": "}}" }, - { "open": "{{{", "close": "}}}" }, - { "open": "\"", "close": "\"", "notIn": ["string"] }, - { "open": "'", "close": "'", "notIn": ["string"] } - ], - "surroundingPairs": [ - ["{{", "}}"], - ["{{{", "}}}"], - ["\"", "\""], - ["'", "'"] - ] -} diff --git a/editor/dve/package.json b/editor/dve/package.json deleted file mode 100644 index 1bcaa23..0000000 --- a/editor/dve/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "dve-language", - "displayName": "DVE Template Language", - "description": "Syntax highlighting for Deserve (.dve) templates", - "version": "0.1.0", - "publisher": "neabyte", - "engines": { - "vscode": "^1.74.0" - }, - "categories": [ - "Programming Languages" - ], - "contributes": { - "languages": [ - { - "id": "dve", - "aliases": [ - "DVE", - "dve" - ], - "extensions": [ - ".dve" - ], - "configuration": "./language-configuration.json" - } - ], - "snippets": [ - { - "language": "dve", - "path": "./snippets/dve.code-snippets" - } - ], - "grammars": [ - { - "language": "dve", - "scopeName": "text.html.dve", - "path": "./syntaxes/dve.tmLanguage.json", - "embeddedLanguages": { - "meta.embedded.line.dve": "dve", - "meta.embedded.block.dve": "dve" - } - } - ] - } -} diff --git a/editor/dve/snippets/dve.code-snippets b/editor/dve/snippets/dve.code-snippets deleted file mode 100644 index 44899f3..0000000 --- a/editor/dve/snippets/dve.code-snippets +++ /dev/null @@ -1,61 +0,0 @@ -{ - "DVE: Tag": { - "prefix": "dve", - "body": ["{{ ${1:value} }}"], - "description": "Insert DVE tag: {{ value }}" - }, - "DVE: Raw Tag": { - "prefix": "dveraw", - "body": ["{{{ ${1:html} }}}"], - "description": "Insert DVE raw tag: {{{ html }}}" - }, - "DVE: Include": { - "prefix": "dveinc", - "body": ["{{> ${1:partials/header.dve} }}"], - "description": "Insert DVE include: {{> path }}" - }, - "DVE: If / Else": { - "prefix": "dveif", - "body": ["{{#if ${1:condition}}}${2:then}{{else}}${3:else}{{/if}}"], - "description": "Insert DVE if/else block" - }, - "DVE: If": { - "prefix": "dveifn", - "body": ["{{#if ${1:condition}}}", " ${2:then}", "{{/if}}"], - "description": "Insert DVE if block (multi-line)" - }, - "DVE: Each": { - "prefix": "dveeach", - "body": ["{{#each ${1:items} as ${2:item}}}", " ${3:{{ item }}}", "{{/each}}"], - "description": "Insert DVE each block" - }, - "DVE: Each (With Meta)": { - "prefix": "dveeachm", - "body": [ - "{{#each ${1:items} as ${2:item}}}", - " ({{ @index }}/{{ @length }}) ${3:{{ item }}}", - "{{/each}}" - ], - "description": "Insert DVE each block with @index/@length" - }, - "DVE: Ternary": { - "prefix": "dvetern", - "body": ["{{ ${1:condition} ? ${2:yes} : ${3:no} }}"], - "description": "Insert DVE ternary: {{ cond ? yes : no }}" - }, - "DVE: Default (Nullish)": { - "prefix": "dvedef", - "body": ["{{ ${1:value} ?? ${2:'fallback'} }}"], - "description": "Insert DVE nullish default: {{ value ?? 'fallback' }}" - }, - "DVE: Optional Chain": { - "prefix": "dveopt", - "body": ["{{ ${1:user}?.${2:name} ?? ${3:'Guest'} }}"], - "description": "Insert DVE optional chain: {{ user?.name ?? 'Guest' }}" - }, - "DVE: Comment (HTML)": { - "prefix": "dvecmt", - "body": [""], - "description": "Insert HTML comment inside template" - } -} diff --git a/editor/dve/syntaxes/dve.tmLanguage.json b/editor/dve/syntaxes/dve.tmLanguage.json deleted file mode 100644 index ac7e8db..0000000 --- a/editor/dve/syntaxes/dve.tmLanguage.json +++ /dev/null @@ -1,141 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", - "name": "DVE", - "scopeName": "text.html.dve", - "fileTypes": ["dve"], - "patterns": [{ "include": "#dve-tags" }, { "include": "text.html.basic" }], - "repository": { - "dve-tags": { - "patterns": [ - { "include": "#dve-raw-tag" }, - { "include": "#dve-include-tag" }, - { "include": "#dve-block-tag" }, - { "include": "#dve-output-tag" } - ] - }, - "dve-raw-tag": { - "begin": "\\{\\{\\{", - "beginCaptures": { - "0": { "name": "punctuation.section.embedded.begin.dve" } - }, - "end": "\\}\\}\\}", - "endCaptures": { - "0": { "name": "punctuation.section.embedded.end.dve" } - }, - "name": "meta.embedded.line.dve meta.tag.raw.dve", - "contentName": "meta.expression.dve", - "patterns": [{ "include": "#expression" }] - }, - "dve-include-tag": { - "begin": "\\{\\{\\s*(>)", - "beginCaptures": { - "0": { "name": "punctuation.section.embedded.begin.dve" }, - "1": { "name": "keyword.control.include.dve" } - }, - "end": "\\}\\}", - "endCaptures": { - "0": { "name": "punctuation.section.embedded.end.dve" } - }, - "name": "meta.embedded.line.dve meta.tag.include.dve", - "patterns": [{ "include": "#path" }] - }, - "dve-block-tag": { - "begin": "\\{\\{\\s*(#if|#each|else|/if|/each)\\b", - "beginCaptures": { - "0": { "name": "punctuation.section.embedded.begin.dve" }, - "1": { "name": "keyword.control.dve" } - }, - "end": "\\}\\}", - "endCaptures": { - "0": { "name": "punctuation.section.embedded.end.dve" } - }, - "name": "meta.embedded.block.dve meta.tag.block.dve", - "patterns": [ - { - "match": "\\b(as)\\s+([a-zA-Z_$][a-zA-Z0-9_$]*)", - "captures": { - "1": { "name": "keyword.operator.as.dve" }, - "2": { "name": "variable.parameter.dve" } - } - }, - { "include": "#expression" } - ] - }, - "dve-output-tag": { - "begin": "\\{\\{", - "beginCaptures": { - "0": { "name": "punctuation.section.embedded.begin.dve" } - }, - "end": "\\}\\}", - "endCaptures": { - "0": { "name": "punctuation.section.embedded.end.dve" } - }, - "name": "meta.embedded.line.dve meta.tag.output.dve", - "contentName": "meta.expression.dve", - "patterns": [{ "include": "#expression" }] - }, - "expression": { - "patterns": [ - { "include": "#string-double" }, - { "include": "#string-single" }, - { "include": "#number" }, - { "include": "#literal" }, - { "include": "#operator" }, - { "include": "#identifier" } - ] - }, - "path": { - "match": "[@a-zA-Z0-9_$./\\\\-]+\\.dve|[@a-zA-Z_$][a-zA-Z0-9_$.]*", - "name": "string.unquoted.path.dve" - }, - "string-double": { - "begin": "\"", - "end": "\"", - "name": "string.quoted.double.dve", - "patterns": [ - { - "match": "\\\\.", - "name": "constant.character.escape.dve" - } - ] - }, - "string-single": { - "begin": "'", - "end": "'", - "name": "string.quoted.single.dve", - "patterns": [ - { - "match": "\\\\.", - "name": "constant.character.escape.dve" - } - ] - }, - "number": { - "match": "\\b[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?\\b", - "name": "constant.numeric.dve" - }, - "literal": { - "match": "\\b(true|false|null|undefined)\\b", - "name": "constant.language.dve" - }, - "operator": { - "match": "\\?\\.|===|!==|==|!=|&&|\\|\\||\\?\\?|>=|<=|[?.!:()+\\-*/%<>]", - "name": "keyword.operator.dve" - }, - "identifier": { - "match": "\\b([a-zA-Z_$@][a-zA-Z0-9_$]*)\\b", - "name": "variable.other.dve" - } - }, - "injections": { - "L:text.html.dve - (meta.embedded.line.dve | meta.embedded.block.dve)": { - "patterns": [{ "include": "#dve-tags" }] - }, - "L:text.html.basic": { - "patterns": [{ "include": "#dve-tags" }] - }, - "L:string.quoted.double.html, L:string.quoted.single.html": { - "patterns": [{ "include": "#dve-tags" }] - } - } -} diff --git a/src/core/API.ts b/src/core/API.ts index e48a044..d4ea749 100644 --- a/src/core/API.ts +++ b/src/core/API.ts @@ -2,92 +2,70 @@ import type * as Types from '@interfaces/index.ts' import { Immutable } from '@neabyte/utils-core' import nodeUrl from 'node:url' -/** Pinned Response constructor */ -const SafeResponse = globalThis.Response -/** Pinned Headers constructor */ -const SafeHeaders = globalThis.Headers -/** Pinned Request constructor */ -const SafeRequest = globalThis.Request -/** Pinned URL constructor */ -const SafeURL = globalThis.URL -/** Pinned Worker constructor */ -const SafeWorker = globalThis.Worker -/** Pinned Error constructor */ -const SafeError = globalThis.Error -/** Pinned TextEncoder constructor */ -const SafeTextEncoder = globalThis.TextEncoder -/** Pinned TextDecoder constructor */ -const SafeTextDecoder = globalThis.TextDecoder -/** Pinned JSON parse function */ -const safeJsonParse = globalThis.JSON.parse -/** Pinned JSON stringify function */ -const safeJsonStringify = globalThis.JSON.stringify -/** Pinned SubtleCrypto instance */ -const safeSubtle = globalThis.crypto.subtle -/** Runtime native dynamic-import resolver */ -const runtimeImport = new Function('specifier', 'return import(specifier)') as ( - specifier: string -) => Promise - /** - * Pinned runtime built-ins for Deserve. - * @description Snapshotted at load before third-party code patches globals. + * Captured native runtime APIs. + * @description Holds frozen references to native globals and helpers. */ export class API { - /** Pinned Response constructor */ - static readonly Response = SafeResponse - /** Pinned Headers constructor */ - static readonly Headers = SafeHeaders - /** Pinned Request constructor */ - static readonly Request = SafeRequest - /** Pinned URL constructor */ - static readonly URL = SafeURL - /** Pinned Worker constructor */ - static readonly Worker = SafeWorker - /** Pinned Error constructor */ - static readonly Error = SafeError - /** Pinned TextEncoder constructor */ - static readonly TextEncoder = SafeTextEncoder - /** Pinned TextDecoder constructor */ - static readonly TextDecoder = SafeTextDecoder - /** Pinned SubtleCrypto instance */ - static readonly subtle = safeSubtle + /** Native Response constructor reference */ + static readonly Response = globalThis.Response + /** Native Headers constructor reference */ + static readonly Headers = globalThis.Headers + /** Native Request constructor reference */ + static readonly Request = globalThis.Request + /** Native URL constructor reference */ + static readonly URL = globalThis.URL + /** Native Error constructor reference */ + static readonly Error = globalThis.Error + /** Native TextEncoder constructor reference */ + static readonly TextEncoder = globalThis.TextEncoder + /** Native TextDecoder constructor reference */ + static readonly TextDecoder = globalThis.TextDecoder + /** Native Worker constructor reference */ + static readonly Worker = globalThis.Worker + /** Native SubtleCrypto instance reference */ + static readonly subtle = globalThis.crypto.subtle + + /** Dynamic import wrapper for route modules */ + private static readonly runtimeImport = new Function( + 'specifier', + 'return import(specifier)' + ) as Types.RuntimeImport /** - * Load route module from file path. - * @description Imports by file URL with optional cache-busting query. - * @param fullPath - Absolute filesystem path to the route module - * @param cacheBust - When true, appends timestamp to force re-import - * @returns Loaded route module + * Import route module from path. + * @description Adds cache-busting query when isolate is set. + * @param fullPath - Absolute path to module file + * @param isolate - Force fresh import with version query + * @returns Promise resolving to imported route module */ - static importRouteModule(fullPath: string, cacheBust = false): Promise { + static importRouteModule(fullPath: string, isolate = false): Promise { const baseUrl = nodeUrl.pathToFileURL(fullPath).href - const importUrl = cacheBust ? `${baseUrl}?t=${Date.now()}` : baseUrl - return runtimeImport(importUrl) + return API.runtimeImport(isolate ? `${baseUrl}?v=${Date.now()}` : baseUrl) } /** - * Parse JSON text with pinned parser. - * @description Uses the snapshotted JSON.parse, immune to patching. - * @param text - JSON source text - * @returns Parsed value + * Parse JSON text into value. + * @description Uses captured native JSON parse function. + * @param text - JSON text to parse + * @returns Parsed value of unknown type */ static jsonParse(text: string): unknown { - return safeJsonParse(text) + return globalThis.JSON.parse(text) } /** - * Serialize value with pinned serializer. - * @description Uses the snapshotted JSON.stringify, immune to patching. + * Serialize value into JSON text. + * @description Uses captured native JSON stringify function. * @param value - Value to serialize - * @returns JSON string + * @returns JSON string representation */ static jsonStringify(value: unknown): string { - return safeJsonStringify(value) + return globalThis.JSON.stringify(value) } } -/** Freeze API class object */ +/** Freeze API class to prevent mutation */ Immutable.freeze(API) -/** Freeze API prototype object */ +/** Freeze API prototype to prevent mutation */ Immutable.freeze(API.prototype) diff --git a/src/core/Constant.ts b/src/core/Constant.ts index 236e9f4..dd112da 100644 --- a/src/core/Constant.ts +++ b/src/core/Constant.ts @@ -1,16 +1,44 @@ import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' /** - * Shared constants and framework singletons. - * @description Centralizes all static data: encoders, regexes, defaults, maps. + * Framework wide constant values. + * @description Holds shared defaults, regexes, and lookup tables. */ export class Constant { - /** Shared UTF-8 text decoder */ - static readonly decoder: TextDecoder = new Core.API.TextDecoder() - /** Shared UTF-8 text encoder */ - static readonly encoder: TextEncoder = new Core.API.TextEncoder() - /** HTML entity map for escaping */ + /** Regex trimming leading and trailing spaces */ + static readonly cookieTrimRegex = /^[ \t]+|[ \t]+$/g + /** Shared UTF-8 text decoder instance */ + static readonly decoder: TextDecoder = new TextDecoder() + /** Default content type for unknown files */ + static readonly defaultContentType = 'application/octet-stream' + /** Default worker pool size */ + static readonly defaultPoolSize = 4 + /** Default queue depth multiplier per worker */ + static readonly defaultQueueFactor = 8 + /** Default queue wait timeout in milliseconds */ + static readonly defaultQueueWaitMs = 2000 + /** Default session cookie option values */ + static readonly defaultSessionOptions: Readonly = { + name: 'session', + httpOnly: true, + maxAge: 86400, + path: '/', + sameSite: 'Lax', + secure: false + } + /** Default worker task timeout in milliseconds */ + static readonly defaultWorkerTaskTimeoutMs = 5000 + /** Regex escaping disposition filename characters */ + static readonly dispositionEscapeRegex = /[\\"]/g + /** Regex matching non-ASCII disposition characters */ + static readonly dispositionNonAsciiRegex = /[\u0080-\u{10FFFF}]/u + /** Regex stripping directory path prefix */ + static readonly dispositionPathRegex = /^.*[\\/]/ + /** Template file extension for views */ + static readonly dveExtension = '.dve' + /** Shared UTF-8 text encoder instance */ + static readonly encoder: TextEncoder = new TextEncoder() + /** HTML entity replacements for escaping */ static readonly htmlEscapeMap: Readonly = { '&': '&', '<': '<', @@ -18,70 +46,66 @@ export class Constant { '"': '"', "'": ''' } - /** Matches characters needing HTML escaping */ + /** Regex matching characters needing HTML escape */ static readonly htmlEscapeRegex = /[&<>"']/g - /** Strips separators and control characters */ - static readonly sanitizeRegex = /^.*[\\/]|\p{Cc}/gu - /** Matches backslash or double-quote characters */ - static readonly escapeRegex = /[\\\"]/g - /** Matches any non-ASCII character */ - static readonly nonAsciiRegex = /[\u0080-\u{10FFFF}]/u - /** Matches all non-ASCII characters globally */ - static readonly nonAsciiGlobalRegex = /[\u0080-\u{10FFFF}]/gu - /** Dotted path regex for fast-path */ - static readonly simplePathRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/ - /** Matches leading and trailing cookie whitespace */ - static readonly cookieTrimRegex = /^[ \t]+|[ \t]+$/g - /** DVE template file extension */ - static readonly dveExtension = '.dve' - /** Default worker pool size */ - static readonly defaultPoolSize = 4 - /** Default worker task timeout in ms */ - static readonly defaultWorkerTaskTimeoutMs = 5_000 - /** Default pending-task multiplier per worker slot */ - static readonly defaultQueueFactor = 8 - /** Default projected-wait deadline in ms */ - static readonly defaultQueueWaitMs = 2_000 - /** Null-body HTTP status codes */ - static readonly nullBodyStatuses: ReadonlySet = new Set([101, 204, 205, 304]) - /** Valid redirect status codes */ - static readonly redirectStatuses: ReadonlySet = new Set([301, 302, 303, 307, 308]) - /** Default max route parameter length */ + /** Maximum allowed route parameter length */ static readonly maxParamLength = 1024 - /** Default max request URL length */ + /** Maximum allowed request URL length */ static readonly maxUrlLength = 8192 - /** Default max #each iterations */ - static readonly defaultMaxIterations = 100_000 - /** Default max #each body executions per render */ - static readonly defaultMaxRenderIterations = 1_000_000 - /** Default max output characters per render */ - static readonly defaultMaxOutputSize = 5_000_000 - /** Maximum template include nesting depth */ - static readonly maxIncludeDepth = 64 - /** Route watcher debounce in milliseconds */ + /** Status codes that forbid response bodies */ + static readonly nullBodyStatuses: ReadonlySet = new Set([101, 204, 205, 304]) + /** Content type for problem detail responses */ + static readonly problemJsonContentType = 'application/problem+json' + /** Status codes treated as HTTP redirects */ + static readonly redirectStatuses: ReadonlySet = new Set([301, 302, 303, 307, 308]) + /** Route reload debounce in milliseconds */ static readonly routeDebounceMs = 150 - /** Template watcher debounce in milliseconds */ + /** Template reload debounce in milliseconds */ static readonly templateDebounceMs = 100 - /** Default session cookie options */ - static readonly defaultSessionOptions: Types.SessionCookieOpts = { - cookieName: 'session', - maxAge: 86400, - path: '/', - sameSite: 'Lax', - httpOnly: true, - secure: true + /** Allowed route module file extensions */ + static readonly allowedExtensions: readonly string[] = ['cjs', 'js', 'jsx', 'mjs', 'ts', 'tsx'] + /** File extension to content type map */ + static readonly contentTypes: Readonly = { + css: 'text/css; charset=utf-8', + csv: 'text/csv; charset=utf-8', + gif: 'image/gif', + htm: 'text/html; charset=utf-8', + html: 'text/html; charset=utf-8', + ico: 'image/x-icon', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', + map: 'application/json; charset=utf-8', + mjs: 'text/javascript; charset=utf-8', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml; charset=utf-8', + txt: 'text/plain; charset=utf-8', + wasm: 'application/wasm', + webp: 'image/webp', + woff: 'font/woff', + woff2: 'font/woff2', + xml: 'application/xml; charset=utf-8' } - /** Problem details JSON content type */ - static readonly problemJsonContentType = 'application/problem+json' - /** Status code to error message */ - static readonly serverErrorMessages: Readonly< - Partial> - > = { + /** Supported HTTP request methods */ + static readonly httpMethods: readonly Types.HttpMethod[] = [ + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT' + ] + /** Status code to reason phrase map */ + static readonly serverErrorMessages: Readonly>> = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', + 406: 'Not Acceptable', 408: 'Request Timeout', 409: 'Conflict', 410: 'Gone', @@ -96,166 +120,22 @@ export class Constant { 503: 'Service Unavailable', 504: 'Gateway Timeout' } - /** Secure default values for security headers */ - static readonly securityHeaderDefaults: Readonly = { - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Resource-Policy': 'same-origin', - 'Origin-Agent-Cluster': '?1', - 'Referrer-Policy': 'no-referrer', - 'X-Content-Type-Options': 'nosniff', - 'X-DNS-Prefetch-Control': 'off', - 'X-Download-Options': 'noopen', - 'X-Frame-Options': 'SAMEORIGIN', - 'X-Permitted-Cross-Domain-Policies': 'none' - } - /** File extensions allowed for route modules */ - static readonly allowedExtensions: readonly Types.RouteFileExtension[] = [ - 'cjs', - 'js', - 'jsx', - 'mjs', - 'ts', - 'tsx' - ] - /** Extension to MIME type map */ - static readonly contentTypes: Readonly = { - html: 'text/html', - htm: 'text/html', - css: 'text/css', - less: 'text/css', - scss: 'text/css', - sass: 'text/css', - js: 'application/javascript', - mjs: 'application/javascript', - cjs: 'application/javascript', - ts: 'application/typescript', - tsx: 'application/typescript', - jsx: 'application/javascript', - json: 'application/json', - jsonld: 'application/ld+json', - ndjson: 'application/x-ndjson', - map: 'application/json', - geojson: 'application/geo+json', - topojson: 'application/json', - md: 'text/markdown', - markdown: 'text/markdown', - sh: 'application/x-sh', - csh: 'application/x-csh', - bash: 'text/plain', - php: 'application/x-httpd-php', - yaml: 'text/yaml', - yml: 'text/yaml', - toml: 'text/toml', - ics: 'text/calendar', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - ico: 'image/x-icon', - avif: 'image/avif', - apng: 'image/apng', - heif: 'image/heif', - heic: 'image/heic', - bmp: 'image/bmp', - tiff: 'image/tiff', - tif: 'image/tiff', - ttf: 'font/ttf', - otf: 'font/otf', - woff: 'font/woff', - woff2: 'font/woff2', - ttc: 'font/collection', - eot: 'application/vnd.ms-fontobject', - pdf: 'application/pdf', - epub: 'application/epub+zip', - azw: 'application/vnd.amazon.ebook', - doc: 'application/msword', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - xls: 'application/vnd.ms-excel', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ppt: 'application/vnd.ms-powerpoint', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - odt: 'application/vnd.oasis.opendocument.text', - ods: 'application/vnd.oasis.opendocument.spreadsheet', - odp: 'application/vnd.oasis.opendocument.presentation', - vsd: 'application/vnd.visio', - mpkg: 'application/vnd.apple.installer+xml', - xml: 'application/xml', - xhtml: 'application/xhtml+xml', - xul: 'application/vnd.mozilla.xul+xml', - csv: 'text/csv', - txt: 'text/plain', - rtf: 'application/rtf', - abw: 'application/x-abiword', - bin: 'application/octet-stream', - zip: 'application/zip', - tar: 'application/x-tar', - gz: 'application/gzip', - bz: 'application/x-bzip', - bz2: 'application/x-bzip2', - xz: 'application/x-xz', - rar: 'application/vnd.rar', - arc: 'application/x-freearc', - jar: 'application/java-archive', - '7z': 'application/x-7z-compressed', - mp4: 'video/mp4', - m4v: 'video/mp4', - mpeg: 'video/mpeg', - webm: 'video/webm', - avi: 'video/x-msvideo', - mov: 'video/quicktime', - mkv: 'video/x-matroska', - wmv: 'video/x-ms-wmv', - flv: 'video/x-flv', - ogv: 'video/ogg', - ogx: 'application/ogg', - '3gp': 'video/3gpp', - '3g2': 'video/3gpp2', - mts: 'video/mp2t', - mp3: 'audio/mpeg', - ogg: 'audio/ogg', - oga: 'audio/ogg', - wav: 'audio/wav', - flac: 'audio/flac', - aac: 'audio/aac', - m4a: 'audio/mp4', - opus: 'audio/opus', - weba: 'audio/webm', - mid: 'audio/midi', - midi: 'audio/midi', - cda: 'application/x-cdf', - glb: 'model/gltf-binary', - gltf: 'model/gltf+json', - wasm: 'application/wasm', - manifest: 'application/manifest+json', - webmanifest: 'application/manifest+json', - serviceworker: 'application/javascript' - } - /** Security header option to name */ + /** Security header names with default values */ static readonly securityHeaders = { - contentSecurityPolicy: 'Content-Security-Policy', - crossOriginEmbedderPolicy: 'Cross-Origin-Embedder-Policy', - crossOriginOpenerPolicy: 'Cross-Origin-Opener-Policy', - crossOriginResourcePolicy: 'Cross-Origin-Resource-Policy', - originAgentCluster: 'Origin-Agent-Cluster', - referrerPolicy: 'Referrer-Policy', - strictTransportSecurity: 'Strict-Transport-Security', - xContentTypeOptions: 'X-Content-Type-Options', - xDnsPrefetchControl: 'X-DNS-Prefetch-Control', - xDownloadOptions: 'X-Download-Options', - xFrameOptions: 'X-Frame-Options', - xPermittedCrossDomainPolicies: 'X-Permitted-Cross-Domain-Policies', - xPoweredBy: 'X-Powered-By' - } as const satisfies Types.StringRecord - /** HTTP methods used for route registration */ - static readonly httpMethods: readonly Types.HttpMethod[] = [ - 'DELETE', - 'GET', - 'HEAD', - 'OPTIONS', - 'PATCH', - 'POST', - 'PUT' - ] + contentSecurityPolicy: { header: 'Content-Security-Policy', default: null }, + crossOriginEmbedderPolicy: { header: 'Cross-Origin-Embedder-Policy', default: null }, + crossOriginOpenerPolicy: { header: 'Cross-Origin-Opener-Policy', default: 'same-origin' }, + crossOriginResourcePolicy: { header: 'Cross-Origin-Resource-Policy', default: 'same-origin' }, + originAgentCluster: { header: 'Origin-Agent-Cluster', default: '?1' }, + referrerPolicy: { header: 'Referrer-Policy', default: 'no-referrer' }, + strictTransportSecurity: { header: 'Strict-Transport-Security', default: null }, + xContentTypeOptions: { header: 'X-Content-Type-Options', default: 'nosniff' }, + xDnsPrefetchControl: { header: 'X-DNS-Prefetch-Control', default: 'off' }, + xDownloadOptions: { header: 'X-Download-Options', default: 'noopen' }, + xFrameOptions: { header: 'X-Frame-Options', default: 'SAMEORIGIN' }, + xPermittedCrossDomainPolicies: { + header: 'X-Permitted-Cross-Domain-Policies', + default: 'none' + } + } as const } diff --git a/src/core/Context.ts b/src/core/Context.ts index 0c55170..7852fd8 100644 --- a/src/core/Context.ts +++ b/src/core/Context.ts @@ -2,440 +2,511 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' import { Immutable } from '@neabyte/utils-core' -/** Symbol channel for internal Context surface */ -export const InternalContext: unique symbol = Symbol('deserve.internal.context') - /** - * Request wrapper with parsed body. - * @description Parses body once, exposes headers, cookies, state. + * Per request context object. + * @description Exposes request reading and response building helpers. */ export class Context { - /** Parsed body, undefined until parsed */ - private bodyData: unknown = undefined - /** Format body was parsed as */ - private bodyParsedAs: Types.BodyParsedFormat | null = null - /** Parsed cookie name-to-value map, lazy */ - private cookieMap: Types.StringRecord | undefined = undefined - /** Custom error handler when set */ - private errorHandler: Types.ErrorHandler | undefined = undefined - /** Framework error captured by handleError */ - private frameworkError: Error | null = null - /** Incoming fetch Request */ - private req: Request - /** Arbitrary state for middleware/handlers */ - private requestState: Types.DataRecord = {} - /** Private framework-wired state, not exposed via ctx.state */ - private frameworkState: Types.DataRecord = Object.create(null) - /** Response headers to send */ - private responseHeaders: Types.StringRecord = {} - /** Cached send helpers, lazy */ - private sendHelpers: Types.SendHelpers | undefined = undefined - /** Set-Cookie values accumulated via setHeader */ - private setCookieValues: string[] = [] - /** Matched route path params */ - private routeParams: Types.StringRecord - /** Parsed request URL */ - private parsedUrl: URL - /** Connection peer IP address */ - private clientIpValue: string | undefined - /** Direct TCP peer IP address */ - private directIpValue: string | undefined - /** Optional router event emitter for observability */ - private emit: Types.EventEmit | undefined + /** Resolved client IP after proxy trust */ + readonly #clientIp: string | undefined + /** Direct peer IP before proxy resolution */ + readonly #directIp: string | undefined + /** Event emitter for observability signals */ + readonly #emitEvent: Types.EventFn + /** Optional error middleware handler */ + readonly #errorHandler: Types.ErrorMiddleware | null + /** Internal control surface for framework */ + readonly #internal: Types.ContextInternal + /** Optional view rendering function */ + readonly #renderer: Types.RenderFn | null + /** Accumulated response header values */ + readonly #responseHeaders: Types.StringRecord = Object.create(null) + /** Accumulated Set-Cookie header values */ + readonly #setCookies: string[] = [] + /** Parsed request URL instance */ + readonly #url: URL + /** Cached parsed request body data */ + #bodyData: unknown = undefined + /** Format used to read request body */ + #bodyFormat: Types.BodyFormat | null = null + /** Cached parsed cookie name value map */ + #cookieMap: Types.StringRecord | undefined = undefined + /** Last framework error captured */ + #frameworkError: Error | null = null + /** Cached frozen request reading helpers */ + #getHelpers: Types.GetHelpers | undefined = undefined + /** Decoded route parameter map */ + #params: Types.StringRecord = Object.create(null) + /** Underlying request instance */ + #req: Request + /** Cached frozen response sending helpers */ + #sendHelpers: Types.SendHelpers | undefined = undefined + /** Installed session controller instance */ + #session: Types.SessionController | null = null + /** Cached frozen response setting helpers */ + #setHelpers: Types.SetHelpers | undefined = undefined + /** Installed validated data controller */ + #validated: Types.ValidatedController | null = null + /** Installed worker pool controller */ + #worker: Types.WorkerController | null = null /** - * Create context for one request. - * @description Binds request, URL, params, and optional error handler. - * @param req - Incoming request + * Construct request context instance. + * @description Wires request, URL, IP, renderer, and event emitter. + * @param req - Incoming request instance * @param url - Parsed request URL - * @param params - Route path params - * @param errorHandler - Optional custom error handler + * @param errorHandler - Optional error middleware handler * @param clientIp - Resolved client IP address - * @param directIp - Direct TCP peer IP address - * @param emit - Optional router event emitter for observability + * @param directIp - Direct peer IP address + * @param renderer - Optional view rendering function + * @param emitEvent - Event emitter for observability */ constructor( req: Request, url: URL, - params?: Types.StringRecord, - errorHandler?: Types.ErrorHandler, - clientIp?: string, - directIp?: string, - emit?: Types.EventEmit + errorHandler: Types.ErrorMiddleware | null, + clientIp: string | undefined, + directIp: string | undefined, + renderer: Types.RenderFn | null, + emitEvent: Types.EventFn ) { - this.req = req - this.parsedUrl = url - this.routeParams = params === undefined ? Object.create(null) : Context.decodeParams(params) - this.errorHandler = errorHandler - this.clientIpValue = clientIp - this.directIpValue = directIp ?? clientIp - this.emit = emit + this.#req = req + this.#url = url + this.#errorHandler = errorHandler + this.#clientIp = clientIp + this.#directIp = directIp ?? clientIp + this.#renderer = renderer + this.#emitEvent = emitEvent + this.#internal = { + emitEvent: (event) => this.#emitEvent(event), + finalizeRaw: (response) => this.#finalizeRaw(response), + getFrameworkError: () => this.#frameworkError, + installSession: (controller) => this.#installSession(controller), + installValidated: (controller) => this.#installValidated(controller), + installWorker: (controller) => this.#installWorker(controller), + setParams: (params) => this.#setParams(params) + } } - /** Internal framework-only Context surface */ - get [InternalContext](): Types.ContextInternal { - const readCookies = (): readonly string[] => this.responseCookies - const readHeadersMap = (): Types.StringRecord => this.responseHeadersMap - return { - finalizeRaw: (response) => this.finalizeRaw(response), - getFrameworkError: () => this.getFrameworkError(), - replaceRequest: (req) => this.replaceRequest(req), - setParams: (params) => this.setParams(params), - setInternalState: (key, value) => this.setInternalState(key, value), - emitEvent: (event) => this.emit?.(event), - get responseCookies() { - return readCookies() - }, - get responseHeadersMap() { - return readHeadersMap() + /** Frozen request reading helpers */ + get get(): Types.GetHelpers { + if (this.#getHelpers === undefined) { + const helpers: Types.GetHelpers = { + ip: (options) => (options?.direct === true ? this.#directIp : this.#clientIp), + method: () => this.#req.method, + url: () => this.#url, + pathname: () => this.#url.pathname, + request: () => this.#req, + header: (key?: string) => this.#lookup(this.#req.headers, key), + cookie: (key?: string) => this.#lookupCookie(key), + query: (key?: string) => this.#lookup(this.#url.searchParams, key), + param: (key?: string) => this.#lookupParam(key), + body: () => this.#readBody() as Promise, + json: () => this.#read('json', (req) => req.json()) as Promise, + text: () => this.#read('text', (req) => req.text()), + formData: () => this.#read('form', (req) => req.formData()), + blob: () => this.#read('blob', (req) => req.blob()), + bytes: () => this.#read('bytes', (req) => req.bytes()), + session: () => this.#session?.state ?? null, + validated: () => this.#readValidated(), + worker: () => this.#readWorker() + } as Types.GetHelpers + this.#getHelpers = Object.freeze(helpers) + } + return this.#getHelpers + } + + /** Frozen response setting helpers */ + get set(): Types.SetHelpers { + if (this.#setHelpers === undefined) { + const helpers: Types.SetHelpers = { + header: (key, value) => { + this.#applyHeader(key, value) + return helpers + }, + headers: (headers) => { + for (const key of Object.keys(headers)) { + this.#applyHeader(key, headers[key]!) + } + return helpers + }, + cookie: (name, value, options) => { + this.#setCookies.push(Core.Cookie.serialize(name, value, options)) + return helpers + }, + session: (data) => this.#writeSession(data) } + this.#setHelpers = Object.freeze(helpers) } + return this.#setHelpers } - /** Direct TCP peer IP address */ - get directIp(): string | undefined { - return this.directIpValue - } - - /** Raw request Headers */ - get headers(): Headers { - return this.req.headers - } - - /** Resolved client IP address */ - get ip(): string | undefined { - return this.clientIpValue - } - - /** Request pathname from URL */ - get pathname(): string { - return this.parsedUrl.pathname - } - - /** Raw Request object */ - get request(): Request { - return this.req - } - - /** Send helpers for response building */ + /** Frozen response sending helpers */ get send(): Types.SendHelpers { - if (!this.sendHelpers) { - this.sendHelpers = Core.Response.create( - this.responseHeaders, - this.setCookieValues, - (url, status, extraHeaders) => + if (this.#sendHelpers === undefined) { + const helpers: Types.SendHelpers = { + json: (data, options) => + this.#build(Core.API.jsonStringify(data), 'application/json', options), + text: (text, options) => this.#build(text, 'text/plain; charset=utf-8', options), + html: (html, options) => this.#build(html, 'text/html; charset=utf-8', options), + custom: (body, options) => this.#build(body, null, options), + download: (body, filename, options) => { + const disposition = Core.Handler.contentDisposition(filename) + const headers = { + ...Core.Handler.toRecord(options?.headers), + 'Content-Disposition': disposition + } + return this.#build(body, Core.Constant.defaultContentType, { ...options, headers }) + }, + empty: (status) => this.#build(null, null, status === undefined ? undefined : { status }), + redirect: (url, status, options) => Core.Redirect.buildResponse( - this.req.url, - this.responseHeaders, - this.setCookieValues, + this.#req.url, + this.#responseHeaders, + this.#setCookies, url, - status, - extraHeaders + status ?? 302, + options?.headers ) - ) + } + this.#sendHelpers = Object.freeze(helpers) } - return this.sendHelpers + return this.#sendHelpers } - /** Shared mutable userland request state */ - get state(): Types.DataRecord { - return this.requestState - } - - /** Full request URL string */ - get url(): string { - return this.req.url + /** + * Build error response for status. + * @description Uses error middleware when present otherwise default. + * @param statusCode - HTTP status code to send + * @param error - Caught error instance + * @returns Promise resolving to error response + */ + async handleError(statusCode: number, error: Error): Promise { + this.#frameworkError = error + if (this.#errorHandler) { + return await Core.Handler.buildResponse(this, statusCode, error, this.#errorHandler) + } + return Core.Handler.errorResponse(this, statusCode) } - /** Read body as ArrayBuffer */ - async arrayBuffer(): Promise { - return await this.readBody('arraybuffer', (req) => req.arrayBuffer()) + /** + * Expose internal control surface. + * @description Returns framework only context internal handle. + * @param ctx - Context instance to unwrap + * @returns Internal control surface object + */ + static internalOf(ctx: Context): Types.ContextInternal { + return ctx.#internal } - /** Read body as Blob */ - async blob(): Promise { - return await this.readBody('blob', (req) => req.blob()) + /** + * Render template into response. + * @description Requires configured view engine to render template. + * @param template - Template name to render + * @param data - View data for template + * @param options - Render options like status + * @returns Promise resolving to rendered response + * @throws When view engine is not configured + */ + async render( + template: string, + data: Types.ViewData = {}, + options: Types.RenderInit = {} + ): Promise { + if (this.#renderer === null) { + throw new Deno.errors.NotSupported( + 'View engine not configured, set views directory in RouterOptions' + ) + } + return await this.#renderer(template, data, options) } - /** Read body by content type */ - async body(): Promise { - if (this.bodyParsedAs !== null) { - return this.bodyData + /** + * Apply a single response header. + * @description Validates header then stores or queues cookie. + * @param key - Header name to apply + * @param value - Header value to set + * @throws When header name or value is invalid + */ + #applyHeader(key: string, value: string): void { + try { + new Core.API.Headers().set(key, value) + } catch { + throw Core.Handler.createStatusError(500, `Invalid response header "${key}"`) } - const mediaType = Context.parseMediaType(this.req.headers.get('content-type')) - if (mediaType === 'application/json') { - try { - this.bodyData = await this.req.json() - } catch (parseError) { - Context.rethrowStatusError(parseError) - this.bodyData = null - } - this.bodyParsedAs = 'json' - } else if ( - mediaType === 'multipart/form-data' || - mediaType === 'application/x-www-form-urlencoded' - ) { - try { - this.bodyData = await this.req.formData() - } catch (parseError) { - Context.rethrowStatusError(parseError) - this.bodyData = null - } - this.bodyParsedAs = 'form' + if (key.toLowerCase() === 'set-cookie') { + this.#setCookies.push(value) } else { - try { - this.bodyData = await this.req.text() - } catch (parseError) { - throw Context.toBodyError(parseError) - } - this.bodyParsedAs = 'text' + this.#responseHeaders[key] = value } - return this.bodyData } /** - * Get cookie by key or all. - * @description Parses Cookie header on first access. - * @param key - Cookie name - * @returns Cookie value or undefined + * Validate optional response status code. + * @description Allows null body statuses and 200 to 599. + * @param status - Status code to validate + * @throws When status is outside allowed range */ - cookie(): Types.StringRecord - cookie(key: string): string | undefined - cookie(key?: string): string | Types.StringRecord | undefined { - if (this.cookieMap === undefined) { - this.parseCookies() + #assertStatus(status?: number): void { + if (status === undefined) { + return + } + if ( + !Number.isInteger(status) || + ((status < 200 || status > 599) && !Core.Constant.nullBodyStatuses.has(status)) + ) { + throw new Deno.errors.InvalidData( + `Response status must be an integer in the 200-599 range, got "${String(status)}"` + ) } - return key ? this.cookieMap?.[key] : this.cookieMap - } - - /** Read body as FormData */ - async formData(): Promise { - return await this.readBody('form', (req) => req.formData()) } /** - * Get typed state value. - * @description Type-safe alternative to `state[key] as T`. - * @template T - Value type encoded in the key - * @param key - Branded state key - * @returns Typed value or undefined + * Build response with headers and cookies. + * @description Merges headers, content type, and Set-Cookie values. + * @param body - Response body or null + * @param contentType - Content type or null + * @param options - Optional response init values + * @returns Constructed response instance */ - getState(key: Types.StateKey): T | undefined { - if (Core.Handler.reservedStateKeys.has(key)) { - return this.frameworkState[key] as T | undefined - } - return this.requestState[key] as T | undefined + #build(body: BodyInit | null, contentType: string | null, options?: Types.SendInit): Response { + this.#assertStatus(options?.status) + const status = options?.status + const isNullBody = status !== undefined && Core.Constant.nullBodyStatuses.has(status) + const extra = Core.Handler.toRecord(options?.headers) + const headers: Types.StringRecord = contentType && !isNullBody + ? { ...this.#responseHeaders, 'Content-Type': contentType, ...extra } + : { ...this.#responseHeaders, ...extra } + const init: ResponseInit = options ? { ...options, headers } : { headers } + const finalBody = isNullBody ? null : body + const response = new Core.API.Response(finalBody, init) + Core.Handler.appendCookies(response.headers, this.#setCookies) + return response } /** - * Build error response via handler. - * @description Uses errorHandler if set else custom response. - * @param statusCode - HTTP status code - * @param error - Error instance - * @returns Error response + * Merge pending headers into response. + * @description Adds missing headers and queued Set-Cookie values. + * @param response - Raw response to finalize + * @returns Same response with merged headers */ - async handleError(statusCode: number, error: Error): Promise { - this.frameworkError = error - if (this.errorHandler) { - return await this.errorHandler(this, statusCode, error) + #finalizeRaw(response: Response): Response { + for (const headerKey of Object.keys(this.#responseHeaders)) { + if (!response.headers.has(headerKey)) { + response.headers.set(headerKey, this.#responseHeaders[headerKey]!) + } } - return Core.Handler.errorResponse(this, statusCode) + if (this.#setCookies.length > 0 && response.headers.get('Set-Cookie') === null) { + Core.Handler.appendCookies(response.headers, this.#setCookies) + } + return response } /** - * Get header by name. - * @description Parses headers on first access, keys lowercased. - * @param key - Header name - * @returns Header value or undefined + * Install session controller instance. + * @description Stores controller for session reads and writes. + * @param controller - Session controller to install */ - header(): Types.StringRecord - header(key: string): string | undefined - header(key?: string): string | Types.StringRecord | undefined { - if (key) { - return this.req.headers.get(key) ?? undefined - } - return Context.collectRecord(this.req.headers) - } - - /** Read body as JSON */ - async json(): Promise { - return await this.readBody('json', (req) => req.json()) + #installSession(controller: Types.SessionController): void { + this.#session = controller } /** - * Get single route param by key. - * @description Returns one named param from route match. - * @param key - Param name from pattern - * @returns Param value or undefined + * Install validated data controller. + * @description Stores controller for validated data reads. + * @param controller - Validated controller to install */ - param(key: string): string | undefined { - return this.routeParams[key] - } - - /** Get all route path params */ - params(): Types.StringRecord { - return { ...this.routeParams } + #installValidated(controller: Types.ValidatedController): void { + this.#validated = controller } /** - * Get all values for query key. - * @description Returns all query values for repeated key. - * @param key - Query parameter name - * @returns Array of values + * Install worker pool controller. + * @description Stores controller for worker task dispatch. + * @param controller - Worker controller to install */ - queries(key: string): string[] { - return this.parsedUrl.searchParams.getAll(key) + #installWorker(controller: Types.WorkerController): void { + this.#worker = controller } /** - * Get query param by key. - * @description Parses search params on first access. - * @param key - Query key - * @returns Query value or undefined + * Look up entry value or record. + * @description Returns single value or full record map. + * @param entries - Iterable key value entries + * @param key - Optional key to look up + * @returns Value, record map, or undefined */ - query(): Types.StringRecord - query(key: string): string | undefined - query(key?: string): string | Types.StringRecord | undefined { - if (key) { - return this.parsedUrl.searchParams.get(key) ?? undefined + #lookup( + entries: Iterable, + key?: string + ): Types.StringRecord | string | undefined { + if (key !== undefined) { + for (const [entryKey, entryValue] of entries) { + if (entryKey === key) { + return entryValue + } + } + return undefined } - return Context.collectRecord(this.parsedUrl.searchParams) + return Context.collectRecord(entries) } /** - * Redirect response to a URL. - * @description Wraps `ctx.send.redirect` with same builder. - * @param url - Target URL (relative same-origin or explicit absolute http(s)) - * @param status - Redirect status code, defaults to 302 - * @param options - Optional extra headers - * @returns Redirect Response with Location header + * Look up cookie value or map. + * @description Parses cookies once then caches the result. + * @param key - Optional cookie name to read + * @returns Cookie value, full map, or undefined */ - redirect( - url: string, - status: Types.RedirectStatus = 302, - options?: Types.RedirectInit - ): Response { - return this.send.redirect(url, status, options) + #lookupCookie(key?: string): Types.StringRecord | string | undefined { + if (this.#cookieMap === undefined) { + this.#cookieMap = this.#parseCookies() + } + return key !== undefined ? this.#cookieMap[key] : this.#cookieMap } /** - * Render template and return HTML response. - * @description Requires viewsDir set in Router, uses ctx.state.view. - * @param templatePath - Path to .dve template relative to viewsDir - * @param data - Data for template - * @returns Response with rendered HTML + * Look up route parameter value. + * @description Returns single param or copied param map. + * @param key - Optional parameter name to read + * @returns Parameter value, copied map, or undefined */ - async render(templatePath: string, data: Types.DataRecord = {}): Promise { - const renderedHtml = await this.requireViewEngine().render(templatePath, data) - return this.send.html(renderedHtml) + #lookupParam(key?: string): Types.StringRecord | string | undefined { + return key !== undefined ? this.#params[key] : { ...this.#params } } /** - * Set one response header. - * @description Merges one header into response headers. - * @param key - Header name - * @param value - Header value - * @returns this for chaining + * Parse cookies from request header. + * @description Splits cookie header into name value pairs. + * @returns Parsed cookie name value record */ - setHeader(key: string, value: string): this { - Context.assertValidHeader(key, value) - this.applyHeader(key, value) - return this + #parseCookies(): Types.StringRecord { + const parsed: Types.StringRecord = Object.create(null) + const cookieHeader = this.#req.headers.get('cookie') + if (cookieHeader) { + for (const cookiePart of cookieHeader.split(';')) { + const trimmedPart = cookiePart.replace(Core.Constant.cookieTrimRegex, '') + const eqIndex = trimmedPart.indexOf('=') + if (eqIndex <= 0) { + continue + } + const cookieName = trimmedPart.slice(0, eqIndex).replace(Core.Constant.cookieTrimRegex, '') + if (cookieName.length > 0 && !Object.hasOwn(parsed, cookieName)) { + parsed[cookieName] = trimmedPart.slice(eqIndex + 1) + } + } + } + return parsed } /** - * Set multiple response headers. - * @description Merges headers into response headers. - * @param headers - Key-value map of headers - * @returns this for chaining + * Read request body in format. + * @description Caches body and blocks conflicting format reads. + * @param format - Body format to read + * @param reader - Reader producing body value + * @returns Promise resolving to body value + * @throws When body already read as another format + * @template R - Body value type returned */ - setHeaders(headers: Types.StringRecord): this { - const entries = Object.entries(headers) - for (const [key, value] of entries) { - Context.assertValidHeader(key, value) + async #read(format: Types.BodyFormat, reader: (req: Request) => Promise): Promise { + if (this.#bodyFormat === format) { + return this.#bodyData as R } - for (const [key, value] of entries) { - this.applyHeader(key, value) + if (this.#bodyFormat !== null) { + throw Core.Handler.createStatusError( + 409, + `Request body already read as ${this.#bodyFormat}` + ) + } + try { + this.#bodyData = await reader(this.#req) + } catch (cause) { + throw Core.Handler.isStatusError(cause) + ? cause + : Core.Handler.createStatusError(400, 'Malformed or unreadable request body') } - return this + this.#bodyFormat = format + return this.#bodyData as R } /** - * Set typed state value. - * @description Type-safe alternative to `state[key] = value`. - * @template T - Value type encoded in the key - * @param key - Branded state key - * @param value - Value matching the key's type - * @throws {Types.StatusError} When the key is a reserved framework key + * Read body using content type. + * @description Chooses JSON, form, or text reader. + * @returns Promise resolving to parsed body */ - setState(key: Types.StateKey, value: T): void { - if (Core.Handler.reservedStateKeys.has(key)) { - throw Core.Handler.createStatusError(500, `State key "${key}" is reserved`) + #readBody(): Promise { + const mediaType = Context.parseMediaType(this.#req.headers.get('content-type')) + if (Context.isJsonMedia(mediaType)) { + return this.#read('json', (req) => req.json()) + } + if ( + mediaType === 'multipart/form-data' || + mediaType === 'application/x-www-form-urlencoded' + ) { + return this.#read('form', (req) => req.formData()) } - this.requestState[key] = value + return this.#read('text', (req) => req.text()) } /** - * Render template with streaming. - * @description Requires viewsDir set in Router, validates before committing. - * @param templatePath - Path to .dve template relative to viewsDir - * @param data - Data for template - * @returns Response with streaming HTML + * Read validated request data. + * @description Requires validate middleware to be registered. + * @returns Validated value of unknown type + * @throws When validate middleware is not registered */ - async streamRender(templatePath: string, data: Types.DataRecord = {}): Promise { - const htmlStream = await this.requireViewEngine().streamRender(templatePath, data) - return this.send.stream(htmlStream, undefined, 'text/html; charset=utf-8') - } - - /** Read body as plain text */ - async text(): Promise { - return await this.readBody('text', (req) => req.text()) - } - - /** All Set-Cookie header values */ - private get responseCookies(): readonly string[] { - return this.setCookieValues + #readValidated(): unknown { + if (this.#validated === null) { + throw new Deno.errors.NotSupported( + 'Validated read requires the validate middleware, register it before reading validated data' + ) + } + return this.#validated.value } - /** Snapshot copy of response headers */ - private get responseHeadersMap(): Types.StringRecord { - return { ...this.responseHeaders } + /** + * Read worker pool controller. + * @description Requires configured worker pool to read. + * @returns Worker controller instance + * @throws When worker pool is not configured + */ + #readWorker(): Types.WorkerController { + if (this.#worker === null) { + throw new Deno.errors.NotSupported( + 'Worker read requires a worker pool, configure RouterOptions worker before reading' + ) + } + return this.#worker } /** - * Route one header pair to its accumulator. - * @description Appends Set-Cookie values, overwrites all other headers. - * @param key - Validated header name - * @param value - Validated header value + * Set decoded route parameters. + * @description Decodes percent encoded parameter values. + * @param params - Raw route parameter map */ - private applyHeader(key: string, value: string): void { - if (key === 'Set-Cookie') { - this.setCookieValues.push(value) - } else { - this.responseHeaders[key] = value - } + #setParams(params: Types.StringRecord): void { + this.#params = Context.decodeParams(params) } /** - * Validate a response header pair. - * @description Delegates to Headers built-in so invalid input fails fast. - * @param key - Header name - * @param value - Header value - * @throws {Types.StatusError} When name or value is not standards compliant + * Write session data through controller. + * @description Requires session middleware to be registered. + * @param data - Session data or null + * @returns Promise resolving when write completes + * @throws When session middleware is not registered */ - private static assertValidHeader(key: string, value: string): void { - try { - new Core.API.Headers().set(key, value) - } catch { - throw Core.Handler.createStatusError(500, `Invalid response header "${key}"`) + #writeSession(data: Types.SessionData | null): Promise { + if (this.#session === null) { + throw new Deno.errors.NotSupported( + 'Session write requires the session middleware, register it before writing session data' + ) } + return this.#session.write(data) } /** - * Collect string entries into a null-proto record. - * @description First occurrence wins, prototype-pollution safe via Object.hasOwn. - * @param entries - Iterable of key/value string pairs - * @returns Null-prototype record of first-seen values + * Collect entries into record map. + * @description Keeps first value for duplicate keys. + * @param entries - Iterable key value entries + * @returns Record map of first values */ private static collectRecord( entries: Iterable @@ -450,10 +521,10 @@ export class Context { } /** - * Percent-decode route param values once. - * @description Decodes each value once, raw fallback on malformed input. - * @param params - Raw params from the router match - * @returns New record with each value decoded once + * Decode percent encoded parameters. + * @description Falls back to raw value on decode failure. + * @param params - Raw route parameter map + * @returns Decoded parameter record map */ private static decodeParams(params: Types.StringRecord): Types.StringRecord { const decoded: Types.StringRecord = Object.create(null) @@ -473,65 +544,22 @@ export class Context { } /** - * Apply accumulated headers to raw Response. - * @description Merges middleware headers and cookies, existing values win. - * @param response - The native Response returned by the handler - * @returns The same Response with accumulated headers and cookies applied + * Check media type is JSON. + * @description Matches JSON, text JSON, and suffix types. + * @param mediaType - Media type to inspect + * @returns True when media type is JSON */ - private finalizeRaw(response: Response): Response { - const headerKeys = Object.keys(this.responseHeaders) - if (headerKeys.length > 0) { - for (const headerKey of headerKeys) { - if (!response.headers.has(headerKey)) { - response.headers.set(headerKey, this.responseHeaders[headerKey]!) - } - } - } - if (this.setCookieValues.length > 0 && response.headers.get('Set-Cookie') === null) { - Core.Handler.appendCookies(response.headers, this.setCookieValues) - } - return response - } - - /** Get captured framework error */ - private getFrameworkError(): Error | null { - return this.frameworkError - } - - /** Throws if body already consumed */ - private guardBodyUse(): void { - if (this.bodyParsedAs !== null) { - throw new Deno.errors.BadResource('Request body already consumed') - } - } - - /** Parse cookies, first occurrence wins */ - private parseCookies(): void { - const parsedCookies = Object.create(null) as Types.StringRecord - const cookieHeader = this.req.headers.get('cookie') - if (cookieHeader) { - const trimRegex = Core.Constant.cookieTrimRegex - for (const cookiePart of cookieHeader.split(';')) { - const trimmedPart = cookiePart.replace(trimRegex, '') - const eqIndex = trimmedPart.indexOf('=') - if (eqIndex <= 0) { - continue - } - const cookieName = trimmedPart.slice(0, eqIndex).replace(trimRegex, '') - const cookieValue = trimmedPart.slice(eqIndex + 1) - if (cookieName && !Object.hasOwn(parsedCookies, cookieName)) { - parsedCookies[cookieName] = cookieValue - } - } - } - this.cookieMap = parsedCookies + private static isJsonMedia(mediaType: string): boolean { + return mediaType === 'application/json' || + mediaType === 'text/json' || + mediaType.endsWith('+json') } /** - * Parse Content-Type to canonical media type. - * @description Lowercases type, drops parameters after first semicolon. - * @param contentType - Raw Content-Type header value or null - * @returns Lowercased media type, empty string when absent + * Parse media type from header. + * @description Strips parameters and lowercases the type. + * @param contentType - Content type header value + * @returns Lowercased media type string */ private static parseMediaType(contentType: string | null): string { if (!contentType) { @@ -541,103 +569,6 @@ export class Context { const typePart = semicolonIndex === -1 ? contentType : contentType.slice(0, semicolonIndex) return typePart.trim().toLowerCase() } - - /** - * Read and cache the body in a single format. - * @description Returns cached value or guards, reads, then caches. - * @template R - Concrete body representation returned by the reader - * @param format - Cache discriminant for the parsed representation - * @param read - Single-use reader pulling the body off the request - * @returns The parsed body value in the requested format - * @throws {Types.StatusError} When the body was already consumed or unreadable - */ - private async readBody( - format: Types.BodyParsedFormat, - read: (req: Request) => Promise - ): Promise { - if (this.bodyParsedAs === format) { - return this.bodyData as R - } - this.guardBodyUse() - try { - this.bodyData = await read(this.req) - } catch (parseError) { - throw Context.toBodyError(parseError) - } - this.bodyParsedAs = format - return this.bodyData as R - } - - /** - * Replace request and reset body state. - * @description Used by body-limiting middleware to replace request. - * @param req - New request to use - */ - private replaceRequest(req: Request): void { - this.req = req - this.bodyData = undefined - this.bodyParsedAs = null - } - - /** - * Resolve the configured view engine or fail. - * @description Single guard for render paths requiring a view engine. - * @returns The wired ViewEngine instance - * @throws {Deno.errors.NotSupported} When no view engine is configured - */ - private requireViewEngine(): Types.ViewEngine { - const viewEngine = this.getState(Core.Handler.stateKeys.view) - if (viewEngine === undefined) { - throw new Deno.errors.NotSupported( - 'View engine not configured, set viewsDir in RouterOptions' - ) - } - return viewEngine - } - - /** - * Re-throw errors carrying valid status. - * @description Ensures lenient body parsing never swallows status errors. - * @param parseError - Error thrown while reading or parsing the body - */ - private static rethrowStatusError(parseError: unknown): void { - if (Core.Handler.isErrorWithStatus(parseError)) { - throw parseError - } - } - - /** - * Set framework-wired state value. - * @description Internal write path for reserved framework keys. - * @template T - Value type encoded in the key - * @param key - Branded reserved state key - * @param value - Value matching the key's type - */ - private setInternalState(key: Types.StateKey, value: T): void { - this.frameworkState[key] = value - } - - /** - * Merge route params into context. - * @description Percent-decodes incoming params, then merges with existing. - * @param params - Params from router match - */ - private setParams(params: Types.StringRecord): void { - this.routeParams = { ...this.routeParams, ...Context.decodeParams(params) } - } - - /** - * Normalize body failure to client error. - * @description Preserves existing status, else classifies as 400. - * @param parseError - Error thrown while reading or parsing the body - * @returns StatusError carrying the resolved status code - */ - private static toBodyError(parseError: unknown): Types.StatusError { - return Core.Handler.isErrorWithStatus(parseError) - ? parseError - : Core.Handler.createStatusError(400, 'Malformed or unreadable request body') - } } -/** Freeze Context prototype methods */ Immutable.freeze(Context.prototype) diff --git a/src/core/Cookie.ts b/src/core/Cookie.ts new file mode 100644 index 0000000..2d23af4 --- /dev/null +++ b/src/core/Cookie.ts @@ -0,0 +1,59 @@ +import type * as Types from '@interfaces/index.ts' + +/** + * Cookie header serialization helper. + * @description Builds Set-Cookie header strings from options. + */ +export class Cookie { + /** + * Serialize cookie into header string. + * @description Encodes name, value, and attribute options. + * @param name - Cookie name to set + * @param value - Cookie value to set + * @param options - Optional cookie attribute values + * @returns Serialized Set-Cookie header string + * @throws When name, expires, maxAge, or sameSite invalid + */ + static serialize(name: string, value: string, options?: Types.CookieInit): string { + if (name.length === 0) { + throw new TypeError('Cookie name must be a non-empty string') + } + const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`] + if (options !== undefined) { + if (options.path !== undefined) { + parts.push(`Path=${options.path}`) + } + if (options.domain !== undefined) { + parts.push(`Domain=${options.domain}`) + } + if (options.expires !== undefined) { + const expires = options.expires instanceof Date + ? options.expires + : new Date(options.expires) + if (Number.isNaN(expires.getTime())) { + throw new TypeError('Cookie expires must be a valid Date or timestamp') + } + parts.push(`Expires=${expires.toUTCString()}`) + } + if (options.maxAge !== undefined) { + if (!Number.isFinite(options.maxAge)) { + throw new TypeError('Cookie maxAge must be a finite number of seconds') + } + parts.push(`Max-Age=${Math.trunc(options.maxAge)}`) + } + if (options.sameSite !== undefined) { + if (options.sameSite === 'None' && options.secure !== true) { + throw new TypeError('Cookie sameSite None requires secure true') + } + parts.push(`SameSite=${options.sameSite}`) + } + if (options.secure === true) { + parts.push('Secure') + } + if (options.httpOnly === true) { + parts.push('HttpOnly') + } + } + return parts.join('; ') + } +} diff --git a/src/core/Guard.ts b/src/core/Guard.ts deleted file mode 100644 index 4c75c61..0000000 --- a/src/core/Guard.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' - -/** - * Process-level fault sentinel for multi-service uptime. - * @description Traps unhandled rejections and uncaught errors process-wide. - */ -export class Guard { - /** Registered emitters, one per serving Router */ - private static readonly emitters = new Set() - /** True once the global listeners have been attached */ - private static installed = false - - /** - * Register a Router emitter and activate guard. - * @description Attaches global listeners exactly once across routers. - * @param emit - The Router's observability emit function - * @returns Unregister function removing this emitter from the fan-out - */ - static register(emit: Types.EventEmit): () => void { - Guard.emitters.add(emit) - Guard.install() - return () => { - Guard.emitters.delete(emit) - } - } - - /** - * Fan a fault to registered emitters. - * @description A faulty subscriber must not break delivery to the others. - * @param origin - Which global hook produced the fault - * @param error - Normalized Error describing the fault - */ - private static dispatch( - origin: 'unhandledrejection' | 'uncaughterror' | 'process:exit', - error: Error - ): void { - for (const emit of Guard.emitters) { - try { - emit(Core.Observability.internalEvent('process:error', { origin, error })) - } catch { - void 0 - } - } - } - - /** Attach global rejection and error listeners once */ - private static install(): void { - if (Guard.installed) { - return - } - Guard.installed = true - globalThis.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { - event.preventDefault() - Guard.dispatch('unhandledrejection', Guard.toError(event.reason)) - }) - globalThis.addEventListener('error', (event: ErrorEvent) => { - event.preventDefault() - Guard.dispatch('uncaughterror', Guard.toError(event.error ?? event.message)) - }) - Guard.interposeExitCapabilities() - } - - /** - * Neutralize every native process-termination capability. - * @description Blocks exit, abort, and self-targeted kills. - */ - private static interposeExitCapabilities(): void { - const ownPid = Deno.pid - const targetsSelf = (args: readonly unknown[]): boolean => args[0] === ownPid - const deno = Deno as unknown as Record - Guard.interposeMethod(deno, 'exit', 'Deno.exit') - Guard.interposeMethod(deno, 'kill', 'Deno.kill', targetsSelf) - const proc = (globalThis as unknown as { process?: Record }).process - if (proc) { - Guard.interposeMethod(proc, 'exit', 'process.exit') - Guard.interposeMethod(proc, 'abort', 'process.abort') - Guard.interposeMethod(proc, 'reallyExit', 'process.reallyExit') - Guard.interposeMethod(proc, 'kill', 'process.kill', targetsSelf) - } - } - - /** - * Replace one termination method with a guarded no-op. - * @description Predicate-matched calls block, others delegate through. - * @param target - Object owning the method (Deno or node process) - * @param name - Method name to interpose - * @param label - Human-readable name for the blocked capability - * @param shouldBlock - Optional guard, when omitted every call is blocked - */ - private static interposeMethod( - target: Record, - name: string, - label: string, - shouldBlock?: (args: readonly unknown[]) => boolean - ): void { - const original = target[name] - if (typeof original !== 'function') { - return - } - const realFn = original as (...args: unknown[]) => unknown - const guarded = (...args: unknown[]): unknown => { - if (shouldBlock && !shouldBlock(args)) { - return realFn.apply(target, args) - } - Guard.dispatch( - 'process:exit', - new Error( - `Blocked ${label}(${ - args.map((value) => String(value)).join(', ') - }) — process termination is not permitted from application code` - ) - ) - return undefined - } - try { - Object.defineProperty(target, name, { - value: guarded, - writable: true, - configurable: true - }) - } catch { - void 0 - } - } - - /** - * Normalize a thrown value into Error. - * @description Wraps non-Error reasons into a real Error. - * @param reason - The rejection reason or thrown value - * @returns A normalized Error instance - */ - private static toError(reason: unknown): Error { - if (reason instanceof Error) { - return reason - } - return new Error( - typeof reason === 'string' ? reason : `Unhandled rejection caused by ${String(reason)}` - ) - } -} diff --git a/src/core/Handler.ts b/src/core/Handler.ts index 8e506f7..dc2fa3d 100644 --- a/src/core/Handler.ts +++ b/src/core/Handler.ts @@ -1,36 +1,16 @@ import type * as Types from '@interfaces/index.ts' -import type { Context } from '@core/Context.ts' import * as Core from '@core/index.ts' /** - * Handler utilities for routing layers. - * @description Static helpers used by routing, middleware, and context layers. + * HTTP error and header helpers. + * @description Builds error responses, headers, and status errors. */ export class Handler { - /** Well-known framework state keys */ - static readonly stateKeys: Types.StateKeysMap = { - view: Handler.stateKey('view'), - worker: Handler.stateKey('worker'), - session: Handler.stateKey('session'), - setSession: Handler.stateKey<(data: Types.DataRecord) => Promise>('setSession'), - clearSession: Handler.stateKey<() => void>('clearSession'), - validated: Handler.stateKey('validated') - } as const - /** Reserved framework state key names, not writable by public setState */ - static readonly reservedStateKeys: ReadonlySet = new Set([ - 'view', - 'worker', - 'session', - 'setSession', - 'clearSession', - 'validated' - ]) - /** - * Append Set-Cookie values to headers. - * @description Appends each cookie value as a Set-Cookie header. - * @param headers - Target Headers instance - * @param cookieValues - Cookie values to append + * Append Set-Cookie header values. + * @description Adds each cookie value as separate header. + * @param headers - Headers instance to mutate + * @param cookieValues - Cookie header strings to append */ static appendCookies(headers: Headers, cookieValues: readonly string[]): void { for (const cookieValue of cookieValues) { @@ -39,13 +19,13 @@ export class Handler { } /** - * Assert a value is a positive finite number. - * @description Single validator for positive-number construction options. - * @param value - Value to validate - * @param label - Option name surfaced in the error message - * @param unit - Optional unit suffix, e.g. "milliseconds" - * @returns The validated positive finite number - * @throws {Deno.errors.InvalidData} When the value is non-finite or <= 0 + * Assert value is positive finite. + * @description Throws labeled error when value is invalid. + * @param value - Number value to validate + * @param label - Label used in error message + * @param unit - Optional unit used in message + * @returns Same value when valid + * @throws When value is not positive finite */ static assertPositiveFinite(value: number, label: string, unit?: string): number { if (!Number.isFinite(value) || value <= 0) { @@ -58,25 +38,25 @@ export class Handler { } /** - * Build error response with format. - * @description Tries middleware then JSON or HTML, masks error messages. - * @param ctx - Request context - * @param statusCode - HTTP status code - * @param error - Thrown error - * @param errorMiddleware - Optional custom handler - * @returns Error response + * Build error response with middleware. + * @description Uses middleware response when valid otherwise default. + * @param ctx - Request context instance + * @param statusCode - HTTP status code to send + * @param error - Caught error instance + * @param errorMiddleware - Optional error middleware handler + * @returns Promise resolving to error response */ static async buildResponse( - ctx: Context, + ctx: Core.Context, statusCode: number, error: globalThis.Error, errorMiddleware: Types.ErrorMiddleware | null ): Promise { if (errorMiddleware) { const customResponse = await errorMiddleware(ctx, { - url: ctx.url, - method: ctx.request.method, - pathname: ctx.pathname, + url: ctx.get.url().href, + method: ctx.get.method(), + pathname: ctx.get.pathname(), statusCode, error }) @@ -88,14 +68,43 @@ export class Handler { } /** - * Create StatusError with status code. - * @description Produces Error with immutable statusCode property attached. - * @param statusCode - HTTP status code - * @param message - Error message - * @returns Error with statusCode property + * Build content disposition header value. + * @description Sanitizes filename and adds UTF-8 fallback. + * @param filename - Download filename to encode + * @returns Content-Disposition header value + */ + static contentDisposition(filename: string): string { + const baseName = filename.replace(Core.Constant.dispositionPathRegex, '') + const safeName = Handler.stripControlChars(baseName) || 'download' + const asciiName = Array.from(safeName, (char) => char.codePointAt(0)! > 127 ? '_' : char).join( + '' + ) + .replace(Core.Constant.dispositionEscapeRegex, (match) => `\\${match}`) + const asciiFallback = asciiName || 'download' + let headerValue = `attachment; filename="${asciiFallback}"` + if (Core.Constant.dispositionNonAsciiRegex.test(safeName)) { + headerValue += `; filename*=UTF-8''${encodeURIComponent(safeName)}` + } + return headerValue + } + + /** + * Create error with status code. + * @description Attaches non-writable status code property. + * @param statusCode - HTTP status code to attach + * @param message - Error message text + * @param cause - Optional cause detail strings + * @returns Error carrying status code */ - static createStatusError(statusCode: number, message: string): Types.StatusError { - const error = new Error(message) as Types.StatusError + static createStatusError( + statusCode: number, + message: string, + cause?: readonly string[] + ): Types.StatusError { + const error = + (cause === undefined + ? new Error(message) + : new Error(message, { cause })) as Types.StatusError Object.defineProperty(error, 'statusCode', { value: statusCode, writable: false, @@ -106,11 +115,11 @@ export class Handler { } /** - * Minimal HTML error page. - * @description Returns simple HTML with status and escaped message. - * @param statusCode - Status code and title - * @param message - Escaped message body - * @returns HTML string + * Build default error HTML page. + * @description Escapes message into simple HTML document. + * @param statusCode - HTTP status code to show + * @param message - Error message text + * @returns HTML error page string */ static defaultErrorHtml(statusCode: number, message: string): string { const escapedMessage = Handler.escapeHtml(message) @@ -126,49 +135,48 @@ export class Handler { } /** - * Build error response by Accept header. - * @description Single source of truth for safe error responses. - * @param ctx - Request context - * @param statusCode - HTTP status code - * @returns Error response with masked message + * Build negotiated error response. + * @description Sends JSON or HTML based on accept. + * @param ctx - Request context instance + * @param statusCode - HTTP status code to send + * @returns Negotiated error response */ - static errorResponse(ctx: Context, statusCode: number): globalThis.Response { + static errorResponse(ctx: Core.Context, statusCode: number): globalThis.Response { const errorMessage = Handler.safeMessage(statusCode) - const wantsJson = Handler.wantsJson(ctx.request.headers) - const reasons = Handler.safeReasons(ctx[Core.InternalContext].getFrameworkError()) + const wantsJson = Handler.wantsJson(ctx.get.request().headers) try { if (wantsJson) { - return ctx.send.json(Handler.problemDetails(statusCode, ctx.pathname, undefined, reasons), { - status: statusCode, + return ctx.send.json(Handler.problemDetails(statusCode, ctx.get.pathname()), { + status: statusCode as Types.HttpStatusCode, headers: { 'Content-Type': Core.Constant.problemJsonContentType } }) } return ctx.send.html(Handler.defaultErrorHtml(statusCode, errorMessage), { - status: statusCode + status: statusCode as Types.HttpStatusCode }) } catch { - return Handler.safeFallbackResponse(ctx, statusCode, errorMessage, wantsJson, reasons) + return Handler.negotiatedResponse(statusCode, errorMessage, wantsJson, ctx.get.pathname()) } } /** * Escape HTML special characters. - * @description Replaces &, <, >, ", ' with HTML entities. - * @param text - Raw string - * @returns Escaped string safe for HTML content + * @description Replaces unsafe characters with HTML entities. + * @param text - Text to escape + * @returns Escaped HTML safe text */ static escapeHtml(text: string): string { return text.replace(Core.Constant.htmlEscapeRegex, (ch) => Core.Constant.htmlEscapeMap[ch]!) } /** - * Extract status code from error. - * @description Prefers statusCode property, then Deno error class, else 500. - * @param error - Unknown value from catch block - * @returns Object with statusCode and Error instance + * Extract status and error value. + * @description Maps Deno errors to HTTP status codes. + * @param error - Unknown thrown value + * @returns Status code and error pair */ static extractError(error: unknown): Types.ExtractedError { - if (Handler.isErrorWithStatus(error)) { + if (Handler.isStatusError(error)) { return { statusCode: error.statusCode, error } } if (error instanceof Error) { @@ -178,53 +186,52 @@ export class Handler { } /** - * Check path is existing directory. - * @description Returns false when path is missing or not directory. - * @param resolvedDir - Absolute directory path - * @returns True when the path exists and is a directory + * Check path is a directory. + * @description Returns false when stat call fails. + * @param path - Filesystem path to check + * @returns True when path is directory */ - static isDirectory(resolvedDir: string): boolean { + static isDirectory(path: string): boolean { try { - return Deno.statSync(resolvedDir).isDirectory + return Deno.statSync(path).isDirectory } catch { return false } } /** - * Narrow a value to a status-bearing Error. - * @description True for Errors whose statusCode is 400-599. - * @param value - Unknown value from a catch block - * @returns True when value is an Error with an in-range statusCode + * Check value carries HTTP status. + * @description Validates error with status in client range. + * @param value - Unknown value to inspect + * @returns True when value is status error */ - static isErrorWithStatus(value: unknown): value is Types.StatusError { + static isStatusError(value: unknown): value is Types.StatusError { if (!(value instanceof Error) || !('statusCode' in value)) { return false } - const statusValue = (value as Types.StatusCodeCarrier).statusCode + const statusValue = (value as Types.StatusCarrier).statusCode return typeof statusValue === 'number' && statusValue >= 400 && statusValue < 600 } /** - * Build a content-negotiated error response. - * @description Single site for JSON or HTML error bodies. - * @param statusCode - HTTP status code to emit - * @param message - Safe masked message - * @param wantsJson - Whether the client prefers JSON - * @param pathname - Optional request pathname included in JSON bodies - * @returns Error response with security headers and the negotiated body + * Build response without context helpers. + * @description Produces JSON or HTML error directly. + * @param statusCode - HTTP status code to send + * @param message - Error message text + * @param wantsJson - Send JSON when true + * @param pathname - Optional request path instance + * @returns Negotiated error response */ static negotiatedResponse( statusCode: number, message: string, wantsJson: boolean, - pathname?: string, - reasons?: readonly string[] + pathname?: string ): globalThis.Response { - const headers = new Core.API.Headers(Core.Constant.securityHeaderDefaults) + const headers = new Core.API.Headers() if (wantsJson) { headers.set('Content-Type', Core.Constant.problemJsonContentType) - const body = Handler.problemDetails(statusCode, pathname, message, reasons) + const body = Handler.problemDetails(statusCode, pathname, message) return new Core.API.Response(Core.API.jsonStringify(body), { status: statusCode, headers }) } headers.set('Content-Type', 'text/html; charset=utf-8') @@ -235,36 +242,31 @@ export class Handler { } /** - * Build structured error problem details. - * @description Returns problem body with type, title, status, optional instance. - * @param statusCode - HTTP status code - * @param pathname - Optional request pathname as instance - * @param title - Optional title overriding safe message - * @param reasons - Optional validation reasons added as errors - * @returns Problem details object + * Build problem details object. + * @description Includes instance path when provided. + * @param statusCode - HTTP status code to report + * @param pathname - Optional instance path value + * @param title - Optional problem title text + * @returns Problem details payload object */ static problemDetails( statusCode: number, pathname?: string, - title?: string, - reasons?: readonly string[] + title?: string ): Types.ProblemDetails { const base: Types.ProblemDetails = { type: 'about:blank', title: title ?? Handler.safeMessage(statusCode), status: statusCode } - const withInstance = pathname === undefined ? base : { ...base, instance: pathname } - return reasons !== undefined && reasons.length > 0 - ? { ...withInstance, errors: reasons } - : withInstance + return pathname === undefined ? base : { ...base, instance: pathname } } /** - * Resolve safe message for status. - * @description Returns known message or generic fallback for status. - * @param statusCode - HTTP status code - * @returns Safe user-facing error message + * Resolve safe status message text. + * @description Falls back by client or server range. + * @param statusCode - HTTP status code to map + * @returns Reason phrase for status */ static safeMessage(statusCode: number): string { return ( @@ -274,42 +276,27 @@ export class Handler { } /** - * Extract safe reasons from error. - * @description Returns string causes only for 422 status errors. - * @param error - Error to inspect, or null - * @returns Reason strings, or undefined when none + * Strip control characters from text. + * @description Removes characters below 32 and delete. + * @param text - Text to sanitize + * @returns Text without control characters */ - static safeReasons(error: Error | null): readonly string[] | undefined { - if ( - error === null || - !Handler.isErrorWithStatus(error) || - error.statusCode !== 422 || - !Array.isArray(error.cause) - ) { - return undefined + static stripControlChars(text: string): string { + let result = '' + for (const char of text) { + const code = char.codePointAt(0)! + if (code >= 32 && code !== 127) { + result += char + } } - const reasons = (error.cause as readonly unknown[]).filter( - (reason): reason is string => typeof reason === 'string' - ) - return reasons.length > 0 ? reasons : undefined + return result } /** - * Create a branded state key. - * @description Returns type-branded string for compile-time safety. - * @template T - The value type this key maps to - * @param key - Raw string key - * @returns Branded StateKey - */ - static stateKey(key: string): Types.StateKey { - return key as Types.StateKey - } - - /** - * Convert HeadersInit to record. - * @description Returns plain string record from any HeadersInit variant. - * @param init - Headers, array, or object - * @returns Key-value string record + * Convert headers init to record. + * @description Normalizes Headers, array, or object input. + * @param init - Optional headers init value + * @returns String record of header pairs */ static toRecord(init?: HeadersInit): Types.StringRecord { if (!init) { @@ -325,10 +312,10 @@ export class Handler { } /** - * Check client prefers JSON response. - * @description Returns true when Accept header includes application/json. - * @param headers - Request headers - * @returns True when JSON is preferred + * Check accept header wants JSON. + * @description Detects JSON or problem JSON accept values. + * @param headers - Request headers instance + * @returns True when client accepts JSON */ static wantsJson(headers: Headers): boolean { const accept = headers.get('accept') @@ -339,10 +326,10 @@ export class Handler { } /** - * Map standard error class to status. - * @description Whitelists unambiguous Deno error types, else returns 500. - * @param error - Error instance to classify - * @returns Canonical HTTP status code + * Map Deno error to status. + * @description Returns specific code or 500 default. + * @param error - Error instance to inspect + * @returns HTTP status code for error */ private static denoErrorStatus(error: Error): number { if (error instanceof Deno.errors.NotFound) { @@ -365,23 +352,4 @@ export class Handler { } return 500 } - - /** - * Build a guaranteed-valid error response. - * @description Emits baseline security headers when send path fails. - * @param ctx - Request context - * @param statusCode - HTTP status code - * @param message - Safe masked message - * @param wantsJson - Whether client prefers JSON - * @returns Safe error response - */ - private static safeFallbackResponse( - ctx: Context, - statusCode: number, - message: string, - wantsJson: boolean, - reasons?: readonly string[] - ): globalThis.Response { - return Handler.negotiatedResponse(statusCode, message, wantsJson, ctx.pathname, reasons) - } } diff --git a/src/core/IpAddress.ts b/src/core/IpAddress.ts index edfc41d..b5268a0 100644 --- a/src/core/IpAddress.ts +++ b/src/core/IpAddress.ts @@ -1,16 +1,16 @@ import type * as Types from '@interfaces/index.ts' /** - * IP address parsing and matching utilities. - * @description Canonical parsing, CIDR matching, shared across middleware. + * IP address parsing and matching. + * @description Parses IPv4 and IPv6 and compiles rules. */ export class IpAddress { /** - * Test an IP against matchers. - * @description Returns true when any matcher accepts the IP. - * @param matchers - Compiled rule matchers - * @param ip - IP address string - * @returns True when any matcher accepts + * Check IP matches any matcher. + * @description Returns true on first matcher hit. + * @param matchers - Compiled IP matcher functions + * @param ip - IP address to test + * @returns True when any matcher matches */ static anyMatch(matchers: readonly Types.IpMatcher[], ip: string): boolean { for (const matcher of matchers) { @@ -22,11 +22,11 @@ export class IpAddress { } /** - * Compile a rule into a matcher. - * @description Handles wildcard, CIDR, and exact addresses. - * @param rule - Single rule string - * @returns Matcher for the rule - * @throws {Deno.errors.InvalidData} When the rule is malformed + * Compile rule into matcher function. + * @description Supports wildcard, exact, and CIDR rules. + * @param rule - IP or CIDR rule string + * @returns Matcher function for the rule + * @throws When rule or CIDR prefix invalid */ static compileRule(rule: string): Types.IpMatcher { if (rule === '*') { @@ -69,11 +69,10 @@ export class IpAddress { } /** - * Compile rule strings into matchers. - * @description Validates each rule into a test. - * @param rules - Rule strings or undefined - * @returns Compiled matcher list - * @throws {Deno.errors.InvalidData} When a rule is malformed + * Compile multiple rules into matchers. + * @description Returns empty array when no rules. + * @param rules - IP or CIDR rule strings + * @returns Array of compiled matcher functions */ static compileRules(rules: readonly string[] | undefined): readonly Types.IpMatcher[] { if (!rules || rules.length === 0) { @@ -83,20 +82,20 @@ export class IpAddress { } /** - * Test whether a string is a valid IP address. - * @description True only for non-empty strings that parse to an IP value. - * @param address - Candidate IP string - * @returns True when the address parses successfully + * Check address is valid IP. + * @description Parses address and checks success. + * @param address - IP address string to validate + * @returns True when address parses successfully */ static isValid(address: string): boolean { return address.length > 0 && IpAddress.parse(address) !== null } /** - * Parse an IP string into a canonical value. - * @description Detects version and folds IPv4-mapped IPv6 to IPv4. - * @param address - IP address string - * @returns Parsed value and version, or null when invalid + * Parse address into value version. + * @description Maps IPv4 in IPv6 to version four. + * @param address - IP address string to parse + * @returns Parsed IP value or null */ static parse(address: string): Types.ParsedIp | null { if (address.includes(':')) { @@ -114,10 +113,10 @@ export class IpAddress { } /** - * Combine eight groups into a 128-bit value. - * @description Shifts each hextet into place. - * @param groups - Eight hextet values - * @returns Combined 128-bit value + * Combine groups into single value. + * @description Shifts each group by sixteen bits. + * @param groups - Bigint groups to combine + * @returns Combined bigint address value */ private static groupsToValue(groups: readonly bigint[]): bigint { let combinedValue = 0n @@ -128,10 +127,10 @@ export class IpAddress { } /** - * Parse IPv4 into a 32-bit value. - * @description Validates four 0-255 octets without leading zeros. - * @param address - IPv4 address string - * @returns Numeric value or null when invalid + * Parse IPv4 address into value. + * @description Validates four octets within byte range. + * @param address - IPv4 address string to parse + * @returns Bigint address value or null */ private static parseIPv4(address: string): bigint | null { const parts = address.split('.') @@ -153,10 +152,10 @@ export class IpAddress { } /** - * Parse IPv6 into a 128-bit value. - * @description Expands "::" and validates hextets, allows IPv4 suffix. - * @param address - IPv6 address string - * @returns Numeric value or null when invalid + * Parse IPv6 address into value. + * @description Expands compression and embedded IPv4 groups. + * @param address - IPv6 address string to parse + * @returns Bigint address value or null */ private static parseIPv6(address: string): bigint | null { const zoneIndex = address.indexOf('%') @@ -194,11 +193,10 @@ export class IpAddress { } const head: bigint[] = [] const tail: bigint[] = [] - const headTarget = groups if (!expand(headParts)) { return null } - head.push(...headTarget) + head.push(...groups) groups.length = 0 if (!expand(tailParts)) { return null diff --git a/src/core/IpResolver.ts b/src/core/IpResolver.ts index 17d7e79..fc4e34b 100644 --- a/src/core/IpResolver.ts +++ b/src/core/IpResolver.ts @@ -2,25 +2,24 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' /** - * Trusted-proxy aware client IP resolution. - * @description Resolves the real visitor IP from the peer and forwarded headers. + * Trusted proxy IP resolver. + * @description Resolves client IP from forwarded headers. */ export class IpResolver { - /** Trusted-proxy presets expanded to CIDR rules */ + /** Preset CIDR rule groups by name */ private static readonly presetRules: Record = { loopback: ['127.0.0.1/8', '::1/128'], linklocal: ['169.254.0.0/16', 'fe80::/10'], uniquelocal: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7'] } - /** Single-IP forwarding header names */ + /** Header names carrying a single IP */ private static readonly singleIpHeaders: readonly string[] = ['cf-connecting-ip', 'x-real-ip'] /** - * Compile a trust-proxy configuration into a tester. - * @description Expands presets and CIDRs, or wraps a predicate. - * @param config - Trust configuration or undefined - * @returns Trust tester, or null when nothing is trusted - * @throws {Deno.errors.InvalidData} When a rule is malformed + * Compile trust proxy configuration. + * @description Expands presets then builds matcher function. + * @param config - Trust proxy configuration value + * @returns Matcher function or null */ static compile(config: Types.TrustProxyConfig | undefined): Types.IpMatcher | null { if (config === undefined) { @@ -46,12 +45,12 @@ export class IpResolver { } /** - * Resolve the real client IP. - * @description Walks forwarded hops right-to-left through trusted proxies. - * @param directPeer - Direct TCP peer IP, or undefined - * @param headers - Request headers - * @param trust - Compiled trust tester, or null when none trusted - * @returns Real client IP, or undefined when peer is unknown + * Resolve client IP from headers. + * @description Walks forwarded chain skipping trusted hops. + * @param directPeer - Direct peer IP address + * @param headers - Request headers instance + * @param trust - Trusted proxy matcher or null + * @returns Resolved client IP or undefined */ static resolve( directPeer: string | undefined, @@ -79,10 +78,10 @@ export class IpResolver { } /** - * Build the forwarded address chain. - * @description Merges X-Forwarded-For and Forwarded header entries. - * @param headers - Request headers - * @returns Ordered client-to-proxy IP chain + * Build forwarded IP hop chain. + * @description Reads x-forwarded-for then forwarded header. + * @param headers - Request headers instance + * @returns Ordered forwarded IP chain */ private static forwardedChain(headers: Headers): readonly string[] { const chain: string[] = [] @@ -121,9 +120,9 @@ export class IpResolver { } /** - * Normalize a forwarded token into a bare IP. + * Normalize forwarded token into IP. * @description Strips quotes, brackets, and trailing port. - * @param token - Raw forwarded token + * @param token - Raw forwarded token value * @returns Valid IP string or undefined */ private static normalizeForwardedToken(token: string): string | undefined { @@ -150,9 +149,9 @@ export class IpResolver { } /** - * Read a single-IP forwarding header. - * @description Returns the first valid configured single-IP header value. - * @param headers - Request headers + * Read single IP from headers. + * @description Checks known single IP header names. + * @param headers - Request headers instance * @returns Valid IP string or undefined */ private static singleHeaderIp(headers: Headers): string | undefined { diff --git a/src/core/Observability.ts b/src/core/Observability.ts index 135e158..91fcfdd 100644 --- a/src/core/Observability.ts +++ b/src/core/Observability.ts @@ -2,39 +2,56 @@ import type * as Types from '@interfaces/index.ts' import { createSignal } from '@neabyte/utils-core' /** - * Central lifecycle and error event bus. - * @description Wraps a typed signal isolating faulty subscriber errors. + * Event emitter and process guard. + * @description Emits events and intercepts process termination. */ export class Observability { - /** Underlying typed signal carrying Deserve events */ - private readonly signal = createSignal<[Types.EventBase]>() - /** Active subscriber count */ - private listeners = 0 + /** Internal signal carrying event payloads */ + readonly #signal = createSignal<[Types.EventBase]>() + /** Active listener count for emit gating */ + #listenerCount = 0 + /** Cleanup function for process capture */ + #captureProcessErrors: (() => void) | null = null /** - * Emit one event to listeners. - * @description No-op when there are no subscribers. - * @param event - Event payload to broadcast + * Emit event to listeners. + * @description Skips emit when no listeners exist. + * @param event - Event payload to emit */ emit(event: Types.EventBase): void { - if (this.listeners === 0) { + if (this.#listenerCount === 0) { return } - this.signal.emit(event) + this.#signal.emit(event) } - /** Report whether any subscriber is registered */ + /** + * Build external event payload. + * @description Stamps type external and current timestamp. + * @param kind - Event kind discriminator + * @param metadata - Event metadata for kind + * @returns Typed external event payload + * @template Kind - Event kind being built + */ + static externalEvent( + kind: Kind, + metadata: Types.EventByKind['metadata'] + ): Types.EventByKind { + return { type: 'external', kind, metadata, timestamp: Date.now() } as Types.EventByKind + } + + /** Check whether any listeners exist */ hasListeners(): boolean { - return this.listeners > 0 + return this.#listenerCount > 0 } /** - * Build an internal lifecycle event. + * Build internal event payload. * @description Stamps type internal and current timestamp. - * @template Kind - Event kind discriminant literal - * @param kind - Event kind discriminant - * @param metadata - Metadata matching the kind - * @returns Fully formed internal event + * @param kind - Event kind discriminator + * @param metadata - Event metadata for kind + * @returns Typed internal event payload + * @template Kind - Event kind being built */ static internalEvent( kind: Kind, @@ -44,22 +61,156 @@ export class Observability { } /** - * Subscribe to every Deserve event. - * @description Listener receives all event types, filter via type. - * @param listener - Callback invoked for each event - * @returns Unsubscribe function + * Subscribe listener to events. + * @description Installs process capture on first listener. + * @param listener - Event listener callback + * @returns Unsubscribe function for listener */ - on(listener: Types.EventListener): () => void { - const unsubscribe = this.signal.subscribe(listener) - this.listeners += 1 + on(listener: Types.EventFn): () => void { + const unsubscribe = this.#signal.subscribe(listener) + this.#listenerCount += 1 + if (this.#listenerCount === 1) { + this.#captureProcessErrors = Observability.#installProcessCapture((event) => this.emit(event)) + } let active = true return () => { if (!active) { return } active = false - this.listeners -= 1 + this.#listenerCount -= 1 unsubscribe() + if (this.#listenerCount === 0 && this.#captureProcessErrors !== null) { + this.#captureProcessErrors() + this.#captureProcessErrors = null + } + } + } + + /** + * Install global process error capture. + * @description Listens for rejections, errors, and exits. + * @param emit - Event emitter for captured errors + * @returns Cleanup function removing capture + */ + static #installProcessCapture(emit: Types.EventFn): () => void { + const onRejection = (event: PromiseRejectionEvent) => { + event.preventDefault() + emit(Observability.#processError('unhandledrejection', event.reason)) + } + const onError = (event: ErrorEvent) => { + event.preventDefault() + emit(Observability.#processError('uncaughterror', event.error ?? event.message)) + } + globalThis.addEventListener('unhandledrejection', onRejection) + globalThis.addEventListener('error', onError) + const restoreExits = Observability.#interposeExits(emit) + return () => { + globalThis.removeEventListener('unhandledrejection', onRejection) + globalThis.removeEventListener('error', onError) + restoreExits() + } + } + + /** + * Interpose process exit methods. + * @description Guards Deno and process termination methods. + * @param emit - Event emitter for blocked calls + * @returns Cleanup function restoring methods + */ + static #interposeExits(emit: Types.EventFn): () => void { + const ownPid = Deno.pid + const targetsSelf = (args: readonly unknown[]): boolean => args[0] === ownPid + const deno = Deno as unknown as Record + const proc = (globalThis as unknown as Types.ProcessGlobal).process + const restores: Array<() => void> = [] + restores.push(Observability.#interposeMethod(emit, deno, 'exit', 'Deno.exit')) + restores.push(Observability.#interposeMethod(emit, deno, 'kill', 'Deno.kill', targetsSelf)) + if (proc) { + restores.push(Observability.#interposeMethod(emit, proc, 'exit', 'process.exit')) + restores.push(Observability.#interposeMethod(emit, proc, 'abort', 'process.abort')) + restores.push(Observability.#interposeMethod(emit, proc, 'reallyExit', 'process.reallyExit')) + restores.push(Observability.#interposeMethod(emit, proc, 'kill', 'process.kill', targetsSelf)) + } + return () => { + for (const restore of restores) { + restore() + } + } + } + + /** + * Replace target method with guard. + * @description Blocks termination and emits process error. + * @param emit - Event emitter for blocked calls + * @param target - Object owning the method + * @param name - Method name to interpose + * @param label - Label used in error message + * @param shouldBlock - Predicate deciding when to block + * @returns Cleanup function restoring method + */ + static #interposeMethod( + emit: Types.EventFn, + target: Record, + name: string, + label: string, + shouldBlock?: (args: readonly unknown[]) => boolean + ): () => void { + const original = target[name] + if (typeof original !== 'function') { + return () => {} + } + const realFn = original as (...args: unknown[]) => unknown + const guarded = (...args: unknown[]): unknown => { + if (shouldBlock && !shouldBlock(args)) { + return realFn.apply(target, args) + } + emit( + Observability.#processError( + 'process:exit', + new Error( + `Blocked ${label}(${ + args.map((value) => String(value)).join(', ') + }) process termination is not permitted from application code` + ) + ) + ) + return undefined + } + try { + Object.defineProperty(target, name, { value: guarded, writable: true, configurable: true }) + } catch { + return () => {} + } + return () => { + try { + Object.defineProperty(target, name, { + value: realFn, + writable: true, + configurable: true + }) + } catch { + void 0 + } + } + } + + /** + * Build process error event. + * @description Wraps reason into error event payload. + * @param origin - Process error origin label + * @param reason - Underlying error reason value + * @returns Process error event payload + */ + static #processError(origin: Types.ProcessErrorOrigin, reason: unknown): Types.EventBase { + const error = reason instanceof Error + ? reason + : new Error(typeof reason === 'string' ? reason : `Process error from ${String(reason)}`) + return { + type: 'external', + kind: 'process:failed', + metadata: { origin, error }, + timestamp: Date.now() } } } diff --git a/src/core/Redirect.ts b/src/core/Redirect.ts index 088108d..a38a27c 100644 --- a/src/core/Redirect.ts +++ b/src/core/Redirect.ts @@ -2,20 +2,21 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' /** - * Builds redirect Response with Location. - * @description Resolves relative URL, merges headers. + * HTTP redirect response builder. + * @description Builds redirect responses with safe location. */ export class Redirect { /** - * Create redirect response to URL. - * @description Resolves relative URL, merges Location and headers. - * @param requestUrl - Base for relative URL - * @param responseHeaders - Headers to merge - * @param setCookieValues - Cookie values to append - * @param url - Target URL (absolute or relative) - * @param status - Redirect status code - * @param extraHeaders - Optional extra headers - * @returns Redirect response + * Build redirect response with location. + * @description Validates status then merges headers and cookies. + * @param requestUrl - Originating request URL + * @param responseHeaders - Accumulated response headers + * @param setCookieValues - Set-Cookie header values + * @param url - Redirect target location + * @param status - Redirect HTTP status code + * @param extraHeaders - Optional extra header values + * @returns Redirect response instance + * @throws When status code is not a redirect */ static buildResponse( requestUrl: string, @@ -45,12 +46,12 @@ export class Redirect { } /** - * Resolve and validate a redirect Location. - * @description Decides on parsed result, relative targets stay same-origin. - * @param requestUrl - Base request URL for relative resolution - * @param url - Caller-supplied redirect target - * @returns Validated absolute URL string - * @throws {Deno.errors.InvalidData} On unparseable, non-http(s), or cross-origin relative input + * Resolve and validate redirect location. + * @description Blocks cross origin unless explicitly absolute. + * @param requestUrl - Originating request URL + * @param url - Redirect target location + * @returns Absolute resolved location URL + * @throws When URL invalid, wrong scheme, or cross origin */ private static resolveLocation(requestUrl: string, url: string): string { const isExplicitAbsolute = /^https?:\/\//i.test(url) @@ -73,7 +74,7 @@ export class Redirect { throw new Deno.errors.InvalidData( `Redirect target "${ url.slice(0, 64) - }" resolves to a different origin, pass a full https:// URL to redirect cross-origin intentionally` + }" resolves to a different origin, pass a full https URL to redirect cross-origin intentionally` ) } return resolvedUrl.href diff --git a/src/core/Rendering.ts b/src/core/Rendering.ts new file mode 100644 index 0000000..c15e81b --- /dev/null +++ b/src/core/Rendering.ts @@ -0,0 +1,160 @@ +import type * as Types from '@interfaces/index.ts' +import * as Core from '@core/index.ts' +import DVE from '@neabyte/dve' + +/** + * Template view rendering engine. + * @description Compiles, caches, and renders DVE templates. + */ +export class Rendering { + /** Underlying DVE engine instance */ + readonly #dve: DVE + /** Base directory for template files */ + readonly #directory: string + /** Compiled template cache by path */ + readonly #cache = new Map() + /** Cached include source text by path */ + readonly #includeCache = new Map() + /** Optional event emitter for render events */ + readonly #emit: Types.EventFn | null + + /** + * Construct rendering engine instance. + * @description Configures directory, limits, and event emitter. + * @param options - Rendering configuration options + * @param emit - Optional event emitter callback + */ + constructor(options: Types.RenderingOptions, emit: Types.EventFn | null = null) { + this.#directory = options.directory ?? './views' + this.#emit = emit + this.#dve = new DVE({ + resolveInclude: (path) => this.#resolveInclude(path), + ...(options.maxIterations !== undefined && { maxIterations: options.maxIterations }), + ...(options.maxRenderIterations !== undefined && { + maxRenderIterations: options.maxRenderIterations + }), + ...(options.maxOutputSize !== undefined && { maxOutputSize: options.maxOutputSize }), + ...(options.maxTemplateSize !== undefined && { maxTemplateSize: options.maxTemplateSize }) + }) + } + + /** Base directory for template files */ + get directory(): string { + return this.#directory + } + + /** + * Invalidate cached compiled template. + * @description Removes cache entry and emits refresh event. + * @param template - Template name to invalidate + */ + invalidate(template: string): void { + const path = this.#resolvePath(template) + this.#cache.delete(path) + this.#includeCache.delete(path) + if (this.#emit !== null) { + this.#emit(Core.Observability.internalEvent('view:invalidated', { paths: [template] })) + } + } + + /** + * Render template into response. + * @description Compiles template then streams or renders output. + * @param template - Template name to render + * @param data - View data for template + * @param options - Render options like status + * @returns Promise resolving to rendered response + * @throws When template compile or render fails + */ + async render( + template: string, + data: Types.ViewData, + options: Types.RenderInit + ): Promise { + const start = this.#emit !== null ? performance.now() : 0 + try { + const compiled = await this.#compile(template) + const headers = { 'Content-Type': 'text/html; charset=utf-8' } + const status = options.status ?? 200 + const body = options.stream === true + ? this.#dve.renderStream(compiled, data, template) + : this.#dve.render(compiled, data, template) + if (this.#emit !== null) { + this.#emit( + Core.Observability.internalEvent('view:rendered', { + path: template, + durationMs: performance.now() - start + }) + ) + } + return new Core.API.Response(body, { status, headers }) + } catch (renderError) { + if (this.#emit !== null) { + this.#emit( + Core.Observability.internalEvent('view:failed', { + path: template, + error: renderError instanceof Error ? renderError : new Error(String(renderError)) + }) + ) + } + throw renderError + } + } + + /** + * Compile template with caching. + * @description Reads, compiles, and caches template once. + * @param template - Template name to compile + * @returns Promise resolving to compiled result + */ + async #compile(template: string): Promise { + const path = this.#resolvePath(template) + const cached = this.#cache.get(path) + if (cached !== undefined) { + return cached + } + const start = this.#emit !== null ? performance.now() : 0 + const compiled = this.#dve.compile(await Deno.readTextFile(path), template) + this.#cache.set(path, compiled) + if (this.#emit !== null) { + this.#emit( + Core.Observability.internalEvent('view:compiled', { + path: template, + durationMs: performance.now() - start + }) + ) + } + return compiled + } + + /** + * Resolve include source with caching. + * @description Reads include text once then reuses cache. + * @param template - Include template name to resolve + * @returns Raw include template source text + */ + #resolveInclude(template: string): string { + const path = this.#resolvePath(template) + const cached = this.#includeCache.get(path) + if (cached !== undefined) { + return cached + } + const source = Deno.readTextFileSync(path) + this.#includeCache.set(path, source) + return source + } + + /** + * Resolve template into file path. + * @description Normalizes path and appends DVE extension. + * @param template - Template name to resolve + * @returns Absolute template file path + */ + #resolvePath(template: string): string { + const normalized = template.replace(/\\/g, '/').replace(/^\/+/, '') + const withExtension = normalized.toLowerCase().endsWith(Core.Constant.dveExtension) + ? normalized + : `${normalized}${Core.Constant.dveExtension}` + return `${this.#directory.replace(/\/+$/, '')}/${withExtension}` + } +} diff --git a/src/core/Response.ts b/src/core/Response.ts deleted file mode 100644 index e0dcea7..0000000 --- a/src/core/Response.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' - -/** - * Factory for ctx.send response helpers. - * @description Merges context headers with each response. - */ -export class Response { - /** - * Create SendHelpers for headers and redirect. - * @description Binds base headers and redirect builder to helpers. - * @param responseHeaders - Base headers for every response - * @param setCookieValues - Cookie values to append - * @param buildRedirect - Function to build redirect Response - * @returns SendHelpers for ctx.send - */ - static create( - responseHeaders: Types.StringRecord, - setCookieValues: readonly string[], - buildRedirect: Types.RedirectBuilder - ): Types.SendHelpers { - const mergedHeaders = (contentType: string, options?: ResponseInit) => { - const extra = options?.headers ? Core.Handler.toRecord(options.headers) : undefined - return extra - ? { ...responseHeaders, 'Content-Type': contentType, ...extra } - : { ...responseHeaders, 'Content-Type': contentType } - } - const toInit = (headers: Types.StringRecord, options?: ResponseInit): ResponseInit => { - if (options?.status !== undefined) { - const status = options.status - if ( - !Number.isInteger(status) || - ((status < 200 || status > 599) && !Core.Constant.nullBodyStatuses.has(status)) - ) { - throw new Deno.errors.InvalidData( - `Response status must be an integer in the 200-599 range, got "${String(status)}"` - ) - } - } - return options ? { ...options, headers } : { headers } - } - const isNullBodyStatus = (options?: ResponseInit): boolean => - options?.status !== undefined && Core.Constant.nullBodyStatuses.has(options.status) - const bodyForStatus = (body: BodyInit | null, options?: ResponseInit): BodyInit | null => - isNullBodyStatus(options) ? null : body - const applyCookies = (response: globalThis.Response): globalThis.Response => { - Core.Handler.appendCookies(response.headers, setCookieValues) - return response - } - return { - custom(body: BodyInit | null, options?: ResponseInit): globalThis.Response { - const extraRecord = options?.headers ? Core.Handler.toRecord(options.headers) : undefined - const headers = extraRecord - ? { ...responseHeaders, ...extraRecord } - : { ...responseHeaders } - return applyCookies( - new Core.API.Response(bodyForStatus(body, options), toInit(headers, options)) - ) - }, - data( - data: Uint8Array | string, - filename: string, - options?: ResponseInit, - contentType = 'application/octet-stream' - ): globalThis.Response { - const encodedBody = typeof data === 'string' ? Core.Constant.encoder.encode(data) : data - if (isNullBodyStatus(options)) { - return applyCookies( - new Core.API.Response(null, toInit(mergedHeaders(contentType, options), options)) - ) - } - return applyCookies( - new Core.API.Response( - encodedBody as BodyInit, - toInit( - { - ...mergedHeaders(contentType, options), - 'Content-Disposition': Response.contentDisposition(filename), - 'Content-Length': encodedBody.length.toString() - }, - options - ) - ) - ) - }, - async file( - filePath: string, - filename?: string, - options?: ResponseInit - ): Promise { - let fsFile: Deno.FsFile | null = null - try { - fsFile = await Deno.open(filePath, { read: true }) - const fileInfo = await fsFile.stat() - const downloadName = filename || filePath.split(/[\\/]/).pop() || 'download' - if (isNullBodyStatus(options)) { - fsFile.close() - return applyCookies( - new Core.API.Response( - null, - toInit( - { - ...mergedHeaders('application/octet-stream', options), - 'Content-Disposition': Response.contentDisposition(downloadName) - }, - options - ) - ) - ) - } - return applyCookies( - new Core.API.Response( - fsFile.readable, - toInit( - { - ...mergedHeaders('application/octet-stream', options), - 'Content-Disposition': Response.contentDisposition(downloadName), - 'Content-Length': fileInfo.size.toString() - }, - options - ) - ) - ) - } catch (error) { - fsFile?.close() - const errorMessage = error instanceof Core.API.Error ? error.message : 'Unknown error' - throw new Deno.errors.NotFound( - `Failed to read file "${filePath}" because ${errorMessage}` - ) - } - }, - html: (html: string, options?: ResponseInit) => - applyCookies( - new Core.API.Response( - bodyForStatus(html, options), - toInit(mergedHeaders('text/html; charset=utf-8', options), options) - ) - ), - json: (data: unknown, options?: ResponseInit) => { - const init = toInit(mergedHeaders('application/json', options), options) - return isNullBodyStatus(options) - ? applyCookies(new Core.API.Response(null, init)) - : applyCookies(Core.API.Response.json(data, init)) - }, - redirect( - url: string, - status: Types.RedirectStatus = 302, - options?: Types.RedirectInit - ): globalThis.Response { - return buildRedirect(url, status, options?.headers) - }, - stream: ( - stream: ReadableStream, - options?: ResponseInit, - contentType = 'application/octet-stream' - ) => - applyCookies( - new Core.API.Response( - bodyForStatus(stream, options), - toInit(mergedHeaders(contentType, options), options) - ) - ), - text: (text: string, options?: ResponseInit) => - applyCookies( - new Core.API.Response( - bodyForStatus(text, options), - toInit(mergedHeaders('text/plain; charset=utf-8', options), options) - ) - ) - } - } - - /** - * Build Content-Disposition header value. - * @description Sanitizes filename, emits ASCII fallback and UTF-8 parameter. - * @param filename - Raw filename string - * @returns Safe attachment header value - */ - private static contentDisposition(filename: string): string { - const basename = filename.replace(Core.Constant.sanitizeRegex, '') - const asciiFallback = basename - .replace(Core.Constant.nonAsciiGlobalRegex, '_') - .replace(Core.Constant.escapeRegex, (ch) => (ch === '\\' ? '\\\\' : '\\"')) - let headerValue = `attachment; filename="${asciiFallback}"` - if (Core.Constant.nonAsciiRegex.test(basename)) { - headerValue += `; filename*=UTF-8''${encodeURIComponent(basename)}` - } - return headerValue - } -} diff --git a/src/core/Static.ts b/src/core/Static.ts index cdf2c77..87a72b0 100644 --- a/src/core/Static.ts +++ b/src/core/Static.ts @@ -2,194 +2,207 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' /** - * Serves static files with caching. - * @description Resolves path under base, enforces same directory. + * Filesystem static file server. + * @description Serves files with caching, ranges, and security. */ export class Static { /** - * Serve one file from static root. - * @description Resolves path under base, sets Content-Type and etag. - * @param ctx - Request context - * @param options - Path, etag, cacheControl - * @param urlPath - URL prefix for static route - * @returns File response or 304 or error + * Serve file from base directory. + * @description Handles caching, ranges, and path containment. + * @param ctx - Request context instance + * @param options - Static serving options + * @param urlPath - URL path relative to mount + * @returns Promise resolving to file response */ - static async serveStaticFile( + static async serveFile( ctx: Core.Context, options: Types.ServeOptions, urlPath: string ): Promise { - try { - const notFound = (message: string): Promise => - ctx.handleError(404, new Deno.errors.NotFound(message)) - let filePath = ctx.pathname - if (urlPath !== '/') { - filePath = ctx.pathname.slice(urlPath.length) - } - if (filePath === '/' || filePath === '') { - filePath = 'index.html' - } else if (filePath.startsWith('/')) { - filePath = filePath.slice(1) - } - const baseNormalized = options.path.replace(/^\.\//, '').replace(/[\\/]+$/, '') || '/' - const fileSegments = filePath.split('/') - for (const segment of fileSegments) { - if (segment.startsWith('.')) { - return await notFound(`Static file "${filePath}" was not found`) - } - } - const fullPath = `${baseNormalized}/${filePath}`.replace(/\\/g, '/') - const fileInfo = await Deno.stat(fullPath).catch(() => null) - if (!fileInfo || !fileInfo.isFile) { - return await notFound(`Static file "${filePath}" was not found`) - } - let baseResolved: string - let fileResolved: string - try { - baseResolved = (await Deno.realPath(baseNormalized)).replace(/[\\/]+$/, '') + '/' - fileResolved = await Deno.realPath(fullPath) - } catch { - return await notFound(`Static file path "${filePath}" cannot be resolved`) - } - const normalizedBase = baseResolved.replace(/\\/g, '/') - const normalizedFile = fileResolved.replace(/\\/g, '/') - if ( - normalizedFile !== normalizedBase.slice(0, -1) && - !normalizedFile.startsWith(normalizedBase) - ) { - return await notFound(`Static file "${filePath}" is outside the base directory`) - } - const fileExtension = filePath.split('.').pop()?.toLowerCase() ?? '' - const contentType = Core.Constant.contentTypes[fileExtension] ?? 'application/octet-stream' - let etag: string | null = null - if (options.etag) { - const hashDigest = await Core.API.subtle.digest( - 'SHA-256', - Core.Constant.encoder.encode(`${fileInfo.size}-${fileInfo.mtime?.getTime() ?? 0}`) - ) - const hashBytes = new Uint8Array(hashDigest) - let hashHex = '' - for (let i = 0; i < hashBytes.length; i++) { - hashHex += (hashBytes[i]!).toString(16).padStart(2, '0') - } - etag = `"${hashHex}"` - } - if (etag && Static.etagMatch(ctx.request.headers.get('If-None-Match'), etag)) { - Static.applyCacheHeaders(ctx, etag, options.cacheControl) - return ctx.send.custom(null, { status: 304 }) - } - ctx.setHeader('Accept-Ranges', 'bytes') - const rangeResult = Static.parseRange( - ctx.request.headers.get('Range'), - fileInfo.size + const baseDirectory = Static.#baseDirectory(options.path) + const relativePath = Static.#relativePath(urlPath) + if (relativePath.split('/').some((segment) => segment.startsWith('.'))) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('static:missing', { path: urlPath }) ) - if (rangeResult === 'unsatisfiable') { - ctx.setHeader('Content-Range', `bytes */${fileInfo.size}`) - return await ctx.handleError( - 416, - new Deno.errors.InvalidData( - `Static file range is not satisfiable for "${filePath}"` - ) - ) - } - const fsFile = await Deno.open(fileResolved, { read: true }) - ctx.setHeader('Content-Type', contentType) - Static.applyCacheHeaders(ctx, etag, options.cacheControl) - if (rangeResult !== null) { - const { start, end } = rangeResult - await fsFile.seek(start, Deno.SeekMode.Start) - ctx.setHeader('Content-Range', `bytes ${start}-${end}/${fileInfo.size}`) - ctx.setHeader('Content-Length', (end - start + 1).toString()) - return ctx.send.custom(Static.boundedStream(fsFile, end - start + 1), { status: 206 }) - } - ctx.setHeader('Content-Length', fileInfo.size.toString()) - return ctx.send.custom(fsFile.readable) - } catch (staticFileError) { - const extractedError = Core.Handler.extractError(staticFileError) - return await ctx.handleError(extractedError.statusCode, extractedError.error) + return await ctx.handleError(404, new Deno.errors.NotFound('static file not found')) + } + const resolved = await Static.#resolveContained( + baseDirectory, + `${baseDirectory}/${relativePath}` + ) + if (resolved === null) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('static:missing', { path: urlPath }) + ) + return await ctx.handleError(404, new Deno.errors.NotFound('static file not found')) } + const method = ctx.get.method() + if (method !== 'GET' && method !== 'HEAD') { + ctx.set.header('Allow', 'GET, HEAD') + return await ctx.handleError( + 405, + new Deno.errors.NotSupported('static file supports GET and HEAD only') + ) + } + const lastModified = resolved.fileInfo.mtime ?? null + const etag = (options.etag ?? true) ? await Static.#computeEtag(resolved.fileInfo) : null + if (Static.#notModified(ctx.get.request().headers, etag, lastModified)) { + Static.#applyCacheHeaders(ctx, options.cacheControl, etag, lastModified) + return ctx.send.empty(304) + } + ctx.set.header('Accept-Ranges', 'bytes') + const ifRange = ctx.get.request().headers.get('If-Range') + const rangeAllowed = ifRange === null || Static.#ifRangeFresh(ifRange, lastModified) + const rangeResult = rangeAllowed + ? Static.#parseRange(ctx.get.request().headers.get('Range'), resolved.fileInfo.size) + : null + const contentType = Static.#contentType(relativePath) + Static.#applyCacheHeaders(ctx, options.cacheControl, etag, lastModified) + if (rangeResult === 'unsatisfiable') { + ctx.set.header('Content-Range', `bytes */${resolved.fileInfo.size}`) + return await ctx.handleError( + 416, + new Deno.errors.InvalidData('static file range is not satisfiable') + ) + } + const file = await Deno.open(resolved.filePath, { read: true }) + ctx.set.header('Content-Type', contentType) + if (rangeResult !== null) { + await file.seek(rangeResult.start, Deno.SeekMode.Start) + const length = rangeResult.end - rangeResult.start + 1 + ctx.set.header( + 'Content-Range', + `bytes ${rangeResult.start}-${rangeResult.end}/${resolved.fileInfo.size}` + ) + ctx.set.header('Content-Length', length.toString()) + return ctx.send.custom(Static.#boundedStream(file, length), { status: 206 }) + } + ctx.set.header('Content-Length', resolved.fileInfo.size.toString()) + return ctx.send.custom(file.readable) } /** - * Apply ETag and Cache-Control headers. - * @description Sets ETag when present and a public max-age when configured. - * @param ctx - Request context receiving the headers - * @param etag - Strong ETag value or null when disabled - * @param cacheControl - Max-age in seconds, or undefined to skip + * Apply cache related response headers. + * @description Sets ETag, Last-Modified, and Cache-Control. + * @param ctx - Request context instance + * @param cacheControl - Max age seconds or undefined + * @param etag - Entity tag value or null + * @param lastModified - Last modified date or null */ - private static applyCacheHeaders( + static #applyCacheHeaders( ctx: Core.Context, + cacheControl: number | undefined, etag: string | null, - cacheControl: number | undefined + lastModified: Date | null ): void { - if (etag) { - ctx.setHeader('ETag', etag) + if (etag !== null) { + ctx.set.header('ETag', etag) + } + if (lastModified !== null) { + ctx.set.header('Last-Modified', lastModified.toUTCString()) } if (cacheControl !== undefined && cacheControl >= 0) { - ctx.setHeader('Cache-Control', `public, max-age=${cacheControl}`) + ctx.set.header('Cache-Control', `public, max-age=${cacheControl}`) + } + } + + /** + * Normalize base directory path. + * @description Strips trailing slashes and validates input. + * @param path - Configured static directory path + * @returns Normalized base directory path + * @throws When path is empty or invalid + */ + static #baseDirectory(path: string): string { + if (typeof path !== 'string' || path.length === 0) { + throw new Deno.errors.InvalidData('static path must be a non-empty string') } + return path.replace(/[\\/]+$/, '') || '/' } /** - * Stream bounded bytes then close file. - * @description Emits at most length bytes, releasing handle on completion. - * @param fsFile - Open file handle positioned at the range start - * @param length - Number of bytes to emit - * @returns ReadableStream emitting at most length bytes + * Build length bounded read stream. + * @description Caps emitted bytes to requested length. + * @param file - Open file handle to read + * @param length - Maximum bytes to emit + * @returns Bounded readable byte stream */ - private static boundedStream(fsFile: Deno.FsFile, length: number): ReadableStream { + static #boundedStream(file: Deno.FsFile, length: number): ReadableStream { let remaining = length - const streamReader = fsFile.readable.getReader() + const reader = file.readable.getReader() return new ReadableStream({ async pull(controller) { if (remaining <= 0) { controller.close() - await streamReader.cancel() + await reader.cancel() return } - const { value, done } = await streamReader.read() - if (done || value === undefined) { + const result = await reader.read() + if (result.done || result.value === undefined) { controller.close() return } - if (value.byteLength <= remaining) { - remaining -= value.byteLength - controller.enqueue(value) - } else { - controller.enqueue(value.subarray(0, remaining)) - remaining = 0 - controller.close() - await streamReader.cancel() + if (result.value.byteLength <= remaining) { + remaining -= result.value.byteLength + controller.enqueue(result.value) + return } + controller.enqueue(result.value.subarray(0, remaining)) + remaining = 0 + controller.close() + await reader.cancel() }, async cancel() { - await streamReader.cancel() + await reader.cancel() } }) } /** - * Weak ETag comparison for If-None-Match. - * @description Matches exact, W/ stripped, list, or wildcard. + * Compute weak ETag for file. + * @description Hashes size and modification time seed. + * @param fileInfo - File info for hashing + * @returns Weak ETag header value + */ + static async #computeEtag(fileInfo: Deno.FileInfo): Promise { + const seed = `${fileInfo.size}-${fileInfo.mtime?.getTime() ?? 0}` + const digest = await Core.API.subtle.digest('SHA-256', Core.Constant.encoder.encode(seed)) + const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')) + .join('') + return `W/"${hex}"` + } + + /** + * Resolve content type from path. + * @description Maps file extension to known type. + * @param relativePath - Relative file path used + * @returns Content type header value + */ + static #contentType(relativePath: string): string { + const extension = relativePath.split('.').pop()?.toLowerCase() ?? '' + return Core.Constant.contentTypes[extension] ?? Core.Constant.defaultContentType + } + + /** + * Check ETag matches header value. + * @description Compares strong and weak tag forms. * @param headerValue - If-None-Match header value - * @param etag - Server-generated strong ETag - * @returns True when any value matches + * @param etag - Current entity tag value + * @returns True when ETag matches */ - private static etagMatch(headerValue: string | null, etag: string): boolean { - if (!headerValue) { + static #etagMatches(headerValue: string | null, etag: string): boolean { + if (headerValue === null) { return false } if (headerValue === '*') { return true } - const strippedEtag = etag.startsWith('W/') ? etag.slice(2) : etag + const target = etag.startsWith('W/') ? etag.slice(2) : etag for (const part of headerValue.split(',')) { const candidate = part.trim() - if (candidate === etag || candidate === strippedEtag) { - return true - } - const weakStripped = candidate.startsWith('W/') ? candidate.slice(2) : candidate - if (weakStripped === strippedEtag) { + const stripped = candidate.startsWith('W/') ? candidate.slice(2) : candidate + if (candidate === etag || stripped === target) { return true } } @@ -197,13 +210,51 @@ export class Static { } /** - * Parse single byte-range against known size. - * @description Reads a single bytes range, ignores invalid values. - * @param headerValue - Raw Range header value or null - * @param size - Total representation size in bytes - * @returns Inclusive start and end, unsatisfiable, or null fallback + * Check If-Range freshness by date. + * @description Rejects entity tags and compares seconds. + * @param ifRange - If-Range header value + * @param lastModified - Last modified date or null + * @returns True when range is fresh */ - private static parseRange( + static #ifRangeFresh(ifRange: string, lastModified: Date | null): boolean { + if (ifRange.startsWith('"') || ifRange.startsWith('W/')) { + return false + } + const since = Date.parse(ifRange) + return lastModified !== null && Number.isFinite(since) && + Math.floor(lastModified.getTime() / 1000) === Math.floor(since / 1000) + } + + /** + * Check resource is not modified. + * @description Evaluates ETag then modified since headers. + * @param headers - Request headers instance + * @param etag - Current entity tag value + * @param lastModified - Last modified date or null + * @returns True when resource unchanged + */ + static #notModified(headers: Headers, etag: string | null, lastModified: Date | null): boolean { + const ifNoneMatch = headers.get('If-None-Match') + if (ifNoneMatch !== null) { + return etag !== null && Static.#etagMatches(ifNoneMatch, etag) + } + const ifModifiedSince = headers.get('If-Modified-Since') + if (ifModifiedSince !== null && lastModified !== null) { + const since = Date.parse(ifModifiedSince) + return Number.isFinite(since) && + Math.floor(lastModified.getTime() / 1000) <= Math.floor(since / 1000) + } + return false + } + + /** + * Parse byte range request header. + * @description Returns range, unsatisfiable, or null result. + * @param headerValue - Range header value + * @param size - Total file size in bytes + * @returns Byte range, unsatisfiable, or null + */ + static #parseRange( headerValue: string | null, size: number ): Types.ByteRange | 'unsatisfiable' | null { @@ -233,14 +284,60 @@ export class Static { end = size - 1 } else { start = Number.parseInt(startToken, 10) - end = endToken === '' ? size - 1 : Number.parseInt(endToken, 10) - if (end > size - 1) { - end = size - 1 - } + end = endToken === '' ? size - 1 : Math.min(Number.parseInt(endToken, 10), size - 1) } if (start > end || start >= size) { return 'unsatisfiable' } return { start, end } } + + /** + * Normalize URL path into relative. + * @description Strips leading slash and defaults index. + * @param urlPath - URL path relative to mount + * @returns Relative file path string + */ + static #relativePath(urlPath: string): string { + let filePath = urlPath + if (filePath.startsWith('/')) { + filePath = filePath.slice(1) + } + if (filePath === '') { + return 'index.html' + } + return filePath + } + + /** + * Resolve file within base directory. + * @description Blocks path escape and non file targets. + * @param baseDirectory - Allowed base directory path + * @param requestedPath - Requested file path + * @returns Resolved file info or null + */ + static async #resolveContained( + baseDirectory: string, + requestedPath: string + ): Promise { + try { + const baseResolved = `${(await Deno.realPath(baseDirectory)).replace(/[\\/]+$/, '')}/` + const fileResolved = await Deno.realPath(requestedPath) + const normalizedBase = baseResolved.replace(/\\/g, '/') + const normalizedFile = fileResolved.replace(/\\/g, '/') + if ( + normalizedFile !== normalizedBase.slice(0, -1) && + !normalizedFile.startsWith(normalizedBase) + ) { + return null + } + const info = await Deno.stat(fileResolved) + if (!info.isFile) { + return null + } + return { fileInfo: info, filePath: fileResolved } + } catch { + return null + } + } } diff --git a/src/core/View.ts b/src/core/View.ts new file mode 100644 index 0000000..6857754 --- /dev/null +++ b/src/core/View.ts @@ -0,0 +1,34 @@ +import * as Core from '@core/index.ts' +import { Superwatcher } from '@neabyte/superwatcher' +import nodePath from 'node:path' + +/** + * Template directory file watcher. + * @description Invalidates view cache on template changes. + */ +export class View { + /** + * Watch template directory for changes. + * @description Invalidates engine cache on DVE file events. + * @param engine - Rendering engine to invalidate + * @returns Disposer function stopping the watcher + */ + static watch(engine: Core.Rendering): () => void { + const resolvedDir = nodePath.resolve(engine.directory) + if (!Core.Handler.isDirectory(resolvedDir)) { + return () => {} + } + const watcher = new Superwatcher({ + path: resolvedDir, + debounceMs: Core.Constant.templateDebounceMs, + ignore: [(path) => !path.endsWith(Core.Constant.dveExtension)], + onChange(events) { + for (const event of events) { + engine.invalidate(event.path.slice(resolvedDir.length + 1)) + } + } + }) + watcher.start() + return () => watcher.dispose() + } +} diff --git a/src/core/Worker.ts b/src/core/Worker.ts index 394cc63..074c83e 100644 --- a/src/core/Worker.ts +++ b/src/core/Worker.ts @@ -2,80 +2,80 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' /** - * Worker pool for CPU-bound tasks. - * @description Payload and result must be structured-clone serializable. + * Worker pool task dispatcher. + * @description Dispatches tasks across workers with queue limits. */ export class Worker { - /** Marks worker-isolate crash for respawn */ - private static workerCrash = class extends Error { - override readonly cause: Error - constructor(cause: Error) { - super('worker crash') - this.cause = cause - } - } - /** Optional lifecycle event emitter */ - private readonly emit: Types.EventEmit | undefined - /** Module URL for spawning workers */ - private readonly scriptUrl: string | URL - /** Per-task dispatch timeout in ms */ - private readonly taskTimeoutMs: number - /** Maximum pending tasks before fast-rejecting */ - private readonly maxQueueDepth: number - /** Maximum projected queue wait in ms */ - private readonly maxQueueWaitMs: number - /** Count of accepted-but-not-settled tasks */ - private pendingCount = 0 - /** Round-robin index for next worker */ - private nextIndex = 0 - /** Pooled worker instances */ - private workers: globalThis.Worker[] = [] - /** Per-worker serialization tail promises */ - private workerTails: Promise[] = [] - /** Per-worker count of pending tasks */ - private slotPending: number[] = [] + /** Optional event emitter for worker events */ + readonly #emit: Types.EventFn | null + /** Maximum pending tasks across pool */ + readonly #maxQueueDepth: number + /** Maximum projected slot wait milliseconds */ + readonly #maxQueueWaitMs: number + /** Worker script URL for respawn */ + readonly #scriptUrl: string + /** Per task timeout in milliseconds */ + readonly #taskTimeoutMs: number + /** Round robin next worker index */ + #nextIndex = 0 + /** Total pending tasks across pool */ + #pendingCount = 0 + /** Pending task count per worker slot */ + #slotPending: number[] + /** Active worker instances in pool */ + #workers: globalThis.Worker[] + /** Tail promise chain per worker slot */ + #workerTails: Promise[] /** - * Construct pool with given workers. - * @description Initializes pool with pre-created worker list. - * @param workers - Pre-created Deno worker instances - * @param scriptUrl - Module URL for respawning crashed workers - * @param taskTimeoutMs - Per-task timeout in milliseconds - * @param maxQueueDepth - Maximum pending tasks before fast-rejecting - * @param maxQueueWaitMs - Maximum projected queue wait before fast-rejecting - * @param emit - Optional lifecycle event emitter + * Construct worker pool instance. + * @description Initializes workers, slots, and tail chains. + * @param workers - Worker instances in pool + * @param scriptUrl - Worker script URL for respawn + * @param taskTimeoutMs - Per task timeout milliseconds + * @param maxQueueDepth - Maximum pending task count + * @param maxQueueWaitMs - Maximum projected wait milliseconds + * @param emit - Optional event emitter callback */ private constructor( workers: globalThis.Worker[], - scriptUrl: string | URL, + scriptUrl: string, taskTimeoutMs: number, maxQueueDepth: number, maxQueueWaitMs: number, - emit?: Types.EventEmit + emit: Types.EventFn | null ) { - this.emit = emit - this.workers = workers - this.scriptUrl = scriptUrl - this.taskTimeoutMs = taskTimeoutMs - this.maxQueueDepth = maxQueueDepth - this.maxQueueWaitMs = maxQueueWaitMs - this.workerTails = workers.map(() => Promise.resolve()) - this.slotPending = workers.map(() => 0) + this.#emit = emit + this.#maxQueueDepth = maxQueueDepth + this.#maxQueueWaitMs = maxQueueWaitMs + this.#scriptUrl = scriptUrl + this.#slotPending = workers.map(() => 0) + this.#taskTimeoutMs = taskTimeoutMs + this.#workers = workers + this.#workerTails = workers.map(() => Promise.resolve()) } /** - * Create worker pool from options. - * @description Spawns module workers from scriptURL, must resolve in app. - * @param options - scriptURL and optional poolSize - * @returns Worker with run and terminate - * @throws {Deno.errors.InvalidData} When taskTimeoutMs is not positive finite + * Create configured worker pool. + * @description Validates options and spawns worker instances. + * @param options - Worker pool configuration options + * @param emit - Optional event emitter callback + * @returns Constructed worker pool instance */ - static createPool(options: Types.WorkerPoolOptions): Worker { - const requestedPoolSize = options.poolSize ?? Core.Constant.defaultPoolSize - if (!Number.isFinite(requestedPoolSize)) { - throw new Deno.errors.InvalidData('Worker poolSize must be a finite number') - } - const workerCount = Math.max(1, Math.floor(requestedPoolSize)) + static createPool( + options: Types.WorkerPoolOptions, + emit: Types.EventFn | null = null + ): Worker { + const poolSize = Math.max( + 1, + Math.floor( + Core.Handler.assertPositiveFinite( + options.poolSize ?? Core.Constant.defaultPoolSize, + 'Worker poolSize', + 'workers' + ) + ) + ) const taskTimeoutMs = Core.Handler.assertPositiveFinite( options.taskTimeoutMs ?? Core.Constant.defaultWorkerTaskTimeoutMs, 'Worker taskTimeoutMs', @@ -83,7 +83,7 @@ export class Worker { ) const maxQueueDepth = Math.floor( Core.Handler.assertPositiveFinite( - options.maxQueueDepth ?? workerCount * Core.Constant.defaultQueueFactor, + options.maxQueueDepth ?? poolSize * Core.Constant.defaultQueueFactor, 'Worker maxQueueDepth', 'tasks' ) @@ -93,130 +93,131 @@ export class Worker { 'Worker maxQueueWaitMs', 'milliseconds' ) - const workerList = Array.from( - { length: workerCount }, + const workers = Array.from( + { length: poolSize }, () => new Core.API.Worker(options.scriptURL, { type: 'module' }) ) return new Worker( - workerList, + workers, options.scriptURL, taskTimeoutMs, maxQueueDepth, maxQueueWaitMs, - options.emit + emit ) } /** - * Run one task in worker pool. - * @description Posts payload, serializes one task per worker. - * @template T - Result type from worker - * @param payload - Serializable payload for the worker - * @returns Promise resolving to worker result - * @throws {Deno.errors.BadResource} When pool empty or worker missing - * @throws {Deno.errors.InvalidData} When worker returns error payload + * Run task on worker pool. + * @description Enforces queue depth and projected wait limits. + * @param payload - Task payload to dispatch + * @returns Promise resolving to task result + * @throws When pool empty or queue full + * @template T - Task result value type */ run(payload: unknown): Promise { - if (this.workers.length === 0) { + if (this.#workers.length === 0) { return Promise.reject(new Deno.errors.BadResource('Worker pool has no available workers')) } - if (this.pendingCount >= this.maxQueueDepth) { - this.emit?.( + if (this.#pendingCount >= this.#maxQueueDepth) { + this.#emit?.( Core.Observability.internalEvent('worker:rejected', { reason: 'queue-depth', - queueDepth: this.pendingCount, - maxQueueDepth: this.maxQueueDepth + queueDepth: this.#pendingCount, + maxQueueDepth: this.#maxQueueDepth }) ) return Promise.reject( new Deno.errors.Busy( - `Worker pool queue is full (${this.pendingCount}/${this.maxQueueDepth})` + `Worker pool queue is full at ${this.#pendingCount} of ${this.#maxQueueDepth}` ) ) } - const workerIndex = this.nextIndex - this.nextIndex = (this.nextIndex + 1) % this.workers.length - if (!this.workers[workerIndex]) { - return Promise.reject(new Deno.errors.BadResource('Worker pool worker at index is missing')) - } - const projectedWaitMs = (this.slotPending[workerIndex] ?? 0) * this.taskTimeoutMs - if (projectedWaitMs > this.maxQueueWaitMs) { - this.emit?.( + const workerIndex = this.#nextIndex + this.#nextIndex = (this.#nextIndex + 1) % this.#workers.length + const projectedWaitMs = (this.#slotPending[workerIndex] ?? 0) * this.#taskTimeoutMs + if (projectedWaitMs > this.#maxQueueWaitMs) { + this.#emit?.( Core.Observability.internalEvent('worker:rejected', { reason: 'queue-wait', - queueDepth: this.pendingCount, - maxQueueDepth: this.maxQueueDepth + queueDepth: this.#pendingCount, + maxQueueDepth: this.#maxQueueDepth }) ) return Promise.reject( new Deno.errors.Busy( - `Worker pool slot busy: projected wait ${projectedWaitMs}ms exceeds ${this.maxQueueWaitMs}ms` + `Worker pool slot busy with projected wait ${projectedWaitMs}ms over ${this.#maxQueueWaitMs}ms` ) ) } - this.pendingCount++ - this.slotPending[workerIndex] = (this.slotPending[workerIndex] ?? 0) + 1 - const priorTail = this.workerTails[workerIndex] ?? Promise.resolve() + this.#pendingCount += 1 + this.#slotPending[workerIndex] = (this.#slotPending[workerIndex] ?? 0) + 1 + const priorTail = this.#workerTails[workerIndex] ?? Promise.resolve() let releaseTail: () => void = () => {} const nextTail = new Promise((resolve) => { releaseTail = resolve }) - this.workerTails[workerIndex] = nextTail - const resultPromise = priorTail + this.#workerTails[workerIndex] = nextTail + return priorTail .then(() => { - const currentWorker = this.workers[workerIndex]! - return Worker.dispatch( + const currentWorker = this.#workers[workerIndex] + if (currentWorker === undefined) { + throw new Deno.errors.BadResource('Worker pool worker at index is missing') + } + return Worker.#dispatch( currentWorker, payload, - this.taskTimeoutMs, + this.#taskTimeoutMs, workerIndex, - this.emit + this.#emit ).catch((dispatchError) => { - if (dispatchError instanceof Worker.workerCrash) { - this.respawnWorker(workerIndex, currentWorker) - throw dispatchError.cause + if ( + dispatchError instanceof Deno.errors.BadResource || + dispatchError instanceof Deno.errors.TimedOut + ) { + this.#respawn(workerIndex, currentWorker) } throw dispatchError }) }) .finally(() => { - this.pendingCount-- - this.slotPending[workerIndex] = Math.max(0, (this.slotPending[workerIndex] ?? 1) - 1) - if (this.workerTails[workerIndex] === nextTail) { - this.workerTails[workerIndex] = Promise.resolve() + this.#pendingCount -= 1 + this.#slotPending[workerIndex] = Math.max(0, (this.#slotPending[workerIndex] ?? 1) - 1) + if (this.#workerTails[workerIndex] === nextTail) { + this.#workerTails[workerIndex] = Promise.resolve() } releaseTail() }) - return resultPromise } - /** Terminate all workers in pool */ + /** Terminate all workers and reset pool */ terminate(): void { - for (const worker of this.workers) { + for (const worker of this.#workers) { worker.terminate() } - this.workers = [] - this.workerTails = [] - this.slotPending = [] + this.#slotPending = [] + this.#workers = [] + this.#workerTails = [] } /** - * Dispatch payload and await reply. - * @description Attaches one-shot listeners, timer respawns hung worker. - * @template T - Result type from worker + * Dispatch payload to single worker. + * @description Resolves on message, rejects on error or timeout. * @param worker - Target worker instance - * @param payload - Serializable payload - * @param taskTimeoutMs - Per-task timeout in milliseconds - * @param workerIndex - Pool slot index for event metadata - * @param emit - Optional lifecycle event emitter - * @returns Promise resolving to worker result + * @param payload - Task payload to post + * @param taskTimeoutMs - Task timeout milliseconds + * @param workerIndex - Worker index for events + * @param emit - Optional event emitter callback + * @returns Promise resolving to task result + * @throws When worker errors, times out, or payload unserializable + * @template T - Task result value type */ - private static dispatch( + static #dispatch( worker: globalThis.Worker, payload: unknown, taskTimeoutMs: number, workerIndex: number, - emit?: Types.EventEmit + emit: Types.EventFn | null ): Promise { return new Promise((resolve, reject) => { const cleanup = () => { @@ -233,16 +234,21 @@ export class Worker { messageData.message ?? 'Worker returned an error with no message' ) ) - } else { - resolve(event.data as T) + return } + resolve(event.data as T) } const onError = (event: ErrorEvent) => { event.preventDefault() cleanup() const crashError = new Deno.errors.BadResource('Worker task failed before responding') - emit?.(Core.Observability.internalEvent('worker:crash', { workerIndex, error: crashError })) - reject(new Worker.workerCrash(crashError)) + emit?.( + Core.Observability.internalEvent('worker:crashed', { + index: workerIndex, + error: crashError + }) + ) + reject(crashError) } const timeoutTimer = setTimeout(() => { cleanup() @@ -252,11 +258,11 @@ export class Worker { emit?.( Core.Observability.internalEvent('worker:timeout', { timeoutMs: taskTimeoutMs, - workerIndex, + index: workerIndex, error: timeoutError }) ) - reject(new Worker.workerCrash(timeoutError)) + reject(timeoutError) }, taskTimeoutMs) worker.addEventListener('message', onMessage) worker.addEventListener('error', onError) @@ -276,13 +282,13 @@ export class Worker { } /** - * Replace crashed worker by index. - * @description Respawns dead slot to unblock future dispatches. - * @param workerIndex - Slot to replace - * @param deadWorker - The crashed worker to terminate + * Respawn crashed worker slot. + * @description Replaces dead worker and emits respawn event. + * @param workerIndex - Worker slot index to respawn + * @param deadWorker - Crashed worker instance */ - private respawnWorker(workerIndex: number, deadWorker: globalThis.Worker): void { - if (this.workers[workerIndex] !== deadWorker) { + #respawn(workerIndex: number, deadWorker: globalThis.Worker): void { + if (this.#workers[workerIndex] !== deadWorker) { return } try { @@ -290,7 +296,7 @@ export class Worker { } catch { void 0 } - this.workers[workerIndex] = new Core.API.Worker(this.scriptUrl, { type: 'module' }) - this.emit?.(Core.Observability.internalEvent('worker:respawn', { workerIndex })) + this.#workers[workerIndex] = new Core.API.Worker(this.#scriptUrl, { type: 'module' }) + this.#emit?.(Core.Observability.internalEvent('worker:respawned', { index: workerIndex })) } } diff --git a/src/core/index.ts b/src/core/index.ts index bb8e85e..6397670 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,12 +2,13 @@ export * from '@core/API.ts' export * from '@core/Constant.ts' export * from '@core/Context.ts' -export * from '@core/Guard.ts' +export * from '@core/Cookie.ts' export * from '@core/Handler.ts' export * from '@core/IpAddress.ts' export * from '@core/IpResolver.ts' export * from '@core/Observability.ts' export * from '@core/Redirect.ts' -export * from '@core/Response.ts' +export * from '@core/Rendering.ts' export * from '@core/Static.ts' +export * from '@core/View.ts' export * from '@core/Worker.ts' diff --git a/src/index.ts b/src/index.ts index 9ac55d2..f365dd2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,8 @@ -/** Public API for Deserve HTTP server. */ -export type * from '@interfaces/index.ts' +/** Public API for Deserve HTTP server */ export { Context } from '@core/index.ts' -export { Mware, WrapMware } from '@middleware/index.ts' export { Router } from '@routing/index.ts' -export { Validator } from '@validation/index.ts' +export { Mware, Validator, Wrap } from '@middleware/index.ts' -/** Re-exports Typebox contract helpers. */ -export type * from '@neabyte/typebox' -export { Define, Loader } from '@neabyte/typebox' +/** Re-exports Typebox contract helpers */ +export type { GuardFn, GuardInput, GuardVerdict } from '@neabyte/typebox' +export type * from '@interfaces/index.ts' diff --git a/src/interfaces/Core.ts b/src/interfaces/Core.ts index 7d7fb31..d2ce174 100644 --- a/src/interfaces/Core.ts +++ b/src/interfaces/Core.ts @@ -1,217 +1,572 @@ -import type * as Types from '@interfaces/index.ts' import type * as Core from '@core/index.ts' - -/** Inclusive byte range for partial content. */ -export interface ByteRange { - /** Inclusive end byte offset */ - readonly end: number - /** Inclusive start byte offset */ - readonly start: number -} +import type * as Types from '@interfaces/index.ts' +import type { ContractFn } from '@neabyte/typebox' +import type DVE from '@neabyte/dve' /** - * Internal framework-only Context surface. - * @description Members reachable cross-module via the InternalContext symbol. + * Internal context control surface. + * @description Framework only hooks for context state. */ export interface ContextInternal { /** - * Apply headers and cookies to Response. - * @description Merges accumulated headers and cookies, existing values win. - * @param response - Native Response to finalize - * @returns Same Response with headers applied + * Emit observability event. + * @description Forwards event to context emitter. + * @param event - Event payload to emit + */ + emitEvent(event: EventBase): void + /** + * Finalize raw response headers. + * @description Merges pending headers and cookies. + * @param response - Raw response to finalize + * @returns Same response with merged headers */ finalizeRaw(response: globalThis.Response): globalThis.Response /** * Read captured framework error. - * @description Returns error set by handleError, null when none. - * @returns Framework Error or null + * @description Returns last error or null. + * @returns Framework error or null */ getFrameworkError(): Error | null /** - * Replace request and reset body state. - * @description Swaps the request and clears parsed body state. - * @param req - New request to use + * Install session controller. + * @description Enables session reads and writes. + * @param controller - Session controller to install */ - replaceRequest(req: Request): void + installSession(controller: Types.SessionController): void /** - * Merge percent-decoded route params. - * @description Spread-merges decoded params into existing route params. - * @param params - Params from the router match + * Install validated data controller. + * @description Enables validated data reads. + * @param controller - Validated controller to install */ - setParams(params: StringRecord): void + installValidated(controller: ValidatedController): void /** - * Write reserved framework state key. - * @description Internal write path for framework-wired keys. - * @template T - Value type encoded in the key - * @param key - Branded reserved state key - * @param value - Value matching the key type + * Install worker pool controller. + * @description Enables worker task dispatch. + * @param controller - Worker controller to install */ - setInternalState(key: StateKey, value: T): void + installWorker(controller: WorkerController): void /** - * Emit lifecycle event on router bus. - * @description No-op when the router has no emitter wired. - * @param event - Lifecycle event to broadcast + * Set decoded route parameters. + * @description Stores parameters for context reads. + * @param params - Route parameter record */ - emitEvent(event: Types.EventBase): void - /** Snapshot of accumulated Set-Cookie values */ - readonly responseCookies: readonly string[] - /** Snapshot copy of accumulated response headers */ - readonly responseHeadersMap: StringRecord + setParams(params: StringRecord): void +} + +/** + * Cookie attribute initialization options. + * @description Configures cookie scope and flags. + */ +export interface CookieInit { + /** Cookie domain scope */ + domain?: string + /** Cookie expiry date or timestamp */ + expires?: Date | number + /** Mark cookie as HTTP only */ + httpOnly?: boolean + /** Cookie max age in seconds */ + maxAge?: number + /** Cookie path scope */ + path?: string + /** Cookie SameSite policy */ + sameSite?: SameSitePolicy + /** Mark cookie as secure */ + secure?: boolean } -/** Error details for error middleware. */ +/** + * Error information for handlers. + * @description Carries error, request, and status data. + */ export interface ErrorInfo { /** Caught error instance */ readonly error: Error - /** HTTP method of failed request */ + /** Request HTTP method */ readonly method: string - /** URL pathname of failed request */ + /** Request path name */ readonly pathname: string - /** HTTP status code for response */ + /** HTTP status code */ readonly statusCode: number - /** Full request URL string */ + /** Full request URL */ readonly url: string } /** - * Builds error response from status. - * @description Constructs HTTP error response using context and middleware. + * Request reading helper methods. + * @description Reads method, URL, headers, and body. */ -export interface ErrorResponseBuilder { +export interface GetHelpers { + /** + * Read client IP address. + * @description Returns direct peer when option set. + * @param options - Optional direct IP flag + * @returns Client IP or undefined + */ + ip(options?: IpDirectOption): string | undefined + /** + * Read request HTTP method. + * @returns Request method string + */ + method(): string + /** + * Read parsed request URL. + * @returns Request URL instance + */ + url(): URL + /** + * Read request path name. + * @returns Request path string + */ + pathname(): string /** - * Build error response. - * @param ctx - Request context instance - * @param statusCode - HTTP status code to send - * @param error - Caught error instance - * @param errorMiddleware - Optional error middleware handler - * @returns Promise resolving to error response + * Read underlying request instance. + * @returns Request instance */ - build( - ctx: Core.Context, - statusCode: number, - error: Error, - errorMiddleware: ErrorMiddleware | null - ): Promise + request(): Request + /** Read request header value or map */ + header: RecordAccessor + /** Read request cookie value or map */ + cookie: RecordAccessor + /** Read query parameter value or map */ + query: RecordAccessor + /** Read route parameter value or map */ + param: RecordAccessor + /** + * Read request body by type. + * @description Chooses reader from content type. + * @returns Promise resolving to body value + * @template T - Body value type + */ + body(): Promise + /** + * Read request body as JSON. + * @returns Promise resolving to parsed JSON + * @template T - JSON value type + */ + json(): Promise + /** + * Read request body as text. + * @returns Promise resolving to body text + */ + text(): Promise + /** + * Read request body as form data. + * @returns Promise resolving to form data + */ + formData(): Promise + /** + * Read request body as blob. + * @returns Promise resolving to body blob + */ + blob(): Promise + /** + * Read request body as bytes. + * @returns Promise resolving to byte array + */ + bytes(): Promise + /** + * Read current session data. + * @returns Session data or null + */ + session(): Types.SessionData | null + /** + * Read validated request data. + * @description Requires validate middleware registration. + * @returns Validated data map + * @template SchemaType - Validation schema type + */ + validated(): ValidatedMap + /** + * Read worker pool controller. + * @returns Worker controller instance + */ + worker(): WorkerController +} + +/** + * Direct IP read option. + * @description Selects direct peer over resolved IP. + */ +export interface IpDirectOption { + /** Read direct peer IP when true */ + direct?: boolean } -/** Parsed IP address value with version. */ +/** + * Parsed IP value and version. + * @description Holds numeric value and protocol version. + */ export interface ParsedIp { - /** Numeric address value */ + /** Numeric IP address value */ readonly value: bigint - /** Address version, 4 or 6 */ + /** IP protocol version */ readonly version: 4 | 6 } -/** Structured error problem details payload. */ +/** + * RFC problem details payload. + * @description Describes error type, title, and status. + */ export interface ProblemDetails { - /** Problem type URI reference */ + /** Problem type URI */ readonly type: string - /** Short human-readable problem summary */ + /** Short problem title */ readonly title: string - /** HTTP status code for problem */ + /** HTTP status code */ readonly status: number - /** Optional URI reference of occurrence */ + /** Request instance path */ readonly instance?: string - /** Optional list of validation reasons */ + /** Detailed error messages */ readonly errors?: readonly string[] } /** - * Response helpers on context. - * @description Provides typed methods for common response formats. + * Template render initialization options. + * @description Sets response status and stream flag. + */ +export interface RenderInit { + /** Response HTTP status code */ + status?: HttpStatusCode + /** Stream rendered output when true */ + stream?: boolean +} + +/** + * Router constructor options. + * @description Configures routes, views, and limits. + */ +export interface RouterOptions { + /** Route loading options */ + routes?: RoutesOptions + /** View rendering options */ + views?: ViewsOptions + /** Enable hot reload watching */ + hotReload?: boolean + /** Maximum request URL length */ + maxUrlLength?: number + /** Request timeout in milliseconds */ + timeoutMs?: number + /** Trusted proxy configuration */ + trustProxy?: TrustProxyConfig + /** Worker pool configuration */ + worker?: WorkerPoolOptions +} + +/** + * Route loading options. + * @description Sets routes directory and parameter limit. + */ +export interface RoutesOptions { + /** Routes directory path */ + directory?: string + /** Maximum route parameter length */ + maxParamLength?: number +} + +/** + * Response sending helper methods. + * @description Builds JSON, text, HTML, and redirects. */ export interface SendHelpers { - /** Build custom response with body */ - readonly custom: ResponseFn<[body: BodyInit | null]> - /** Build binary data download response */ - readonly data: ResponseFn<[data: Uint8Array | string, filename: string], [contentType?: string]> - /** Serve file from filesystem path */ - readonly file: ResponseFn<[filePath: string, filename?: string], [], Promise> - /** Build HTML content response */ - readonly html: ResponseFn<[html: string]> - /** Build JSON serialized response */ - readonly json: ResponseFn<[data: unknown]> - /** Build redirect response to URL */ - readonly redirect: (url: string, status?: RedirectStatus, options?: RedirectInit) => Response - /** Build streaming response with ReadableStream */ - readonly stream: ResponseFn<[stream: ReadableStream], [contentType?: string]> - /** Build plain text response */ - readonly text: ResponseFn<[text: string]> + /** + * Send JSON response body. + * @description Serializes data as JSON. + * @param data - Data to serialize + * @param options - Optional response init + * @returns JSON response instance + * @template T - Data value type + */ + json(data: T, options?: SendInit): Response + /** + * Send plain text response. + * @param text - Text body to send + * @param options - Optional response init + * @returns Text response instance + */ + text(text: string, options?: SendInit): Response + /** + * Send HTML response body. + * @param html - HTML body to send + * @param options - Optional response init + * @returns HTML response instance + */ + html(html: string, options?: SendInit): Response + /** + * Send custom response body. + * @param body - Response body or null + * @param options - Optional response init + * @returns Custom response instance + */ + custom(body: BodyInit | null, options?: SendInit): Response + /** + * Send file download response. + * @description Adds content disposition header. + * @param body - Download body content + * @param filename - Suggested download filename + * @param options - Optional response init + * @returns Download response instance + */ + download(body: DownloadBody, filename: string, options?: SendInit): Response + /** + * Send empty response body. + * @param status - Optional HTTP status code + * @returns Empty response instance + */ + empty(status?: HttpStatusCode): Response + /** + * Send redirect response. + * @description Validates and resolves location. + * @param url - Redirect target location + * @param status - Optional redirect status + * @param options - Optional redirect init + * @returns Redirect response instance + */ + redirect(url: string, status?: RedirectStatus, options?: RedirectInit): Response } -/** Static file serving options. */ +/** + * Static file serving options. + * @description Sets path, ETag, and cache control. + */ export interface ServeOptions { - /** Cache-Control max-age in seconds */ - readonly cacheControl?: number - /** Enable ETag header generation */ - readonly etag?: boolean /** Filesystem path to static directory */ - readonly path: string + path: string + /** Enable ETag header generation */ + etag?: boolean + /** Cache-Control max age seconds */ + cacheControl?: number } /** - * Serves static files from path. - * @description Handles static file requests using serve options. + * Response setting helper methods. + * @description Sets headers, cookies, and session. */ -export interface StaticHandler { +export interface SetHelpers { + /** + * Set single response header. + * @param key - Header name to set + * @param value - Header value to set + * @returns Same helpers for chaining + */ + header(key: string, value: string): SetHelpers + /** + * Set multiple response headers. + * @param headers - Header name value record + * @returns Same helpers for chaining + */ + headers(headers: StringRecord): SetHelpers /** - * Serve static file response. - * @param ctx - Request context instance - * @param options - Static file serving options - * @param urlPath - URL path to resolve - * @returns Promise resolving to file response + * Set response cookie value. + * @param name - Cookie name to set + * @param value - Cookie value to set + * @param options - Optional cookie attributes + * @returns Same helpers for chaining */ - serve(ctx: Core.Context, options: ServeOptions, urlPath: string): Promise + cookie(name: string, value: string, options?: CookieInit): SetHelpers + /** + * Write session data to cookie. + * @param data - Session data or null + * @returns Promise resolving when write completes + */ + session(data: Types.SessionData | null): Promise } -/** Worker message payload data. */ +/** + * Validated data controller. + * @description Exposes frozen validated value. + */ +export interface ValidatedController { + /** Frozen validated value */ + readonly value: ValidatedValue +} + +/** + * View rendering options. + * @description Sets views directory and render limits. + */ +export interface ViewsOptions { + /** Views directory path */ + directory?: string + /** Maximum loop iterations per block */ + maxIterations?: number + /** Maximum body executions per render */ + maxRenderIterations?: number + /** Maximum output characters per render */ + maxOutputSize?: number + /** Maximum template size in characters */ + maxTemplateSize?: number +} + +/** + * Worker pool task controller. + * @description Dispatches payloads to worker pool. + */ +export interface WorkerController { + /** + * Run task on worker pool. + * @param payload - Task payload to dispatch + * @returns Promise resolving to task result + * @template T - Task result type + */ + run(payload: unknown): Promise +} + +/** + * Worker response message data. + * @description Carries error flag and message. + */ export interface WorkerMessageData { - /** True when message indicates error */ + /** Error flag set on failure */ readonly error?: boolean - /** Human-readable message text */ + /** Error message when failed */ readonly message?: string } -/** Worker pool creation options. */ +/** + * Worker pool configuration options. + * @description Sets script, size, and queue limits. + */ export interface WorkerPoolOptions { - /** Optional lifecycle event emitter */ - readonly emit?: Types.EventEmit - /** Maximum pending tasks before fast-rejecting */ + /** Maximum pending task count */ readonly maxQueueDepth?: number - /** Maximum projected queue wait in ms */ + /** Maximum projected wait milliseconds */ readonly maxQueueWaitMs?: number - /** Number of workers in pool */ + /** Worker pool size */ readonly poolSize?: number - /** URL to worker script module */ + /** Worker script URL */ readonly scriptURL: string - /** Per-task timeout in milliseconds */ + /** Per task timeout milliseconds */ readonly taskTimeoutMs?: number } +/** Supported request body read format */ +export type BodyFormat = 'blob' | 'bytes' | 'form' | 'json' | 'text' + +/** Inclusive byte range start and end */ +export type ByteRange = { readonly start: number; readonly end: number } + +/** Compiled template result from DVE */ +export type CompileResult = ReturnType + /** - * Handle to run worker tasks. - * @description Dispatches payloads to pooled worker threads. + * Context bound handler function. + * @description Receives context plus extra arguments. + * @template Args - Extra argument tuple type + * @template R - Handler return value type */ -export interface WorkerRunHandle { - /** - * Run task on worker. - * @template T - Expected return type - * @param payload - Data to send to worker - * @returns Promise resolving to worker result - */ - run(payload: unknown): Promise +export type ContextFn = ( + ctx: Core.Context, + ...args: Args +) => MaybeAsync + +/** Download response body source type */ +export type DownloadBody = ReadableStream | BufferSource | string + +/** Error handling middleware function */ +export type ErrorMiddleware = ContextFn<[info: ErrorInfo], Response | null> + +/** Union of all lifecycle events */ +export type EventBase = { + [Kind in keyof EventSchemaMap]: LifecycleEvent +}[keyof EventSchemaMap] + +/** + * Lifecycle event by kind. + * @description Extracts event matching given kind. + * @template Kind - Event kind discriminator + */ +export type EventByKind = Extract + +/** Event channel internal or external */ +export type EventChannel = 'internal' | 'external' + +/** Event metadata carrying an error */ +export type EventErrorMeta = { error: Error } + +/** + * Event listener callback function. + * @description Receives a lifecycle event. + * @param event - Lifecycle event payload + */ +export type EventFn = (event: EventBase) => void + +/** Union of all event kinds */ +export type EventKind = keyof EventSchemaMap + +/** Request event metadata with metrics */ +export type EventRequestMeta = RequestMetrics & { + method: string + statusCode: number + url: string + durationMs: number + error?: Error +} + +/** Route event metadata path and pattern */ +export type EventRouteMeta = { path: string; pattern: string } + +/** Event kind to metadata schema map */ +export type EventSchemaMap = { + 'auth:failed': { reason: AuthFailReason } + 'body:rejected': { limit: number; declared: number | null } + 'cors:blocked': { origin: string } + 'csrf:failed': { rule: CsrfRuleName } & EventErrorMeta + 'ip:denied': { ip: string } + 'process:failed': { origin: ProcessErrorOrigin } & EventErrorMeta + 'request:completed': EventRequestMeta + 'request:failed': EventRequestMeta + 'route:added': EventRouteMeta + 'route:failed': { path: string } & EventErrorMeta + 'route:ignored': { path: string; reason: string } + 'route:removed': EventRouteMeta + 'route:updated': EventRouteMeta + 'server:started': { port: number; hostname: string } + 'server:stopped': Record + 'session:invalid': { cookieName: string; reason: SessionInvalidReason } + 'static:missing': { path: string } + 'validate:failed': { source: ValidationSource; reasons: readonly string[] } + 'view:compiled': EventViewMeta + 'view:failed': { path: string } & EventErrorMeta + 'view:invalidated': { paths: readonly string[] } + 'view:rendered': EventViewMeta + 'websocket:rejected': { reason: WebSocketRejectReason } + 'worker:crashed': EventWorkerMeta & EventErrorMeta + 'worker:rejected': { reason: WorkerRejectReason; queueDepth: number; maxQueueDepth: number } + 'worker:respawned': EventWorkerMeta + 'worker:timeout': { timeoutMs: number } & EventWorkerMeta & EventErrorMeta } -/** Body format the request parsed. */ -export type BodyParsedFormat = 'arraybuffer' | 'blob' | 'form' | 'json' | 'text' +/** View event metadata path and duration */ +export type EventViewMeta = { path: string; durationMs: number } + +/** Worker event metadata worker index */ +export type EventWorkerMeta = { index: number } + +/** Extracted status code and error pair */ +export type ExtractedError = Pick + +/** Supported HTTP request method names */ +export type HttpMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT' -/** 4xx client error status codes. */ -export type ClientErrorCode = +/** Supported HTTP response status codes */ +export type HttpStatusCode = + | 200 + | 201 + | 202 + | 204 + | 206 + | 301 + | 302 + | 303 + | 304 + | 307 + | 308 | 400 | 401 | 403 | 404 | 405 + | 406 | 408 | 409 | 410 @@ -219,129 +574,214 @@ export type ClientErrorCode = | 414 | 415 | 422 + | 426 | 429 + | 500 + | 501 + | 502 + | 503 + | 504 /** - * Context-receiving function type. - * @description Generic for handlers that take context and return R. - * @template Args - Additional argument types after context - * @template R - Return type wrapped in MaybeAsync + * IP matcher predicate function. + * @description Tests whether IP matches a rule. + * @param ip - IP address to test + * @returns True when IP matches */ -export type ContextFn = ( - ctx: Core.Context, - ...args: Args -) => MaybeAsync +export type IpMatcher = (ip: string) => boolean -/** Generic string-keyed data record. */ -export type DataRecord = Record +/** + * Lifecycle event payload shape. + * @description Holds channel, kind, metadata, and timestamp. + * @template Kind - Event kind discriminator + * @template Metadata - Event metadata shape + */ +export type LifecycleEvent = { + /** Event channel internal or external */ + readonly type: EventChannel + /** Event kind discriminator */ + readonly kind: Kind + /** Frozen event metadata */ + readonly metadata: Readonly + /** Event creation timestamp */ + readonly timestamp: number +} + +/** + * Value or promise of value. + * @description Wraps synchronous or async result. + * @template T - Wrapped value type + */ +export type MaybeAsync = T | Promise /** - * Handler for route error responses. - * @description Produces response from context, status, and error. + * Request middleware function. + * @description Receives context and next continuation. + * @param ctx - Request context instance + * @param next - Next middleware continuation + * @returns Response or undefined promise */ -export type ErrorHandler = ContextFn<[statusCode: number, error: Error], Response> +export type MiddlewareFn = (ctx: Core.Context, next: NextFn) => ReturnType /** - * Custom handler before error response. - * @description Intercepts errors before default error response is built. + * Middleware next continuation function. + * @description Invokes the next middleware in chain. + * @returns Response or undefined promise */ -export type ErrorMiddleware = ContextFn<[error: ErrorInfo], Response | null> +export type NextFn = () => Promise -/** Extracted status code and error. */ -export type ExtractedError = Pick +/** Process error origin discriminator */ +export type ProcessErrorOrigin = + | 'process:exit' + | 'process:signal' + | 'uncaughterror' + | 'unhandledrejection' -/** HTTP method literal union. */ -export type HttpMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT' +/** Global object exposing optional process */ +export type ProcessGlobal = { process?: Record } -/** HTTP status code branded type. */ -export type HttpStatusCode = ClientErrorCode | ServerErrorCode +/** + * Record value and key accessor. + * @description Returns full record or single value. + */ +export type RecordAccessor = { + (): StringRecord + (key: string): string | undefined +} -/** Matcher predicate for an IP string. */ -export type IpMatcher = (ip: string) => boolean +/** Redirect response init headers only */ +export type RedirectInit = Pick + +/** Allowed HTTP redirect status codes */ +export type RedirectStatus = 301 | 302 | 303 | 307 | 308 /** - * Sync or async value wrapper. - * @template T - Wrapped value type + * View render function signature. + * @description Renders template with data and options. + * @param template - Template name to render + * @param data - View data for template + * @param options - Render options like status + * @returns Promise resolving to rendered response */ -export type MaybeAsync = T | Promise +export type RenderFn = (template: string, data: ViewData, options: RenderInit) => Promise + +/** Resolved rendering options from router */ +export type RenderingOptions = NonNullable['views']> + +/** Optional request metrics for events */ +export type RequestMetrics = { + ip?: string + route?: string + serverAddress?: string + serverPort?: number + userAgent?: string + requestSize?: number + responseSize?: number +} + +/** Resolved file info and path */ +export type ResolvedFile = { readonly fileInfo: Deno.FileInfo; readonly filePath: string } + +/** Route handler returning a response */ +export type RouteHandler = ContextFn<[], Response> + +/** Imported route module export record */ +export type RouteModule = Record /** - * Callback that builds redirect response. - * @description Constructs redirect Response with status and headers. - * @param url - Target redirect URL - * @param status - HTTP redirect status code - * @param extraHeaders - Additional headers to include - * @returns Redirect Response instance + * Dynamic module import function. + * @description Imports module by specifier string. + * @param specifier - Module specifier to import + * @returns Promise resolving to route module */ -export type RedirectBuilder = ( - url: string, - status: RedirectStatus, - extraHeaders?: HeadersInit -) => Response +export type RuntimeImport = (specifier: string) => Promise -/** Optional headers for redirect init. */ -export type RedirectInit = Pick +/** Cookie SameSite policy value */ +export type SameSitePolicy = 'Lax' | 'None' | 'Strict' -/** Valid HTTP redirect status codes. */ -export type RedirectStatus = 301 | 302 | 303 | 307 | 308 +/** Response send init without status override */ +export type SendInit = Omit & { status?: HttpStatusCode } + +/** Reason a session was rejected */ +export type SessionInvalidReason = 'expired' | 'malformed' | 'tampered' + +/** CSRF rule that threw during evaluation */ +export type CsrfRuleName = 'origin' | 'secFetchSite' + +/** Reason basic auth rejected credentials */ +export type AuthFailReason = 'missing' | 'malformed' | 'invalid' + +/** Reason a websocket handshake was rejected */ +export type WebSocketRejectReason = 'origin' | 'version' | 'malformed' /** - * Response factory from payload args. - * @description Appends optional ResponseInit and trailing args to payload. - * @template Args - Leading payload argument tuple - * @template Tail - Optional trailing argument tuple after options - * @template R - Response return wrapper, sync or promised + * Validation source reader function. + * @description Reads a value from request helpers. + * @param get - Request reading helpers + * @returns Source value or promise */ -export type ResponseFn< - Args extends readonly unknown[] = [], - Tail extends readonly unknown[] = [], - R extends Response | Promise = Response -> = (...args: [...Args, options?: ResponseInit, ...rest: Tail]) => R +export type SourceReader = (get: GetHelpers) => Promise | unknown -/** 5xx server error status codes. */ -export type ServerErrorCode = 500 | 501 | 502 | 503 | 504 +/** Source reader map by validation source */ +export type SourceReaders = Readonly> /** - * Branded key for state access. - * @template T - Value type stored under key + * Static file serving function. + * @description Serves response for a URL path. + * @param ctx - Request context instance + * @param urlPath - URL path relative to mount + * @returns Response or promise of response */ -export type StateKey = string & { readonly __stateValue: T } +export type StaticFn = (ctx: Core.Context, urlPath: string) => MaybeAsync /** - * Carrier of an HTTP status code. - * @description Single atom for status-bearing values, widen via S. - * @template S - Confidence of the statusCode value + * Object carrying an HTTP status. + * @description Holds a readonly status code value. + * @template S - Status code value type */ export type StatusCarrier = { readonly statusCode: S } -/** Error-like object with unknown statusCode property. */ -export type StatusCodeCarrier = StatusCarrier - -/** Error with attached HTTP status code. */ -export type StatusError = Error & { statusCode: number } +/** Error carrying an HTTP status code */ +export type StatusError = Error & StatusCarrier -/** String key-value tuple pair. */ +/** Tuple of two string values */ export type StringPair = [string, string] -/** String-to-string key-value record. */ +/** Record of string keys to strings */ export type StringRecord = Record +/** Trusted proxy rules or matcher */ +export type TrustProxyConfig = readonly string[] | IpMatcher + /** - * Widened tag carrier for fail-closed reads. - * @description Readonly record exposing one string-typed discriminant key. - * @template K - Discriminant property name + * Validated output map by schema. + * @description Maps schema keys to validated outputs. + * @template SchemaType - Validation schema type */ -export type TagCarrier = { readonly [P in K]: string } +export type ValidatedMap = { + readonly [Key in keyof SchemaType]: SchemaType[Key] extends ContractFn + ? ValidatedOutput + : never +} /** - * Discriminated-union member with tag. - * @description Joins a readonly tag literal with payload shape. - * @template Tag - Discriminant property name - * @template K - Literal value of the discriminant - * @template Shape - Payload properties for the member + * Validated output of a contract. + * @description Awaited return type of contract function. + * @template ContractType - Contract function type */ -export type TaggedVariant = - & { - readonly [P in Tag]: K - } - & Readonly +export type ValidatedOutput = Awaited> + +/** Frozen validated value record */ +export type ValidatedValue = Readonly> + +/** Validation schema by request source */ +export type ValidationSchema = Partial> + +/** Request source for validation */ +export type ValidationSource = 'body' | 'cookies' | 'headers' | 'query' + +/** View template data record */ +export type ViewData = Record + +/** Reason a worker task was rejected */ +export type WorkerRejectReason = 'queue-depth' | 'queue-wait' diff --git a/src/interfaces/Middleware.ts b/src/interfaces/Middleware.ts index 50a0024..fdf79c8 100644 --- a/src/interfaces/Middleware.ts +++ b/src/interfaces/Middleware.ts @@ -1,89 +1,121 @@ import type * as Types from '@interfaces/index.ts' import type * as Core from '@core/index.ts' -/** Basic Auth middleware options. */ +/** + * Basic auth middleware options. + * @description Configures allowed users and realm. + */ export interface BasicAuthOptions { - /** Allowed user credentials list */ + /** Allowed basic auth users */ readonly users: readonly BasicAuthUser[] + /** Optional authentication realm name */ + readonly realm?: string } -/** Single Basic Auth user credential. */ +/** + * Basic auth user credentials. + * @description Holds username and password pair. + */ export interface BasicAuthUser { - /** Login username string */ + /** Account username value */ readonly username: string - /** Login password string */ + /** Account password value */ readonly password: string } -/** Body size limit middleware options. */ +/** + * Body limit middleware options. + * @description Sets maximum allowed request body bytes. + */ export interface BodyLimitOptions { - /** Maximum body size in bytes */ + /** Maximum request body size in bytes */ readonly limit: number } -/** CORS middleware options. */ +/** + * CORS middleware options. + * @description Configures allowed origins, methods, and headers. + */ export interface CorsOptions { /** Allowed request header names */ readonly allowedHeaders?: readonly string[] - /** Allow credentials in requests */ + /** Allow credentials on requests */ readonly credentials?: boolean - /** Headers exposed to client scripts */ + /** Exposed response header names */ readonly exposedHeaders?: readonly string[] - /** Preflight cache duration in seconds */ + /** Preflight cache max age seconds */ readonly maxAge?: number - /** Allowed HTTP methods for CORS */ + /** Allowed HTTP request methods */ readonly methods?: readonly Types.HttpMethod[] - /** Allowed origin or origin list */ + /** Allowed request origin values */ readonly origin?: string | readonly string[] } -/** CSRF middleware options. */ +/** + * CSRF middleware options. + * @description Configures origin and fetch site rules. + */ export interface CsrfOptions { - /** Allowed origin, list, or predicate */ + /** Allowed origin rule or predicate */ readonly origin?: string | readonly string[] | CsrfRulePredicate - /** Allowed sec-fetch-site, list, or predicate */ + /** Allowed fetch site rule or predicate */ readonly secFetchSite?: string | readonly string[] | CsrfRulePredicate } -/** IP restriction middleware options. */ +/** + * IP filter middleware options. + * @description Configures whitelist and blacklist rules. + */ export interface IpOptions { - /** Allowed IP, CIDR, or wildcard rules */ + /** Allowed IP or CIDR rules */ readonly whitelist?: readonly string[] - /** Denied IP, CIDR, or wildcard rules */ + /** Blocked IP or CIDR rules */ readonly blacklist?: readonly string[] } -/** Middleware bound to optional path. */ -export interface MiddlewareEntry { - /** Middleware handler function */ - readonly handler: MiddlewareFn - /** Path prefix to match */ - readonly path: string +/** + * Session controller for context. + * @description Exposes session state and write method. + */ +export interface SessionController { + /** Current session state or null */ + readonly state: SessionData | null + /** + * Write session data to cookie. + * @description Persists or clears session state. + * @param data - Session data or null + * @returns Promise resolving when write completes + */ + write(data: SessionData | null): Promise } -/** Session middleware cookie options. */ -export interface SessionOptions { +/** + * Default session cookie values. + * @description Holds name and cookie attribute defaults. + */ +export interface SessionDefaults { /** Session cookie name */ - readonly cookieName?: string - /** Secret key for cookie signing */ - readonly cookieSecret: string - /** Restrict cookie to HTTP only */ - readonly httpOnly?: boolean - /** Cookie expiry in seconds */ - readonly maxAge?: number + readonly name: string + /** Mark cookie as HTTP only */ + readonly httpOnly: boolean + /** Cookie max age in seconds */ + readonly maxAge: number /** Cookie path scope */ - readonly path?: string - /** SameSite cookie policy attribute */ - readonly sameSite?: SameSitePolicy - /** Require HTTPS for cookie */ - readonly secure?: boolean + readonly path: string + /** Cookie SameSite policy */ + readonly sameSite: Types.SameSitePolicy + /** Mark cookie as secure */ + readonly secure: boolean } -/** WebSocket upgrade middleware options. */ +/** + * WebSocket upgrade middleware options. + * @description Configures listener path, origin policy, and lifecycle callbacks. + */ export interface WebSocketOptions { /** Allowed handshake origins or wildcard */ readonly allowedOrigins?: readonly string[] | '*' - /** Listener event name override */ + /** Path prefix that triggers an upgrade */ readonly listener?: string /** Called on socket connection open */ readonly onConnect?: SocketCallback @@ -95,59 +127,41 @@ export interface WebSocketOptions { readonly onMessage?: SocketCallback } -/** Async-resolved middleware result promise. */ -export type AsyncMiddlewareResult = Promise> - /** - * CSRF rule predicate over a header value. - * @description Returns true when the value is allowed. - * @param value - Incoming header value to test + * CSRF rule predicate function. + * @description Validates value against request context. + * @param value - Header value to validate * @param ctx - Request context instance - * @returns True when the value passes the rule + * @returns True when value is allowed */ export type CsrfRulePredicate = (value: string, ctx: Core.Context) => boolean -/** - * Middleware function with context. - * @description Processes request with context and next chain. - */ -export type MiddlewareFn = Types.ContextFn<[next: NextFn], Response | undefined> - -/** Middleware return type alias. */ -export type MiddlewareResult = ReturnType - -/** Next function in middleware chain. */ -export type NextFn = () => AsyncMiddlewareResult - -/** Route handler receiving context. */ -export type RouteHandler = Types.ContextFn<[], Response> - -/** SameSite cookie attribute value. */ -export type SameSitePolicy = 'Strict' | 'Lax' | 'None' - -/** Derived security header option key union. */ +/** Security header configuration key */ export type SecurityHeaderKey = keyof typeof Core.Constant.securityHeaders -/** Header value or false to omit. */ +/** Security header value or disable flag */ export type SecurityHeaderValue = string | false -/** Security header partial options map. */ +/** Security headers middleware options map */ export type SecurityHeadersOptions = Partial> -/** Session cookie options all required. */ -export type SessionCookieOpts = Required> +/** Session data key value record */ +export type SessionData = Record -/** Decoded signed session cookie result. */ +/** Session decode success or failure result */ export type SessionDecodeResult = - | { readonly data: Types.DataRecord } - | { readonly reason: 'tampered' | 'expired' | 'malformed' } + | { readonly data: SessionData } + | { readonly reason: Types.SessionInvalidReason } + +/** Session options with required secret */ +export type SessionOptions = { readonly secret: string } & Partial /** - * Socket lifecycle callback with event. - * @description Handles WebSocket events with socket and context. - * @template E - Event subtype constraint + * Socket lifecycle callback function. + * @description Receives the socket, originating event, and request context. + * @template E - Event subtype delivered to the callback * @param socket - WebSocket connection instance - * @param event - DOM event from socket + * @param event - Event emitted by the socket * @param ctx - Request context instance */ export type SocketCallback = ( diff --git a/src/interfaces/Observability.ts b/src/interfaces/Observability.ts deleted file mode 100644 index 6753073..0000000 --- a/src/interfaces/Observability.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type * as Types from '@interfaces/index.ts' - -/** Metadata atom carrying an Error. */ -type ErrorMeta = { - /** Error instance describing the fault */ - error: Error -} - -/** Metadata atom carrying a route path. */ -type RouteMeta = { - /** Registered route path string */ - routePath: string -} - -/** - * Discriminated union of lifecycle events. - * @description Discriminated by kind, with fields under metadata. - */ -export type EventBase = - | LifecycleEvent<'server:listening', { port: number; hostname: string }> - | LifecycleEvent<'server:shutdown', Record> - | LifecycleEvent< - 'route:loaded' | 'route:reloaded' | 'route:removed', - RouteMeta & { pattern: string } - > - | LifecycleEvent<'route:skipped', RouteMeta & { reason: string }> - | LifecycleEvent<'route:error' | 'reload:error', RouteMeta & ErrorMeta> - | LifecycleEvent< - 'process:error', - ErrorMeta & { origin: 'unhandledrejection' | 'uncaughterror' | 'process:exit' } - > - | LifecycleEvent<'view:compiled' | 'view:rendered', { path: string; durationMs: number }> - | LifecycleEvent<'view:refreshed', { paths: readonly string[] }> - | LifecycleEvent<'view:error', { path: string } & ErrorMeta> - | LifecycleEvent< - 'session:invalid', - { cookieName: string; reason: 'tampered' | 'expired' | 'malformed' } - > - | LifecycleEvent<'csrf:rule-error', { rule: 'origin' | 'secFetchSite' } & ErrorMeta> - | LifecycleEvent<'worker:timeout', { timeoutMs: number; workerIndex: number } & ErrorMeta> - | LifecycleEvent<'worker:crash', { workerIndex: number } & ErrorMeta> - | LifecycleEvent<'worker:respawn', { workerIndex: number }> - | LifecycleEvent< - 'worker:rejected', - { reason: 'queue-depth' | 'queue-wait'; queueDepth: number; maxQueueDepth: number } - > - | LifecycleEvent< - 'request:complete' | 'request:error', - & { - method: string - statusCode: number - url: string - durationMs: number - ip?: string - } - & Types.RequestMetrics - & Partial - > - -/** - * Event member selected by kind. - * @description Distributes over the union to keep grouped kinds. - * @template Kind - Event kind discriminant literal - */ -export type EventByKind = EventBase extends infer Member - ? Member extends { kind: infer MemberKind } ? Kind extends MemberKind ? Member : never - : never - : never - -/** Origin channel of an event. */ -export type EventChannel = 'internal' | 'external' - -/** Emit function passed into internal subsystems. */ -export type EventEmit = (event: EventBase) => void - -/** Discriminant value of a lifecycle event. */ -export type EventKind = EventBase['kind'] - -/** Listener invoked for emitted events. */ -export type EventListener = (event: EventBase) => void - -/** - * Lifecycle event envelope with metadata. - * @description Pairs a kind discriminant with its readonly metadata. - * @template Kind - Event kind discriminant literal - * @template Metadata - Event-specific metadata shape - */ -export type LifecycleEvent = { - /** Origin channel of the event */ - readonly type: EventChannel - /** Event kind discriminant value */ - readonly kind: Kind - /** Readonly event-specific metadata */ - readonly metadata: Readonly - /** Creation time in epoch milliseconds */ - readonly timestamp: number -} diff --git a/src/interfaces/Rendering.ts b/src/interfaces/Rendering.ts deleted file mode 100644 index 706af5f..0000000 --- a/src/interfaces/Rendering.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type * as Types from '@interfaces/index.ts' - -/** Compiled DVE template result. */ -export interface CompileResult { - /** Parsed AST node array */ - readonly ast: readonly AstNode[] -} - -/** DVE template parser stack frame. */ -export interface DveStackFrame { - /** True when inside else branch */ - inElse: boolean - /** Block node type discriminant */ - readonly kind: AstBlockKind - /** Parent block AST node */ - readonly node: AstBlockNode -} - -/** Rendering engine constructor options. */ -export interface EngineOptions { - /** Optional lifecycle event emitter */ - readonly emit?: Types.EventEmit - /** Maximum loop iterations per #each block */ - readonly maxIterations?: number - /** Maximum #each body executions per render */ - readonly maxRenderIterations?: number - /** Maximum total output characters per render */ - readonly maxOutputSize?: number - /** Directory path for template views */ - readonly viewsDir: string -} - -/** Per-render cumulative resource budget. */ -export interface RenderBudget { - /** Total #each body executions this render */ - iterations: number - /** Total output characters this render */ - outputSize: number -} - -/** - * View engine for templates. - * @description Renders templates to string or readable stream. - */ -export interface ViewEngine { - /** - * Render template to string. - * @param templatePath - Path to template file - * @param data - Template data record - * @returns Promise resolving to rendered HTML - */ - render(...args: TemplateArgs): Promise - /** - * Render template to readable stream. - * @param templatePath - Path to template file - * @param data - Template data record - * @returns Promise resolving to a ReadableStream of rendered output - */ - streamRender(...args: TemplateArgs): Promise -} - -/** - * Watchable engine for cache invalidation. - * @description Supports file watching and cache refresh. - */ -export interface WatchableEngine extends Pick { - /** - * Invalidate cached template file. - * @param absPath - Absolute path to invalidate - */ - invalidateFile(absPath: string): void - /** - * Emit view:refreshed for changed paths. - * @param paths - Absolute paths that were refreshed - */ - notifyRefresh(paths: readonly string[]): void - /** Refresh all template paths */ - refreshPaths(): void -} - -/** Arithmetic sign for expressions. */ -export type ArithmeticSign = '+' | '-' - -/** Block-level AST node type discriminants. */ -export type AstBlockKind = AstBlockNode['type'] - -/** Block-level AST node with children. */ -export type AstBlockNode = Extract - -/** DVE template AST node union. */ -export type AstNode = - | (Types.TaggedVariant<'type', 'each', { path: string; itemName: string }> & { nodes: AstNode[] }) - | (Types.TaggedVariant<'type', 'if', { path: string }> & { - thenNodes: AstNode[] - elseNodes: AstNode[] - }) - | Types.TaggedVariant<'type', 'include', { templatePath: string }> - | Types.TaggedVariant<'type', 'text', { value: string }> - | Types.TaggedVariant<'type', 'var', { path: string; raw: boolean }> - -/** AST node type discriminant values. */ -export type AstNodeType = AstNode['type'] - -/** Binary operator literals. */ -export type BinaryOp = - | '!=' - | '!==' - | '%' - | '&&' - | '*' - | '/' - | '<' - | '<=' - | '==' - | '===' - | '>' - | '>=' - | '??' - | '||' - | ArithmeticSign - -/** DVE expression AST node union. */ -export type ExprNode = - | Types.TaggedVariant<'type', 'binary', { op: BinaryOp; left: ExprNode; right: ExprNode }> - | Types.TaggedVariant<'type', 'ident', { name: string }> - | Types.TaggedVariant<'type', 'literal', { value: string | number }> - | Types.TaggedVariant<'type', 'member', { object: ExprNode; property: string }> - | Types.TaggedVariant< - 'type', - 'ternary', - { test: ExprNode; consequent: ExprNode; alternate: ExprNode } - > - | Types.TaggedVariant<'type', 'unary', { op: UnaryOp; arg: ExprNode }> - -/** Expression node type discriminant values. */ -export type ExprNodeType = ExprNode['type'] - -/** Node shape exposing op discriminant. */ -export type ExprOpCarrier = Types.TagCarrier<'op'> - -/** DVE expression evaluator token. */ -export type ExprToken = - | Types.TaggedVariant<'kind', 'ident', { value: string }> - | Types.TaggedVariant<'kind', 'number', { value: number }> - | Types.TaggedVariant<'kind', 'op', { value: TokenOp }> - | Types.TaggedVariant<'kind', 'string', { value: string }> - -/** Expression token kind discriminant values. */ -export type ExprTokenKind = ExprToken['kind'] - -/** Node shape exposing type discriminant. */ -export type ExprTypeCarrier = Types.TagCarrier<'type'> - -/** Structural operators in expression tokens. */ -export type StructuralOp = '(' | ')' | '.' | ':' | '?' | '?.' - -/** Template method parameter tuple. */ -export type TemplateArgs = [templatePath: string, data?: Types.DataRecord] - -/** All operator literals in tokens. */ -export type TokenOp = BinaryOp | StructuralOp | UnaryOp - -/** Unary operator literals. */ -export type UnaryOp = '!' | ArithmeticSign diff --git a/src/interfaces/Routing.ts b/src/interfaces/Routing.ts index 2784aec..c50112f 100644 --- a/src/interfaces/Routing.ts +++ b/src/interfaces/Routing.ts @@ -1,121 +1,70 @@ import type * as Types from '@interfaces/index.ts' import type * as Core from '@core/index.ts' -/** Request handler configuration options. */ -export interface HandlerOptions extends - Partial< - Pick< - Types.EngineOptions, - 'maxIterations' | 'maxRenderIterations' | 'maxOutputSize' | 'viewsDir' - > - > { - /** Custom error response builder */ - readonly errorResponseBuilder?: Types.ErrorResponseBuilder - /** Maximum route parameter length */ - readonly maxParamLength?: number - /** Maximum request URL length */ - readonly maxUrlLength?: number - /** Request timeout in milliseconds */ - readonly requestTimeoutMs?: number - /** Static file handler instance */ - readonly staticHandler?: Types.StaticHandler - /** Trusted proxy configuration for IP resolution */ - readonly trustProxy?: TrustProxyConfig - /** Worker pool configuration options */ - readonly worker?: Types.WorkerPoolOptions +/** + * Scoped middleware registration entry. + * @description Pairs path prefix with middleware handler. + */ +export interface MiddlewareEntry { + /** Path prefix scoping the middleware */ + readonly path: string + /** Middleware handler function */ + readonly handler: Types.MiddlewareFn } -/** Server listen address info. */ -export interface ListenAddr { - /** Bound hostname */ - readonly hostname: string - /** Bound port number */ - readonly port: number -} - -/** Per-request context and error holder. */ +/** + * Mutable per request state holder. + * @description Carries context, error, URL, and pattern. + */ export interface RequestHolder { - /** Request context, null before creation */ + /** Active request context or null */ ctx: Core.Context | null - /** Framework error captured during handling */ + /** Captured framework error or null */ frameworkError: Error | null - /** Resolved client IP, undefined when unknown */ - clientIp: string | undefined - /** Matched route pattern, undefined when unmatched */ - routePattern: string | undefined - /** Parsed request URL, reused to avoid re-parsing for metrics */ + /** Parsed request URL or undefined */ parsedUrl: URL | undefined + /** Matched route pattern or undefined */ + routePattern: string | undefined } -/** Optional OTel-aligned request metrics. */ -export interface RequestMetrics { - /** Matched route pattern */ - route?: string - /** Resolved server hostname */ - serverAddress?: string - /** Resolved server port number */ - serverPort?: number - /** Request User-Agent header value */ - userAgent?: string - /** Request body size in bytes */ - requestSize?: number - /** Response body size in bytes */ - responseSize?: number -} - -/** Route change entry for hot-reload. */ -export interface RouteChangeEntry { - /** Absolute filesystem path to module */ +/** + * Route file change descriptor. + * @description Holds full path and route path. + */ +export interface RouteChange { + /** Absolute path to route file */ readonly fullPath: string - /** Registered route path pattern */ + /** Route path relative to directory */ readonly routePath: string } -/** Shared route entry fields. */ -export interface RouteEntryBase { - /** URL pattern for route matching */ +/** + * Registered route lookup entry. + * @description Pairs route handler with its pattern. + */ +export interface RouteEntry { + /** Route handler function */ + readonly handler: Types.RouteHandler + /** Route pattern string */ readonly pattern: string } -/** Router constructor and serve options. */ -export interface RouterOptions extends HandlerOptions { - /** Directory path for route modules */ - readonly routesDir?: string +/** + * Static mount registration entry. + * @description Pairs URL prefix with serving handler. + */ +export interface StaticMount { + /** URL prefix for static files */ + readonly urlPrefix: string + /** Serving handler for the mount */ + readonly handler: Types.StaticFn } -/** Well-known framework state keys shape. */ -export interface StateKeysMap { - /** Key for the view engine */ - readonly view: Types.StateKey - /** Key for the worker handle */ - readonly worker: Types.StateKey - /** Key for current session data */ - readonly session: Types.StateKey - /** Key for the session setter */ - readonly setSession: Types.StateKey<(data: Types.DataRecord) => Promise> - /** Key for the session clearer */ - readonly clearSession: Types.StateKey<() => void> - /** Key for validated request data */ - readonly validated: Types.StateKey -} - -/** Route entry for type-safe dispatch. */ -export type RouteEntry = - | (RouteEntryBase & { - readonly kind: 'handler' - readonly handler: Types.RouteHandler - }) - | (RouteEntryBase & { - readonly kind: 'static' - readonly execute: (ctx: Core.Context) => Promise - readonly urlPath: string - }) - -/** Allowed route module file extensions. */ -export type RouteFileExtension = 'cjs' | 'js' | 'jsx' | 'mjs' | 'ts' | 'tsx' - -/** Loaded route module with method exports. */ -export type RouteModule = Record - -/** Trusted proxy configuration for IP resolution. */ -export type TrustProxyConfig = readonly string[] | Types.IpMatcher +/** + * Deno server request handler. + * @description Handles request and returns response promise. + * @param req - Incoming request instance + * @param info - Optional Deno serve handler info + * @returns Promise resolving to response + */ +export type ServeHandler = (req: Request, info?: Deno.ServeHandlerInfo) => Promise diff --git a/src/interfaces/Validation.ts b/src/interfaces/Validation.ts deleted file mode 100644 index ea62937..0000000 --- a/src/interfaces/Validation.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import type * as Core from '@core/index.ts' -import type { ContractFn } from '@neabyte/typebox' - -/** Per-source validation contract schema. */ -export interface ValidationSchema { - /** Contract for raw request body */ - readonly body?: ContractFn - /** Contract for parsed request cookies */ - readonly cookies?: ContractFn - /** Contract for request header record */ - readonly headers?: ContractFn - /** Contract for parsed JSON body */ - readonly json?: ContractFn - /** Contract for matched route params */ - readonly params?: ContractFn - /** Contract for query string record */ - readonly query?: ContractFn -} - -/** - * Extract raw data from a source. - * @description Reads one validation source from the context. - * @param ctx - Request context instance - * @returns Source value, possibly async - */ -export type SourceExtractor = (ctx: Core.Context) => Types.MaybeAsync - -/** - * Validated output mapped from a schema. - * @description Maps each source to its contract output type. - * @template SchemaType - Validation schema being validated - */ -export type ValidatedData = { - readonly [Key in keyof SchemaType]: SchemaType[Key] extends (input: never) => infer OutputType - ? Awaited - : never -} - -/** Allowed validation source key. */ -export type ValidationSource = keyof ValidationSchema diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index e460aa0..6a72de4 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,7 +1,4 @@ -/** Re-exports interfaces public API. */ +/** Re-exports interfaces public API */ export * from '@interfaces/Core.ts' export * from '@interfaces/Middleware.ts' -export * from '@interfaces/Observability.ts' -export * from '@interfaces/Rendering.ts' export * from '@interfaces/Routing.ts' -export * from '@interfaces/Validation.ts' diff --git a/src/middleware/BasicAuth.ts b/src/middleware/BasicAuth.ts index d12ae3a..92e2a11 100644 --- a/src/middleware/BasicAuth.ts +++ b/src/middleware/BasicAuth.ts @@ -1,84 +1,98 @@ import type * as Types from '@interfaces/index.ts' -import type * as CoreTypes from '@core/index.ts' +import * as Core from '@core/index.ts' import * as Middleware from '@middleware/index.ts' /** - * Basic Auth middleware for users. - * @description Validates Authorization header, constant-time compare. + * HTTP Basic Authentication middleware. + * @description Validates Authorization header against user list. */ export class BasicAuth { /** * Create Basic Auth middleware. - * @description Validates Authorization header against the user list. - * @param options - List of username/password pairs - * @returns Middleware that returns 401 when invalid + * @description Builds middleware checking credentials against users. + * @param options - Basic auth configuration with users + * @returns Middleware function enforcing authentication * @throws {Deno.errors.InvalidData} When users array is empty */ static create(options: Types.BasicAuthOptions): Types.MiddlewareFn { - if (!options.users || options.users.length === 0) { + if (options.users.length === 0) { throw new Deno.errors.InvalidData('BasicAuth requires at least one user in the users array') } const users = options.users - return Middleware.WrapMware('BasicAuth error', async (ctx: CoreTypes.Context, next) => { - const authHeader = ctx.header('authorization') - const spaceIndex = authHeader ? authHeader.indexOf(' ') : -1 + const challenge = `Basic realm="${options.realm ?? 'Secure Area'}"` + return Middleware.Wrap.apply('basicAuth', async (ctx, next) => { + const authHeader = ctx.get.header('authorization') + const spaceIndex = authHeader === undefined ? -1 : authHeader.indexOf(' ') const scheme = spaceIndex > 0 ? authHeader!.slice(0, spaceIndex) : '' if (scheme.toLowerCase() !== 'basic') { - ctx.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"') + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('auth:failed', { reason: 'missing' }) + ) + ctx.set.header('WWW-Authenticate', challenge) return await ctx.handleError( 401, new Deno.errors.PermissionDenied('Missing or invalid Authorization header') ) } - try { - const credentials = atob(authHeader!.slice(spaceIndex + 1).trim()) - const colonIndex = credentials.indexOf(':') - if (colonIndex <= 0) { - ctx.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"') - return await ctx.handleError( - 401, - new Deno.errors.PermissionDenied('Malformed Basic Auth credentials') - ) - } - let isValid = false - for (const user of users) { - if (BasicAuth.constantTimeEqual(credentials, `${user.username}:${user.password}`)) { - isValid = true - } - } - if (!isValid) { - ctx.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"') - return await ctx.handleError( - 401, - new Deno.errors.PermissionDenied('Invalid username or password') - ) + const credentials = BasicAuth.decodeCredentials(authHeader!.slice(spaceIndex + 1).trim()) + if (credentials === null || credentials.indexOf(':') <= 0) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('auth:failed', { reason: 'malformed' }) + ) + ctx.set.header('WWW-Authenticate', challenge) + return await ctx.handleError( + 401, + new Deno.errors.PermissionDenied('Malformed Basic Auth credentials') + ) + } + let isAuthorized = false + for (const user of users) { + if (BasicAuth.constantTimeEqual(credentials, `${user.username}:${user.password}`)) { + isAuthorized = true } - return await next() - } catch (error) { - ctx.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"') + } + if (!isAuthorized) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('auth:failed', { reason: 'invalid' }) + ) + ctx.set.header('WWW-Authenticate', challenge) return await ctx.handleError( 401, - new Deno.errors.PermissionDenied( - `BasicAuth failed because ${error instanceof Error ? error.message : 'unknown error'}` - ) + new Deno.errors.PermissionDenied('Invalid username or password') ) } + return await next() }) } /** - * Constant-time string comparison for credentials. - * @description Compares two strings in constant time. - * @param inputStr - First string - * @param expectedStr - Second string - * @returns True when equal + * Compare two strings in constant time. + * @description Prevents timing attacks during credential check. + * @param inputValue - Provided credential string + * @param expectedValue - Expected credential string + * @returns True when both strings match exactly */ - private static constantTimeEqual(inputStr: string, expectedStr: string): boolean { - const maxLength = Math.max(inputStr.length, expectedStr.length) - let mismatch = inputStr.length ^ expectedStr.length - for (let i = 0; i < maxLength; i++) { - mismatch |= (inputStr.charCodeAt(i) || 0) ^ (expectedStr.charCodeAt(i) || 0) + private static constantTimeEqual(inputValue: string, expectedValue: string): boolean { + const maxLength = Math.max(inputValue.length, expectedValue.length) + let mismatch = inputValue.length ^ expectedValue.length + for (let charIndex = 0; charIndex < maxLength; charIndex++) { + mismatch |= (inputValue.charCodeAt(charIndex) || 0) ^ + (expectedValue.charCodeAt(charIndex) || 0) } return mismatch === 0 } + + /** + * Decode base64 credential string. + * @description Returns null when base64 decoding fails. + * @param encoded - Base64 encoded credential pair + * @returns Decoded string or null on failure + */ + private static decodeCredentials(encoded: string): string | null { + try { + return atob(encoded) + } catch { + return null + } + } } diff --git a/src/middleware/BodyLimit.ts b/src/middleware/BodyLimit.ts index 7d1d342..dc15bcf 100644 --- a/src/middleware/BodyLimit.ts +++ b/src/middleware/BodyLimit.ts @@ -4,113 +4,39 @@ import * as Middleware from '@middleware/index.ts' /** * Request body size limit middleware. - * @description Rejects or streams with limit, returns 413 when exceeded. + * @description Rejects requests exceeding configured byte limit. */ export class BodyLimit { - /** Cached body-support verdict per method */ - private static readonly bodyMethodCache = new Map() - /** * Create body limit middleware. - * @description Rejects or limits body stream, returns 413. - * @param options - Max size in bytes - * @returns Middleware that enforces limit + * @description Builds middleware checking content-length header. + * @param options - Body limit configuration in bytes + * @returns Middleware function enforcing size limit */ static create(options: Types.BodyLimitOptions): Types.MiddlewareFn { - const maxSize = Core.Handler.assertPositiveFinite(options.limit, 'Body limit', 'bytes') - return Middleware.WrapMware('Body limit error', async (ctx, next) => { - if (ctx.request.method === 'GET' || ctx.request.method === 'HEAD') { + const maxBytes = Core.Handler.assertPositiveFinite(options.limit, 'Body limit', 'bytes') + return Middleware.Wrap.apply('bodyLimit', async (ctx, next) => { + const method = ctx.get.method() + if (method === 'GET' || method === 'HEAD') { return await next() } - if (ctx.headers.has('content-length') && !ctx.headers.has('transfer-encoding')) { - const contentLength = Number(ctx.headers.get('content-length')) - if (Number.isNaN(contentLength) || contentLength < 0 || contentLength > maxSize) { + const contentLength = ctx.get.header('content-length') + if (contentLength !== undefined) { + const declaredBytes = Number(contentLength) + if (!Number.isInteger(declaredBytes) || declaredBytes < 0 || declaredBytes > maxBytes) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('body:rejected', { + limit: maxBytes, + declared: Number.isInteger(declaredBytes) && declaredBytes >= 0 ? declaredBytes : null + }) + ) return await ctx.handleError( 413, - new Deno.errors.InvalidData(`Request body exceeds ${maxSize} bytes limit`) + new Deno.errors.InvalidData(`Request body exceeds ${maxBytes} bytes limit`) ) } } - const requestBody = ctx.request.body - if (requestBody && BodyLimit.methodAllowsBody(ctx.request.method)) { - const limitedStream = BodyLimit.createLimitStream(requestBody, maxSize) - const limitedRequest = new Core.API.Request(ctx.request.url, { - method: ctx.request.method, - headers: ctx.request.headers, - body: limitedStream, - duplex: 'half' - } as RequestInit) - ctx[Core.InternalContext].replaceRequest(limitedRequest) - } return await next() }) } - - /** - * Wrap stream with byte limit. - * @description Reads stream and enqueues until limit, then errors. - * @param stream - Request body stream - * @param maxBytes - Max bytes before error - * @returns Limited stream or null - */ - private static createLimitStream( - stream: ReadableStream | null, - maxBytes: number - ): ReadableStream | null { - if (!stream) { - return null - } - let bytesRead = 0 - return new ReadableStream({ - async start(controller) { - const reader = stream.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } - bytesRead += value.length - if (bytesRead > maxBytes) { - reader.cancel() - controller.error( - Core.Handler.createStatusError(413, `Request body exceeds ${maxBytes} bytes limit`) - ) - return - } - controller.enqueue(value) - } - controller.close() - } catch (readError) { - controller.error(readError) - } - } - }) - } - - /** - * Check if method supports body. - * @description Probes Request constructor and memoizes per method. - * @param method - Incoming request method - * @returns True when method allows a body - */ - private static methodAllowsBody(method: string): boolean { - const cached = BodyLimit.bodyMethodCache.get(method) - if (cached !== undefined) { - return cached - } - let allowed: boolean - try { - void new Core.API.Request('http://body-limit.invalid/', { - method, - body: new ReadableStream(), - duplex: 'half' - } as RequestInit) - allowed = true - } catch { - allowed = false - } - BodyLimit.bodyMethodCache.set(method, allowed) - return allowed - } } diff --git a/src/middleware/CORS.ts b/src/middleware/CORS.ts index 48bc429..f79c033 100644 --- a/src/middleware/CORS.ts +++ b/src/middleware/CORS.ts @@ -3,15 +3,16 @@ import * as Core from '@core/index.ts' import * as Middleware from '@middleware/index.ts' /** - * CORS middleware for cross-origin requests. - * @description Handles preflight and sets Allow-Origin and related headers. + * Cross-Origin Resource Sharing middleware. + * @description Sets CORS headers and handles preflight requests. */ -export class Cors { +export class CORS { /** - * Create CORS middleware with options. - * @description Handles preflight, sets Allow-Origin and related headers. - * @param options - Origin, methods, headers, credentials, maxAge - * @returns Middleware function + * Create CORS middleware. + * @description Builds middleware applying configured CORS policy. + * @param options - CORS configuration options + * @returns Middleware function applying CORS headers + * @throws {Deno.errors.InvalidData} When credentials used with wildcard origin */ static create(options: Types.CorsOptions = {}): Types.MiddlewareFn { const allowedOrigins = options.origin ?? '*' @@ -26,59 +27,76 @@ export class Cors { const maxAge = options.maxAge ?? 86400 if (credentials && allowedOrigins === '*') { throw new Deno.errors.InvalidData( - 'CORS credentials cannot be used with wildcard origin "*", specify explicit origin(s)' + 'CORS credentials cannot be used with wildcard origin "*", specify explicit origins' ) } - const hasVaryOrigin = allowedOrigins !== '*' + const varyOrigin = allowedOrigins !== '*' const methodsHeader = methods.join(', ') const headersHeader = allowedHeaders.join(', ') const maxAgeHeader = maxAge.toString() const exposedHeader = exposedHeaders.length > 0 ? exposedHeaders.join(', ') : null - return Middleware.WrapMware('CORS error', async (ctx, next) => { - const requestOrigin = ctx.header('origin') - if (!requestOrigin) { + return Middleware.Wrap.apply('cors', async (ctx, next) => { + const requestOrigin = ctx.get.header('origin') + if (requestOrigin === undefined) { return await next() } - let matchedOrigin: string | null = null - if (allowedOrigins === '*') { - matchedOrigin = '*' - } else if (typeof allowedOrigins === 'string') { - matchedOrigin = allowedOrigins === requestOrigin ? requestOrigin : null - } else if (allowedOrigins.includes(requestOrigin)) { - matchedOrigin = requestOrigin + const matchedOrigin = CORS.matchOrigin(allowedOrigins, requestOrigin) + if (varyOrigin) { + ctx.set.header('Vary', 'Origin') } - if (ctx.request.method === 'OPTIONS') { - if (hasVaryOrigin) { - ctx.setHeader('Vary', 'Origin') - } - if (matchedOrigin) { - ctx.setHeader('Access-Control-Allow-Origin', matchedOrigin) - ctx.setHeader('Access-Control-Allow-Methods', methodsHeader) - ctx.setHeader('Access-Control-Allow-Headers', headersHeader) - ctx.setHeader('Access-Control-Max-Age', maxAgeHeader) + if (ctx.get.method() === 'OPTIONS') { + if (matchedOrigin !== null) { + ctx.set.header('Access-Control-Allow-Origin', matchedOrigin) + ctx.set.header('Access-Control-Allow-Methods', methodsHeader) + ctx.set.header('Access-Control-Allow-Headers', headersHeader) + ctx.set.header('Access-Control-Max-Age', maxAgeHeader) if (credentials) { - ctx.setHeader('Access-Control-Allow-Credentials', 'true') + ctx.set.header('Access-Control-Allow-Credentials', 'true') } - if (exposedHeader) { - ctx.setHeader('Access-Control-Expose-Headers', exposedHeader) + if (exposedHeader !== null) { + ctx.set.header('Access-Control-Expose-Headers', exposedHeader) } - return ctx.send.custom(null, { status: 204 }) + } else { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('cors:blocked', { origin: requestOrigin }) + ) } - return ctx.send.custom(null, { status: 403 }) - } - if (hasVaryOrigin) { - ctx.setHeader('Vary', 'Origin') + return ctx.send.empty(204) } - if (matchedOrigin) { - ctx.setHeader('Access-Control-Allow-Origin', matchedOrigin) + if (matchedOrigin !== null) { + ctx.set.header('Access-Control-Allow-Origin', matchedOrigin) if (credentials) { - ctx.setHeader('Access-Control-Allow-Credentials', 'true') + ctx.set.header('Access-Control-Allow-Credentials', 'true') } - if (exposedHeader) { - ctx.setHeader('Access-Control-Expose-Headers', exposedHeader) + if (exposedHeader !== null) { + ctx.set.header('Access-Control-Expose-Headers', exposedHeader) } + } else { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('cors:blocked', { origin: requestOrigin }) + ) } return await next() }) } + + /** + * Match request origin against allowed set. + * @description Returns matched origin value or null. + * @param allowedOrigins - Allowed origin or origin list + * @param requestOrigin - Origin from incoming request + * @returns Matched origin string or null + */ + private static matchOrigin( + allowedOrigins: string | readonly string[], + requestOrigin: string + ): string | null { + if (allowedOrigins === '*') { + return '*' + } + if (typeof allowedOrigins === 'string') { + return allowedOrigins === requestOrigin ? requestOrigin : null + } + return allowedOrigins.includes(requestOrigin) ? requestOrigin : null + } } diff --git a/src/middleware/CSRF.ts b/src/middleware/CSRF.ts index 567618d..d678bd5 100644 --- a/src/middleware/CSRF.ts +++ b/src/middleware/CSRF.ts @@ -1,34 +1,33 @@ import type * as Types from '@interfaces/index.ts' -import type * as CoreTypes from '@core/index.ts' import * as Core from '@core/index.ts' import * as Middleware from '@middleware/index.ts' /** - * CSRF middleware for state-changing requests. - * @description Verifies Origin and Sec-Fetch-Site headers, denies by default. + * Cross-Site Request Forgery protection middleware. + * @description Validates origin and sec-fetch-site headers. */ export class CSRF { /** - * Create CSRF middleware with options. - * @description Validates unsafe methods via Origin or Sec-Fetch-Site. - * @param options - Allowed origin and sec-fetch-site rules - * @returns Middleware function + * Create CSRF middleware. + * @description Builds middleware blocking forged cross-site requests. + * @param options - CSRF configuration options + * @returns Middleware function enforcing CSRF protection */ static create(options: Types.CsrfOptions = {}): Types.MiddlewareFn { const originRule = options.origin const secFetchRule = options.secFetchSite ?? ['same-origin'] - return Middleware.WrapMware('CSRF error', async (ctx, next) => { - const method = ctx.request.method + return Middleware.Wrap.apply('csrf', async (ctx, next) => { + const method = ctx.get.method() if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { return await next() } - const allowedOrigin = originRule ?? new Core.API.URL(ctx.request.url).origin - const origin = ctx.header('origin') - const secFetchSite = ctx.header('sec-fetch-site') - const isOriginValid = origin !== undefined && - CSRF.matches(origin, allowedOrigin, ctx, 'origin') + const allowedOrigin = originRule ?? new Core.API.URL(ctx.get.url().href).origin + const requestOrigin = ctx.get.header('origin') + const secFetchSite = ctx.get.header('sec-fetch-site') + const isOriginValid = requestOrigin !== undefined && + CSRF.matchesRule(requestOrigin, allowedOrigin, 'origin', ctx) const isSecFetchValid = secFetchSite !== undefined && - CSRF.matches(secFetchSite, secFetchRule, ctx, 'secFetchSite') + CSRF.matchesRule(secFetchSite, secFetchRule, 'secFetchSite', ctx) if (isOriginValid || isSecFetchValid) { return await next() } @@ -40,27 +39,27 @@ export class CSRF { } /** - * Check header value against allow rule. - * @description Supports exact string, string list, or predicate function. - * @param value - Incoming header value - * @param rule - Allowed string, list, or predicate - * @param ctx - Request context instance - * @param ruleLabel - Rule identifier for error events - * @returns True when the value is allowed + * Test value against a CSRF rule. + * @description Supports string, list, or predicate rules. + * @param value - Header value being checked + * @param rule - String, list, or predicate rule + * @param ruleName - Rule name for error reporting + * @param ctx - Request context for predicate rules + * @returns True when value satisfies the rule */ - private static matches( + private static matchesRule( value: string, rule: string | readonly string[] | Types.CsrfRulePredicate, - ctx: CoreTypes.Context, - ruleLabel: 'origin' | 'secFetchSite' + ruleName: Types.CsrfRuleName, + ctx: Core.Context ): boolean { if (typeof rule === 'function') { try { return rule(value, ctx) === true } catch (ruleError) { - ctx[Core.InternalContext].emitEvent( - Core.Observability.internalEvent('csrf:rule-error', { - rule: ruleLabel, + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('csrf:failed', { + rule: ruleName, error: ruleError instanceof Error ? ruleError : new Error(String(ruleError)) }) ) diff --git a/src/middleware/IP.ts b/src/middleware/IP.ts index 5bf4966..27a9db3 100644 --- a/src/middleware/IP.ts +++ b/src/middleware/IP.ts @@ -3,32 +3,40 @@ import * as Core from '@core/index.ts' import * as Middleware from '@middleware/index.ts' /** - * IP restriction middleware for access control. - * @description Allows or denies requests by connection IP address. + * IP address filtering middleware. + * @description Allows or denies requests by client IP. */ export class IP { /** - * Create IP restriction middleware with options. - * @description Whitelist takes precedence, then blacklist, fail-safe deny. - * @param options - Allowed and denied IP rules - * @returns Middleware function - * @throws {Deno.errors.InvalidData} When a rule is malformed + * Create IP filter middleware. + * @description Builds middleware applying whitelist and blacklist. + * @param options - IP filter configuration options + * @returns Middleware function enforcing IP rules */ static create(options: Types.IpOptions = {}): Types.MiddlewareFn { const whitelist = Core.IpAddress.compileRules(options.whitelist) const blacklist = Core.IpAddress.compileRules(options.blacklist) - return Middleware.WrapMware('IP restriction error', async (ctx, next) => { - const ip = ctx.ip - if (ip === undefined) { + return Middleware.Wrap.apply('ip', async (ctx, next) => { + const clientIp = ctx.get.ip() + if (clientIp === undefined) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('ip:denied', { ip: 'unknown' }) + ) return await IP.deny(ctx) } if (whitelist.length > 0) { - if (Core.IpAddress.anyMatch(whitelist, ip)) { + if (Core.IpAddress.anyMatch(whitelist, clientIp)) { return await next() } + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('ip:denied', { ip: clientIp }) + ) return await IP.deny(ctx) } - if (blacklist.length > 0 && Core.IpAddress.anyMatch(blacklist, ip)) { + if (blacklist.length > 0 && Core.IpAddress.anyMatch(blacklist, clientIp)) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('ip:denied', { ip: clientIp }) + ) return await IP.deny(ctx) } return await next() @@ -36,15 +44,12 @@ export class IP { } /** - * Deny the current request. - * @description Sends a 403 permission denied response. - * @param ctx - Request context instance - * @returns Forbidden response + * Deny request with forbidden response. + * @description Routes denial through context error handler. + * @param ctx - Request context to handle denial + * @returns Promise resolving to forbidden response */ - private static deny(ctx: Parameters[0]): Types.AsyncMiddlewareResult { - return ctx.handleError( - 403, - new Deno.errors.PermissionDenied('Access denied by IP restriction') - ) + private static deny(ctx: Parameters[0]): Promise { + return ctx.handleError(403, new Deno.errors.PermissionDenied('Access denied by IP restriction')) } } diff --git a/src/middleware/Loaders.ts b/src/middleware/Loaders.ts deleted file mode 100644 index ec6c480..0000000 --- a/src/middleware/Loaders.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Re-exports middleware classes. */ -export * from '@middleware/BasicAuth.ts' -export * from '@middleware/BodyLimit.ts' -export * from '@middleware/CORS.ts' -export * from '@middleware/CSRF.ts' -export * from '@middleware/IP.ts' -export * from '@middleware/SecHeaders.ts' -export * from '@middleware/Session.ts' -export * from '@middleware/Validator.ts' -export * from '@middleware/WebSocket.ts' diff --git a/src/middleware/Mware.ts b/src/middleware/Mware.ts new file mode 100644 index 0000000..27c96b9 --- /dev/null +++ b/src/middleware/Mware.ts @@ -0,0 +1,22 @@ +import type * as Types from '@interfaces/index.ts' +import * as Middleware from '@middleware/index.ts' + +/** + * Middleware factory collection. + * @description Convenience factories for built-in middleware. + */ +export const Mware = { + basicAuth: (options: Types.BasicAuthOptions): Types.MiddlewareFn => + Middleware.BasicAuth.create(options), + bodyLimit: (options: Types.BodyLimitOptions): Types.MiddlewareFn => + Middleware.BodyLimit.create(options), + cors: (options?: Types.CorsOptions): Types.MiddlewareFn => Middleware.CORS.create(options), + csrf: (options?: Types.CsrfOptions): Types.MiddlewareFn => Middleware.CSRF.create(options), + ip: (options?: Types.IpOptions): Types.MiddlewareFn => Middleware.IP.create(options), + securityHeaders: (options?: Types.SecurityHeadersOptions): Types.MiddlewareFn => + Middleware.SecurityHeaders.create(options), + session: (options: Types.SessionOptions): Types.MiddlewareFn => + Middleware.Session.create(options), + websocket: (options?: Types.WebSocketOptions): Types.MiddlewareFn => + Middleware.WebSocket.create(options) +} as const diff --git a/src/middleware/SecHeaders.ts b/src/middleware/SecHeaders.ts deleted file mode 100644 index 60607ae..0000000 --- a/src/middleware/SecHeaders.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' -import * as Middleware from '@middleware/index.ts' - -/** - * Security headers middleware. - * @description Sets configurable security headers on response. - */ -export class SecHeaders { - /** - * Create security headers middleware. - * @description Sets secure defaults then applies overrides, false omits header. - * @param options - Header values, false to omit a default - * @returns Middleware that sets headers - */ - static create(options: Types.SecurityHeadersOptions = {}): Types.MiddlewareFn { - const resolvedHeaders: Types.StringPair[] = [] - for (const [key, name] of Object.entries(Core.Constant.securityHeaders)) { - const headerValue = options[key as Types.SecurityHeaderKey] - if (headerValue === false) { - continue - } - if (headerValue !== undefined) { - resolvedHeaders.push([name, headerValue]) - } else if (name in Core.Constant.securityHeaderDefaults) { - resolvedHeaders.push([name, Core.Constant.securityHeaderDefaults[name]!]) - } - } - return Middleware.WrapMware('Security headers error', async (ctx, next) => { - for (const [name, value] of resolvedHeaders) { - ctx.setHeader(name, value) - } - return await next() - }) - } -} diff --git a/src/middleware/SecurityHeaders.ts b/src/middleware/SecurityHeaders.ts new file mode 100644 index 0000000..a34760d --- /dev/null +++ b/src/middleware/SecurityHeaders.ts @@ -0,0 +1,37 @@ +import type * as Types from '@interfaces/index.ts' +import * as Core from '@core/index.ts' +import * as Middleware from '@middleware/index.ts' + +/** + * Security response headers middleware. + * @description Applies configurable security headers to responses. + */ +export class SecurityHeaders { + /** + * Create security headers middleware. + * @description Resolves header values from options and defaults. + * @param options - Security headers configuration options + * @returns Middleware function setting security headers + */ + static create(options: Types.SecurityHeadersOptions = {}): Types.MiddlewareFn { + const resolvedHeaders: Types.StringPair[] = [] + for (const optionKey of Object.keys(Core.Constant.securityHeaders)) { + const entry = Core.Constant.securityHeaders[optionKey as Types.SecurityHeaderKey] + const optionValue = options[optionKey as Types.SecurityHeaderKey] + if (optionValue === false) { + continue + } + if (optionValue !== undefined) { + resolvedHeaders.push([entry.header, optionValue]) + } else if (entry.default !== null) { + resolvedHeaders.push([entry.header, entry.default]) + } + } + return Middleware.Wrap.apply('securityHeaders', async (ctx, next) => { + for (const [headerName, headerValue] of resolvedHeaders) { + ctx.set.header(headerName, headerValue) + } + return await next() + }) + } +} diff --git a/src/middleware/Session.ts b/src/middleware/Session.ts index cc75e56..3cb7422 100644 --- a/src/middleware/Session.ts +++ b/src/middleware/Session.ts @@ -3,241 +3,146 @@ import * as Core from '@core/index.ts' import * as Middleware from '@middleware/index.ts' /** - * Cookie-based session middleware. - * @description Manages set/clear in ctx.state with HMAC signing. + * Signed cookie session middleware. + * @description Manages HMAC-signed session cookies per request. */ export class Session { + /** Base64url encoding options without padding */ + private static readonly base64UrlOptions = { alphabet: 'base64url', omitPadding: true } as const + /** * Create session middleware. - * @description Populates ctx.state with session and helpers, requires cookieSecret. - * @param options - Session options with required cookieSecret - * @returns Middleware that populates ctx.state.session - * @throws {Deno.errors.InvalidData} When cookieSecret is missing or empty + * @description Builds middleware loading and signing session cookies. + * @param options - Session configuration with secret + * @returns Middleware function managing session state + * @throws {Deno.errors.InvalidData} When options fail validation */ static create(options: Types.SessionOptions): Types.MiddlewareFn { - if (!options.cookieSecret || options.cookieSecret.length < 32) { + if (options.secret.length < 32) { throw new Deno.errors.InvalidData( - 'Session cookieSecret must be at least 32 characters for HMAC-SHA256 security' + 'Session secret must be at least 32 characters for HMAC-SHA256 strength' ) } - const maxAge = Core.Handler.assertPositiveFinite( - options.maxAge ?? Core.Constant.defaultSessionOptions.maxAge, - 'Session maxAge', - 'seconds' - ) - const path = options.path ?? Core.Constant.defaultSessionOptions.path - if (!path) { - throw new Deno.errors.InvalidData('Session path must be a non-empty string') + const maxAge = options.maxAge ?? Core.Constant.defaultSessionOptions.maxAge + if (!Number.isInteger(maxAge) || maxAge <= 0) { + throw new Deno.errors.InvalidData('Session maxAge must be a positive whole number of seconds') } const sameSite = options.sameSite ?? Core.Constant.defaultSessionOptions.sameSite const secure = options.secure ?? Core.Constant.defaultSessionOptions.secure if (sameSite === 'None' && !secure) { throw new Deno.errors.InvalidData( - 'Session SameSite=None requires secure=true, browsers reject insecure SameSite=None cookies' + 'Session SameSite None requires secure true, browsers reject insecure SameSite None cookies' ) } - const sessionOptions: Types.SessionCookieOpts = { - cookieName: options.cookieName ?? Core.Constant.defaultSessionOptions.cookieName, - maxAge, - path, - sameSite, - httpOnly: options.httpOnly ?? Core.Constant.defaultSessionOptions.httpOnly, - secure - } - let hmacKeyPromise: Promise | null = null - const getHmacKey = (): Promise => { - hmacKeyPromise ??= Core.API.subtle.importKey( + const name = options.name ?? Core.Constant.defaultSessionOptions.name + const path = options.path ?? Core.Constant.defaultSessionOptions.path + const httpOnly = options.httpOnly ?? Core.Constant.defaultSessionOptions.httpOnly + const cookieInit: Types.CookieInit = { httpOnly, maxAge, path, sameSite, secure } + let signingKey: Promise | null = null + const loadKey = (): Promise => { + signingKey ??= Core.API.subtle.importKey( 'raw', - Core.Constant.encoder.encode(options.cookieSecret), + Core.Constant.encoder.encode(options.secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'] ) - return hmacKeyPromise + return signingKey } - return Middleware.WrapMware('Session error', async (ctx, next) => { - const key = await getHmacKey() - const cookieValue = ctx.cookie(sessionOptions.cookieName) - let sessionState: Types.DataRecord | null = null - if (cookieValue) { + return Middleware.Wrap.apply('session', async (ctx, next) => { + const key = await loadKey() + const cookieValue = ctx.get.cookie(name) + let sessionState: Types.SessionData | null = null + if (cookieValue !== undefined) { const decoded = await Session.decodePayload(cookieValue, key, maxAge) if ('data' in decoded) { sessionState = decoded.data } else { - ctx[Core.InternalContext].emitEvent( + Core.Context.internalOf(ctx).emitEvent( Core.Observability.internalEvent('session:invalid', { - cookieName: sessionOptions.cookieName, + cookieName: name, reason: decoded.reason }) ) } } - ctx[Core.InternalContext].setInternalState( - Core.Handler.stateKeys.session, - sessionState - ) - ctx[Core.InternalContext].setInternalState( - Core.Handler.stateKeys.setSession, - async (sessionData: Types.DataRecord) => { - const encodedPayload = await Session.encodePayload(sessionData, key) - ctx.setHeader( - 'Set-Cookie', - Session.setCookieHeader(sessionOptions.cookieName, encodedPayload, sessionOptions) - ) + Core.Context.internalOf(ctx).installSession({ + state: sessionState, + write: async (data) => { + if (data === null) { + ctx.set.cookie(name, '', { ...cookieInit, maxAge: 0 }) + return + } + const encoded = await Session.encodePayload(data, key) + ctx.set.cookie(name, encoded, cookieInit) } - ) - ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.clearSession, () => { - ctx.setHeader( - 'Set-Cookie', - Session.clearCookieHeader(sessionOptions.cookieName, sessionOptions.path) - ) }) return await next() }) } /** - * Decode base64url to bytes. - * @description Converts base64url string to Uint8Array with padding. - * @param encodedStr - Base64url encoded string - * @returns Decoded byte array - */ - private static base64UrlDecode(encodedStr: string): Uint8Array { - const base64 = encodedStr.replace(/[-_]/g, (sourceChar) => sourceChar === '-' ? '+' : '/') - const padLength = base64.length % 4 - const padded = padLength ? base64 + '='.repeat(4 - padLength) : base64 - const binary = atob(padded) - const bytes = new Uint8Array(binary.length) - for (let charIndex = 0; charIndex < binary.length; charIndex++) { - bytes[charIndex] = binary.charCodeAt(charIndex) - } - return bytes - } - - /** - * Encode bytes to base64url. - * @description Converts Uint8Array to base64url string without padding. - * @param bytes - Raw byte array to encode - * @returns Base64url encoded string - */ - private static base64UrlEncode(bytes: Uint8Array): string { - let binary = '' - for (let charIndex = 0; charIndex < bytes.length; charIndex++) { - binary += String.fromCharCode(bytes[charIndex]!) - } - return btoa(binary).replace( - /[+/=]/g, - (sourceChar) => sourceChar === '+' ? '-' : sourceChar === '/' ? '_' : '' - ) - } - - /** - * Build cookie header to clear session. - * @description Header string Max-Age=0 for name and path. - * @param cookieName - Cookie name to clear - * @param path - Cookie path - * @returns Set-Cookie header string - */ - private static clearCookieHeader(cookieName: string, path: string): string { - return `${encodeURIComponent(cookieName)}=; Path=${path}; Max-Age=0` - } - - /** - * Decode and verify signed session payload. - * @description Returns data on success, or a failure reason. - * @param encodedValue - Cookie value payload dot signature base64url - * @param key - CryptoKey for HMAC verification - * @param maxAge - Maximum age in seconds for server-side expiry - * @returns Session data or a discriminated failure reason + * Decode and verify session payload. + * @description Verifies signature and checks expiry window. + * @param value - Raw signed cookie value + * @param key - HMAC key for verification + * @param maxAge - Maximum session age in seconds + * @returns Decode result with data or reason */ private static async decodePayload( - encodedValue: string, + value: string, key: CryptoKey, maxAge: number ): Promise { try { - const decodedValue = encodedValue.includes('%') - ? decodeURIComponent(encodedValue) - : encodedValue - const dotIndex = decodedValue.lastIndexOf('.') - if (dotIndex <= 0 || dotIndex === decodedValue.length - 1) { + const dotIndex = value.lastIndexOf('.') + if (dotIndex <= 0 || dotIndex === value.length - 1) { return { reason: 'malformed' } } - const payloadB64 = decodedValue.slice(0, dotIndex) - const signatureB64 = decodedValue.slice(dotIndex + 1) - const signatureBytes = Session.base64UrlDecode(signatureB64) - const isValid = await Core.API.subtle.verify( + const payloadPart = value.slice(0, dotIndex) + const signaturePart = value.slice(dotIndex + 1) + const signatureBytes = Uint8Array.fromBase64(signaturePart, Session.base64UrlOptions) + const isAuthentic = await Core.API.subtle.verify( 'HMAC', key, - new Uint8Array(signatureBytes).buffer as ArrayBuffer, - Core.Constant.encoder.encode(payloadB64) + signatureBytes, + Core.Constant.encoder.encode(payloadPart) ) - if (!isValid) { + if (!isAuthentic) { return { reason: 'tampered' } } - const payloadBytes = Session.base64UrlDecode(payloadB64) - const payloadStr = Core.Constant.decoder.decode(payloadBytes) - const parsedPayload = Core.API.jsonParse(payloadStr) as Types.DataRecord - const issuedAt = parsedPayload['_iat'] + const payloadBytes = Uint8Array.fromBase64(payloadPart, Session.base64UrlOptions) + const parsed = Core.API.jsonParse( + Core.Constant.decoder.decode(payloadBytes) + ) as Types.SessionData + const issuedAt = parsed['_iat'] if (typeof issuedAt !== 'number' || Math.floor(Date.now() / 1000) - issuedAt > maxAge) { return { reason: 'expired' } } - const { _iat, ...sessionData } = parsedPayload - return { data: sessionData } + delete parsed['_iat'] + return { data: parsed } } catch { return { reason: 'malformed' } } } /** - * Encode and sign session with HMAC. - * @description Embeds _iat timestamp, returns payload dot signature. - * @param sessionData - Session object to encode - * @param key - CryptoKey for HMAC signing - * @returns Signed cookie value string + * Encode and sign session payload. + * @description Stamps issue time and appends HMAC signature. + * @param data - Session data to encode + * @param key - HMAC key for signing + * @returns Signed base64url cookie value */ - private static async encodePayload( - sessionData: Types.DataRecord, - key: CryptoKey - ): Promise { - const stampedPayload = { ...sessionData, _iat: Math.floor(Date.now() / 1000) } - const payloadStr = Core.API.jsonStringify(stampedPayload) - const payloadBytes = Core.Constant.encoder.encode(payloadStr) - const payloadB64 = Session.base64UrlEncode(payloadBytes) - const signatureBuffer = await Core.API.subtle.sign( + private static async encodePayload(data: Types.SessionData, key: CryptoKey): Promise { + const stamped = { ...data, _iat: Math.floor(Date.now() / 1000) } + const payloadPart = Core.Constant.encoder.encode(Core.API.jsonStringify(stamped)) + .toBase64(Session.base64UrlOptions) + const signature = await Core.API.subtle.sign( 'HMAC', key, - Core.Constant.encoder.encode(payloadB64) + Core.Constant.encoder.encode(payloadPart) ) - const signatureB64 = Session.base64UrlEncode(new Uint8Array(signatureBuffer)) - return `${payloadB64}.${signatureB64}` - } - - /** - * Build Set-Cookie header with value. - * @description Joins name value Path Max-Age SameSite HttpOnly. - * @param name - Cookie name - * @param value - Encoded payload - * @param opts - Cookie opts path maxAge sameSite httpOnly - * @returns Set-Cookie header string - */ - private static setCookieHeader( - name: string, - value: string, - opts: Types.SessionCookieOpts - ): string { - const cookieParts = [ - `${encodeURIComponent(name)}=${encodeURIComponent(value)}`, - `Path=${opts.path}`, - `Max-Age=${opts.maxAge}`, - `SameSite=${opts.sameSite}` - ] - if (opts.httpOnly) { - cookieParts.push('HttpOnly') - } - if (opts.secure) { - cookieParts.push('Secure') - } - return cookieParts.join('; ') + const signaturePart = new Uint8Array(signature).toBase64(Session.base64UrlOptions) + return `${payloadPart}.${signaturePart}` } } diff --git a/src/middleware/Validate.ts b/src/middleware/Validate.ts new file mode 100644 index 0000000..b19acfa --- /dev/null +++ b/src/middleware/Validate.ts @@ -0,0 +1,96 @@ +import type * as Types from '@interfaces/index.ts' +import type { ContractFn } from '@neabyte/typebox' +import * as Core from '@core/index.ts' +import * as Middleware from '@middleware/index.ts' + +/** + * Request validation middleware. + * @description Validates request sources against contracts. + */ +export class Validate { + /** Source readers mapping sources to getters */ + private static readonly sourceReaders: Types.SourceReaders = { + body: (get) => get.body(), + cookies: (get) => get.cookie(), + headers: (get) => get.header(), + query: (get) => get.query() + } + + /** + * Create validation middleware. + * @description Validates configured sources against schema contracts. + * @param schema - Validation schema keyed by source + * @returns Middleware function validating request data + * @throws {Deno.errors.InvalidData} When schema source is invalid + * @template SchemaType - Validation schema shape + */ + static check( + schema: SchemaType + ): Types.MiddlewareFn { + const sources = Object.keys(schema) as Types.ValidationSource[] + if (sources.length === 0) { + throw new Deno.errors.InvalidData('Validation schema needs at least one source contract') + } + for (const source of sources) { + if (!(source in Validate.sourceReaders)) { + throw new Deno.errors.InvalidData( + `Validation source "${source}" is not supported, use body cookies headers or query` + ) + } + } + return Middleware.Wrap.apply('validate', async (ctx, next) => { + const validated: Record = {} + for (const source of sources) { + const contract = schema[source]! + const input = await Validate.sourceReaders[source](ctx.get) + try { + validated[source] = Validate.runContract(contract, input) + } catch (caught) { + const reasons = Validate.reasonsFrom(caught) + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('validate:failed', { source, reasons }) + ) + throw caught + } + } + Core.Context.internalOf(ctx).installValidated({ value: validated }) + return await next() + }) + } + + /** + * Extract failure reasons from error. + * @description Reads cause array, message, or default reason. + * @param caught - Caught error value to inspect + * @returns List of failure reason strings + */ + private static reasonsFrom(caught: unknown): readonly string[] { + if (caught instanceof Error && Array.isArray(caught.cause)) { + const reasons = caught.cause.filter((reason): reason is string => typeof reason === 'string') + if (reasons.length > 0) { + return reasons + } + } + if (caught instanceof Error && caught.message.length > 0) { + return [caught.message] + } + return ['validation failed'] + } + + /** + * Run a contract against input value. + * @description Throws status error when validation fails. + * @param contract - Contract function to execute + * @param input - Raw input value to validate + * @returns Validated value from the contract + * @throws {Error} Status error when contract rejects input + */ + private static runContract(contract: ContractFn, input: unknown) { + try { + return (contract as (value: never) => unknown)(input as never) + } catch (caught) { + const reasons = Validate.reasonsFrom(caught) + throw Core.Handler.createStatusError(422, reasons.join('; '), reasons) + } + } +} diff --git a/src/middleware/Validator.ts b/src/middleware/Validator.ts index 9d421a3..38c7794 100644 --- a/src/middleware/Validator.ts +++ b/src/middleware/Validator.ts @@ -1,47 +1,13 @@ import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' import * as Middleware from '@middleware/index.ts' -import * as Validation from '@validation/index.ts' -import type { ContractFn } from '@neabyte/typebox' +import { Define } from '@neabyte/typebox' /** - * Request validation middleware for sources. - * @description Runs source contracts, stores validated data on context. + * Validator factory collection. + * @description Exposes validation check and schema define. */ -export class Validator { - /** - * Create validation middleware from schema. - * @description Validates each source contract, stores result on context. - * @param schema - Per-source validation contracts - * @returns Middleware that validates request sources - * @throws {Deno.errors.InvalidData} When schema has no source contract - * @throws {Deno.errors.InvalidData} When schema validates params, unavailable before routing - */ - static create(schema: Types.ValidationSchema): Types.MiddlewareFn { - const entries = Object.entries(schema).filter( - (entry): entry is [Types.ValidationSource, ContractFn] => typeof entry[1] === 'function' - ) - if (entries.length === 0) { - throw new Deno.errors.InvalidData('Validator requires at least one source contract') - } - if (entries.some(([source]) => source === 'params')) { - throw new Deno.errors.InvalidData( - 'Validator cannot validate params in middleware, route params resolve after middleware runs, validate them inside the handler with Validator.check(contract, ctx.params())' - ) - } - return Middleware.WrapMware('Validation error', async (ctx, next) => { - const existing = ctx.getState(Core.Handler.stateKeys.validated) - const validated: Types.DataRecord = { ...existing } - for (const [source, contract] of entries) { - const input = await Validation.Source.extract(source, ctx) - try { - validated[source] = contract(input as never) - } catch (error) { - throw Validation.Reason.toStatusError(error) - } - } - ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.validated, validated) - return await next() - }) - } -} +export const Validator = { + check: (schema: SchemaType): Types.MiddlewareFn => + Middleware.Validate.check(schema), + define: Define +} as const diff --git a/src/middleware/WebSocket.ts b/src/middleware/WebSocket.ts index 53d0843..940b881 100644 --- a/src/middleware/WebSocket.ts +++ b/src/middleware/WebSocket.ts @@ -1,11 +1,10 @@ import type * as Types from '@interfaces/index.ts' -import type * as CoreTypes from '@core/index.ts' import * as Core from '@core/index.ts' import * as Middleware from '@middleware/index.ts' /** * WebSocket upgrade middleware. - * @description Upgrades request on path, calls connect/message/close/error. + * @description Upgrades matching requests and binds lifecycle callbacks. */ export class WebSocket { /** @@ -18,77 +17,99 @@ export class WebSocket { const rawListener = options.listener ?? '' const listener = rawListener.length > 1 ? rawListener.replace(/\/+$/, '') : rawListener const allowedOrigins = options.allowedOrigins - return Middleware.WrapMware( - 'WebSocket upgrade failed', - async (ctx: CoreTypes.Context, next) => { - if (!listener) { - return await next() - } - if (ctx.header('upgrade')?.toLowerCase() !== 'websocket') { - return await next() - } - if (ctx.request.method !== 'GET') { - return await next() - } - if ( - listener !== '/' && - ctx.pathname !== listener && - !ctx.pathname.startsWith(listener + '/') - ) { - return await next() - } - if (!WebSocket.isOriginAllowed(ctx, allowedOrigins)) { - return await ctx.handleError( - 403, - new Deno.errors.PermissionDenied( - 'WebSocket handshake rejected because the Origin is not allowed' - ) - ) - } - let upgrade: ReturnType - try { - upgrade = Deno.upgradeWebSocket(ctx.request) - } catch (upgradeError) { - return await ctx.handleError( - 400, - new Deno.errors.InvalidData( - `WebSocket handshake is malformed because ${ - upgradeError instanceof Error ? upgradeError.message : String(upgradeError) - }` - ) + return Middleware.Wrap.apply('websocket', async (ctx, next) => { + if (!listener) { + return await next() + } + if (ctx.get.header('upgrade')?.toLowerCase() !== 'websocket') { + return await next() + } + if (ctx.get.method() !== 'GET') { + return await next() + } + if ( + listener !== '/' && + ctx.get.pathname() !== listener && + !ctx.get.pathname().startsWith(`${listener}/`) + ) { + return await next() + } + if (!WebSocket.isOriginAllowed(ctx, allowedOrigins)) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('websocket:rejected', { reason: 'origin' }) + ) + return await ctx.handleError( + 403, + new Deno.errors.PermissionDenied( + 'WebSocket handshake rejected because the Origin is not allowed' ) - } - const { socket, response } = upgrade - socket.addEventListener('open', (event) => { - options.onConnect?.(socket, event, ctx) - }) - socket.addEventListener('message', (event) => { - options.onMessage?.(socket, event, ctx) - }) - socket.addEventListener('close', (event) => { - options.onDisconnect?.(socket, event, ctx) - }) - socket.addEventListener('error', (event) => { - options.onError?.(socket, event, ctx) + ) + } + const version = ctx.get.header('sec-websocket-version')?.trim() + if (version === undefined) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('websocket:rejected', { reason: 'version' }) + ) + return await ctx.handleError( + 400, + new Deno.errors.InvalidData('WebSocket handshake requires Sec-WebSocket-Version 13') + ) + } + if (version !== '13') { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('websocket:rejected', { reason: 'version' }) + ) + return ctx.send.custom(null, { + status: 426, + headers: { 'Sec-WebSocket-Version': '13', 'Upgrade': 'websocket' } }) - return response } - ) + let upgrade: ReturnType + try { + upgrade = Deno.upgradeWebSocket(ctx.get.request()) + } catch (upgradeError) { + Core.Context.internalOf(ctx).emitEvent( + Core.Observability.internalEvent('websocket:rejected', { reason: 'malformed' }) + ) + return await ctx.handleError( + 400, + new Deno.errors.InvalidData( + `WebSocket handshake is malformed because ${ + upgradeError instanceof Error ? upgradeError.message : String(upgradeError) + }` + ) + ) + } + const socket = upgrade.socket + socket.addEventListener('open', (event) => { + options.onConnect?.(socket, event, ctx) + }) + socket.addEventListener('message', (event) => { + options.onMessage?.(socket, event, ctx) + }) + socket.addEventListener('close', (event) => { + options.onDisconnect?.(socket, event, ctx) + }) + socket.addEventListener('error', (event) => { + options.onError?.(socket, event, ctx) + }) + return upgrade.response + }) } /** - * Decide whether handshake Origin is allowed. - * @description Validates Origin, missing Origin allowed only without configured policy. - * @param ctx - Request context - * @param allowedOrigins - Configured allowlist, '*', or undefined for same-origin + * Check whether handshake Origin is allowed. + * @description Missing Origin passes only when no policy is configured. + * @param ctx - Request context instance + * @param allowedOrigins - Configured allowlist, wildcard, or undefined for same-origin * @returns True when the handshake may proceed */ private static isOriginAllowed( - ctx: CoreTypes.Context, + ctx: Parameters[0], allowedOrigins: readonly string[] | '*' | undefined ): boolean { - const requestOrigin = ctx.header('origin') - if (!requestOrigin) { + const requestOrigin = ctx.get.header('origin') + if (requestOrigin === undefined) { return allowedOrigins === undefined } if (allowedOrigins === '*') { @@ -98,7 +119,7 @@ export class WebSocket { return allowedOrigins.includes(requestOrigin) } try { - return new Core.API.URL(ctx.request.url).origin === requestOrigin + return new Core.API.URL(ctx.get.request().url).origin === requestOrigin } catch { return false } diff --git a/src/middleware/Wrap.ts b/src/middleware/Wrap.ts new file mode 100644 index 0000000..f5c212a --- /dev/null +++ b/src/middleware/Wrap.ts @@ -0,0 +1,27 @@ +import type * as Types from '@interfaces/index.ts' +import * as Core from '@core/index.ts' + +/** + * Middleware error wrapper utility. + * @description Catches errors and prefixes label to message. + */ +export class Wrap { + /** + * Wrap middleware with error handling. + * @description Catches thrown errors and routes to handler. + * @param label - Middleware name for message prefix + * @param middleware - Middleware function to wrap + * @returns Wrapped middleware function + */ + static apply(label: string, middleware: Types.MiddlewareFn): Types.MiddlewareFn { + return async (ctx, next) => { + try { + return await middleware(ctx, next) + } catch (caught) { + const extracted = Core.Handler.extractError(caught) + extracted.error.message = `[${label}] ${extracted.error.message}` + return await ctx.handleError(extracted.statusCode, extracted.error) + } + } + } +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts index a4ff637..2d99111 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,59 +1,13 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' -import * as Loader from '@middleware/Loaders.ts' -import { Immutable } from '@neabyte/utils-core' - -/** - * Prebuilt middleware factories. - * @description Common middleware creators for auth, CORS, session. - */ -export const Mware = { - /** Basic Auth middleware factory */ - basicAuth: (options: Types.BasicAuthOptions): Types.MiddlewareFn => - Loader.BasicAuth.create(options), - /** Body size limit middleware factory */ - bodyLimit: (options: Types.BodyLimitOptions): Types.MiddlewareFn => - Loader.BodyLimit.create(options), - /** CORS middleware factory */ - cors: (options?: Types.CorsOptions): Types.MiddlewareFn => Loader.Cors.create(options), - /** CSRF middleware factory */ - csrf: (options?: Types.CsrfOptions): Types.MiddlewareFn => Loader.CSRF.create(options), - /** IP restriction middleware factory */ - ip: (options: Types.IpOptions): Types.MiddlewareFn => Loader.IP.create(options), - /** Security headers middleware factory */ - securityHeaders: (options?: Types.SecurityHeadersOptions): Types.MiddlewareFn => - Loader.SecHeaders.create(options), - /** Session middleware factory */ - session: (options: Types.SessionOptions): Types.MiddlewareFn => Loader.Session.create(options), - /** Validation middleware factory */ - validator: (schema: Types.ValidationSchema): Types.MiddlewareFn => - Loader.Validator.create(schema), - /** WebSocket upgrade middleware factory */ - websocket: (options?: Types.WebSocketOptions): Types.MiddlewareFn => - Loader.WebSocket.create(options) -} - -/** - * Wrap middleware with try/catch and label. - * @description Catches errors and calls ctx.handleError, preserves original error. - * @param label - Context label for error diagnostics - * @param middleware - Middleware to run - * @returns Middleware that delegates and catches - */ -export function WrapMware(label: string, middleware: Types.MiddlewareFn): Types.MiddlewareFn { - return async (ctx, next) => { - try { - return await middleware(ctx, next) - } catch (error) { - const extracted = Core.Handler.extractError(error) - extracted.error.message = `[${label}] ${extracted.error.message}` - return await ctx.handleError(extracted.statusCode, extracted.error) - } - } -} - -/** Freeze Mware factory registry */ -Immutable.harden(Mware) - -/** Re-exports middleware public API. */ -export * from '@middleware/Loaders.ts' +/** Re-exports middleware public API */ +export * from '@middleware/BasicAuth.ts' +export * from '@middleware/BodyLimit.ts' +export * from '@middleware/CORS.ts' +export * from '@middleware/CSRF.ts' +export * from '@middleware/IP.ts' +export * from '@middleware/Mware.ts' +export * from '@middleware/SecurityHeaders.ts' +export * from '@middleware/Session.ts' +export * from '@middleware/Validate.ts' +export * from '@middleware/Validator.ts' +export * from '@middleware/WebSocket.ts' +export * from '@middleware/Wrap.ts' diff --git a/src/rendering/Discover.ts b/src/rendering/Discover.ts deleted file mode 100644 index af7fa77..0000000 --- a/src/rendering/Discover.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as Core from '@core/index.ts' - -/** - * Scan directory for .dve templates. - * @description Collects relative paths for Engine validation. - */ -export class Discover { - /** - * Discover .dve template paths. - * @description Recursively scans viewsDir for templates. - * @param viewsDir - Root directory to scan - * @returns Set of relative template paths - */ - static async discoverPaths(viewsDir: string): Promise> { - const collectedPaths = new Set() - await Discover.collectPaths(viewsDir, '', collectedPaths) - return collectedPaths - } - - /** - * Collect .dve paths recursively. - * @description Walks directories and accumulates relative paths. - * @param targetDir - Directory to read - * @param basePath - Current relative path prefix - * @param collectedPaths - Set to add discovered paths - */ - private static async collectPaths( - targetDir: string, - basePath: string, - collectedPaths: Set - ): Promise { - try { - for await (const dirEntry of Deno.readDir(targetDir)) { - const fullPath = `${targetDir}/${dirEntry.name}` - const relativePath = basePath ? `${basePath}/${dirEntry.name}` : dirEntry.name - if (dirEntry.isDirectory) { - await Discover.collectPaths(fullPath, relativePath, collectedPaths) - } else if ( - dirEntry.isFile && relativePath.toLowerCase().endsWith(Core.Constant.dveExtension) - ) { - collectedPaths.add(relativePath) - } - } - } catch (error) { - if (!(error instanceof Deno.errors.NotFound)) { - throw error - } - } - } -} diff --git a/src/rendering/Engine.ts b/src/rendering/Engine.ts deleted file mode 100644 index f70f210..0000000 --- a/src/rendering/Engine.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' -import * as Rendering from '@rendering/index.ts' -import * as EngineParts from '@rendering/engine/index.ts' - -/** - * Template rendering engine. - * @description Compiles and renders DVE templates with cache. - */ -export class Engine implements Types.ViewEngine, Types.WatchableEngine { - /** Default views directory */ - private readonly defaultViewsDir: string - /** Max iterations per #each block */ - private readonly maxIterations: number - /** Max total #each body executions per render */ - private readonly maxRenderIterations: number - /** Max total output characters per render */ - private readonly maxOutputSize: number - /** Compiled template cache */ - private readonly compileCache = new Map() - /** Optional lifecycle event emitter */ - private readonly emit: Types.EventEmit | undefined - /** Discovered template path cache */ - private discoveredPaths: Set | null = null - - /** - * Create new engine instance. - * @description Stores default viewsDir from options. - * @param options - Engine configuration options - */ - constructor(options: Types.EngineOptions) { - this.defaultViewsDir = options.viewsDir - this.maxIterations = options.maxIterations ?? Core.Constant.defaultMaxIterations - this.maxRenderIterations = options.maxRenderIterations ?? - Core.Constant.defaultMaxRenderIterations - this.maxOutputSize = options.maxOutputSize ?? Core.Constant.defaultMaxOutputSize - this.emit = options.emit - } - - /** Views directory for path resolution */ - get viewsDir(): string { - return this.defaultViewsDir - } - - /** - * Invalidate cached template by absolute path. - * @description Clears file and compile caches for the path. - * @param absPath - Absolute template file path - */ - invalidateFile(absPath: string): void { - this.compileCache.delete(absPath) - } - - /** - * Emit view:refreshed for changed paths. - * @description Called by watcher after invalidating changed paths. - * @param paths - Absolute paths that were refreshed - */ - notifyRefresh(paths: readonly string[]): void { - this.emit?.(Core.Observability.internalEvent('view:refreshed', { paths: [...paths] })) - } - - /** Reset discovered template paths */ - refreshPaths(): void { - this.discoveredPaths = null - } - - /** - * Render template with data. - * @description Loads template and produces final HTML. - * @param templatePath - Relative template path - * @param data - Template scope data - * @param depth - Current include nesting depth - * @returns Rendered HTML string - * @throws {Deno.errors.NotFound} When template path not discovered - * @throws {Deno.errors.InvalidData} When include depth exceeded - */ - async render( - templatePath: string, - data: Types.DataRecord = {}, - depth = 0, - budget?: Types.RenderBudget - ): Promise { - if (depth > Core.Constant.maxIncludeDepth) { - throw new Deno.errors.InvalidData( - `Template include depth exceeded ${Core.Constant.maxIncludeDepth} for "${templatePath}"` - ) - } - const renderStart = depth === 0 ? performance.now() : 0 - const renderBudget = budget ?? { iterations: 0, outputSize: 0 } - if (depth > 0) { - const compiled = await this.resolveTemplate(templatePath) - return await this.renderNodes(compiled.ast, data, this.defaultViewsDir, depth, renderBudget) - } - try { - const compiled = await this.resolveTemplate(templatePath) - const outputHtml = await this.renderNodes( - compiled.ast, - data, - this.defaultViewsDir, - depth, - renderBudget - ) - this.emit?.( - Core.Observability.internalEvent('view:rendered', { - path: templatePath, - durationMs: performance.now() - renderStart - }) - ) - return outputHtml - } catch (renderError) { - const error = renderError instanceof Error ? renderError : new Error(String(renderError)) - this.emit?.(Core.Observability.internalEvent('view:error', { path: templatePath, error })) - throw renderError - } - } - - /** - * Render template with streaming. - * @description Resolves template up front, then streams compiled AST. - * @param templatePath - Relative template path - * @param data - Template scope data - * @returns Promise resolving to a ReadableStream with HTML content - * @throws {Deno.errors.NotFound} When template not found - * @throws {Deno.errors.InvalidData} When the template fails to compile - */ - async streamRender(templatePath: string, data: Types.DataRecord = {}): Promise { - const compiled = await this.resolveTemplate(templatePath) - const { readable, writable } = new TransformStream() - this.renderStream(compiled, templatePath, data, writable).catch((error: Error) => { - this.emit?.(Core.Observability.internalEvent('view:error', { path: templatePath, error })) - }) - return readable - } - - /** - * Compile template and cache. - * @description Parses template text into AST nodes. - * @param absTemplatePath - Absolute path to template - * @returns Compile result with AST - */ - private async compileTemplate(absTemplatePath: string): Promise { - const cachedCompile = this.compileCache.get(absTemplatePath) - if (cachedCompile) { - return cachedCompile - } - const compileStart = performance.now() - const template = await Deno.readTextFile(absTemplatePath) - const ast = EngineParts.Parser.parse(template) - const compileResult = { ast } - this.compileCache.set(absTemplatePath, compileResult) - this.emit?.( - Core.Observability.internalEvent('view:compiled', { - path: absTemplatePath, - durationMs: performance.now() - compileStart - }) - ) - return compileResult - } - - /** - * Render node to chunk. - * @description Renders individual node to HTML chunk. - * @param node - AST node to render - * @param data - Template scope data - * @param viewsDir - Root directory for includes - * @param depth - Current include nesting depth - * @param budget - Per-render cumulative resource budget - * @returns HTML chunk string or null - */ - private async renderChunk( - node: Types.AstNode, - data: Types.DataRecord, - viewsDir: string, - depth: number, - budget: Types.RenderBudget - ): Promise { - if (node.type === 'text') { - return node.value - } - if (node.type === 'var') { - const lookupValue = EngineParts.Eval.evaluate(node.path, data) - const stringValue = lookupValue === null || lookupValue === undefined - ? '' - : String(lookupValue) - return node.raw ? stringValue : EngineParts.Utils.escape(stringValue) - } - if (node.type === 'include') { - return await this.render(node.templatePath, data, depth + 1, budget) - } - if (node.type === 'if') { - const lookupValue = EngineParts.Eval.evaluate(node.path, data) - const nodes = lookupValue ? node.thenNodes : node.elseNodes - return await this.renderNodes(nodes, data, viewsDir, depth, budget) - } - if (node.type === 'each') { - const lookupValue = EngineParts.Eval.evaluate(node.path, data) - if (!Array.isArray(lookupValue)) { - return null - } - if (lookupValue.length > this.maxIterations) { - throw new Deno.errors.InvalidData( - `Template #each exceeded ${this.maxIterations} iterations (got ${lookupValue.length})` - ) - } - const length = lookupValue.length - budget.iterations += length - if (budget.iterations > this.maxRenderIterations) { - throw new Deno.errors.InvalidData( - `Template render exceeded ${this.maxRenderIterations} total iterations` - ) - } - let outputHtml = '' - for (let index = 0; index < length; index++) { - const item = lookupValue[index] - const scopeData: Types.DataRecord = { - ...data, - [node.itemName]: item, - '@index': index, - '@first': index === 0, - '@last': index === length - 1, - '@length': length - } - outputHtml += await this.renderNodes(node.nodes, scopeData, viewsDir, depth, budget) - } - return outputHtml - } - return null - } - - /** - * Render AST nodes to HTML. - * @description Evaluates variables, includes, and blocks. - * @param ast - Parsed template AST nodes - * @param data - Current scope data - * @param viewsDir - Root directory for includes - * @param depth - Current include nesting depth - * @param budget - Per-render cumulative resource budget - * @returns Rendered HTML string - */ - private async renderNodes( - ast: readonly Types.AstNode[], - data: Types.DataRecord, - viewsDir: string, - depth: number, - budget: Types.RenderBudget - ): Promise { - let outputHtml = '' - for (const node of ast) { - const chunk = await this.renderChunk(node, data, viewsDir, depth, budget) - if (chunk) { - budget.outputSize += chunk.length - if (budget.outputSize > this.maxOutputSize) { - throw new Deno.errors.InvalidData( - `Template render exceeded ${this.maxOutputSize} output characters` - ) - } - outputHtml += chunk - } - } - return outputHtml - } - - /** - * Render compiled template nodes to stream. - * @description Streams HTML output progressively from an already-compiled AST. - * @param compiled - Pre-resolved compiled template - * @param templatePath - Relative template path for events - * @param data - Template scope data - * @param writable - Writable stream for output - */ - private async renderStream( - compiled: Types.CompileResult, - templatePath: string, - data: Types.DataRecord, - writable: WritableStream - ): Promise { - const writer = writable.getWriter() - const renderStart = performance.now() - const budget: Types.RenderBudget = { iterations: 0, outputSize: 0 } - try { - for (const node of compiled.ast) { - const chunk = await this.renderChunk(node, data, this.defaultViewsDir, 0, budget) - if (chunk) { - budget.outputSize += chunk.length - if (budget.outputSize > this.maxOutputSize) { - throw new Deno.errors.InvalidData( - `Template render exceeded ${this.maxOutputSize} output characters` - ) - } - await writer.write(Core.Constant.encoder.encode(chunk)) - } - } - this.emit?.( - Core.Observability.internalEvent('view:rendered', { - path: templatePath, - durationMs: performance.now() - renderStart - }) - ) - } finally { - await writer.close() - } - } - - /** - * Resolve template path to compiled result. - * @description Discovers paths, normalizes, validates, and compiles. - * @param templatePath - Relative template path - * @returns Compiled template with AST - * @throws {Deno.errors.NotFound} When template not found - */ - private async resolveTemplate(templatePath: string): Promise { - if (this.discoveredPaths === null) { - this.discoveredPaths = await Rendering.Discover.discoverPaths(this.defaultViewsDir) - } - const normalizedPath = templatePath.replace(/\\/g, '/') - const pathWithExtension = normalizedPath.toLowerCase().endsWith(Core.Constant.dveExtension) - ? normalizedPath - : `${normalizedPath}${Core.Constant.dveExtension}` - if (!this.discoveredPaths.has(pathWithExtension)) { - throw new Deno.errors.NotFound(`Template "${templatePath}" not found in views directory`) - } - const absPath = EngineParts.Utils.join(this.defaultViewsDir, pathWithExtension) - return await this.compileTemplate(absPath) - } -} diff --git a/src/rendering/Watcher.ts b/src/rendering/Watcher.ts deleted file mode 100644 index 2bf8373..0000000 --- a/src/rendering/Watcher.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' -import * as EngineParts from '@rendering/engine/index.ts' -import { Superwatcher } from '@neabyte/superwatcher' -import nodePath from 'node:path' - -/** - * File watcher for DVE templates. - * @description Watches viewsDir and invalidates Engine caches on change. - */ -export class Watcher { - /** - * Start watching template directory. - * @description Uses Superwatcher with cache invalidation. - * @param engine - Engine instance to invalidate - * @returns Stop handle releasing the watcher - */ - static watch(engine: Types.WatchableEngine): () => void { - const viewsDir = engine.viewsDir - const resolvedDir = nodePath.resolve(viewsDir) - if (!Core.Handler.isDirectory(resolvedDir)) { - return () => {} - } - const watcher = new Superwatcher({ - path: resolvedDir, - debounceMs: Core.Constant.templateDebounceMs, - ignore: [(path: string) => !path.endsWith(Core.Constant.dveExtension)], - onChange(events) { - const refreshedPaths: string[] = [] - for (const event of events) { - const relativePath = event.path.slice(resolvedDir.length + 1) - const absPath = EngineParts.Utils.join(viewsDir, relativePath) - engine.invalidateFile(absPath) - refreshedPaths.push(absPath) - } - if (events.length > 0) { - engine.refreshPaths() - engine.notifyRefresh(refreshedPaths) - } - } - }) - watcher.start() - return () => watcher.dispose() - } -} diff --git a/src/rendering/engine/Eval.ts b/src/rendering/engine/Eval.ts deleted file mode 100644 index 50498d3..0000000 --- a/src/rendering/engine/Eval.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' -import * as EngineParts from '@rendering/engine/index.ts' - -/** - * DVE expression evaluator. - * @description Evaluates expression AST against scope object. - */ -export class Eval { - /** - * Evaluate expression in scope. - * @description Tokenizes, parses, and evaluates expression. - * @param expression - Expression source text - * @param scope - Scope data for identifiers - * @returns Evaluated expression value - * @throws {Deno.errors.InvalidData} When expression parse fails - */ - static evaluate(expression: string, scope: Types.DataRecord): unknown { - const trimmedExpression = expression.trim() - if (!trimmedExpression) { - return undefined - } - if (Core.Constant.simplePathRegex.test(trimmedExpression)) { - return EngineParts.Utils.lookup(scope, trimmedExpression) - } - const exprTokens = EngineParts.Tokenizer.tokenize(trimmedExpression) - const exprParser = new EngineParts.Expression(exprTokens) - const astNode = exprParser.parse() - exprParser.assertEnd() - return Eval.evalNode(astNode, scope) - } - - /** - * Evaluate binary expression node. - * @description Short-circuits logical ops, else applies the operator. - * @param exprNode - Binary AST node - * @param scope - Scope data for identifiers - * @returns Evaluated value - * @throws {Deno.errors.InvalidData} When the operator is not whitelisted - */ - private static evalBinary( - exprNode: Extract, - scope: Types.DataRecord - ): unknown { - if (exprNode.op === '&&') { - const leftValue = Eval.evalNode(exprNode.left, scope) - return leftValue ? Eval.evalNode(exprNode.right, scope) : leftValue - } - if (exprNode.op === '||') { - const leftValue = Eval.evalNode(exprNode.left, scope) - return leftValue ? leftValue : Eval.evalNode(exprNode.right, scope) - } - if (exprNode.op === '??') { - const leftValue = Eval.evalNode(exprNode.left, scope) - return leftValue === null || leftValue === undefined - ? Eval.evalNode(exprNode.right, scope) - : leftValue - } - const leftValue = Eval.evalNode(exprNode.left, scope) - const rightValue = Eval.evalNode(exprNode.right, scope) - switch (exprNode.op) { - case '===': - return leftValue === rightValue - case '!==': - return leftValue !== rightValue - case '==': - return leftValue == rightValue - case '!=': - return leftValue != rightValue - case '>': - return (leftValue as never) > (rightValue as never) - case '<': - return (leftValue as never) < (rightValue as never) - case '>=': - return (leftValue as never) >= (rightValue as never) - case '<=': - return (leftValue as never) <= (rightValue as never) - case '+': - return (leftValue as never) + (rightValue as never) - case '-': - return (leftValue as never) - (rightValue as never) - case '*': - return (leftValue as never) * (rightValue as never) - case '/': - return (leftValue as never) / (rightValue as never) - case '%': - return (leftValue as never) % (rightValue as never) - default: - throw new Deno.errors.InvalidData( - `Unsupported DVE binary operator "${(exprNode as Types.ExprOpCarrier).op}"` - ) - } - } - - /** - * Resolve identifier to keyword or scope. - * @description Keywords return literals, names read own scope properties. - * @param identName - Identifier name - * @param scope - Scope data for identifiers - * @returns Resolved value - */ - private static evalIdent(identName: string, scope: Types.DataRecord): unknown { - switch (identName) { - case 'true': - return true - case 'false': - return false - case 'null': - return null - case 'undefined': - return undefined - default: - return Object.hasOwn(scope, identName) ? scope[identName] : undefined - } - } - - /** - * Resolve member access to own property. - * @description Reads only own properties via Object.hasOwn. - * @param exprNode - Member AST node - * @param scope - Scope data for identifiers - * @returns Resolved value or undefined - */ - private static evalMember( - exprNode: Extract, - scope: Types.DataRecord - ): unknown { - const objectValue = Eval.evalNode(exprNode.object, scope) - return EngineParts.Utils.readOwn(objectValue, exprNode.property) - } - - /** - * Evaluate single AST node. - * @description Whitelist dispatch rejecting any unknown node type. - * @param exprNode - Expression AST node - * @param scope - Scope data for identifiers - * @returns Evaluated value - * @throws {Deno.errors.InvalidData} When the node type is not whitelisted - */ - private static evalNode(exprNode: Types.ExprNode, scope: Types.DataRecord): unknown { - switch (exprNode.type) { - case 'literal': - return exprNode.value - case 'ident': - return Eval.evalIdent(exprNode.name, scope) - case 'member': - return Eval.evalMember(exprNode, scope) - case 'unary': - return Eval.evalUnary(exprNode, scope) - case 'binary': - return Eval.evalBinary(exprNode, scope) - case 'ternary': - return Eval.evalNode(exprNode.test, scope) - ? Eval.evalNode(exprNode.consequent, scope) - : Eval.evalNode(exprNode.alternate, scope) - default: - throw new Deno.errors.InvalidData( - `Unsupported DVE expression node "${(exprNode as Types.ExprTypeCarrier).type}"` - ) - } - } - - /** - * Evaluate unary expression node. - * @description Rejects any operator outside the unary grammar. - * @param exprNode - Unary AST node - * @param scope - Scope data for identifiers - * @returns Evaluated value - * @throws {Deno.errors.InvalidData} When the operator is not whitelisted - */ - private static evalUnary( - exprNode: Extract, - scope: Types.DataRecord - ): unknown { - const argValue = Eval.evalNode(exprNode.arg, scope) - switch (exprNode.op) { - case '!': - return !argValue - case '+': - return typeof argValue === 'number' ? argValue : Number(argValue) - case '-': - return -(typeof argValue === 'number' ? argValue : Number(argValue)) - default: - throw new Deno.errors.InvalidData( - `Unsupported DVE unary operator "${(exprNode as Types.ExprOpCarrier).op}"` - ) - } - } -} diff --git a/src/rendering/engine/Expression.ts b/src/rendering/engine/Expression.ts deleted file mode 100644 index 3b924e9..0000000 --- a/src/rendering/engine/Expression.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type * as Types from '@interfaces/index.ts' - -/** - * DVE expression parser. - * @description Builds expression AST from token list. - */ -export class Expression { - /** Current token stream index */ - private tokenIndex = 0 - - /** - * Create parser for token list. - * @description Holds token array for parsing. - * @param tokens - Expression tokens from tokenizer - */ - constructor(private readonly tokens: Types.ExprToken[]) {} - - /** Assert no remaining tokens */ - assertEnd(): void { - if (this.tokenIndex < this.tokens.length) { - throw new Deno.errors.InvalidData('Unexpected token in DVE expression') - } - } - - /** Parse tokens into expression AST */ - parse(): Types.ExprNode { - return this.parseTry() - } - - /** Advance and return current token */ - private consume(): Types.ExprToken | undefined { - const currentToken = this.tokens[this.tokenIndex] - this.tokenIndex++ - return currentToken - } - - /** - * Consume token and require operator. - * @description Throws if current token is not given op. - * @param expectedOp - Expected operator string - * @throws {Deno.errors.InvalidData} When token is not the operator - */ - private expectOp(expectedOp: Types.TokenOp): void { - const currentToken = this.consume() - if (!currentToken || currentToken.kind !== 'op' || currentToken.value !== expectedOp) { - throw new Deno.errors.InvalidData(`Expected '${expectedOp}' in DVE expression`) - } - } - - /** - * Match and consume operator if present. - * @description Advances only when current op equals value. - * @param expectedOp - Operator to match - * @returns True when matched and consumed - */ - private matchOp(expectedOp: Types.TokenOp): boolean { - const currentToken = this.peek() - if (currentToken?.kind === 'op' && currentToken.value === expectedOp) { - this.tokenIndex++ - return true - } - return false - } - - /** Parse additive expression */ - private parseAdd(): Types.ExprNode { - let exprNode = this.parseMul() - while (true) { - const currentToken = this.peek() - if ( - currentToken?.kind === 'op' && - (currentToken.value === '+' || currentToken.value === '-') - ) { - this.consume() - const rightNode = this.parseMul() - exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode } - continue - } - return exprNode - } - } - - /** Parse logical AND expression */ - private parseAnd(): Types.ExprNode { - let exprNode = this.parseEq() - while (this.matchOp('&&')) { - const rightNode = this.parseEq() - exprNode = { type: 'binary', op: '&&', left: exprNode, right: rightNode } - } - return exprNode - } - - /** Parse equality expression */ - private parseEq(): Types.ExprNode { - let exprNode = this.parseRel() - while (true) { - const currentToken = this.peek() - if ( - currentToken?.kind === 'op' && - (currentToken.value === '===' || - currentToken.value === '!==' || - currentToken.value === '==' || - currentToken.value === '!=') - ) { - this.consume() - const rightNode = this.parseRel() - exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode } - continue - } - return exprNode - } - } - - /** Parse member access expression */ - private parseMem(): Types.ExprNode { - let exprNode = this.parsePrim() - while (true) { - if (this.matchOp('.')) { - const propToken = this.consume() - if (!propToken || propToken.kind !== 'ident') { - throw new Deno.errors.InvalidData('Expected identifier after "." in DVE expression') - } - exprNode = { type: 'member', object: exprNode, property: propToken.value } - continue - } - if (this.matchOp('?.')) { - const propToken = this.consume() - if (!propToken || propToken.kind !== 'ident') { - throw new Deno.errors.InvalidData('Expected identifier after "?." in DVE expression') - } - exprNode = { type: 'member', object: exprNode, property: propToken.value } - continue - } - return exprNode - } - } - - /** Parse multiplicative expression */ - private parseMul(): Types.ExprNode { - let exprNode = this.parseUn() - while (true) { - const currentToken = this.peek() - if ( - currentToken?.kind === 'op' && - (currentToken.value === '*' || currentToken.value === '/' || currentToken.value === '%') - ) { - this.consume() - const rightNode = this.parseUn() - exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode } - continue - } - return exprNode - } - } - - /** Parse nullish coalescing expression */ - private parseNil(): Types.ExprNode { - let exprNode = this.parseOr() - while (this.matchOp('??')) { - const rightNode = this.parseOr() - exprNode = { type: 'binary', op: '??', left: exprNode, right: rightNode } - } - return exprNode - } - - /** Parse logical OR expression */ - private parseOr(): Types.ExprNode { - let exprNode = this.parseAnd() - while (this.matchOp('||')) { - const rightNode = this.parseAnd() - exprNode = { type: 'binary', op: '||', left: exprNode, right: rightNode } - } - return exprNode - } - - /** Parse primary expression */ - private parsePrim(): Types.ExprNode { - const currentToken = this.consume() - if (!currentToken) { - throw new Deno.errors.InvalidData('Unexpected end of DVE expression') - } - if (currentToken.kind === 'number') { - return { type: 'literal', value: currentToken.value } - } - if (currentToken.kind === 'string') { - return { type: 'literal', value: currentToken.value } - } - if (currentToken.kind === 'ident') { - return { type: 'ident', name: currentToken.value } - } - if (currentToken.kind === 'op' && currentToken.value === '(') { - const innerNode = this.parse() - this.expectOp(')') - return innerNode - } - throw new Deno.errors.InvalidData('Invalid primary in DVE expression') - } - - /** Parse relational expression */ - private parseRel(): Types.ExprNode { - let exprNode = this.parseAdd() - while (true) { - const currentToken = this.peek() - if ( - currentToken?.kind === 'op' && - (currentToken.value === '>' || - currentToken.value === '<' || - currentToken.value === '>=' || - currentToken.value === '<=') - ) { - this.consume() - const rightNode = this.parseAdd() - exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode } - continue - } - return exprNode - } - } - - /** Parse ternary and nullish */ - private parseTry(): Types.ExprNode { - let exprNode = this.parseNil() - if (this.matchOp('?')) { - const consequent = this.parse() - this.expectOp(':') - const alternate = this.parse() - exprNode = { type: 'ternary', test: exprNode, consequent, alternate } - } - return exprNode - } - - /** Parse unary or member expression */ - private parseUn(): Types.ExprNode { - const currentToken = this.peek() - if ( - currentToken?.kind === 'op' && - (currentToken.value === '!' || currentToken.value === '+' || currentToken.value === '-') - ) { - this.consume() - const argNode = this.parseUn() - return { type: 'unary', op: currentToken.value as Types.UnaryOp, arg: argNode } - } - return this.parseMem() - } - - /** Read current token without advancing */ - private peek(): Types.ExprToken | undefined { - return this.tokens[this.tokenIndex] - } -} diff --git a/src/rendering/engine/Parser.ts b/src/rendering/engine/Parser.ts deleted file mode 100644 index 0b08f57..0000000 --- a/src/rendering/engine/Parser.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type * as Types from '@interfaces/index.ts' - -/** - * DVE template parser. - * @description Converts template text into AST nodes. - */ -export class Parser { - /** - * Parse template into AST. - * @description Extracts tags and builds block structures. - * @param templateText - Raw template content - * @returns List of AST nodes - * @throws {Deno.errors.InvalidData} When template has unclosed blocks - */ - static parse(templateText: string): Types.AstNode[] { - const astNodes: Types.AstNode[] = [] - const templateTagRegex = /\{\{\{[\s\S]*?\}\}\}|\{\{[\s\S]*?\}\}/g - let scanIndex = 0 - const frameStack: Types.DveStackFrame[] = [] - const appendAstNode = (node: Types.AstNode) => { - const stackFrame = frameStack[frameStack.length - 1] - if (!stackFrame) { - astNodes.push(node) - return - } - if (stackFrame.kind === 'if') { - const ifNode = stackFrame.node as Extract - if (stackFrame.inElse) { - ifNode.elseNodes.push(node) - } else { - ifNode.thenNodes.push(node) - } - return - } - const eachNode = stackFrame.node as Extract - eachNode.nodes.push(node) - } - let tagMatch: RegExpExecArray | null - while ((tagMatch = templateTagRegex.exec(templateText)) !== null) { - const rawTemplateTag = tagMatch[0] ?? '' - const tagStartIndex = tagMatch.index - if (tagStartIndex > scanIndex) { - appendAstNode({ type: 'text', value: templateText.slice(scanIndex, tagStartIndex) }) - } - scanIndex = tagStartIndex + rawTemplateTag.length - if (rawTemplateTag.startsWith('{{{')) { - const tagContent = rawTemplateTag.slice(3, -3).trim() - if (tagContent) { - appendAstNode({ type: 'var', path: tagContent, raw: true }) - } - continue - } - const tagContent = rawTemplateTag.slice(2, -2).trim() - if (!tagContent) { - continue - } - if (tagContent.startsWith('>')) { - const includeTemplatePath = tagContent.slice(1).trim() - if (includeTemplatePath) { - appendAstNode({ type: 'include', templatePath: includeTemplatePath }) - } - continue - } - if (tagContent.startsWith('#if ')) { - const dataPath = tagContent.slice(4).trim() - const ifNode: Extract = { - type: 'if', - path: dataPath, - thenNodes: [], - elseNodes: [] - } - appendAstNode(ifNode) - frameStack.push({ kind: 'if', node: ifNode, inElse: false }) - continue - } - if (tagContent === 'else') { - const stackFrame = frameStack[frameStack.length - 1] - if (!stackFrame || stackFrame.kind !== 'if') { - throw new Deno.errors.InvalidData('Unexpected {{else}} without matching {{#if}} block') - } - stackFrame.inElse = true - continue - } - if (tagContent === '/if') { - const stackFrame = frameStack[frameStack.length - 1] - if (!stackFrame || stackFrame.kind !== 'if') { - throw new Deno.errors.InvalidData('Unexpected {{/if}} without matching {{#if}} block') - } - frameStack.pop() - continue - } - if (tagContent.startsWith('#each ')) { - const eachClauseText = tagContent.slice(6).trim() - const asClauseMatch = eachClauseText.match(/^(.+)\s+as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)$/) - const dataPath = (asClauseMatch?.[1] ?? eachClauseText).trim() - const itemName = (asClauseMatch?.[2] ?? 'item').trim() - const eachNode: Extract = { - type: 'each', - path: dataPath, - itemName, - nodes: [] - } - appendAstNode(eachNode) - frameStack.push({ kind: 'each', node: eachNode, inElse: false }) - continue - } - if (tagContent === '/each') { - const stackFrame = frameStack[frameStack.length - 1] - if (!stackFrame || stackFrame.kind !== 'each') { - throw new Deno.errors.InvalidData('Unexpected {{/each}} without matching {{#each}} block') - } - frameStack.pop() - continue - } - appendAstNode({ type: 'var', path: tagContent, raw: false }) - } - if (scanIndex < templateText.length) { - appendAstNode({ type: 'text', value: templateText.slice(scanIndex) }) - } - if (frameStack.length > 0) { - const unclosedFrame = frameStack[frameStack.length - 1] - const blockLabel = unclosedFrame?.kind === 'each' ? '#each' : '#if' - throw new Deno.errors.InvalidData(`Unclosed {{${blockLabel}}} block in DVE template`) - } - return astNodes - } -} diff --git a/src/rendering/engine/Tokenizer.ts b/src/rendering/engine/Tokenizer.ts deleted file mode 100644 index ee252ed..0000000 --- a/src/rendering/engine/Tokenizer.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type * as Types from '@interfaces/index.ts' - -/** - * DVE expression tokenizer. - * @description Converts expression string into token list. - */ -export class Tokenizer { - /** - * Tokenize expression into tokens. - * @description Supports strings, numbers, idents, operators. - * @param expressionText - Raw expression text - * @returns List of expression tokens - * @throws {Deno.errors.InvalidData} When tokenization fails - */ - static tokenize(expressionText: string): Types.ExprToken[] { - const exprTokens: Types.ExprToken[] = [] - let cursorIndex = 0 - while (cursorIndex < expressionText.length) { - const currentChar = expressionText[cursorIndex] ?? '' - if (Tokenizer.isWhitespace(currentChar)) { - cursorIndex++ - continue - } - const twoCharOp = expressionText.slice(cursorIndex, cursorIndex + 2) - const threeCharOp = expressionText.slice(cursorIndex, cursorIndex + 3) - if (threeCharOp === '===') { - exprTokens.push({ kind: 'op', value: '===' }) - cursorIndex += 3 - continue - } - if (threeCharOp === '!==') { - exprTokens.push({ kind: 'op', value: '!==' }) - cursorIndex += 3 - continue - } - if ( - twoCharOp === '&&' || - twoCharOp === '||' || - twoCharOp === '??' || - twoCharOp === '>=' || - twoCharOp === '<=' || - twoCharOp === '==' || - twoCharOp === '!=' - ) { - exprTokens.push({ kind: 'op', value: twoCharOp }) - cursorIndex += 2 - continue - } - if (twoCharOp === '?.') { - exprTokens.push({ kind: 'op', value: '?.' }) - cursorIndex += 2 - continue - } - if ( - currentChar === '(' || - currentChar === ')' || - currentChar === '?' || - currentChar === ':' || - currentChar === '.' || - currentChar === '!' || - currentChar === '+' || - currentChar === '-' || - currentChar === '*' || - currentChar === '/' || - currentChar === '%' || - currentChar === '>' || - currentChar === '<' - ) { - exprTokens.push({ kind: 'op', value: currentChar }) - cursorIndex++ - continue - } - if (currentChar === "'" || currentChar === '"') { - const quoteChar = currentChar - cursorIndex++ - let stringValue = '' - let isClosed = false - while (cursorIndex < expressionText.length) { - const currentStringChar = expressionText[cursorIndex] ?? '' - if (currentStringChar === '\\') { - const escapeCode = expressionText[cursorIndex + 1] ?? '' - if (escapeCode === 'n') { - stringValue += '\n' - } else if (escapeCode === 't') { - stringValue += '\t' - } else if (escapeCode === 'r') { - stringValue += '\r' - } else { - stringValue += escapeCode - } - cursorIndex += 2 - continue - } - if (currentStringChar === quoteChar) { - cursorIndex++ - isClosed = true - break - } - stringValue += currentStringChar - cursorIndex++ - } - if (!isClosed) { - throw new Deno.errors.InvalidData('Unterminated string literal in DVE expression') - } - exprTokens.push({ kind: 'string', value: stringValue }) - continue - } - if (Tokenizer.isDigitChar(currentChar)) { - let endIndex = cursorIndex - while ( - endIndex < expressionText.length && - Tokenizer.isDigitChar(expressionText[endIndex] ?? '') - ) { - endIndex++ - } - if (expressionText[endIndex] === '.') { - endIndex++ - while ( - endIndex < expressionText.length && - Tokenizer.isDigitChar(expressionText[endIndex] ?? '') - ) { - endIndex++ - } - } - const expChar = expressionText[endIndex] - if (expChar === 'e' || expChar === 'E') { - endIndex++ - const signChar = expressionText[endIndex] - if (signChar === '+' || signChar === '-') { - endIndex++ - } - while ( - endIndex < expressionText.length && - Tokenizer.isDigitChar(expressionText[endIndex] ?? '') - ) { - endIndex++ - } - } - const numberText = expressionText.slice(cursorIndex, endIndex) - exprTokens.push({ kind: 'number', value: Number(numberText) }) - cursorIndex = endIndex - continue - } - if (Tokenizer.isIdentStart(currentChar)) { - let endIndex = cursorIndex + 1 - while ( - endIndex < expressionText.length && - Tokenizer.isIdentifierChar(expressionText[endIndex] ?? '') - ) { - endIndex++ - } - const identifierName = expressionText.slice(cursorIndex, endIndex) - exprTokens.push({ kind: 'ident', value: identifierName }) - cursorIndex = endIndex - continue - } - throw new Deno.errors.InvalidData( - `Invalid DVE expression token near ${expressionText.slice(cursorIndex, cursorIndex + 10)}` - ) - } - return exprTokens - } - - /** - * Check if character is digit. - * @description Tests for ASCII 0-9 characters. - * @param inputChar - Single character to test - * @returns True when digit - */ - private static isDigitChar(inputChar: string): boolean { - return inputChar >= '0' && inputChar <= '9' - } - - /** - * Check if character starts identifier. - * @description Tests for letters, underscore, dollar, at sign. - * @param inputChar - Single character to test - * @returns True when valid identifier start - */ - private static isIdentStart(inputChar: string): boolean { - return ( - (inputChar >= 'a' && inputChar <= 'z') || - (inputChar >= 'A' && inputChar <= 'Z') || - inputChar === '_' || - inputChar === '$' || - inputChar === '@' - ) - } - - /** - * Check if character continues identifier. - * @description Tests for identifier start chars or digits. - * @param inputChar - Single character to test - * @returns True when valid identifier char - */ - private static isIdentifierChar(inputChar: string): boolean { - return Tokenizer.isIdentStart(inputChar) || Tokenizer.isDigitChar(inputChar) - } - - /** - * Check if character is whitespace. - * @description Tests for space, newline, tab, carriage return. - * @param inputChar - Single character to test - * @returns True when whitespace - */ - private static isWhitespace(inputChar: string): boolean { - return inputChar === ' ' || inputChar === '\n' || inputChar === '\t' || inputChar === '\r' - } -} diff --git a/src/rendering/engine/Utils.ts b/src/rendering/engine/Utils.ts deleted file mode 100644 index 47eb43a..0000000 --- a/src/rendering/engine/Utils.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' - -/** - * Engine helper utilities. - * @description Provides path lookup and escaping helpers. - */ -export class Utils { - /** - * Escape HTML special characters. - * @description Converts characters to safe HTML entities. - * @param rawText - Raw text to escape - * @returns Escaped HTML-safe string - */ - static escape(rawText: string): string { - return Core.Handler.escapeHtml(rawText) - } - - /** - * Join root and relative path. - * @description Normalizes slashes for filesystem paths. - * @param rootDir - Root directory path - * @param relativePath - Relative template path - * @returns Joined path string - */ - static join(rootDir: string, relativePath: string): string { - const normalizedRootDir = rootDir.replace(/\/+$/, '') - const normalizedRelativePath = relativePath.replace(/^\/+/, '').replace(/\\/g, '/') - return `${normalizedRootDir}/${normalizedRelativePath}` - } - - /** - * Lookup dotted path value. - * @description Traverses object by dot-separated segments using own properties. - * @param dataObject - Root data object - * @param dataPath - Dotted lookup path - * @returns Resolved value or undefined - */ - static lookup(dataObject: unknown, dataPath: string): unknown { - let currentValue: unknown = dataObject - let scanStart = 0 - const pathLength = dataPath.length - while (scanStart <= pathLength) { - let scanEnd = dataPath.indexOf('.', scanStart) - if (scanEnd === -1) { - scanEnd = pathLength - } - let segStart = scanStart - let segEnd = scanEnd - while (segStart < segEnd && dataPath.charCodeAt(segStart) === 32) { - segStart++ - } - while (segEnd > segStart && dataPath.charCodeAt(segEnd - 1) === 32) { - segEnd-- - } - if (segEnd > segStart) { - const pathSegment = dataPath.slice(segStart, segEnd) - currentValue = Utils.readOwn(currentValue, pathSegment) - } - scanStart = scanEnd + 1 - } - return currentValue - } - - /** - * Read one own property safely. - * @description Reads own data properties from object or string bases. - * @param base - Value to read the property from - * @param key - Property name to resolve - * @returns Own property value or undefined when not safely readable - */ - static readOwn(base: unknown, key: string): unknown { - if (base === null || base === undefined) { - return undefined - } - if (typeof base !== 'object' && typeof base !== 'string') { - return undefined - } - if (!Object.hasOwn(base as Types.DataRecord, key)) { - return undefined - } - return (base as Types.DataRecord)[key] - } -} diff --git a/src/rendering/engine/index.ts b/src/rendering/engine/index.ts deleted file mode 100644 index 9a77b11..0000000 --- a/src/rendering/engine/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Re-export engine public API. */ -export * from '@rendering/engine/Eval.ts' -export * from '@rendering/engine/Expression.ts' -export * from '@rendering/engine/Parser.ts' -export * from '@rendering/engine/Tokenizer.ts' -export * from '@rendering/engine/Utils.ts' diff --git a/src/rendering/index.ts b/src/rendering/index.ts deleted file mode 100644 index 92dd38f..0000000 --- a/src/rendering/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Re-export rendering public API. */ -export * from '@rendering/Discover.ts' -export * from '@rendering/Engine.ts' -export * from '@rendering/Watcher.ts' diff --git a/src/routing/Handler.ts b/src/routing/Handler.ts index 3feb803..d3efe38 100644 --- a/src/routing/Handler.ts +++ b/src/routing/Handler.ts @@ -1,396 +1,186 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' -import * as Rendering from '@rendering/index.ts' import * as Routing from '@routing/index.ts' import { FastRouter } from '@neabyte/fast-router' /** - * Core request handler: middleware, routing, static. - * @description Scans routes, runs middleware, dispatches to handler. + * Core request handler engine. + * @description Manages routes, middleware, statics, and dispatch. */ export class Handler { - /** Default error response builder using Error */ - private static readonly defaultErrorBuilder: Types.ErrorResponseBuilder = { - build: (ctx, statusCode, error, errorMiddleware) => - Core.Handler.buildResponse(ctx, statusCode, error, errorMiddleware) - } - /** Default static file handler */ - private static readonly defaultStaticHandler: Types.StaticHandler = { - serve: (ctx, options, urlPath) => Core.Static.serveStaticFile(ctx, options, urlPath) - } - /** Middleware list with path prefix */ - private entryMiddleware: Types.MiddlewareEntry[] = [] - /** Custom error handler when set */ - private errorMiddleware: Types.ErrorMiddleware | null = null - /** Error response builder instance */ - private errorResponseBuilder: Types.ErrorResponseBuilder - /** Fast router for route matching */ - private routerInstance = new FastRouter() - /** Max route param length */ - private maxParamLength: number | undefined - /** Max request URL length */ - private maxUrlLength: number | undefined + /** Registered entry middleware list */ + #entryMiddleware: Types.MiddlewareEntry[] = [] + /** Registered global error middleware */ + #errorMiddleware: Types.ErrorMiddleware | null = null + /** Underlying fast router instance */ + #routerInstance = new FastRouter() + /** Observability event emitter */ + readonly #events = new Core.Observability() + /** Maximum allowed route parameter length */ + readonly #maxParamLength: number + /** Maximum allowed request URL length */ + readonly #maxUrlLength: number + /** Registered static mount list */ + readonly #staticMounts: Types.StaticMount[] = [] /** Request timeout in milliseconds */ - private requestTimeoutMs: number | undefined - /** Compiled trusted-proxy tester or null */ - private trustTester: Types.IpMatcher | null - /** Static file handler instance */ - private staticHandler: Types.StaticHandler - /** Optional worker pool instance */ - private workerPool: Core.Worker | undefined - /** Optional view engine instance */ - private viewEngine: Types.ViewEngine | undefined - /** Lifecycle and error event bus */ - private readonly events = new Core.Observability() + readonly #timeoutMs: number | undefined + /** Trusted proxy IP matcher */ + readonly #trustTester: Types.IpMatcher | null + /** View rendering engine instance */ + readonly #viewEngine: Core.Rendering | null + /** Resolved routes directory path */ + readonly #routesDir: string + /** Worker controller for offloading */ + readonly #workerController: Types.WorkerController | null + /** Worker pool instance */ + readonly #workerPool: Core.Worker | null /** - * Create handler with optional overrides. - * @description Uses default builder and static handler when omitted. - * @param options - Error builder, static handler, request timeout, worker pool + * Create request handler. + * @description Resolves limits, proxy, views, and worker pool. + * @param options - Optional router configuration */ - constructor(options?: Types.HandlerOptions) { - this.errorResponseBuilder = options?.errorResponseBuilder ?? Handler.defaultErrorBuilder - this.staticHandler = options?.staticHandler ?? Handler.defaultStaticHandler - this.maxUrlLength = Handler.validatePositiveOption( + constructor(options?: Types.RouterOptions) { + this.#routesDir = options?.routes?.directory ?? './routes' + this.#maxUrlLength = Handler.#resolveLimit( options?.maxUrlLength, Core.Constant.maxUrlLength, 'maxUrlLength' ) - this.maxParamLength = Handler.validatePositiveOption( - options?.maxParamLength, + this.#maxParamLength = Handler.#resolveLimit( + options?.routes?.maxParamLength, Core.Constant.maxParamLength, 'maxParamLength' ) - const timeoutValue = options?.requestTimeoutMs - if (timeoutValue !== undefined) { - Core.Handler.assertPositiveFinite(timeoutValue, 'requestTimeoutMs', 'milliseconds') + if (options?.timeoutMs !== undefined) { + Core.Handler.assertPositiveFinite(options.timeoutMs, 'timeoutMs', 'milliseconds') } - this.requestTimeoutMs = timeoutValue - this.trustTester = Core.IpResolver.compile(options?.trustProxy) - this.workerPool = options?.worker !== undefined - ? Core.Worker.createPool({ - ...options.worker, - emit: (event) => this.events.emit(event) - }) - : undefined - this.viewEngine = options?.viewsDir !== undefined - ? new Rendering.Engine({ - viewsDir: options.viewsDir, - emit: (event) => this.events.emit(event), - ...(options.maxIterations !== undefined && { maxIterations: options.maxIterations }), - ...(options.maxRenderIterations !== undefined && { - maxRenderIterations: options.maxRenderIterations - }), - ...(options.maxOutputSize !== undefined && { maxOutputSize: options.maxOutputSize }) - }) - : undefined + this.#timeoutMs = options?.timeoutMs + this.#trustTester = Core.IpResolver.compile(options?.trustProxy) + this.#viewEngine = options?.views !== undefined + ? new Core.Rendering(options.views, (event) => this.#events.emit(event)) + : null + this.#workerPool = options?.worker !== undefined + ? Core.Worker.createPool(options.worker, (event) => this.#events.emit(event)) + : null + this.#workerController = this.#workerPool === null + ? null + : { run: (payload) => this.#workerPool!.run(payload) } + } + + /** Resolved routes directory path */ + get routesDir(): string { + return this.#routesDir + } + + /** View rendering engine or null */ + get viewEngine(): Core.Rendering | null { + return this.#viewEngine } /** - * Register middleware for path or all. - * @description Validates each handler then appends by prefix. - * @param path - Path prefix, '', or '*' - * @param handlers - Middleware functions - * @throws {TypeError} When a handler is not a function + * Register path-scoped middleware handlers. + * @description Validates and appends middleware entries. + * @param path - Path scope for the middleware + * @param handlers - Middleware functions to register + * @throws {TypeError} When a handler is not function */ - addMiddleware(path: string, ...handlers: Types.MiddlewareFn[]): void { + addMiddleware(path: string, handlers: Types.MiddlewareFn[]): void { for (const middlewareHandler of handlers) { if (typeof middlewareHandler !== 'function') { throw new TypeError( `Middleware for "${path || '*'}" must be a function, got ${typeof middlewareHandler}` ) } - this.entryMiddleware.push({ path, handler: middlewareHandler }) + this.#entryMiddleware.push({ path, handler: middlewareHandler }) } } /** - * Register static file route for urlPath. - * @description Registers static handler for all HTTP methods. - * @param urlPath - URL prefix for static files - * @param options - Path, etag, cacheControl + * Mount static file source. + * @description Normalizes prefix and sorts by length. + * @param urlPrefix - URL prefix to mount under + * @param source - Static handler or serve options */ - addStaticRoute(urlPath: string, options: Types.ServeOptions): void { - if (typeof options?.path !== 'string' || options.path.length === 0) { - throw new TypeError( - `Static route "${urlPath}" requires a non-empty string "path" option, got ${typeof options - ?.path}` - ) - } - const isAbsolute = options.path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(options.path) - const resolvedPath = isAbsolute ? options.path : `${Deno.cwd()}/${options.path}` - const resolvedOptions: Types.ServeOptions = options.path === resolvedPath - ? options - : { ...options, path: resolvedPath } - const routePattern = urlPath === '/' ? '/**' : `${urlPath}/**` - const routeEntry: Types.RouteEntry = { - kind: 'static', - execute: (ctx: Core.Context) => this.staticHandler.serve(ctx, resolvedOptions, urlPath), - pattern: routePattern, - urlPath - } - this.routerInstance.add('GET', routePattern, routeEntry) + addStatic(urlPrefix: string, source: Types.StaticFn | Types.ServeOptions): void { + const handler = typeof source === 'function' ? source : Handler.#fileHandler(urlPrefix, source) + this.#staticMounts.push({ urlPrefix: Handler.#normalizePrefix(urlPrefix), handler }) + this.#staticMounts.sort((first, second) => second.urlPrefix.length - first.urlPrefix.length) } - /** Build Deno.serve request handler */ - createHandler(): (req: Request, info?: Deno.ServeHandlerInfo) => Promise { - const eventReporter: Types.EventEmit = (event) => this.events.emit(event) - const boundHandleResponse = this.handleResponse.bind(this) - const workerPool = this.workerPool - const workerHandle: Types.WorkerRunHandle | undefined = workerPool - ? { run: (payload: unknown) => workerPool.run(payload) } - : undefined - const handleRequest = async ( - req: Request, - holder: Types.RequestHolder, - clientIp?: string, - directIp?: string - ): Promise => { - if ( - this.maxUrlLength !== undefined && - this.maxUrlLength > 0 && - req.url.length > this.maxUrlLength - ) { - holder.frameworkError = new Deno.errors.InvalidData( - 'Request URL exceeds maximum allowed length' - ) - return Routing.Respond.negotiatedError(req, 414, 'URI Too Long') - } - holder.parsedUrl = new Core.API.URL(req.url) - holder.ctx = new Core.Context( - req, - holder.parsedUrl, - undefined, - boundHandleResponse, - clientIp, - directIp, - (event) => this.events.emit(event) - ) - if (workerHandle) { - holder.ctx[Core.InternalContext].setInternalState( - Core.Handler.stateKeys.worker, - workerHandle - ) - } - if (this.viewEngine !== undefined) { - holder.ctx[Core.InternalContext].setInternalState( - Core.Handler.stateKeys.view, - this.viewEngine - ) - } - try { - if (this.entryMiddleware.length > 0) { - const middlewareResult = await this.executeMiddlewares( - holder.ctx, - holder.parsedUrl.pathname - ) - if (middlewareResult !== undefined) { - return middlewareResult - } - } - const routeResult = this.routerInstance.find(req.method, holder.parsedUrl.pathname) - if (routeResult) { - const routeEntry = 'data' in routeResult ? routeResult.data : null - if (!routeEntry) { - return await holder.ctx.handleError( - 404, - new Deno.errors.NotFound('No route data found for matched pattern') - ) - } - holder.routePattern = routeEntry.pattern - if ('params' in routeResult && routeResult.params) { - if (this.maxParamLength !== undefined && this.maxParamLength > 0) { - for (const paramValue of Object.values(routeResult.params)) { - if (paramValue.length > this.maxParamLength) { - return await holder.ctx.handleError( - 414, - new Deno.errors.InvalidData('Route parameter exceeds maximum allowed length') - ) - } - } - } - holder.ctx[Core.InternalContext].setParams(routeResult.params) - } - if (routeEntry.kind === 'static') { - return await routeEntry.execute(holder.ctx) - } - const handlerResult = await routeEntry.handler(holder.ctx) - if (Routing.Respond.isGenuineResponse(handlerResult)) { - return holder.ctx[Core.InternalContext].finalizeRaw(handlerResult) - } - return await holder.ctx.handleError( - 500, - new TypeError( - `Route handler for ${holder.parsedUrl.pathname} must return a Response instance` - ) - ) - } - const supportedMethods = new Set() - for (const method of Core.Constant.httpMethods) { - if (this.routerInstance.find(method, holder.parsedUrl.pathname)) { - supportedMethods.add(method) - } - } - if (supportedMethods.has('GET')) { - supportedMethods.add('HEAD') - } - if (supportedMethods.size > 0) { - holder.ctx.setHeader('Allow', [...supportedMethods].sort().join(', ')) - return await holder.ctx.handleError( - 405, - new Deno.errors.NotSupported( - `Method ${req.method} not allowed for ${holder.parsedUrl.pathname}` - ) - ) - } - return await holder.ctx.handleError( - 404, - new Deno.errors.NotFound(`No route found for ${req.method} ${holder.parsedUrl.pathname}`) - ) - } catch (handlerError) { - const extracted = Core.Handler.extractError(handlerError) - return await holder.ctx.handleError(extracted.statusCode, extracted.error) - } - } + /** Build Deno serve request handler */ + createHandler(): Types.ServeHandler { return async (req: Request, info?: Deno.ServeHandlerInfo) => { const isHead = req.method === 'HEAD' - if (isHead) { - req = new Core.API.Request(req, { method: 'GET' }) - } - const observe = this.events.hasListeners() + const sourceReq = isHead ? new Core.API.Request(req, { method: 'GET' }) : req + const observe = this.#events.hasListeners() const requestStart = observe ? performance.now() : 0 const holder: Types.RequestHolder = { ctx: null, frameworkError: null, - clientIp: undefined, - routePattern: undefined, - parsedUrl: undefined + parsedUrl: undefined, + routePattern: undefined } const remoteAddr = info?.remoteAddr const directIp = remoteAddr && remoteAddr.transport === 'tcp' ? remoteAddr.hostname : undefined - let timedOut = false let finalResponse: Response try { - const clientIp = Core.IpResolver.resolve(directIp, req.headers, this.trustTester) - holder.clientIp = clientIp - if (this.requestTimeoutMs !== undefined && this.requestTimeoutMs > 0) { - const abortController = new AbortController() - const timeoutTimer = setTimeout(() => abortController.abort(), this.requestTimeoutMs) - try { - finalResponse = await Promise.race([ - handleRequest(req, holder, clientIp, directIp), - new Promise((resolve) => { - abortController.signal.addEventListener('abort', () => { - timedOut = true - holder.frameworkError = new Deno.errors.TimedOut( - `Request exceeded ${this.requestTimeoutMs}ms timeout` - ) - resolve(Routing.Respond.negotiatedError(req, 503, 'Service Unavailable')) - }) - }) - ]) - } finally { - clearTimeout(timeoutTimer) - } - } else { - finalResponse = await handleRequest(req, holder, clientIp, directIp) - } + const clientIp = Core.IpResolver.resolve(directIp, sourceReq.headers, this.#trustTester) + finalResponse = this.#timeoutMs !== undefined && this.#timeoutMs > 0 + ? await this.#withTimeout(sourceReq, holder, clientIp, directIp) + : await this.#handleRequest(sourceReq, holder, clientIp, directIp) } catch (fatalError) { holder.frameworkError = Core.Handler.extractError(fatalError).error - finalResponse = Routing.Respond.safeServerError(req, 500) + finalResponse = Routing.Respond.safeServerError(sourceReq, 500) } - if (!Routing.Respond.isGenuineResponse(finalResponse)) { - holder.frameworkError = new TypeError('Response is not a genuine Response instance') - finalResponse = Routing.Respond.safeServerError(req, 500) + if (!Routing.Respond.isGenuine(finalResponse)) { + finalResponse = Routing.Respond.safeServerError(sourceReq, 500) } - try { - if (observe) { - Routing.Report.reportRequest( - eventReporter, - req, - finalResponse, - requestStart, - holder, - timedOut - ) - } - if (isHead) { - return await Routing.Respond.toHeadResponse(finalResponse) - } - return finalResponse - } catch { - return Routing.Respond.safeServerError(req, 500) + if (observe) { + Routing.Report.reportRequest( + (event) => this.#events.emit(event), + sourceReq, + finalResponse, + requestStart, + holder, + false + ) } + return isHead ? await Routing.Respond.toHeadResponse(finalResponse) : finalResponse } } /** - * Convert file path to route pattern. - * @description Drops extension, [id] to :id, index to /. - * @param routePath - Path like users/[id].ts - * @returns Pattern like /users/:id or null - */ - createPattern(routePath: string): string | null { - return Routing.Scanner.createPattern(routePath, Core.Constant.allowedExtensions) - } - - /** Release framework-owned resources */ - dispose(): void { - this.workerPool?.terminate() - this.workerPool = undefined - } - - /** - * Emit lifecycle or error event. - * @description Used by the router to surface server-level events. - * @param event - Event payload to broadcast + * Emit an observability event. + * @description Forwards event to internal emitter. + * @param event - Event to emit */ emitEvent(event: Types.EventBase): void { - this.events.emit(event) - } - - /** View engine when viewsDir configured */ - getViewEngine(): Types.ViewEngine | undefined { - return this.viewEngine - } - - /** - * Build error response via builder. - * @description Delegates to errorResponseBuilder with optional middleware. - * @param ctx - Request context - * @param statusCode - HTTP status - * @param error - Error instance - * @returns Error response - */ - async handleResponse(ctx: Core.Context, statusCode: number, error: Error): Promise { - try { - return await this.errorResponseBuilder.build(ctx, statusCode, error, this.errorMiddleware) - } catch { - return Core.Handler.errorResponse(ctx, statusCode) - } + this.#events.emit(event) } /** - * Subscribe to lifecycle and error events. - * @description Listener receives every Deserve event, filter via event.type. - * @param listener - Callback invoked for each event - * @returns Unsubscribe function + * Subscribe to handler events. + * @description Registers listener for emitted events. + * @param listener - Event listener function + * @returns Unsubscribe function removing the listener */ - onEvent(listener: Types.EventListener): () => void { - return this.events.on(listener) + onEvent(listener: Types.EventFn): () => void { + return this.#events.on(listener) } /** - * Reload a single route file. - * @description Removes old route, re-imports module, registers new handlers. - * @param fullPath - Absolute file path - * @param routePath - Relative route path from routesDir + * Reload a single route module. + * @description Reimports, validates, and re-registers the route. + * @param fullPath - Absolute module file path + * @param routePath - Relative route file path + * @returns Promise resolving when reload completes */ async reloadRoute(fullPath: string, routePath: string): Promise { const routePattern = Routing.Scanner.createPattern(routePath, Core.Constant.allowedExtensions) - if (!routePattern) { + if (routePattern === null) { return } try { @@ -398,18 +188,21 @@ export class Handler { Routing.Scanner.validateModule(fileModule, routePath, Core.Constant.httpMethods) this.removeRoute(routePattern) Routing.Scanner.registerHandlers( - this.routerInstance, + this.#routerInstance, fileModule, routePattern, Core.Constant.httpMethods ) - this.events.emit( - Core.Observability.internalEvent('route:reloaded', { routePath, pattern: routePattern }) + this.#events.emit( + Core.Observability.internalEvent('route:updated', { + path: routePath, + pattern: routePattern + }) ) } catch (reloadError) { - this.events.emit( - Core.Observability.internalEvent('reload:error', { - routePath, + this.#events.emit( + Core.Observability.internalEvent('route:failed', { + path: routePath, error: reloadError instanceof Error ? reloadError : new Error(String(reloadError)) }) ) @@ -417,117 +210,86 @@ export class Handler { } /** - * Remove route pattern from router. - * @description Removes all method entries, emits removed when path given. + * Remove route across all methods. + * @description Deletes pattern from every HTTP method. * @param routePattern - Route pattern to remove - * @param routePath - Optional relative route path for the removal event */ - removeRoute(routePattern: string, routePath?: string): void { + removeRoute(routePattern: string): void { for (const method of Core.Constant.httpMethods) { - this.routerInstance.remove(method, routePattern) - } - if (routePath !== undefined) { - this.events.emit( - Core.Observability.internalEvent('route:removed', { routePath, pattern: routePattern }) - ) + this.#routerInstance.remove(method, routePattern) } } - /** - * Scan directory and register file-based routes. - * @description Imports route modules and adds to router. - * @param targetDir - Directory to scan - * @param basePath - Base path prefix for route paths - */ - async scanRoutes(targetDir: string, basePath = ''): Promise { - return await Routing.Scanner.explore( - this.routerInstance, - targetDir, - basePath, + /** Scan routes directory for handlers */ + async scanRoutes(): Promise { + await Routing.Scanner.explore( + this.#routerInstance, + this.#routesDir, + '', Core.Constant.httpMethods, Core.Constant.allowedExtensions, - (event) => this.events.emit(event) + this.#events.hasListeners() ? (event) => this.#events.emit(event) : null ) } /** - * Set custom error response builder. - * @description Replaces default builder for error responses. - * @param builder - Builds final error Response - */ - setErrorBuilder(builder: Types.ErrorResponseBuilder): void { - this.errorResponseBuilder = builder - } - - /** - * Set custom error middleware. - * @description Invoked before default error response when set. - * @param errorMiddleware - Called before default error response + * Set global error middleware. + * @description Stores handler invoked on unhandled errors. + * @param errorMiddleware - Error middleware to register */ setErrorMiddleware(errorMiddleware: Types.ErrorMiddleware): void { - this.errorMiddleware = errorMiddleware - } - - /** - * Set custom static file handler. - * @description Replaces default static file serving implementation. - * @param handler - Serves static files for route - */ - setStaticHandler(handler: Types.StaticHandler): void { - this.staticHandler = handler + this.#errorMiddleware = errorMiddleware } - /** - * Ensure module exports one HTTP method. - * @description Delegates validation to Scanner with HTTP methods. - * @param routeModule - Loaded route module - * @param routePath - Path for error messages - * @throws {Deno.errors.InvalidData} When no method exported - */ - validateModule(routeModule: Types.RouteModule, routePath: string): void { - Routing.Scanner.validateModule(routeModule, routePath, Core.Constant.httpMethods) + /** Terminate worker pool if present */ + terminate(): void { + if (this.#workerPool !== null) { + this.#workerPool.terminate() + } } /** - * Match middleware entry path against pathname. - * @description Empty or star matches all, else boundary prefix match. - * @param entryPath - Registered middleware path - * @param pathname - Request pathname - * @returns True when the entry applies to the pathname + * Test middleware entry path match. + * @description Supports exact, prefix, and wildcard paths. + * @param entryPath - Configured middleware path + * @param pathname - Request pathname to test + * @returns True when entry applies to pathname */ - private static entryMatchesPath(entryPath: string, pathname: string): boolean { + static #entryMatchesPath(entryPath: string, pathname: string): boolean { if (entryPath === '' || entryPath === '*') { return true } if (entryPath.endsWith('/**')) { const prefix = entryPath.slice(0, -3) - return pathname === prefix || pathname.startsWith(prefix + '/') + return pathname === prefix || pathname.startsWith(`${prefix}/`) } - return pathname === entryPath || pathname.startsWith(entryPath + '/') + return pathname === entryPath || pathname.startsWith(`${entryPath}/`) } /** - * Run middleware chain for pathname. - * @description Filters by path then runs next chain. - * @param ctx - Request context - * @param pathname - Request pathname for path matching - * @returns Response from middleware or undefined to continue + * Run matching middleware chain. + * @description Dispatches middleware respecting next call order. + * @param ctx - Request context to pass through + * @param pathname - Request pathname for matching + * @returns Response when produced or undefined + * @throws {Error} When next called multiple times + * @throws {TypeError} When middleware returns invalid value */ - private async executeMiddlewares( + async #executeMiddlewares( ctx: Core.Context, pathname: string - ): Types.AsyncMiddlewareResult { + ): Promise { let lastIndex = -1 - const dispatch = async (index: number): Types.AsyncMiddlewareResult => { + const dispatch = async (index: number): Promise => { if (index <= lastIndex) { throw new Error('next() called multiple times') } lastIndex = index - if (index >= this.entryMiddleware.length) { + if (index >= this.#entryMiddleware.length) { return undefined } - const middlewareEntry = this.entryMiddleware[index]! - if (!Handler.entryMatchesPath(middlewareEntry.path, pathname)) { + const middlewareEntry = this.#entryMiddleware[index]! + if (!Handler.#entryMatchesPath(middlewareEntry.path, pathname)) { return await dispatch(index + 1) } let nextCalled = false @@ -539,7 +301,7 @@ export class Handler { if (middlewareResponse === undefined) { return nextCalled ? undefined : await dispatch(index + 1) } - if (Routing.Respond.isGenuineResponse(middlewareResponse)) { + if (Routing.Respond.isGenuine(middlewareResponse)) { return middlewareResponse } throw new TypeError( @@ -550,15 +312,160 @@ export class Handler { } /** - * Validate optional positive-number construction option. - * @description Returns default when omitted, throws on invalid value. - * @param inputValue - Developer-provided value or undefined - * @param defaultValue - Default used when omitted - * @param optionName - Option name for the error message - * @returns Validated positive number - * @throws {Deno.errors.InvalidData} When an explicit value is non-positive or non-finite + * Build static file serve handler. + * @description Validates options path and wraps serveFile. + * @param urlPrefix - URL prefix for error messages + * @param options - Static serve options + * @returns Static file handler function + * @throws {TypeError} When options path is empty + */ + static #fileHandler(urlPrefix: string, options: Types.ServeOptions): Types.StaticFn { + if (typeof options.path !== 'string' || options.path.length === 0) { + throw new TypeError(`static("${urlPrefix}") requires a non-empty options path`) + } + return (ctx, urlPath) => Core.Static.serveFile(ctx, options, urlPath) + } + + /** + * Handle a single request. + * @description Runs middleware, routing, statics, and errors. + * @param req - Incoming request to handle + * @param holder - Request state holder + * @param clientIp - Resolved client IP address + * @param directIp - Direct connection IP address + * @returns Promise resolving to final response + */ + async #handleRequest( + req: Request, + holder: Types.RequestHolder, + clientIp: string | undefined, + directIp: string | undefined + ): Promise { + if (req.url.length > this.#maxUrlLength) { + holder.frameworkError = new Deno.errors.InvalidData( + 'Request URL exceeds maximum allowed length' + ) + return Routing.Respond.negotiatedError(req, 414, 'URI Too Long') + } + holder.parsedUrl = new Core.API.URL(req.url) + const pathname = holder.parsedUrl.pathname + const ctx = new Core.Context( + req, + holder.parsedUrl, + this.#errorMiddleware, + clientIp, + directIp, + this.#viewEngine === null + ? null + : (template, data, options) => this.#viewEngine!.render(template, data, options), + (event) => this.#events.emit(event) + ) + holder.ctx = ctx + if (this.#workerController !== null) { + Core.Context.internalOf(ctx).installWorker(this.#workerController) + } + try { + if (this.#entryMiddleware.length > 0) { + const middlewareResult = await this.#executeMiddlewares(ctx, pathname) + if (middlewareResult !== undefined) { + return middlewareResult + } + } + const routeResult = this.#routerInstance.find(req.method, pathname) + if (routeResult && 'data' in routeResult && routeResult.data) { + holder.routePattern = routeResult.data.pattern + if ('params' in routeResult && routeResult.params) { + for (const paramValue of Object.values(routeResult.params)) { + if (paramValue.length > this.#maxParamLength) { + return await ctx.handleError( + 414, + new Deno.errors.InvalidData('Route parameter exceeds maximum allowed length') + ) + } + } + Core.Context.internalOf(ctx).setParams(routeResult.params) + } + const handlerResult = await routeResult.data.handler(ctx) + if (Routing.Respond.isGenuine(handlerResult)) { + return Core.Context.internalOf(ctx).finalizeRaw(handlerResult) + } + return await ctx.handleError( + 500, + new TypeError(`Route handler for ${pathname} must return a Response instance`) + ) + } + const supportedMethods = new Set() + for (const method of Core.Constant.httpMethods) { + if (this.#routerInstance.find(method, pathname)) { + supportedMethods.add(method) + } + } + if (supportedMethods.has('GET')) { + supportedMethods.add('HEAD') + } + if (supportedMethods.size > 0) { + ctx.set.header('Allow', [...supportedMethods].sort().join(', ')) + return await ctx.handleError( + 405, + new Deno.errors.NotSupported(`Method ${req.method} not allowed for ${pathname}`) + ) + } + const staticMount = this.#matchStatic(pathname) + if (staticMount !== null) { + const urlPath = Handler.#stripPrefix(staticMount.urlPrefix, pathname) + return await staticMount.handler(ctx, urlPath) + } + return await ctx.handleError( + 404, + new Deno.errors.NotFound(`No route found for ${req.method} ${pathname}`) + ) + } catch (handlerError) { + const extracted = Core.Handler.extractError(handlerError) + return await ctx.handleError(extracted.statusCode, extracted.error) + } + } + + /** + * Match pathname to static mount. + * @description Returns first mount covering the pathname. + * @param pathname - Request pathname to match + * @returns Matching static mount or null + */ + #matchStatic(pathname: string): Types.StaticMount | null { + for (const mount of this.#staticMounts) { + if (mount.urlPrefix === '/') { + return mount + } + if (pathname === mount.urlPrefix || pathname.startsWith(`${mount.urlPrefix}/`)) { + return mount + } + } + return null + } + + /** + * Normalize static mount prefix. + * @description Adds leading slash and trims trailing slashes. + * @param prefix - Raw URL prefix to normalize + * @returns Normalized URL prefix string + */ + static #normalizePrefix(prefix: string): string { + if (prefix === '' || prefix === '/') { + return '/' + } + const withLeading = prefix.startsWith('/') ? prefix : `/${prefix}` + return withLeading.replace(/\/+$/, '') + } + + /** + * Resolve numeric limit option. + * @description Returns default or validated positive value. + * @param inputValue - Provided option value or undefined + * @param defaultValue - Fallback default value + * @param optionName - Option name for error messages + * @returns Resolved positive numeric limit */ - private static validatePositiveOption( + static #resolveLimit( inputValue: number | undefined, defaultValue: number, optionName: string @@ -567,4 +474,52 @@ export class Handler { ? defaultValue : Core.Handler.assertPositiveFinite(inputValue, optionName) } + + /** + * Strip mount prefix from pathname. + * @description Returns remaining path after the prefix. + * @param urlPrefix - Mount prefix to strip + * @param pathname - Request pathname to trim + * @returns Pathname without the mount prefix + */ + static #stripPrefix(urlPrefix: string, pathname: string): string { + if (urlPrefix === '/') { + return pathname + } + return pathname.slice(urlPrefix.length) + } + + /** + * Handle request with timeout race. + * @description Aborts and returns 503 on timeout. + * @param req - Incoming request to handle + * @param holder - Request state holder + * @param clientIp - Resolved client IP address + * @param directIp - Direct connection IP address + * @returns Promise resolving to final response + */ + async #withTimeout( + req: Request, + holder: Types.RequestHolder, + clientIp: string | undefined, + directIp: string | undefined + ): Promise { + const abortController = new AbortController() + const timeoutTimer = setTimeout(() => abortController.abort(), this.#timeoutMs) + try { + return await Promise.race([ + this.#handleRequest(req, holder, clientIp, directIp), + new Promise((resolve) => { + abortController.signal.addEventListener('abort', () => { + holder.frameworkError = new Deno.errors.TimedOut( + `Request exceeded ${this.#timeoutMs}ms timeout` + ) + resolve(Routing.Respond.negotiatedError(req, 503, 'Service Unavailable')) + }) + }) + ]) + } finally { + clearTimeout(timeoutTimer) + } + } } diff --git a/src/routing/Report.ts b/src/routing/Report.ts index a26b107..6c4b702 100644 --- a/src/routing/Report.ts +++ b/src/routing/Report.ts @@ -2,22 +2,22 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' /** - * Observability helpers for the routing layer. - * @description Stateless boundary emit and OTel-aligned metric derivation. + * Request observability reporter. + * @description Emits request completion and error events. */ export class Report { /** - * Emit boundary observability for completed request. - * @description Emits request:complete plus request:error when status exceeds. - * @param emit - Event reporter - * @param req - Incoming request - * @param response - Final response sent to the client - * @param startTime - performance.now() captured at request entry - * @param holder - Per-request holder with ctx and any framework Error - * @param timedOut - True when the response is the synthetic 503 timeout + * Report request completion and errors. + * @description Emits complete and error events with metadata. + * @param emit - Event emitter function + * @param req - Incoming request being reported + * @param response - Final response produced + * @param startTime - Request start timestamp in milliseconds + * @param holder - Request state holder + * @param timedOut - Whether request timed out */ static reportRequest( - emit: Types.EventEmit, + emit: Types.EventFn, req: Request, response: Response, startTime: number, @@ -25,39 +25,28 @@ export class Report { timedOut: boolean ): void { const frameworkError = holder.frameworkError ?? - holder.ctx?.[Core.InternalContext].getFrameworkError() ?? null + (holder.ctx === null ? null : Core.Context.internalOf(holder.ctx).getFrameworkError()) const channel: Types.EventChannel = timedOut || frameworkError !== null || holder.ctx === null ? 'internal' : 'external' - const baseMetadata = { + const metadata = { method: req.method, statusCode: response.status, url: req.url, durationMs: performance.now() - startTime, - ...(holder.clientIp !== undefined && { ip: holder.clientIp }), ...Report.requestMetrics(req, response, holder), ...(frameworkError !== null && { error: frameworkError }) } - emit({ - type: channel, - kind: 'request:complete', - metadata: baseMetadata, - timestamp: Date.now() - }) + emit({ type: channel, kind: 'request:completed', metadata, timestamp: Date.now() }) if (response.status >= 400) { - emit({ - type: channel, - kind: 'request:error', - metadata: baseMetadata, - timestamp: Date.now() - }) + emit({ type: channel, kind: 'request:failed', metadata, timestamp: Date.now() }) } } /** - * Parse Content-Length into a byte count. - * @description Returns undefined for missing, chunked, or malformed values. - * @param value - Raw Content-Length header value or null + * Parse content-length header value. + * @description Returns undefined for invalid numeric values. + * @param value - Raw content-length header value * @returns Parsed byte count or undefined */ private static contentLength(value: string | null): number | undefined { @@ -72,12 +61,12 @@ export class Report { } /** - * Derive optional OTel-aligned request/response metrics. - * @description Forwards only values known for certain, omits the rest. - * @param req - Incoming request - * @param response - Final response sent to the client - * @param holder - Per-request holder carrying the matched route pattern - * @returns Partial metadata with route, server, user-agent, and sizes + * Collect metrics from request response. + * @description Gathers size, address, agent, and route data. + * @param req - Incoming request to inspect + * @param response - Final response to inspect + * @param holder - Request state holder + * @returns Assembled request metrics object */ private static requestMetrics( req: Request, @@ -87,14 +76,16 @@ export class Report { const userAgent = req.headers.get('user-agent') ?? undefined const requestSize = Report.contentLength(req.headers.get('content-length')) const responseSize = Report.contentLength(response.headers.get('content-length')) - let serverAddress: string | undefined - let serverPort: number | undefined + const clientIp = holder.ctx?.get.ip() const authority = holder.parsedUrl ?? Report.tryParseUrl(req.url) - if (authority !== undefined) { - serverAddress = authority.hostname || undefined - serverPort = authority.port === '' ? undefined : Number.parseInt(authority.port, 10) - } + const serverAddress = authority !== undefined && authority.hostname !== '' + ? authority.hostname + : undefined + const serverPort = authority !== undefined && authority.port !== '' + ? Number.parseInt(authority.port, 10) + : undefined return { + ...(clientIp !== undefined && { ip: clientIp }), ...(holder.routePattern !== undefined && { route: holder.routePattern }), ...(serverAddress !== undefined && { serverAddress }), ...(serverPort !== undefined && Number.isFinite(serverPort) && { serverPort }), @@ -105,10 +96,10 @@ export class Report { } /** - * Parse a URL without throwing. - * @description Fallback for metrics when the URL is unparsed. - * @param url - Raw request URL - * @returns Parsed URL or undefined when malformed + * Parse URL string safely. + * @description Returns undefined when URL parsing fails. + * @param url - URL string to parse + * @returns Parsed URL or undefined */ private static tryParseUrl(url: string): URL | undefined { try { diff --git a/src/routing/Respond.ts b/src/routing/Respond.ts index 1546806..b9926f7 100644 --- a/src/routing/Respond.ts +++ b/src/routing/Respond.ts @@ -1,18 +1,17 @@ -import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' /** - * Response-building helpers for the routing layer. - * @description Stateless construction of error, HEAD, and validated responses. + * Response helper utilities. + * @description Builds error and HEAD responses safely. */ export class Respond { /** - * Verify a value is a genuine Response. - * @description Rejects prototype-only fakes whose slot access throws. - * @param value - Candidate returned by a handler or middleware - * @returns True only when the value is a real Response whose slots are readable + * Check value is a genuine Response. + * @description Verifies instance and readable status access. + * @param value - Value to test for Response + * @returns True when value is usable Response */ - static isGenuineResponse(value: unknown): value is Response { + static isGenuine(value: unknown): value is Response { if (!(value instanceof Core.API.Response)) { return false } @@ -26,34 +25,32 @@ export class Respond { /** * Build content-negotiated error response. - * @description Returns JSON or HTML with security headers by Accept. - * @param req - Incoming request - * @param statusCode - HTTP status code to emit - * @param label - Masked error label - * @returns Error Response with security headers + * @description Chooses JSON or text based on request. + * @param req - Incoming request for negotiation + * @param statusCode - HTTP status code to return + * @param label - Error label text + * @returns Negotiated error response */ static negotiatedError(req: Request, statusCode: number, label: string): Response { return Core.Handler.negotiatedResponse(statusCode, label, Core.Handler.wantsJson(req.headers)) } /** - * Build masked error response without Context. - * @description Content-negotiated fallback for faults escaping handleRequest. - * @param req - Incoming request - * @param statusCode - Masked status code to emit - * @returns Error Response with security headers + * Build safe server error response. + * @description Uses safe message for the status code. + * @param req - Incoming request for negotiation + * @param statusCode - HTTP status code to return + * @returns Negotiated safe error response */ static safeServerError(req: Request, statusCode: number): Response { - const errorLabel = Core.Constant.serverErrorMessages[statusCode as Types.HttpStatusCode] ?? - 'Internal Server Error' - return Respond.negotiatedError(req, statusCode, errorLabel) + return Respond.negotiatedError(req, statusCode, Core.Handler.safeMessage(statusCode)) } /** - * Build HEAD response preserving GET headers. - * @description Strips and cancels body, keeps existing Content-Length unchanged. - * @param response - The fully built GET-equivalent response - * @returns Bodyless response with preserved representation headers + * Convert response into HEAD response. + * @description Drops body and preserves status headers. + * @param response - Source response to convert + * @returns Promise resolving to bodyless response */ static async toHeadResponse(response: Response): Promise { const headHeaders = new Core.API.Headers(response.headers) diff --git a/src/routing/Router.ts b/src/routing/Router.ts index dc79f32..76e6314 100644 --- a/src/routing/Router.ts +++ b/src/routing/Router.ts @@ -1,152 +1,165 @@ import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' -import * as Rendering from '@rendering/index.ts' import * as Routing from '@routing/index.ts' import { Immutable } from '@neabyte/utils-core' /** - * Public API for routes and middleware. - * @description Wraps Handler and exposes serve, use, static, catch. + * Public file-based router facade. + * @description Configures middleware, statics, and serves requests. */ export class Router { - /** Wrapped Handler instance */ - private handler: Routing.Handler - /** Directory path for file-based routes */ - private routesDir: string + /** Internal request handler instance */ + readonly #handler: Routing.Handler + /** Whether hot reload is enabled */ + readonly #hotReload: boolean /** - * Create router with routes and options. - * @description Sets Handler options and routes directory. - * @param options - Routes dir, error builder, static handler, worker pool + * Create router instance. + * @description Builds handler and configures hot reload. + * @param options - Optional router configuration */ constructor(options?: Types.RouterOptions) { - this.handler = new Routing.Handler(options) - this.routesDir = options?.routesDir ?? './routes' + this.#handler = new Routing.Handler(options) + this.#hotReload = options?.hotReload !== false Object.freeze(this) } /** - * Set error middleware for all errors. - * @description Replaces or adds error handler before default response. - * @param errorHandler - Function receiving ctx and error info + * Register global error middleware. + * @description Sets handler invoked on unhandled errors. + * @param handler - Error middleware to register */ - catch(errorHandler: Types.ErrorMiddleware): void { - this.handler.setErrorMiddleware(errorHandler) + catch(handler: Types.ErrorMiddleware): void { + this.#handler.setErrorMiddleware(handler) } /** - * Subscribe to lifecycle and error events. - * @description Listener receives every event, filter via event.type. - * @param listener - Callback invoked for each event - * @returns Unsubscribe function + * Subscribe to router events. + * @description Registers listener for emitted events. + * @param listener - Event listener function + * @returns Unsubscribe function removing the listener */ - on(listener: Types.EventListener): () => void { - return this.handler.onEvent(listener) + on(listener: Types.EventFn): () => void { + return this.#handler.onEvent(listener) } /** - * Scan routes and start HTTP server. - * @description Serves on port/host, optional AbortSignal for shutdown. - * @param port - Port number, env PORT or 8000 - * @param hostname - Host, default 0.0.0.0 - * @param signal - Optional abort to stop server + * Scan routes and start server. + * @description Serves requests until shutdown or abort. + * @param port - Optional port to listen on + * @param hostname - Optional hostname to bind + * @param signal - Optional abort signal for shutdown + * @returns Promise resolving when server finishes */ - async serve(port?: number): Promise - async serve(port?: number, hostname?: string): Promise - async serve(port?: number, hostname?: string, signal?: AbortSignal): Promise async serve(port?: number, hostname?: string, signal?: AbortSignal): Promise { - await this.handler.scanRoutes(this.routesDir) - const watcherStops = this.startWatchers() - const unregisterGuard = Core.Guard.register((event) => this.handler.emitEvent(event)) + await this.#handler.scanRoutes() + const disposeWatchers = this.#startWatchers() const resolvedPort = port ?? (Number(Deno.env.get('PORT')) || 8000) const resolvedHost = hostname ?? '0.0.0.0' - const handler = this.handler.createHandler() - const onListen = (addr: Types.ListenAddr) => { - this.handler.emitEvent( - Core.Observability.internalEvent('server:listening', { - port: addr.port, - hostname: addr.hostname - }) - ) - } - const server = Deno.serve({ port: resolvedPort, hostname: resolvedHost, onListen, handler }) + const server = Deno.serve( + { port: resolvedPort, hostname: resolvedHost }, + this.#handler.createHandler() + ) + this.#handler.emitEvent( + Core.Observability.internalEvent('server:started', { + port: resolvedPort, + hostname: resolvedHost + }) + ) const drain = () => { + disposeWatchers() server.shutdown().catch(() => {}) } - const onSignal = signal ? null : drain + const signalHandlers = new Map void>() + for (const name of Router.#shutdownSignals()) { + const handler = () => { + this.#handler.emitEvent( + Core.Observability.externalEvent('process:failed', { + origin: 'process:signal', + error: new Error(`Received ${name} graceful shutdown initiated`) + }) + ) + drain() + } + signalHandlers.set(name, handler) + Deno.addSignalListener(name, handler) + } if (signal) { signal.addEventListener('abort', drain, { once: true }) - } else { - for (const name of Router.shutdownSignals()) { - Deno.addSignalListener(name, drain) - } } try { await server.finished } finally { + disposeWatchers() + this.#handler.terminate() + this.#handler.emitEvent(Core.Observability.internalEvent('server:stopped', {})) + for (const [name, handler] of signalHandlers) { + Deno.removeSignalListener(name, handler) + } if (signal) { signal.removeEventListener('abort', drain) - } else if (onSignal) { - for (const name of Router.shutdownSignals()) { - Deno.removeSignalListener(name, onSignal) - } } - unregisterGuard() - for (const stop of watcherStops) { - stop() - } - this.handler.dispose() - this.handler.emitEvent(Core.Observability.internalEvent('server:shutdown', {})) } } /** - * Register static route at URL path. - * @description Serves files from options.path under urlPath. - * @param urlPath - URL prefix for static files - * @param options - Path, etag, cacheControl + * Mount static file source. + * @description Serves files under the given URL path. + * @param urlPath - URL prefix to mount under + * @param source - Static handler or serve options */ - static(urlPath: string, options: Types.ServeOptions): void { - this.handler.addStaticRoute(urlPath, options) + static(urlPath: string, source: Types.StaticFn | Types.ServeOptions): void { + this.#handler.addStatic(urlPath, source) } /** - * Add global or path-scoped middleware. - * @description Scopes middleware to path prefix when string given. - * @param pathOrMiddleware - Path prefix or first middleware - * @param handlers - One or more middleware functions + * Register path or global middleware. + * @description Adds middleware scoped to path or all. + * @param pathOrHandler - Path string or middleware function + * @param handlers - Additional middleware functions + * @throws {TypeError} When path given without middleware */ - use(...handlers: Types.MiddlewareFn[]): void - use(path: string, ...handlers: Types.MiddlewareFn[]): void - use(pathOrMiddleware: string | Types.MiddlewareFn, ...handlers: Types.MiddlewareFn[]): void { - if (typeof pathOrMiddleware === 'string') { + use(pathOrHandler: string | Types.MiddlewareFn, ...handlers: Types.MiddlewareFn[]): void { + if (typeof pathOrHandler === 'string') { if (handlers.length === 0) { - throw new TypeError(`use("${pathOrMiddleware}") requires at least one middleware function`) + throw new TypeError(`use("${pathOrHandler}") requires at least one middleware function`) } - this.handler.addMiddleware(pathOrMiddleware, ...handlers) + this.#handler.addMiddleware(pathOrHandler, handlers) } else { - this.handler.addMiddleware('', pathOrMiddleware, ...handlers) + this.#handler.addMiddleware('', [pathOrHandler, ...handlers]) } } - /** Signals that trigger graceful shutdown */ - private static shutdownSignals(): readonly Deno.Signal[] { + /** Resolve platform shutdown signals */ + static #shutdownSignals(): readonly Deno.Signal[] { if (Deno.build.os === 'windows') { - return ['SIGINT'] + return ['SIGBREAK', 'SIGINT'] } - return ['SIGINT', 'SIGTERM'] + return ['SIGHUP', 'SIGINT', 'SIGTERM'] } - /** Start watchers for routes and templates */ - private startWatchers(): (() => void)[] { - const stops: (() => void)[] = [Routing.Watcher.watch(this.handler, this.routesDir)] - const viewEngine = this.handler.getViewEngine() - if (viewEngine instanceof Rendering.Engine) { - stops.push(Rendering.Watcher.watch(viewEngine)) + /** Start route and view watchers */ + #startWatchers(): () => void { + if (!this.#hotReload) { + return () => {} + } + const disposers = [Routing.Watcher.watch(this.#handler, this.#handler.routesDir)] + const viewEngine = this.#handler.viewEngine + if (viewEngine !== null) { + disposers.push(Core.View.watch(viewEngine)) + } + let disposed = false + return () => { + if (disposed) { + return + } + disposed = true + for (const dispose of disposers) { + dispose() + } } - return stops } } -/** Freeze Router prototype methods */ +/** Freeze Router prototype to prevent mutation */ Immutable.freeze(Router.prototype) diff --git a/src/routing/Scanner.ts b/src/routing/Scanner.ts index b17a32e..c489b72 100644 --- a/src/routing/Scanner.ts +++ b/src/routing/Scanner.ts @@ -3,16 +3,16 @@ import * as Core from '@core/index.ts' import type { FastRouter } from '@neabyte/fast-router' /** - * File-based route discovery and pattern creation. - * @description Walks directory, converts paths to route patterns. + * Filesystem route scanner. + * @description Discovers, validates, and registers route modules. */ export class Scanner { /** - * Convert file path to router pattern. - * @description Drops extension, [id] to :id, /index to /. - * @param routePath - Relative path like users/[id].ts + * Build route pattern from path. + * @description Returns null when path is not loadable. + * @param routePath - Relative route file path * @param extensions - Allowed file extensions - * @returns Pattern or null if skipped + * @returns Route pattern string or null */ static createPattern(routePath: string, extensions: readonly string[]): string | null { const pathExtension = routePath.split('.').pop()?.toLowerCase() ?? '' @@ -40,14 +40,16 @@ export class Scanner { } /** - * Recursively scan directory and register routes. - * @description Imports each route file, validates and adds to router. - * @param routerInstance - Router to add routes to + * Recursively scan directory for routes. + * @description Imports, validates, and registers found route modules. + * @param routerInstance - Router receiving registered handlers * @param targetDir - Directory to scan - * @param basePath - Path prefix for route paths - * @param methods - HTTP methods to register + * @param basePath - Accumulated relative base path + * @param methods - Supported HTTP methods * @param extensions - Allowed file extensions - * @param emit - Optional lifecycle event emitter + * @param emit - Optional event emitter + * @returns Promise resolving when scan completes + * @throws {Error} When scanning fails for non-missing directory */ static async explore( routerInstance: FastRouter, @@ -55,7 +57,7 @@ export class Scanner { basePath: string, methods: readonly string[], extensions: readonly string[], - emit?: Types.EventEmit + emit: Types.EventFn | null = null ): Promise { try { for await (const dirEntry of Deno.readDir(targetDir)) { @@ -63,39 +65,34 @@ export class Scanner { const routePath = basePath ? `${basePath}/${dirEntry.name}` : dirEntry.name if (dirEntry.isDirectory) { await Scanner.explore(routerInstance, fullPath, routePath, methods, extensions, emit) - } else { - const pathExtension = dirEntry.name.split('.').pop()?.toLowerCase() - if (!extensions.includes(pathExtension ?? '')) { - continue - } - try { - const fileModule = await Core.API.importRouteModule(fullPath) - const routePattern = Scanner.createPattern(routePath, extensions) - if (routePattern) { - Scanner.validateModule(fileModule, routePath, methods) - Scanner.registerHandlers(routerInstance, fileModule, routePattern, methods) - emit?.( - Core.Observability.internalEvent('route:loaded', { - routePath, - pattern: routePattern - }) - ) - } else if (/[^\x20-\x7E]/.test(dirEntry.name)) { - emit?.( - Core.Observability.internalEvent('route:skipped', { - routePath, - reason: 'filename contains non-ASCII characters' - }) - ) - } - } catch (fileError) { - emit?.( - Core.Observability.internalEvent('route:error', { - routePath, - error: fileError instanceof Error ? fileError : new Error(String(fileError)) + continue + } + const pathExtension = dirEntry.name.split('.').pop()?.toLowerCase() + if (!extensions.includes(pathExtension ?? '')) { + continue + } + const routePattern = Scanner.createPattern(routePath, extensions) + if (routePattern === null) { + if (emit !== null) { + emit( + Core.Observability.internalEvent('route:ignored', { + path: routePath, + reason: 'route path does not match a loadable pattern' }) ) } + continue + } + const fileModule = await Core.API.importRouteModule(fullPath) + Scanner.validateModule(fileModule, routePath, methods) + Scanner.registerHandlers(routerInstance, fileModule, routePattern, methods) + if (emit !== null) { + emit( + Core.Observability.internalEvent('route:added', { + path: routePath, + pattern: routePattern + }) + ) } } } catch (scanError) { @@ -107,12 +104,12 @@ export class Scanner { } /** - * Register handlers from module to router. - * @description Iterates methods, checks for function exports, adds to router. - * @param routerInstance - Router to add routes to - * @param fileModule - Loaded route module - * @param routePattern - Route URL pattern - * @param methods - HTTP methods to register + * Register module handlers on router. + * @description Adds function exports for each supported method. + * @param routerInstance - Router receiving registered handlers + * @param fileModule - Imported route module + * @param routePattern - Route pattern to register + * @param methods - Supported HTTP methods */ static registerHandlers( routerInstance: FastRouter, @@ -125,22 +122,18 @@ export class Scanner { if (typeof routeHandler !== 'function') { continue } - const routeEntry: Types.RouteEntry = { - kind: 'handler', - handler: routeHandler, - pattern: routePattern - } - routerInstance.add(method, routePattern, routeEntry) + routerInstance.add(method, routePattern, { handler: routeHandler, pattern: routePattern }) } } /** - * Ensure module exports one HTTP method. - * @description Validates at least one method export is a function. - * @param module - Loaded route module object - * @param routePath - Path for error messages - * @param methods - Valid HTTP method names - * @throws {Deno.errors.InvalidData} When no method or non-function export + * Validate route module exports. + * @description Ensures method exports are functions and present. + * @param module - Imported route module + * @param routePath - Relative route file path + * @param methods - Supported HTTP methods + * @throws {TypeError} When a method export is not function + * @throws {Deno.errors.InvalidData} When no method is exported */ static validateModule( module: Types.RouteModule, diff --git a/src/routing/Watcher.ts b/src/routing/Watcher.ts index 2424dd6..921b1f2 100644 --- a/src/routing/Watcher.ts +++ b/src/routing/Watcher.ts @@ -6,60 +6,64 @@ import { createSequential } from '@neabyte/utils-core' import nodePath from 'node:path' /** - * File watcher for route modules. - * @description Watches routesDir and hot-reloads routes on change. + * Route directory file watcher. + * @description Reloads and removes routes on file changes. */ export class Watcher { /** - * Start watching routes directory. - * @description Uses Superwatcher with sequential reloading. - * @param handler - Handler instance to reload routes on - * @param routesDir - Routes directory to watch - * @returns Stop handle releasing the watcher + * Watch routes directory for changes. + * @description Debounces reloads and removals on filesystem events. + * @param handler - Route handler to update + * @param routesDir - Directory path to watch + * @returns Dispose function stopping the watcher */ static watch(handler: Routing.Handler, routesDir: string): () => void { - const extensions = Core.Constant.allowedExtensions - const extensionSet: Set = new Set(extensions) const resolvedDir = nodePath.resolve(routesDir) if (!Core.Handler.isDirectory(resolvedDir)) { return () => {} } - const reloader = createSequential(async () => { - for (const routePath of pendingRemovals) { - const routePattern = Routing.Scanner.createPattern(routePath, extensions) - if (routePattern) { - handler.removeRoute(routePattern, routePath) - } + const extensions = new Set(Core.Constant.allowedExtensions) + const pendingChanges = new Map() + const pendingRemovals = new Map() + const drain = createSequential(async () => { + for (const [routePattern, routePath] of pendingRemovals) { + handler.removeRoute(routePattern) + handler.emitEvent( + Core.Observability.internalEvent('route:removed', { + path: routePath, + pattern: routePattern + }) + ) } - for (const entry of pendingChanges.values()) { - await handler.reloadRoute(entry.fullPath, entry.routePath) + pendingRemovals.clear() + for (const change of pendingChanges.values()) { + await handler.reloadRoute(change.fullPath, change.routePath) } pendingChanges.clear() - pendingRemovals.clear() }) - const pendingChanges = new Map() - const pendingRemovals = new Set() const watcher = new Superwatcher({ path: resolvedDir, debounceMs: Core.Constant.routeDebounceMs, - ignore: [ - (path: string) => { - const fileExtension = path.split('.').pop()?.toLowerCase() ?? '' - return !extensionSet.has(fileExtension) - } - ], + ignore: [(path) => !extensions.has(path.split('.').pop()?.toLowerCase() ?? '')], onChange(events) { for (const event of events) { const routePath = event.path.slice(resolvedDir.length + 1) + const routePattern = Routing.Scanner.createPattern( + routePath, + Core.Constant.allowedExtensions + ) + if (routePattern === null) { + continue + } if (event.kind === 'remove') { - pendingRemovals.add(routePath) pendingChanges.delete(event.path) + pendingRemovals.set(routePattern, routePath) } else { - pendingRemovals.delete(routePath) + pendingRemovals.delete(routePattern) pendingChanges.set(event.path, { fullPath: event.path, routePath }) } } - reloader.execute().catch(() => {}) + drain.execute().catch(() => {}) } }) watcher.start() diff --git a/src/routing/index.ts b/src/routing/index.ts index 5886b8b..8967d1f 100644 --- a/src/routing/index.ts +++ b/src/routing/index.ts @@ -1,4 +1,4 @@ -/** Re-exports routing public API. */ +/** Re-exports routing public API */ export * from '@routing/Handler.ts' export * from '@routing/Report.ts' export * from '@routing/Respond.ts' diff --git a/src/validation/Reason.ts b/src/validation/Reason.ts deleted file mode 100644 index bd154fe..0000000 --- a/src/validation/Reason.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' - -/** - * Validation error to status mapper. - * @description Maps validation throws to HTTP status errors. - */ -export class Reason { - /** - * Map a validation error to status. - * @description Preserves existing status, else maps client input to 422. - * @param error - Unknown value thrown during validation - * @returns StatusError carrying the resolved status code - */ - static toStatusError(error: unknown): Types.StatusError { - if (Core.Handler.isErrorWithStatus(error)) { - return error - } - if (error instanceof Error && Array.isArray(error.cause)) { - const reasons = (error.cause as readonly unknown[]).filter( - (reason): reason is string => typeof reason === 'string' - ) - const message = reasons.length > 0 ? reasons.join('; ') : 'Validation failed' - const statusError = Core.Handler.createStatusError(422, message) - Object.defineProperty(statusError, 'cause', { - value: reasons, - writable: false, - enumerable: false, - configurable: false - }) - return statusError - } - return Core.Handler.createStatusError(422, 'Unprocessable request input') - } -} diff --git a/src/validation/Source.ts b/src/validation/Source.ts deleted file mode 100644 index deb0e7f..0000000 --- a/src/validation/Source.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import type * as Core from '@core/index.ts' - -/** - * Request source value extractor. - * @description Reads validation input from request sources. - */ -export class Source { - /** Extractor map per validation source */ - private static readonly extractors: Readonly< - Record - > = { - body: (ctx) => ctx.body(), - cookies: (ctx) => ctx.cookie(), - headers: (ctx) => ctx.header(), - json: (ctx) => ctx.json(), - params: (ctx) => ctx.params(), - query: (ctx) => ctx.query() - } - - /** - * Extract input value for source. - * @description Reads request data for the given source. - * @param source - Validation source name - * @param ctx - Request context instance - * @returns Extracted source value - */ - static extract(source: Types.ValidationSource, ctx: Core.Context): Types.MaybeAsync { - return Source.extractors[source](ctx) - } -} diff --git a/src/validation/Validator.ts b/src/validation/Validator.ts deleted file mode 100644 index 9a8ea92..0000000 --- a/src/validation/Validator.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import * as Core from '@core/index.ts' -import * as Validation from '@validation/index.ts' -import type { ContractFn, ContractInput } from '@neabyte/typebox' - -/** - * Standalone contract validation helpers. - * @description Validates input and reads validated request data. - */ -export class Validator { - /** - * Validate input against a contract. - * @description Runs contract, maps failures to status errors. - * @param contract - Typebox contract function - * @param input - Value to validate - * @returns Validated contract result - * @throws {Types.StatusError} When validation fails - * @template ContractType - Contract function type - */ - static check( - contract: ContractType, - input: ContractInput - ): ReturnType { - try { - return contract(input as never) as ReturnType - } catch (error) { - throw Validation.Reason.toStatusError(error) - } - } - - /** - * Read validated data from context. - * @description Returns state set by the validator middleware. - * @param ctx - Request context instance - * @returns Validated data for the schema - * @throws {Types.StatusError} When no validated data exists - * @template SchemaType - Validation schema type - */ - static read( - ctx: Core.Context - ): Types.ValidatedData { - const validated = ctx.getState(Core.Handler.stateKeys.validated) - if (validated === undefined) { - throw Core.Handler.createStatusError( - 500, - 'No validated data found, register Mware.validator(schema) before reading it' - ) - } - return validated as Types.ValidatedData - } -} diff --git a/src/validation/index.ts b/src/validation/index.ts deleted file mode 100644 index fe70e27..0000000 --- a/src/validation/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Re-exports validation public API. */ -export * from '@validation/Reason.ts' -export * from '@validation/Source.ts' -export * from '@validation/Validator.ts' diff --git a/tests/config/HumanError.test.ts b/tests/config/HumanError.test.ts deleted file mode 100644 index 0643536..0000000 --- a/tests/config/HumanError.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { assertEquals, assertThrows } from '@std/assert' -import * as Core from '@core/index.ts' -import * as Middleware from '@middleware/index.ts' -import * as Routing from '@routing/index.ts' - -const echoWorkerUrl = import.meta.resolve('@tests/fixtures/echo_worker.ts') - -Deno.test('BodyLimit with limit 0 is rejected at creation', () => { - assertThrows( - () => Middleware.Mware.bodyLimit({ limit: 0 }), - Deno.errors.InvalidData, - 'positive finite' - ) -}) - -Deno.test('BodyLimit with negative limit is rejected at creation', () => { - assertThrows( - () => Middleware.Mware.bodyLimit({ limit: -1 }), - Deno.errors.InvalidData, - 'positive finite' - ) -}) - -Deno.test('Router constructor accepts poolSize 0 as 1', async () => { - const router = new Routing.Router({ - routesDir: './routes', - worker: { scriptURL: echoWorkerUrl, poolSize: 0 } - }) - const handler = (router as unknown as { handler: unknown }).handler as { - workerPool?: Core.Worker - } - assertEquals(handler.workerPool !== undefined, true) - try { - const result = await handler.workerPool!.run('ok') - assertEquals(result, 'ok') - } finally { - handler.workerPool!.terminate() - } -}) - -Deno.test('Router constructor throws on invalid worker config', () => { - assertThrows(() => { - new Routing.Router({ - routesDir: './routes', - worker: { scriptURL: 'not-a-valid-worker-specifier', poolSize: 1 } - }) - }) -}) - -Deno.test('Worker#createPool with poolSize 0 creates working pool', async () => { - const pool = Core.Worker.createPool({ scriptURL: echoWorkerUrl, poolSize: 0 }) - try { - const result = await pool.run('hello') - assertEquals(result, 'hello') - } finally { - pool.terminate() - } -}) - -Deno.test('Worker#createPool with poolSize 0 uses 1', () => { - assertThrows(() => Core.Worker.createPool({ scriptURL: 'not-a-valid-worker-specifier' })) -}) diff --git a/tests/core/API.test.ts b/tests/core/API.test.ts index 13112a0..960e6c9 100644 --- a/tests/core/API.test.ts +++ b/tests/core/API.test.ts @@ -1,55 +1,41 @@ import { assertEquals } from '@std/assert' import * as Core from '@core/index.ts' -import * as Routing from '@routing/index.ts' -Deno.test('API jsonParse and jsonStringify resist a patched global JSON', () => { - const realParse = globalThis.JSON.parse - const realStringify = globalThis.JSON.stringify - try { - ;(globalThis.JSON as unknown as { parse: unknown }).parse = () => ({ hijacked: true }) - ;(globalThis.JSON as unknown as { stringify: unknown }).stringify = () => 'hijacked' - assertEquals(Core.API.jsonParse('{"value":42}'), { value: 42 }) - assertEquals(Core.API.jsonStringify({ value: 42 }), '{"value":42}') - } finally { - ;(globalThis.JSON as unknown as { parse: unknown }).parse = realParse - ;(globalThis.JSON as unknown as { stringify: unknown }).stringify = realStringify - } -}) - -Deno.test('API table is frozen and exposes pinned built-ins', () => { - assertEquals(Object.isFrozen(Core.API), true) +Deno.test('API exposes pinned native built-ins', () => { assertEquals(typeof Core.API.Response, 'function') assertEquals(typeof Core.API.Headers, 'function') + assertEquals(typeof Core.API.Request, 'function') assertEquals(typeof Core.API.URL, 'function') - assertEquals(typeof Core.API.jsonParse, 'function') + assertEquals(typeof Core.API.Error, 'function') + assertEquals(typeof Core.API.TextEncoder, 'function') + assertEquals(typeof Core.API.TextDecoder, 'function') + assertEquals(typeof Core.API.Worker, 'function') assertEquals(typeof Core.API.subtle, 'object') }) -Deno.test('Handler produces a genuine Response when globalThis.Response is patched after load', async () => { - const realResponse = globalThis.Response +Deno.test('API importRouteModule rejects for a missing module', async () => { + let threw = false try { - ;(globalThis as unknown as { Response: unknown }).Response = class { - constructor() { - throw new Error('hijacked Response') - } - static json(): never { - throw new Error('hijacked Response.json') - } - } - const serve = new Routing.Handler().createHandler() - const html = await serve( - new Request('http://localhost/missing', { headers: { accept: 'text/html' } }) - ) - const json = await serve( - new Request('http://localhost/missing', { headers: { accept: 'application/json' } }) - ) - assertEquals(html instanceof realResponse, true) - assertEquals(html.status, 404) - assertEquals(html.headers.get('content-type'), 'text/html; charset=utf-8') - assertEquals(json instanceof realResponse, true) - assertEquals(json.status, 404) - assertEquals(json.headers.get('content-type'), 'application/problem+json') - } finally { - ;(globalThis as unknown as { Response: unknown }).Response = realResponse + await Core.API.importRouteModule('/does/not/exist/route.ts') + } catch { + threw = true } + assertEquals(threw, true) +}) + +Deno.test('API jsonParse and jsonStringify round-trip a value', () => { + const text = Core.API.jsonStringify({ value: 42, nested: { ok: true } }) + assertEquals(Core.API.jsonParse(text), { value: 42, nested: { ok: true } }) +}) + +Deno.test('API jsonParse parses JSON text', () => { + assertEquals(Core.API.jsonParse('{"value":42}'), { value: 42 }) +}) + +Deno.test('API jsonStringify serializes value', () => { + assertEquals(Core.API.jsonStringify({ value: 42 }), '{"value":42}') +}) + +Deno.test('API table is frozen', () => { + assertEquals(Object.isFrozen(Core.API), true) }) diff --git a/tests/core/Constant.test.ts b/tests/core/Constant.test.ts index de68980..1a61bc1 100644 --- a/tests/core/Constant.test.ts +++ b/tests/core/Constant.test.ts @@ -1,35 +1,60 @@ import { assertEquals } from '@std/assert' import * as Core from '@core/index.ts' -Deno.test('Constant#allowedExtensions contains expected route extensions', () => { - assertEquals(Core.Constant.allowedExtensions.includes('ts'), true) - assertEquals(Core.Constant.allowedExtensions.includes('tsx'), true) - assertEquals(Core.Constant.allowedExtensions.includes('js'), true) - assertEquals(Core.Constant.allowedExtensions.includes('jsx'), true) - assertEquals(Core.Constant.allowedExtensions.includes('mjs'), true) - assertEquals(Core.Constant.allowedExtensions.includes('cjs'), true) - assertEquals(Core.Constant.allowedExtensions.length, 6) -}) - -Deno.test('Constant#contentTypes has common MIME types', () => { - assertEquals(Core.Constant.contentTypes['html'], 'text/html') - assertEquals(Core.Constant.contentTypes['json'], 'application/json') +Deno.test('Constant content types map known extensions', () => { + assertEquals(Core.Constant.contentTypes['html'], 'text/html; charset=utf-8') + assertEquals(Core.Constant.contentTypes['css'], 'text/css; charset=utf-8') assertEquals(Core.Constant.contentTypes['png'], 'image/png') - assertEquals(Core.Constant.contentTypes['js'], 'application/javascript') - assertEquals(Core.Constant.contentTypes['txt'], 'text/plain') - assertEquals(Core.Constant.contentTypes['pdf'], 'application/pdf') - assertEquals(Core.Constant.contentTypes['css'], 'text/css') - assertEquals(Core.Constant.contentTypes['svg'], 'image/svg+xml') -}) - -Deno.test('Constant#httpMethods contains standard HTTP methods', () => { - assertEquals(Core.Constant.httpMethods, [ - 'DELETE', - 'GET', - 'HEAD', - 'OPTIONS', - 'PATCH', - 'POST', - 'PUT' - ]) + assertEquals(Core.Constant.defaultContentType, 'application/octet-stream') +}) + +Deno.test('Constant default session options use safe defaults', () => { + assertEquals(Core.Constant.defaultSessionOptions.name, 'session') + assertEquals(Core.Constant.defaultSessionOptions.httpOnly, true) + assertEquals(Core.Constant.defaultSessionOptions.sameSite, 'Lax') +}) + +Deno.test('Constant exposes default numeric limits', () => { + assertEquals(Core.Constant.maxUrlLength, 8192) + assertEquals(Core.Constant.maxParamLength, 1024) + assertEquals(Core.Constant.defaultPoolSize, 4) + assertEquals(Core.Constant.defaultQueueFactor, 8) + assertEquals(Core.Constant.defaultQueueWaitMs, 2000) + assertEquals(Core.Constant.defaultWorkerTaskTimeoutMs, 5000) +}) + +Deno.test('Constant html escape map covers unsafe characters', () => { + assertEquals(Core.Constant.htmlEscapeMap['&'], '&') + assertEquals(Core.Constant.htmlEscapeMap['<'], '<') + assertEquals(Core.Constant.htmlEscapeMap['>'], '>') + assertEquals(Core.Constant.htmlEscapeMap['"'], '"') + assertEquals(Core.Constant.htmlEscapeMap["'"], ''') +}) + +Deno.test('Constant http methods include common verbs', () => { + assertEquals(Core.Constant.httpMethods.includes('GET'), true) + assertEquals(Core.Constant.httpMethods.includes('POST'), true) + assertEquals(Core.Constant.httpMethods.includes('DELETE'), true) +}) + +Deno.test('Constant null body and redirect status sets', () => { + assertEquals(Core.Constant.nullBodyStatuses.has(204), true) + assertEquals(Core.Constant.nullBodyStatuses.has(200), false) + assertEquals(Core.Constant.redirectStatuses.has(302), true) + assertEquals(Core.Constant.redirectStatuses.has(200), false) +}) + +Deno.test('Constant security headers carry defaults', () => { + assertEquals(Core.Constant.securityHeaders.xContentTypeOptions.default, 'nosniff') + assertEquals(Core.Constant.securityHeaders.xFrameOptions.default, 'SAMEORIGIN') +}) + +Deno.test('Constant server error messages map status codes', () => { + assertEquals(Core.Constant.serverErrorMessages[404], 'Not Found') + assertEquals(Core.Constant.serverErrorMessages[500], 'Internal Server Error') +}) + +Deno.test('Constant shared encoder and decoder are usable', () => { + const bytes = Core.Constant.encoder.encode('hi') + assertEquals(Core.Constant.decoder.decode(bytes), 'hi') }) diff --git a/tests/core/Context.test.ts b/tests/core/Context.test.ts index a50d5c6..9756f96 100644 --- a/tests/core/Context.test.ts +++ b/tests/core/Context.test.ts @@ -1,1054 +1,183 @@ import { assertEquals } from '@std/assert' import * as Core from '@core/index.ts' +import Helper from '@tests/helper.ts' -function createTestContext( - url = 'http://localhost/', - routeParams: Record = {}, - requestInit?: RequestInit -): Core.Context { - const request = new Request(url, requestInit) - return new Core.Context(request, new URL(url), routeParams) -} - -Deno.test('Context#arrayBuffer reads body as ArrayBuffer', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'hello' }) - const buf = await ctx.arrayBuffer() - assertEquals(buf.byteLength, 5) -}) - -Deno.test('Context#arrayBuffer returns cached on second call', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'test' }) - const first = await ctx.arrayBuffer() - const second = await ctx.arrayBuffer() - assertEquals(first, second) -}) - -Deno.test('Context#arrayBuffer then json throws already consumed', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' }) - await ctx.arrayBuffer() - let thrown = false - try { - await ctx.json() - } catch (e) { - thrown = true - assertEquals((e as Error).message, 'Request body already consumed') - } - assertEquals(thrown, true) -}) - -Deno.test('Context#blob reads body as Blob', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' }) - const blob = await ctx.blob() - assertEquals(blob.size, 4) -}) - -Deno.test('Context#blob returns cached on second call', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' }) - const first = await ctx.blob() - const second = await ctx.blob() - assertEquals(first, second) -}) - -Deno.test('Context#blob then text throws already consumed', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' }) - await ctx.blob() - let thrown = false - try { - await ctx.text() - } catch (e) { - thrown = true - assertEquals((e as Error).message, 'Request body already consumed') - } - assertEquals(thrown, true) -}) - -Deno.test('Context#body is not fooled by a parameter containing a type token', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: '{"a":3}', - headers: new Headers({ 'Content-Type': 'text/html; z=application/json' }) - } - ) - const body = await ctx.body() - assertEquals(body, '{"a":3}') -}) - -Deno.test('Context#body parses JSON and caches result', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: JSON.stringify({ a: 1, b: 'x' }), - headers: new Headers({ 'Content-Type': 'application/json' }) - } - ) - const firstParsedBody = (await ctx.body()) as { a: number; b: string } - assertEquals(firstParsedBody.a, 1) - assertEquals(firstParsedBody.b, 'x') - const secondParsedBody = await ctx.body() - assertEquals(secondParsedBody, firstParsedBody) -}) - -Deno.test('Context#body parses form-urlencoded as FormData', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: 'foo=bar&baz=qux', - headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' }) - } - ) - const formData = (await ctx.body()) as FormData - assertEquals(formData.get('foo'), 'bar') - assertEquals(formData.get('baz'), 'qux') -}) - -Deno.test('Context#body parses mixed-case Application/Json as JSON', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: '{"a":2}', - headers: new Headers({ 'Content-Type': 'Application/Json; charset=utf-8' }) - } - ) - const body = (await ctx.body()) as { a: number } - assertEquals(body.a, 2) -}) - -Deno.test('Context#body parses multipart/form-data', async () => { - const multipartBody = - `------TestBoundary\r\nContent-Disposition: form-data; name="field1"\r\n\r\nvalue1\r\n------TestBoundary--\r\n` - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: multipartBody, - headers: new Headers({ 'Content-Type': `multipart/form-data; boundary=----TestBoundary` }) - } - ) - const formData = (await ctx.body()) as FormData - assertEquals(formData.get('field1'), 'value1') -}) - -Deno.test('Context#body parses uppercase application/json case-insensitively', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: '{"a":1}', - headers: new Headers({ 'Content-Type': 'APPLICATION/JSON' }) - } - ) - const body = (await ctx.body()) as { a: number } - assertEquals(body.a, 1) -}) - -Deno.test('Context#body re-throws a status-bearing JSON read error instead of returning null', async () => { - const stream = new ReadableStream({ - pull() { - throw Object.assign(new globalThis.Error('Payload Too Large'), { statusCode: 413 }) - } +Deno.test('Context get.cookie parses cookie header', () => { + const ctx = Helper.createTestContext('http://localhost/', { + headers: { cookie: 'a=1; b=2' } }) - const req = new Request( - 'http://localhost/', - { - method: 'POST', - body: stream, - headers: new Headers({ 'Content-Type': 'application/json' }), - duplex: 'half' - } as RequestInit & { duplex: 'half' } - ) - const ctx = new Core.Context(req, new URL('http://localhost/'), {}) - let thrown = false - try { - await ctx.body() - } catch (e) { - thrown = true - assertEquals((e as { statusCode?: number }).statusCode, 413) - } - assertEquals(thrown, true) + assertEquals(ctx.get.cookie('a'), '1') + assertEquals(ctx.get.cookie('b'), '2') }) -Deno.test('Context#body re-throws a status-bearing form read error instead of returning null', async () => { - const stream = new ReadableStream({ - pull() { - throw Object.assign(new globalThis.Error('Payload Too Large'), { statusCode: 413 }) - } +Deno.test('Context get.header reads a single header value', () => { + const ctx = Helper.createTestContext('http://localhost/', { + headers: { 'x-test': 'value' } }) - const req = new Request( - 'http://localhost/', - { - method: 'POST', - body: stream, - headers: new Headers({ 'Content-Type': 'multipart/form-data; boundary=x' }), - duplex: 'half' - } as RequestInit & { duplex: 'half' } - ) - const ctx = new Core.Context(req, new URL('http://localhost/'), {}) - let thrown = false - try { - await ctx.body() - } catch (e) { - thrown = true - assertEquals((e as { statusCode?: number }).statusCode, 413) - } - assertEquals(thrown, true) + assertEquals(ctx.get.header('x-test'), 'value') }) -Deno.test('Context#body returns null for malformed JSON instead of throwing', async () => { - const ctx = createTestContext('http://localhost/', {}, { +Deno.test('Context get.json reads JSON body', async () => { + const ctx = Helper.createTestContext('http://localhost/', { method: 'POST', - body: '{not valid json!!!', - headers: new Headers({ 'Content-Type': 'application/json' }) + body: JSON.stringify({ name: 'x' }), + headers: { 'content-type': 'application/json' } }) - const result = await ctx.body() - assertEquals(result, null) + assertEquals(await ctx.get.json(), { name: 'x' }) }) -Deno.test('Context#body returns null for malformed multipart instead of throwing', async () => { - const ctx = createTestContext('http://localhost/', {}, { +Deno.test('Context get.json throws after body was already consumed as text', async () => { + const ctx = Helper.createTestContext('http://localhost/', { method: 'POST', - body: 'this is not multipart data', - headers: new Headers({ 'Content-Type': 'multipart/form-data; boundary=----nonexistent' }) + body: 'hello' }) - const result = await ctx.body() - assertEquals(result, null) -}) - -Deno.test('Context#body then formData throws when body already parsed as non-form', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: JSON.stringify({ a: 1 }), - headers: new Headers({ 'Content-Type': 'application/json' }) - } - ) - await ctx.body() - let thrown = false + await ctx.get.text() + let threw = false try { - await ctx.formData() - } catch (e) { - thrown = true - assertEquals((e as Error).message, 'Request body already consumed') + await ctx.get.json() + } catch { + threw = true } - assertEquals(thrown, true) -}) - -Deno.test('Context#body trims surrounding whitespace in content-type', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: '{"a":4}', - headers: new Headers({ 'Content-Type': ' application/json ' }) - } - ) - const body = (await ctx.body()) as { a: number } - assertEquals(body.a, 4) -}) - -Deno.test('Context#body with no content-type returns text', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'default text' }) - const body = await ctx.body() - assertEquals(body, 'default text') -}) - -Deno.test('Context#body with plain text content-type returns string', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: 'just text', - headers: new Headers({ 'Content-Type': 'text/plain' }) - } - ) - const body = await ctx.body() - assertEquals(body, 'just text') -}) - -Deno.test('Context#cookie caching returns same map on repeated calls', () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { headers: new Headers({ Cookie: 'a=1; b=2' }) } - ) - const first = ctx.cookie() as Record - const second = ctx.cookie() as Record - assertEquals(first, second) - assertEquals(first['a'], '1') - assertEquals(first['b'], '2') + assertEquals(threw, true) }) -Deno.test('Context#cookie does not let a non-breaking-space name shadow a real cookie', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: '\u00A0sid=attacker; sid=legit' }) - }) - assertEquals(ctx.cookie('sid'), 'legit') - assertEquals(ctx.cookie('\u00A0sid'), 'attacker') +Deno.test('Context get.method returns request method', () => { + const ctx = Helper.createTestContext('http://localhost/', { method: 'POST' }) + assertEquals(ctx.get.method(), 'POST') }) -Deno.test('Context#cookie map uses a null prototype', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: 'sid=abc' }) - }) - const cookies = ctx.cookie() as Record - assertEquals(Object.getPrototypeOf(cookies), null) +Deno.test('Context get.query reads a single query value', () => { + const ctx = Helper.createTestContext('http://localhost/?page=2') + assertEquals(ctx.get.query('page'), '2') }) -Deno.test('Context#cookie returns first value when duplicate keys exist', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: 'sid=first; sid=second' }) - }) - assertEquals(ctx.cookie('sid'), 'first') +Deno.test('Context get.query returns a record when no key given', () => { + const ctx = Helper.createTestContext('http://localhost/?a=1&b=2') + assertEquals(ctx.get.query(), { a: '1', b: '2' }) }) -Deno.test('Context#cookie returns value by key', () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - headers: new Headers({ Cookie: 'sid=abc123; foo=bar' }) - } - ) - assertEquals(ctx.cookie('sid'), 'abc123') - assertEquals(ctx.cookie('foo'), 'bar') - assertEquals(ctx.cookie('missing'), undefined) +Deno.test('Context get.url and pathname expose parsed URL', () => { + const ctx = Helper.createTestContext('http://localhost/users?page=2') + assertEquals(ctx.get.url().href, 'http://localhost/users?page=2') + assertEquals(ctx.get.pathname(), '/users') }) -Deno.test('Context#cookie skips entries without equals sign', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: 'malformed; valid=yes; alsobroken' }) - }) - assertEquals(ctx.cookie('malformed'), undefined) - assertEquals(ctx.cookie('valid'), 'yes') - assertEquals(ctx.cookie('alsobroken'), undefined) -}) - -Deno.test('Context#cookie treats reserved names as plain data keys', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: '__proto__=x; toString=y; constructor=z' }) - }) - const cookies = ctx.cookie() as Record - assertEquals(cookies['__proto__'], 'x') - assertEquals(cookies['toString'], 'y') - assertEquals(cookies['constructor'], 'z') - assertEquals(Object.hasOwn(cookies, '__proto__'), true) -}) - -Deno.test('Context#cookie trims only SP and HTAB around names like browsers do', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: ' sid \t=value;\tfoo = bar' }) - }) - assertEquals(ctx.cookie('sid'), 'value') - assertEquals(ctx.cookie('foo'), ' bar') -}) - -Deno.test('Context#cookie trims whitespace from cookie key', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: ' token =abc123' }) - }) - assertEquals(ctx.cookie('token'), 'abc123') -}) - -Deno.test('Context#cookie with empty cookie header returns empty map', () => { - const ctx = createTestContext('http://localhost/') - const all = ctx.cookie() as Record - assertEquals(Object.keys(all).length, 0) -}) - -Deno.test('Context#cookie with reserved names keeps Object prototype intact', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ Cookie: '__proto__=evil; toString=hijack' }) - }) - ctx.cookie() - assertEquals(typeof ({}).toString, 'function') - assertEquals(({} as Record)['evil' as string], undefined) -}) - -Deno.test('Context#cookie with value containing = sign', () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { headers: new Headers({ Cookie: 'token=abc=def=ghi' }) } - ) - assertEquals(ctx.cookie('token'), 'abc=def=ghi') -}) - -Deno.test('Context#cookie without key returns all cookies', () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - headers: new Headers({ Cookie: 'a=1; b=2' }) - } - ) - const allCookies = ctx.cookie() as Record - assertEquals(allCookies['a'], '1') - assertEquals(allCookies['b'], '2') -}) - -Deno.test('Context#formData reads form data directly', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: 'key=value', - headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' }) - } - ) - const fd = await ctx.formData() - assertEquals(fd.get('key'), 'value') -}) - -Deno.test('Context#formData returns cached on second call', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: 'a=1', - headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' }) - } - ) - const first = await ctx.formData() - const second = await ctx.formData() - assertEquals(first, second) -}) - -Deno.test('Context#formData then blob throws already consumed', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: 'key=value', - headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' }) - } - ) - await ctx.formData() - let thrown = false +Deno.test('Context get.validated throws without validate middleware', () => { + const ctx = Helper.createTestContext() + let threw = false try { - await ctx.blob() + ctx.get.validated() } catch (e) { - thrown = true - assertEquals((e as Error).message, 'Request body already consumed') + threw = true + assertEquals(e instanceof Deno.errors.NotSupported, true) } - assertEquals(thrown, true) -}) - -Deno.test('Context#getState and setState are mutable and shared', () => { - const ctx = createTestContext('http://localhost/') - const customKey = Core.Handler.stateKey<{ user: string }>('customUser') - assertEquals(ctx.getState(customKey), undefined) - ctx.setState(customKey, { user: 'x' }) - assertEquals(ctx.getState(customKey)?.user, 'x') + assertEquals(threw, true) }) -Deno.test('Context#handleError when errorHandler throws propagates', async () => { - const request = new Request('http://localhost/') - const ctx = new Core.Context(request, new URL('http://localhost/'), {}, (_ctx, _code, _err) => { - throw new Error('handler threw') - }) - let thrown = false +Deno.test('Context get.worker throws without worker pool', () => { + const ctx = Helper.createTestContext() + let threw = false try { - await ctx.handleError(500, new Error('original')) + ctx.get.worker() } catch (e) { - thrown = true - assertEquals((e as Error).message, 'handler threw') + threw = true + assertEquals(e instanceof Deno.errors.NotSupported, true) } - assertEquals(thrown, true) + assertEquals(threw, true) }) -Deno.test('Context#handleError with handler uses custom response', async () => { - const request = new Request('http://localhost/') - const ctx = new Core.Context(request, new URL('http://localhost/'), {}, async (_, statusCode) => { - return new Response(`custom ${statusCode}`, { status: statusCode }) +Deno.test('Context handleError builds a default error response', async () => { + const ctx = Helper.createTestContext('http://localhost/missing', { + headers: { accept: 'text/html' } }) - const res = await ctx.handleError(418, new Error('teapot')) - assertEquals(res.status, 418) - assertEquals(await res.text(), 'custom 418') -}) - -Deno.test('Context#handleError without handler returns error page with status', async () => { - const ctx = createTestContext('http://localhost/') - const res = await ctx.handleError(503, new Error('unavailable')) - assertEquals(res.status, 503) - const body = await res.text() - assertEquals(body.includes('503'), true) + const res = await ctx.handleError(404, new Error('nope')) + assertEquals(res.status, 404) + assertEquals(res.headers.get('content-type'), 'text/html; charset=utf-8') }) -Deno.test('Context#handleError without handler returns response with status only', async () => { - const ctx = createTestContext('http://localhost/') - const res = await ctx.handleError(503, new Error('unavailable')) - assertEquals(res.status, 503) -}) - -Deno.test('Context#header map uses a null prototype', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ 'X-Custom': 'value' }) - }) - const all = ctx.header() as Record - assertEquals(Object.getPrototypeOf(all), null) -}) - -Deno.test('Context#header returns value by key (case-insensitive)', () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - headers: new Headers({ 'X-Custom': 'value', Accept: 'text/html' }) - } - ) - assertEquals(ctx.header('x-custom'), 'value') - assertEquals(ctx.header('Accept'), 'text/html') -}) - -Deno.test('Context#header treats reserved names as plain data keys', () => { - const ctx = createTestContext('http://localhost/', {}, { - headers: new Headers({ toString: 'y', constructor: 'z' }) +Deno.test('Context handleError negotiates JSON when accepted', async () => { + const ctx = Helper.createTestContext('http://localhost/missing', { + headers: { accept: 'application/json' } }) - const all = ctx.header() as Record - assertEquals(all['tostring'], 'y') - assertEquals(all['constructor'], 'z') - assertEquals(typeof ({}).toString, 'function') + const res = await ctx.handleError(404, new Error('nope')) + assertEquals(res.status, 404) + assertEquals(res.headers.get('content-type'), 'application/problem+json') }) -Deno.test('Context#header without key returns all headers', () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { headers: new Headers({ 'X-A': '1', 'X-B': '2' }) } - ) - const all = ctx.header() as Record - assertEquals(all['x-a'], '1') - assertEquals(all['x-b'], '2') +Deno.test('Context internalOf exposes the control surface', () => { + const ctx = Helper.createTestContext() + const internal = Core.Context.internalOf(ctx) + assertEquals(typeof internal.setParams, 'function') + assertEquals(typeof internal.finalizeRaw, 'function') }) -Deno.test('Context#json reads body as JSON', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: JSON.stringify({ ok: true }), - headers: new Headers({ 'Content-Type': 'application/json' }) - } - ) - const data = await ctx.json() - assertEquals(data, { ok: true }) -}) - -Deno.test('Context#json returns cached on second call', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: JSON.stringify({ x: 1 }), - headers: new Headers({ 'Content-Type': 'application/json' }) - } - ) - const first = await ctx.json() - const second = await ctx.json() - assertEquals(first, second) -}) - -Deno.test('Context#json then arrayBuffer throws already consumed', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: JSON.stringify({ x: 1 }), - headers: new Headers({ 'Content-Type': 'application/json' }) - } - ) - await ctx.json() - let thrown = false +Deno.test('Context render throws when no view engine configured', async () => { + const ctx = Helper.createTestContext() + let threw = false try { - await ctx.arrayBuffer() + await ctx.render('template') } catch (e) { - thrown = true - assertEquals((e as Error).message, 'Request body already consumed') - } - assertEquals(thrown, true) -}) - -Deno.test('Context#json then text throws already consumed', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: JSON.stringify({ x: 1 }), - headers: new Headers({ 'Content-Type': 'application/json' }) - } - ) - await ctx.json() - let thrown = false - try { - await ctx.text() - } catch (e) { - thrown = true - assertEquals((e as Error).message, 'Request body already consumed') - } - assertEquals(thrown, true) -}) - -Deno.test('Context#param falls back to raw value on malformed percent-encoding', () => { - const ctx = createTestContext('http://localhost/x', { id: '%', other: '%zz', plain: 'ok' }) - assertEquals(ctx.param('id'), '%') - assertEquals(ctx.param('other'), '%zz') - assertEquals(ctx.param('plain'), 'ok') -}) - -Deno.test('Context#param percent-decodes route params (consistent with query)', () => { - const ctx = createTestContext('http://localhost/users/john%20doe', { id: 'john%20doe' }) - assertEquals(ctx.param('id'), 'john doe') - const unicodeCtx = createTestContext('http://localhost/users/caf%C3%A9', { id: 'caf%C3%A9' }) - assertEquals(unicodeCtx.param('id'), 'café') - const slashCtx = createTestContext('http://localhost/x', { id: 'a%2Fb' }) - assertEquals(slashCtx.param('id'), 'a/b') -}) - -Deno.test('Context#param returns route param by key', () => { - const ctx = createTestContext('http://localhost/items/42', { id: '42' }) - assertEquals(ctx.param('id'), '42') - assertEquals(ctx.param('missing'), undefined) -}) - -Deno.test('Context#params returns copy of route params', () => { - const ctx = createTestContext('http://localhost/', { a: '1', b: '2' }) - const paramsCopy = ctx.params() - assertEquals(paramsCopy, { a: '1', b: '2' }) - paramsCopy['a'] = 'x' - assertEquals(ctx.param('a'), '1') -}) - -Deno.test('Context#pathname returns URL pathname', () => { - const ctx = createTestContext('http://localhost/items/42') - assertEquals(ctx.pathname, '/items/42') -}) - -Deno.test('Context#queries returns all values for a key', () => { - const ctx = createTestContext('http://localhost/?tag=a&tag=b') - assertEquals(ctx.queries('tag'), ['a', 'b']) -}) - -Deno.test('Context#queries returns empty array for missing key', () => { - const ctx = createTestContext('http://localhost/?a=1') - assertEquals(ctx.queries('missing'), []) -}) - -Deno.test('Context#query all params returns first-wins map', () => { - const ctx = createTestContext('http://localhost/?a=1&a=2&b=3') - const all = ctx.query() as Record - assertEquals(all['a'], '1') - assertEquals(all['b'], '3') -}) - -Deno.test('Context#query map uses a null prototype', () => { - const ctx = createTestContext('http://localhost/?a=1') - const all = ctx.query() as Record - assertEquals(Object.getPrototypeOf(all), null) -}) - -Deno.test('Context#query returns first value when duplicate keys exist', () => { - const ctx = createTestContext('http://localhost/?role=user&role=admin') - assertEquals(ctx.query('role'), 'user') -}) - -Deno.test('Context#query returns query value by key', () => { - const ctx = createTestContext('http://localhost/?foo=bar&baz=qux') - assertEquals(ctx.query('foo'), 'bar') - assertEquals(ctx.query('baz'), 'qux') - assertEquals(ctx.query('missing'), undefined) -}) - -Deno.test('Context#query treats reserved names as plain data keys', () => { - const ctx = createTestContext('http://localhost/?__proto__=p&toString=t&constructor=c') - const all = ctx.query() as Record - assertEquals(all['__proto__'], 'p') - assertEquals(all['toString'], 't') - assertEquals(all['constructor'], 'c') - assertEquals(Object.hasOwn(all, '__proto__'), true) -}) - -Deno.test('Context#query without key returns all params', () => { - const ctx = createTestContext('http://localhost/?a=1&b=2') - const all = ctx.query() as Record - assertEquals(all['a'], '1') - assertEquals(all['b'], '2') -}) - -Deno.test('Context#redirect blocks open-redirect normalization bypass', () => { - const ctx = createTestContext('http://localhost/login') - let thrown = false - try { - ctx.redirect('/\\evil.com') - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Context#redirect defaults to 302', () => { - const ctx = createTestContext('http://localhost/login') - const res = ctx.redirect('/home') - assertEquals(res.status, 302) -}) - -Deno.test('Context#redirect returns same-origin relative redirect (documented convenience)', () => { - const ctx = createTestContext('http://localhost/login') - const res = ctx.redirect('/dashboard', 301) - assertEquals(res.status, 301) - assertEquals(res.headers.get('Location'), 'http://localhost/dashboard') -}) - -Deno.test('Context#redirect with extra headers merges them', () => { - const ctx = createTestContext('http://localhost/') - const res = ctx.redirect('/login', 302, { headers: new Headers({ 'X-Extra': 'val' }) }) - assertEquals(res.headers.get('Location'), 'http://localhost/login') - assertEquals(res.headers.get('X-Extra'), 'val') -}) - -Deno.test('Context#render throws Deno.errors.NotSupported', async () => { - const ctx = createTestContext('http://localhost/') - let thrown = false - try { - await ctx.render('hello.dve') - } catch (e) { - thrown = true + threw = true assertEquals(e instanceof Deno.errors.NotSupported, true) } - assertEquals(thrown, true) -}) - -Deno.test('Context#render throws when view engine not configured', async () => { - const ctx = createTestContext('http://localhost/') - let thrown = false - try { - await ctx.render('hello.dve') - } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('View engine not configured'), true) - } - assertEquals(thrown, true) + assertEquals(threw, true) }) -Deno.test('Context#replaceRequest resets body so body can be read again', async () => { - const ctx = createTestContext( - 'http://localhost/', - {}, - { - method: 'POST', - body: 'foo=bar', - headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' }) - } - ) - const first = (await ctx.body()) as FormData - assertEquals(first.get('foo'), 'bar') - const newReq = new Request('http://localhost/', { - method: 'POST', - body: 'baz=qux', - headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' }) - }) - ctx[Core.InternalContext].replaceRequest(newReq) - const second = (await ctx.body()) as FormData - assertEquals(second.get('baz'), 'qux') +Deno.test('Context send.empty builds a null body response', () => { + const ctx = Helper.createTestContext() + const res = ctx.send.empty(204) + assertEquals(res.status, 204) + assertEquals(res.body, null) }) -Deno.test('Context#responseHeadersMap mutation does not corrupt emitted response headers', () => { - const ctx = createTestContext() - ctx.setHeader('X-Real', 'real') - const map = ctx[Core.InternalContext].responseHeadersMap as Record - map['X-Injected-Via-Map'] = 'evil' - delete map['X-Real'] - const res = ctx.send.html('

ok

') - assertEquals(res.headers.get('X-Real'), 'real') - assertEquals(res.headers.get('X-Injected-Via-Map'), null) -}) - -Deno.test('Context#responseHeadersMap returns snapshot, mutation does not leak into response', () => { - const ctx = createTestContext() - ctx.setHeader('X-Real', 'real') - const map = ctx[Core.InternalContext].responseHeadersMap - ;(map as Record)['X-Injected'] = 'mutated' - delete (map as Record)['X-Real'] - const fresh = ctx[Core.InternalContext].responseHeadersMap - assertEquals(fresh['X-Real'], 'real') - assertEquals(fresh['X-Injected'], undefined) -}) - -Deno.test('Context#send.data with Uint8Array', () => { - const ctx = createTestContext('http://localhost/') - const data = new TextEncoder().encode('binary data') - const res = ctx.send.data(data, 'file.bin') - assertEquals(res.headers.get('Content-Disposition'), 'attachment; filename="file.bin"') -}) - -Deno.test('Context#send.html returns 200 HTML response', () => { - const ctx = createTestContext('http://localhost/') - const res = ctx.send.html('

ok

') - assertEquals(res.status, 200) - assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8') -}) - -Deno.test('Context#send.json returns 200 with application/json', () => { - const ctx = createTestContext('http://localhost/') +Deno.test('Context send.json builds a JSON response', async () => { + const ctx = Helper.createTestContext() const res = ctx.send.json({ ok: true }) - assertEquals(res.status, 200) - assertEquals(res.headers.get('Content-Type'), 'application/json') -}) - -Deno.test('Context#send.redirect returns 302 with Location header', () => { - const ctx = createTestContext('http://localhost/') - const res = ctx.send.redirect('/login') - assertEquals(res.status, 302) - assertEquals(res.headers.get('Location'), 'http://localhost/login') -}) - -Deno.test('Context#send.redirect with extra headers', () => { - const ctx = createTestContext('http://localhost/') - const res = ctx.send.redirect('/login', 302, { headers: new Headers({ 'X-Extra': 'val' }) }) - assertEquals(res.status, 302) - assertEquals(res.headers.get('Location'), 'http://localhost/login') - assertEquals(res.headers.get('X-Extra'), 'val') -}) - -Deno.test('Context#send.redirect with status uses given status', () => { - const ctx = createTestContext('http://localhost/') - const res = ctx.send.redirect('/gone', 301) - assertEquals(res.status, 301) -}) - -Deno.test('Context#send.text returns text/plain', () => { - const ctx = createTestContext('http://localhost/') - const res = ctx.send.text('plain') - assertEquals(res.status, 200) - assertEquals(res.headers.get('Content-Type'), 'text/plain; charset=utf-8') + assertEquals(await res.json(), { ok: true }) + assertEquals(res.headers.get('content-type'), 'application/json') }) -Deno.test('Context#setHeader Set-Cookie does not appear in responseHeadersMap', () => { - const ctx = createTestContext() - ctx.setHeader('Set-Cookie', 'token=abc') - ctx.setHeader('X-Custom', 'yes') - assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-Custom'], 'yes') - assertEquals(ctx[Core.InternalContext].responseHeadersMap['Set-Cookie'], undefined) -}) - -Deno.test('Context#setHeader accumulates multiple Set-Cookie values', () => { - const ctx = createTestContext() - ctx.setHeader('Set-Cookie', 'a=1; Path=/') - ctx.setHeader('Set-Cookie', 'b=2; Path=/') - ctx.setHeader('Set-Cookie', 'c=3; Path=/') - assertEquals(ctx[Core.InternalContext].responseCookies.length, 3) - assertEquals(ctx[Core.InternalContext].responseCookies[0], 'a=1; Path=/') - assertEquals(ctx[Core.InternalContext].responseCookies[2], 'c=3; Path=/') -}) - -Deno.test('Context#setHeader chaining works', () => { - const ctx = createTestContext('http://localhost/') - const result = ctx.setHeader('X-A', '1').setHeader('X-B', '2') - assertEquals(result, ctx) - assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-A'], '1') - assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-B'], '2') -}) - -Deno.test('Context#setHeader merges into response', () => { - const ctx = createTestContext('http://localhost/') - ctx.setHeader('X-Custom', 'value') - const res = ctx.send.html('

ok

') - assertEquals(res.headers.get('X-Custom'), 'value') - assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8') -}) - -Deno.test('Context#setHeader rejects an invalid header name', () => { - const ctx = createTestContext('http://localhost/') - let thrown = false - try { - ctx.setHeader('Bad\nName', 'value') - } catch { - thrown = true - } - assertEquals(thrown, true) -}) - -Deno.test('Context#setHeader rejects an invalid header value', () => { - const ctx = createTestContext('http://localhost/') - let thrown = false - try { - ctx.setHeader('X-Ok', 'bad\nvalue') - } catch { - thrown = true - } - assertEquals(thrown, true) -}) - -Deno.test('Context#setHeaders applies nothing when a later entry is invalid', () => { - const ctx = createTestContext('http://localhost/') - try { - ctx.setHeaders({ 'X-Good': 'ok', 'In valid': 'bad' }) - } catch { - ctx - } - assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-Good'], undefined) -}) - -Deno.test('Context#setHeaders rejects the whole batch when one entry is invalid', () => { - const ctx = createTestContext('http://localhost/') - let thrown = false +Deno.test('Context send.json rejects invalid status code', () => { + const ctx = Helper.createTestContext() + let threw = false try { - ctx.setHeaders({ 'X-Good': 'ok', 'In valid': 'bad' }) - } catch { - thrown = true - } - assertEquals(thrown, true) -}) - -Deno.test('Context#setHeaders returns this for chaining', () => { - const ctx = createTestContext('http://localhost/') - const result = ctx.setHeaders({ 'X-A': '1', 'X-B': '2' }) - assertEquals(result, ctx) -}) - -Deno.test('Context#setHeaders routes Set-Cookie entries to cookie array', () => { - const ctx = createTestContext() - ctx.setHeaders({ 'X-A': '1', 'Set-Cookie': 'sid=abc', 'X-B': '2' }) - assertEquals(ctx[Core.InternalContext].responseCookies.length, 1) - assertEquals(ctx[Core.InternalContext].responseCookies[0], 'sid=abc') - assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-A'], '1') - assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-B'], '2') -}) - -Deno.test('Context#setHeaders sets multiple headers', () => { - const ctx = createTestContext('http://localhost/') - ctx.setHeaders({ 'X-A': '1', 'X-B': '2' }) - const res = ctx.send.html('

ok

') - assertEquals(res.headers.get('X-A'), '1') - assertEquals(res.headers.get('X-B'), '2') -}) - -Deno.test('Context#setParams merges additional params', () => { - const ctx = createTestContext('http://localhost/', { id: '1' }) - ctx[Core.InternalContext].setParams({ name: 'test' }) - assertEquals(ctx.param('id'), '1') - assertEquals(ctx.param('name'), 'test') -}) - -Deno.test('Context#setParams percent-decodes merged params', () => { - const ctx = createTestContext('http://localhost/', {}) - ctx[Core.InternalContext].setParams({ name: 'a%20b' }) - assertEquals(ctx.param('name'), 'a b') -}) - -Deno.test('Context#setState rejects reserved framework keys', () => { - const ctx = createTestContext() - for (const reserved of ['view', 'worker', 'session', 'setSession', 'clearSession']) { - let threw = false - try { - ctx.setState(Core.Handler.stateKey(reserved), 'attacker') - } catch (e) { - threw = true - assertEquals((e as Error).message.includes('reserved'), true) - } - assertEquals(threw, true) - } -}) - -Deno.test('Context#state cannot clobber framework internals via delete', () => { - const ctx = createTestContext() - ctx[Core.InternalContext].setInternalState( - Core.Handler.stateKeys.view, - { render: async () => '' } as never - ) - delete (ctx.state as Record)['view'] - assertEquals(ctx.getState(Core.Handler.stateKeys.view) !== undefined, true) -}) - -Deno.test('Context#state does not expose framework-wired internal keys', () => { - const ctx = createTestContext() - ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.setSession, async () => {}) - ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.session, { userId: '1' }) - assertEquals(typeof ctx.getState(Core.Handler.stateKeys.setSession), 'function') - assertEquals(ctx.getState(Core.Handler.stateKeys.session)?.['userId'], '1') - assertEquals(Object.hasOwn(ctx.state, 'setSession'), false) - assertEquals(Object.hasOwn(ctx.state, 'session'), false) -}) - -Deno.test('Context#state exposes a live mutable record (documented API)', () => { - const ctx = createTestContext() - assertEquals(typeof ctx.state, 'object') - ctx.state['foo'] = 'bar' - assertEquals(ctx.state['foo'], 'bar') -}) - -Deno.test('Context#streamRender throws Deno.errors.NotSupported', async () => { - const ctx = createTestContext('http://localhost/') - let thrown = false - try { - await ctx.streamRender('hello.dve') + ctx.send.json({}, { status: 999 as 200 }) } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.NotSupported, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Context#streamRender throws when view engine not configured', async () => { - const ctx = createTestContext('http://localhost/') - let thrown = false - try { - await ctx.streamRender('hello.dve') - } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('View engine not configured'), true) + threw = true + assertEquals(e instanceof Deno.errors.InvalidData, true) } - assertEquals(thrown, true) + assertEquals(threw, true) }) -Deno.test('Context#text reads body as string', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'plain text' }) - const text = await ctx.text() - assertEquals(text, 'plain text') +Deno.test('Context send.text builds a text response', async () => { + const ctx = Helper.createTestContext() + const res = ctx.send.text('hello') + assertEquals(await res.text(), 'hello') + assertEquals(res.headers.get('content-type'), 'text/plain; charset=utf-8') }) -Deno.test('Context#text returns cached on second call', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'cached' }) - const first = await ctx.text() - const second = await ctx.text() - assertEquals(first, second) +Deno.test('Context set.header and set.cookie reflect in response', () => { + const ctx = Helper.createTestContext() + ctx.set.header('x-a', '1').cookie('sid', 'abc') + const res = ctx.send.text('x') + assertEquals(res.headers.get('x-a'), '1') + assertEquals(res.headers.get('set-cookie')?.startsWith('sid=abc'), true) }) -Deno.test('Context#text then arrayBuffer throws already consumed', async () => { - const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' }) - await ctx.text() - let thrown = false +Deno.test('Context set.session write throws without session middleware', async () => { + const ctx = Helper.createTestContext() + let threw = false try { - await ctx.arrayBuffer() + await ctx.set.session({ id: 1 }) } catch (e) { - thrown = true - assertEquals((e as Error).message, 'Request body already consumed') + threw = true + assertEquals(e instanceof Deno.errors.NotSupported, true) } - assertEquals(thrown, true) + assertEquals(threw, true) }) -Deno.test('Context#url returns request url', () => { - const ctx = createTestContext('http://localhost/items?q=1') - assertEquals(ctx.url, 'http://localhost/items?q=1') +Deno.test('Context setParams decodes percent-encoded values', () => { + const ctx = Helper.createTestContext('http://localhost/') + Core.Context.internalOf(ctx).setParams({ name: 'a%20b' }) + assertEquals(ctx.get.param('name'), 'a b') }) diff --git a/tests/core/Cookie.test.ts b/tests/core/Cookie.test.ts new file mode 100644 index 0000000..8e7d3f3 --- /dev/null +++ b/tests/core/Cookie.test.ts @@ -0,0 +1,82 @@ +import { assertEquals } from '@std/assert' +import * as Core from '@core/index.ts' + +Deno.test('Cookie serialize allows SameSite None with secure', () => { + const value = Core.Cookie.serialize('sid', 'abc', { sameSite: 'None', secure: true }) + assertEquals(value.includes('SameSite=None'), true) + assertEquals(value.includes('Secure'), true) +}) + +Deno.test('Cookie serialize appends expires from Date', () => { + const value = Core.Cookie.serialize('sid', 'abc', { expires: new Date(0) }) + assertEquals(value.includes('Expires='), true) +}) + +Deno.test('Cookie serialize appends maxAge and flags', () => { + const value = Core.Cookie.serialize('sid', 'abc', { + maxAge: 60, + secure: true, + httpOnly: true + }) + assertEquals(value.includes('Max-Age=60'), true) + assertEquals(value.includes('Secure'), true) + assertEquals(value.includes('HttpOnly'), true) +}) + +Deno.test('Cookie serialize appends path and domain', () => { + const value = Core.Cookie.serialize('sid', 'abc', { path: '/', domain: 'example.com' }) + assertEquals(value.includes('Path=/'), true) + assertEquals(value.includes('Domain=example.com'), true) +}) + +Deno.test('Cookie serialize builds basic name value pair', () => { + assertEquals(Core.Cookie.serialize('sid', 'abc'), 'sid=abc') +}) + +Deno.test('Cookie serialize throws on SameSite None without secure', () => { + let threw = false + try { + Core.Cookie.serialize('sid', 'abc', { sameSite: 'None' }) + } catch (e) { + threw = true + assertEquals(e instanceof TypeError, true) + } + assertEquals(threw, true) +}) + +Deno.test('Cookie serialize throws on empty name', () => { + let threw = false + try { + Core.Cookie.serialize('', 'abc') + } catch (e) { + threw = true + assertEquals(e instanceof TypeError, true) + } + assertEquals(threw, true) +}) + +Deno.test('Cookie serialize throws on invalid expires', () => { + let threw = false + try { + Core.Cookie.serialize('sid', 'abc', { expires: new Date('invalid') }) + } catch (e) { + threw = true + assertEquals(e instanceof TypeError, true) + } + assertEquals(threw, true) +}) + +Deno.test('Cookie serialize throws on non-finite maxAge', () => { + let threw = false + try { + Core.Cookie.serialize('sid', 'abc', { maxAge: Infinity }) + } catch (e) { + threw = true + assertEquals(e instanceof TypeError, true) + } + assertEquals(threw, true) +}) + +Deno.test('Cookie serialize url-encodes name and value', () => { + assertEquals(Core.Cookie.serialize('a b', 'c d'), 'a%20b=c%20d') +}) diff --git a/tests/core/Error.test.ts b/tests/core/Error.test.ts deleted file mode 100644 index 7576010..0000000 --- a/tests/core/Error.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { assertEquals } from '@std/assert' -import * as Core from '@core/index.ts' - -Deno.test('Error#buildResponse 500 does not leak error message', async () => { - const request = new Request('http://localhost/', { - headers: new Headers({ Accept: 'application/json' }) - }) - const ctx = new Core.Context(request, new URL('http://localhost/'), {}) - const res = await Core.Handler.buildResponse( - ctx, - 500, - new globalThis.Error('Connection to ************************ failed'), - null - ) - const body = (await res.json()) as { title: string } - assertEquals(body.title, 'Internal Server Error') - assertEquals(JSON.stringify(body).includes('password'), false) -}) - -Deno.test('Error#buildResponse HTML output escapes message content', async () => { - const request = new Request('http://localhost/') - const ctx = new Core.Context(request, new URL('http://localhost/'), {}) - const res = await Core.Handler.buildResponse(ctx, 404, new globalThis.Error('irrelevant'), null) - const html = await res.text() - assertEquals(html.includes('' - const html = await engine.render('attack-raw.dve', { payload }) - assertEquals(html.trim(), payload) -}) - -Deno.test('Engine#render security: self-including template is stopped by include depth limit', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - await assertRejects( - () => engine.render('each-recurse.dve', {}), - Deno.errors.InvalidData, - 'include depth' - ) -}) - -Deno.test('Engine#render security: the constructor.constructor RCE gadget resolves to nothing', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - const html = await engine.render('attack-proto-chain.dve', { x: {} }) - assertEquals(html.trim(), '[|]') -}) - -Deno.test('Engine#render security: unterminated string literal is rejected', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - await assertRejects( - () => engine.render('attack-unclosed-string.dve', {}), - Error, - 'Unterminated string literal in DVE expression' - ) -}) - -Deno.test('Engine#render supports JS-like expressions in {{ ... }}', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - - const htmlGuest = await engine.render('expr.dve', {}) - assertEquals(htmlGuest.trim(), 'Hello Guest.\nUSER\nSum=7') - - const htmlAdmin = await engine.render('expr.dve', { user: { name: 'Nea', isAdmin: true } }) - assertEquals(htmlAdmin.trim(), 'Hello Nea.\nADMIN\nSum=7') -}) - -Deno.test('Engine#render throws when template not found', async () => { - const engine = new Rendering.Engine({ viewsDir: '/nonexistent-' + Date.now() }) - await assertRejects( - () => engine.render('missing.dve', {}), - Error, - 'Template "missing.dve" not found in views directory' - ) -}) - -Deno.test('Engine#render variable with undefined value renders empty', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - const html = await engine.render('hello.dve', {}) - assertEquals(html.trim(), 'Hello .') -}) - -Deno.test('Engine#render with backslash in path normalizes', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - const html = await engine.render('hello.dve', { name: 'Backslash' }) - assertEquals(html.trim(), 'Hello Backslash.') -}) - -Deno.test('Engine#render with empty data object', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - const html = await engine.render('hello.dve', {}) - assertEquals(html.trim(), 'Hello .') -}) - -Deno.test('Engine#render with null variable value renders empty', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - const html = await engine.render('hello.dve', { name: null }) - assertEquals(html.trim(), 'Hello .') -}) - -Deno.test({ - name: 'Engine#streamRender produces correct output', - fn: async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - const stream = await engine.streamRender('hello.dve', { name: 'Test' }) - const reader = stream.getReader() - let result = '' - const decoder = new TextDecoder() - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } - result += decoder.decode(value, { stream: true }) - } - assertEquals(result.trim(), 'Hello Test.') - }, - sanitizeOps: false -}) - -Deno.test({ - name: 'Engine#streamRender rejects before streaming when template is missing', - fn: async () => { - const engine = new Rendering.Engine({ viewsDir: '/nonexistent-' + Date.now() }) - await assertRejects( - () => engine.streamRender('missing.dve', {}), - Deno.errors.NotFound - ) - }, - sanitizeOps: false -}) - -Deno.test('Engine#streamRender returns ReadableStream response', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const engine = new Rendering.Engine({ viewsDir }) - const stream = await engine.streamRender('hello.dve', { name: 'Stream' }) - assertEquals(stream instanceof ReadableStream, true) - await stream.cancel() -}) - -Deno.test('Engine#viewsDir returns configured directory', () => { - const engine = new Rendering.Engine({ viewsDir: '/tmp/views' }) - assertEquals(engine.viewsDir, '/tmp/views') -}) - -Deno.test('Watcher#watch skips a non-existent views directory without throwing', () => { - const engine = new Rendering.Engine({ viewsDir: './does-not-exist-views-dir-xyz' }) - Rendering.Watcher.watch(engine) - assertEquals(true, true) -}) diff --git a/tests/rendering/Watcher.test.ts b/tests/rendering/Watcher.test.ts deleted file mode 100644 index 0ae8d84..0000000 --- a/tests/rendering/Watcher.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type * as Types from '@interfaces/index.ts' -import { assertEquals } from '@std/assert' -import * as Rendering from '@rendering/index.ts' - -const writeGranted = (await Deno.permissions.query({ name: 'write' })).state === 'granted' - -function createFakeEngine(viewsDir: string): { - engine: Types.WatchableEngine - invalidated: string[] - refreshCount: number - refreshedBatches: string[][] -} { - const invalidated: string[] = [] - const refreshedBatches: string[][] = [] - let refreshCount = 0 - const engine: Types.WatchableEngine = { - viewsDir, - invalidateFile(absPath: string): void { - invalidated.push(absPath) - }, - refreshPaths(): void { - refreshCount++ - }, - notifyRefresh(paths: readonly string[]): void { - refreshedBatches.push([...paths]) - } - } - return { - engine, - invalidated, - get refreshCount(): number { - return refreshCount - }, - refreshedBatches - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function makeViewsDir(): Promise { - const dir = await Deno.makeTempDir({ prefix: 'deserve-views-' }) - return await Deno.realPath(dir) -} - -Deno.test({ - name: 'Watcher#watch ignores non-.dve files', - ignore: !writeGranted, - fn: async () => { - const dir = await makeViewsDir() - const recorder = createFakeEngine(dir) - const stop = Rendering.Watcher.watch(recorder.engine) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/notes.txt`, 'not a template') - await delay(400) - assertEquals(recorder.invalidated.length, 0) - assertEquals(recorder.refreshCount, 0) - } finally { - stop() - await Deno.remove(dir, { recursive: true }) - } - } -}) - -Deno.test({ - name: 'Watcher#watch invalidates and refreshes when a .dve template changes', - ignore: !writeGranted, - fn: async () => { - const dir = await makeViewsDir() - await Deno.writeTextFile(`${dir}/page.dve`, 'Hello {{ name }}.') - const recorder = createFakeEngine(dir) - const stop = Rendering.Watcher.watch(recorder.engine) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/page.dve`, 'Hello {{ name }}!') - await delay(400) - assertEquals(recorder.invalidated.length >= 1, true) - assertEquals(recorder.invalidated.some((p) => p.endsWith('page.dve')), true) - assertEquals(recorder.refreshCount >= 1, true) - assertEquals(recorder.refreshedBatches.length >= 1, true) - } finally { - stop() - await Deno.remove(dir, { recursive: true }) - } - } -}) - -Deno.test('Watcher#watch returns a no-op stop handle for a non-existent directory', () => { - const recorder = createFakeEngine('./does-not-exist-views-dir-' + Date.now()) - const stop = Rendering.Watcher.watch(recorder.engine) - assertEquals(typeof stop, 'function') - stop() - assertEquals(recorder.invalidated.length, 0) -}) - -Deno.test({ - name: 'Watcher#watch stop handle releases the watcher and halts further invalidation', - ignore: !writeGranted, - fn: async () => { - const dir = await makeViewsDir() - const recorder = createFakeEngine(dir) - const stop = Rendering.Watcher.watch(recorder.engine) - await delay(50) - stop() - await delay(50) - await Deno.writeTextFile(`${dir}/late.dve`, 'late') - await delay(400) - try { - assertEquals(recorder.invalidated.length, 0) - } finally { - await Deno.remove(dir, { recursive: true }) - } - } -}) diff --git a/tests/rendering/engine/Eval.test.ts b/tests/rendering/engine/Eval.test.ts deleted file mode 100644 index d9262a6..0000000 --- a/tests/rendering/engine/Eval.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { assertEquals, assertThrows } from '@std/assert' -import { Eval } from '@rendering/engine/Eval.ts' - -Deno.test('Eval#evaluate NaN arithmetic', () => { - const result = Eval.evaluate('a / b', { a: 0, b: 0 }) - assertEquals(Number.isNaN(result as number), true) -}) - -Deno.test('Eval#evaluate arithmetic addition', () => { - assertEquals(Eval.evaluate('a + b', { a: 3, b: 4 }), 7) -}) - -Deno.test('Eval#evaluate arithmetic division', () => { - assertEquals(Eval.evaluate('a / b', { a: 10, b: 2 }), 5) -}) - -Deno.test('Eval#evaluate arithmetic modulo', () => { - assertEquals(Eval.evaluate('a % b', { a: 10, b: 3 }), 1) -}) - -Deno.test('Eval#evaluate arithmetic multiplication', () => { - assertEquals(Eval.evaluate('a * b', { a: 3, b: 4 }), 12) -}) - -Deno.test('Eval#evaluate arithmetic subtraction', () => { - assertEquals(Eval.evaluate('a - b', { a: 10, b: 3 }), 7) -}) - -Deno.test('Eval#evaluate chained nullish coalescing', () => { - assertEquals(Eval.evaluate('a ?? b ?? c', { c: 'found' }), 'found') -}) - -Deno.test('Eval#evaluate complex nested expression', () => { - assertEquals(Eval.evaluate('a > 0 ? a * 2 : -a', { a: 5 }), 10) - assertEquals(Eval.evaluate('a > 0 ? a * 2 : -a', { a: -3 }), 3) -}) - -Deno.test('Eval#evaluate decimal number literal', () => { - assertEquals(Eval.evaluate('3.14', {}), 3.14) -}) - -Deno.test('Eval#evaluate deep dotted path', () => { - assertEquals(Eval.evaluate('a.b.c', { a: { b: { c: 42 } } }), 42) -}) - -Deno.test('Eval#evaluate division by zero returns Infinity', () => { - assertEquals(Eval.evaluate('a / b', { a: 1, b: 0 }), Infinity) -}) - -Deno.test('Eval#evaluate does not resolve inherited __proto__ identifier', () => { - assertEquals(Eval.evaluate('__proto__', {}), undefined) -}) - -Deno.test('Eval#evaluate does not resolve inherited constructor identifier', () => { - assertEquals(Eval.evaluate('constructor', {}), undefined) -}) - -Deno.test('Eval#evaluate does not resolve inherited member access', () => { - assertEquals(Eval.evaluate('a.constructor', { a: { x: 1 } }), undefined) - assertEquals(Eval.evaluate('a.toString', { a: {} }), undefined) -}) - -Deno.test('Eval#evaluate does not resolve inherited toString identifier', () => { - assertEquals(Eval.evaluate('toString', {}), undefined) -}) - -Deno.test('Eval#evaluate does not resolve string prototype __proto__', () => { - assertEquals(Eval.evaluate('a.__proto__', { a: 'abc' }), undefined) -}) - -Deno.test('Eval#evaluate does not resolve string prototype constructor', () => { - assertEquals(Eval.evaluate('a.constructor', { a: 'abc' }), undefined) -}) - -Deno.test('Eval#evaluate does not resolve string prototype method', () => { - assertEquals(Eval.evaluate('a.toUpperCase', { a: 'abc' }), undefined) -}) - -Deno.test('Eval#evaluate dotted path fast path', () => { - assertEquals(Eval.evaluate('user.name', { user: { name: 'Bob' } }), 'Bob') -}) - -Deno.test('Eval#evaluate dotted path on missing key returns undefined', () => { - assertEquals(Eval.evaluate('a.b.c', { a: {} }), undefined) -}) - -Deno.test('Eval#evaluate dotted path on null returns undefined', () => { - assertEquals(Eval.evaluate('a.b', { a: null }), undefined) -}) - -Deno.test('Eval#evaluate empty expression returns undefined', () => { - assertEquals(Eval.evaluate('', {}), undefined) -}) - -Deno.test('Eval#evaluate keyword literal name is overridden by an own scope property', () => { - assertEquals(Eval.evaluate('true', { true: 'shadowed' }), 'shadowed') -}) - -Deno.test('Eval#evaluate keyword literals resolve to their values inside expressions', () => { - assertEquals(Eval.evaluate('a == true', { a: true }), true) - assertEquals(Eval.evaluate('a ? true : false', { a: 1 }), true) - assertEquals(Eval.evaluate('a ? true : false', { a: 0 }), false) - assertEquals(Eval.evaluate('true && 5', {}), 5) - assertEquals(Eval.evaluate('a ?? null', { a: undefined }), null) -}) - -Deno.test('Eval#evaluate literal false via expression', () => { - assertEquals(Eval.evaluate('!true', {}), false) -}) - -Deno.test('Eval#evaluate literal true via expression', () => { - assertEquals(Eval.evaluate('!false', {}), true) -}) - -Deno.test('Eval#evaluate logical AND short-circuits', () => { - assertEquals(Eval.evaluate('a && b', { a: false, b: 42 }), false) - assertEquals(Eval.evaluate('a && b', { a: true, b: 42 }), 42) -}) - -Deno.test('Eval#evaluate logical OR short-circuits', () => { - assertEquals(Eval.evaluate('a || b', { a: 'yes', b: 'no' }), 'yes') - assertEquals(Eval.evaluate('a || b', { a: '', b: 'fallback' }), 'fallback') -}) - -Deno.test('Eval#evaluate loose equality', () => { - assertEquals(Eval.evaluate('a == b', { a: 1, b: '1' }), true) -}) - -Deno.test('Eval#evaluate loose inequality', () => { - assertEquals(Eval.evaluate('a != b', { a: 1, b: 2 }), true) -}) - -Deno.test('Eval#evaluate member access on non-object returns undefined', () => { - assertEquals(Eval.evaluate('a.b', { a: 42 }), undefined) -}) - -Deno.test('Eval#evaluate member access on null returns undefined', () => { - assertEquals(Eval.evaluate('a.b', { a: null }), undefined) -}) - -Deno.test('Eval#evaluate member access via expression path', () => { - assertEquals(Eval.evaluate('a.b + 1', { a: { b: 5 } }), 6) -}) - -Deno.test('Eval#evaluate nullish coalescing', () => { - assertEquals(Eval.evaluate('a ?? b', { a: null, b: 'default' }), 'default') - assertEquals(Eval.evaluate('a ?? b', { a: undefined, b: 'default' }), 'default') - assertEquals(Eval.evaluate('a ?? b', { a: 0, b: 'default' }), 0) - assertEquals(Eval.evaluate('a ?? b', { a: '', b: 'default' }), '') -}) - -Deno.test('Eval#evaluate number literal', () => { - assertEquals(Eval.evaluate('42', {}), 42) -}) - -Deno.test('Eval#evaluate optional chaining returns undefined for null object', () => { - assertEquals(Eval.evaluate('a?.b', { a: null }), undefined) -}) - -Deno.test('Eval#evaluate parenthesized expression', () => { - assertEquals(Eval.evaluate('(a + b) * c', { a: 1, b: 2, c: 3 }), 9) -}) - -Deno.test('Eval#evaluate reads own length of a string primitive', () => { - assertEquals(Eval.evaluate('a.length', { a: 'hello' }), 5) -}) - -Deno.test('Eval#evaluate rejects computed member access', () => { - assertThrows(() => Eval.evaluate('arr[0]', { arr: [1, 2, 3] }), Deno.errors.InvalidData) - assertThrows(() => Eval.evaluate('a["b"]', { a: { b: 1 } }), Deno.errors.InvalidData) -}) - -Deno.test('Eval#evaluate rejects function call expressions', () => { - assertThrows(() => Eval.evaluate('fn()', { fn: () => 9 }), Deno.errors.InvalidData) - assertThrows(() => Eval.evaluate('a.b()', { a: { b: () => 9 } }), Deno.errors.InvalidData) -}) - -Deno.test('Eval#evaluate relational operators', () => { - assertEquals(Eval.evaluate('a > b', { a: 5, b: 3 }), true) - assertEquals(Eval.evaluate('a < b', { a: 3, b: 5 }), true) - assertEquals(Eval.evaluate('a >= b', { a: 5, b: 5 }), true) - assertEquals(Eval.evaluate('a <= b', { a: 3, b: 5 }), true) -}) - -Deno.test('Eval#evaluate resolves an own data property shadowing a builtin name', () => { - assertEquals(Eval.evaluate('a.hasOwnProperty', { a: { hasOwnProperty: 'mine' } }), 'mine') -}) - -Deno.test('Eval#evaluate scientific notation number literals', () => { - assertEquals(Eval.evaluate('1e3', {}), 1000) - assertEquals(Eval.evaluate('2E2', {}), 200) - assertEquals(Eval.evaluate('1.5e2', {}), 150) - assertEquals(Eval.evaluate('5e-1', {}), 0.5) -}) - -Deno.test('Eval#evaluate scientific notation participates in arithmetic', () => { - assertEquals(Eval.evaluate('1e3 + 1', {}), 1001) -}) - -Deno.test('Eval#evaluate simple identifier from scope', () => { - assertEquals(Eval.evaluate('name', { name: 'Alice' }), 'Alice') -}) - -Deno.test('Eval#evaluate strict equality', () => { - assertEquals(Eval.evaluate('a === b', { a: 1, b: 1 }), true) - assertEquals(Eval.evaluate('a === b', { a: 1, b: '1' }), false) -}) - -Deno.test('Eval#evaluate strict inequality', () => { - assertEquals(Eval.evaluate('a !== b', { a: 1, b: 2 }), true) - assertEquals(Eval.evaluate('a !== b', { a: 1, b: 1 }), false) -}) - -Deno.test('Eval#evaluate string concatenation', () => { - assertEquals(Eval.evaluate('a + b', { a: 'hello ', b: 'world' }), 'hello world') -}) - -Deno.test('Eval#evaluate string literal', () => { - assertEquals(Eval.evaluate('"hello"', {}), 'hello') -}) - -Deno.test('Eval#evaluate ternary operator', () => { - assertEquals(Eval.evaluate('a ? "yes" : "no"', { a: true }), 'yes') - assertEquals(Eval.evaluate('a ? "yes" : "no"', { a: false }), 'no') -}) - -Deno.test('Eval#evaluate true/false/null in complex expression', () => { - assertEquals(Eval.evaluate('1 === 1', {}), true) - assertEquals(Eval.evaluate('1 === 2', {}), false) - assertEquals(Eval.evaluate('a ?? "fallback"', {}), 'fallback') -}) - -Deno.test('Eval#evaluate true/false/null/undefined are simple paths', () => { - assertEquals(Eval.evaluate('true', {}), undefined) - assertEquals(Eval.evaluate('false', {}), undefined) - assertEquals(Eval.evaluate('null', {}), undefined) - assertEquals(Eval.evaluate('undefined', {}), undefined) -}) - -Deno.test('Eval#evaluate unary minus negates', () => { - assertEquals(Eval.evaluate('-a', { a: 5 }), -5) - assertEquals(Eval.evaluate('-a', { a: '3' }), -3) -}) - -Deno.test('Eval#evaluate unary minus on non-numeric', () => { - assertEquals(Eval.evaluate('-a', { a: true }), -1) -}) - -Deno.test('Eval#evaluate unary not', () => { - assertEquals(Eval.evaluate('!a', { a: true }), false) - assertEquals(Eval.evaluate('!a', { a: false }), true) - assertEquals(Eval.evaluate('!a', { a: 0 }), true) -}) - -Deno.test('Eval#evaluate unary plus converts to number', () => { - assertEquals(Eval.evaluate('+a', { a: '5' }), 5) - assertEquals(Eval.evaluate('+a', { a: 3 }), 3) -}) - -Deno.test('Eval#evaluate unary plus on null', () => { - assertEquals(Eval.evaluate('+a', { a: null }), 0) -}) - -Deno.test('Eval#evaluate unary plus on undefined', () => { - const result = Eval.evaluate('+a', {}) - assertEquals(Number.isNaN(result as number), true) -}) - -Deno.test('Eval#evaluate uses string length in array-style condition', () => { - assertEquals(Eval.evaluate('a.length > 0', { a: 'x' }), true) -}) - -Deno.test('Eval#evaluate whitespace-only returns undefined', () => { - assertEquals(Eval.evaluate(' ', {}), undefined) -}) diff --git a/tests/rendering/engine/Expression.test.ts b/tests/rendering/engine/Expression.test.ts deleted file mode 100644 index bd84bc0..0000000 --- a/tests/rendering/engine/Expression.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { assertEquals, assertThrows } from '@std/assert' -import { Tokenizer } from '@rendering/engine/Tokenizer.ts' -import { Expression } from '@rendering/engine/Expression.ts' - -function parseExpr(expr: string) { - const tokens = Tokenizer.tokenize(expr) - const parser = new Expression(tokens) - const node = parser.parse() - parser.assertEnd() - return node -} - -Deno.test('Expression#assertEnd throws on unconsumed tokens', () => { - const tokens = Tokenizer.tokenize('a b') - const parser = new Expression(tokens) - parser.parse() - assertThrows(() => parser.assertEnd(), Error, 'Unexpected token') -}) - -Deno.test('Expression#parse binary addition', () => { - const node = parseExpr('a + b') - assertEquals(node.type, 'binary') -}) - -Deno.test('Expression#parse binary multiplication', () => { - const node = parseExpr('a * b') - assertEquals(node.type, 'binary') -}) - -Deno.test('Expression#parse chained member access', () => { - const node = parseExpr('a.b.c.d') - assertEquals(node.type, 'member') -}) - -Deno.test('Expression#parse empty tokens throws', () => { - assertThrows( - () => { - const parser = new Expression([]) - parser.parse() - }, - Error, - 'Unexpected end' - ) -}) - -Deno.test('Expression#parse equality', () => { - const node = parseExpr('a === b') - assertEquals(node.type, 'binary') -}) - -Deno.test('Expression#parse expected identifier after ?. throws', () => { - assertThrows(() => parseExpr('a?. 5'), Error, 'Expected identifier after "?."') -}) - -Deno.test('Expression#parse expected identifier after dot throws', () => { - assertThrows(() => parseExpr('a. 5'), Error, 'Expected identifier after "."') -}) - -Deno.test('Expression#parse identifier', () => { - const node = parseExpr('foo') - assertEquals(node.type, 'ident') -}) - -Deno.test('Expression#parse inequality operators', () => { - for (const op of ['!==', '!=', '==']) { - const node = parseExpr(`a ${op} b`) - assertEquals(node.type, 'binary') - assertEquals((node as { op: string }).op, op) - } -}) - -Deno.test('Expression#parse invalid primary token throws', () => { - assertThrows(() => parseExpr(')'), Error, 'Invalid primary') -}) - -Deno.test('Expression#parse left-associative addition', () => { - const node = parseExpr('a + b + c') - assertEquals(node.type, 'binary') - assertEquals((node as { left: { type: string } }).left.type, 'binary') -}) - -Deno.test('Expression#parse logical AND and OR', () => { - const node = parseExpr('a && b || c') - assertEquals(node.type, 'binary') -}) - -Deno.test('Expression#parse member access', () => { - const node = parseExpr('a.b.c') - assertEquals(node.type, 'member') -}) - -Deno.test('Expression#parse missing closing paren throws', () => { - assertThrows(() => parseExpr('(a + b'), Error, "Expected ')'") -}) - -Deno.test('Expression#parse modulo and division', () => { - for (const op of ['/', '%']) { - const node = parseExpr(`a ${op} b`) - assertEquals(node.type, 'binary') - } -}) - -Deno.test('Expression#parse nested ternary', () => { - const node = parseExpr('a ? b ? 1 : 2 : 3') - assertEquals(node.type, 'ternary') -}) - -Deno.test('Expression#parse nullish coalescing', () => { - const node = parseExpr('a ?? b') - assertEquals(node.type, 'binary') -}) - -Deno.test('Expression#parse number literal', () => { - const node = parseExpr('42') - assertEquals(node.type, 'literal') -}) - -Deno.test('Expression#parse optional chaining', () => { - const node = parseExpr('a?.b') - assertEquals(node.type, 'member') -}) - -Deno.test('Expression#parse parenthesized expression', () => { - const node = parseExpr('(a + b) * c') - assertEquals(node.type, 'binary') -}) - -Deno.test('Expression#parse relational operators', () => { - for (const op of ['>', '<', '>=', '<=']) { - const node = parseExpr(`a ${op} b`) - assertEquals(node.type, 'binary') - } -}) - -Deno.test('Expression#parse string literal', () => { - const node = parseExpr('"hello"') - assertEquals(node.type, 'literal') -}) - -Deno.test('Expression#parse subtraction operator', () => { - const node = parseExpr('a - b') - assertEquals(node.type, 'binary') - assertEquals((node as { op: string }).op, '-') -}) - -Deno.test('Expression#parse ternary', () => { - const node = parseExpr('a ? b : c') - assertEquals(node.type, 'ternary') -}) - -Deno.test('Expression#parse ternary missing colon throws', () => { - assertThrows(() => parseExpr('a ? b c'), Error, "Expected ':'") -}) - -Deno.test('Expression#parse unary minus', () => { - const node = parseExpr('-5') - assertEquals(node.type, 'unary') -}) - -Deno.test('Expression#parse unary not', () => { - const node = parseExpr('!x') - assertEquals(node.type, 'unary') -}) - -Deno.test('Expression#parse unary plus', () => { - const node = parseExpr('+x') - assertEquals(node.type, 'unary') -}) diff --git a/tests/rendering/engine/Parser.test.ts b/tests/rendering/engine/Parser.test.ts deleted file mode 100644 index a27998a..0000000 --- a/tests/rendering/engine/Parser.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { assertEquals, assertThrows } from '@std/assert' -import { Parser } from '@rendering/engine/Parser.ts' - -Deno.test('Parser#parse /each closing if block throws (mismatch)', () => { - assertThrows( - () => Parser.parse('{{#if ok}}{{/each}}'), - Error, - 'Unexpected {{/each}} without matching {{#each}}' - ) -}) - -Deno.test('Parser#parse /each without each throws', () => { - assertThrows(() => Parser.parse('{{/each}}'), Error, 'Unexpected {{/each}} without matching') -}) - -Deno.test('Parser#parse /if closing each block throws (mismatch)', () => { - assertThrows( - () => Parser.parse('{{#each items}}{{/if}}'), - Error, - 'Unexpected {{/if}} without matching {{#if}}' - ) -}) - -Deno.test('Parser#parse /if without if throws', () => { - assertThrows(() => Parser.parse('{{/if}}'), Error, 'Unexpected {{/if}} without matching') -}) - -Deno.test('Parser#parse consecutive tags without text between', () => { - const nodes = Parser.parse('{{a}}{{b}}') - assertEquals(nodes.length, 2) - assertEquals(nodes[0]?.type, 'var') - assertEquals((nodes[0] as { path: string }).path, 'a') - assertEquals(nodes[1]?.type, 'var') - assertEquals((nodes[1] as { path: string }).path, 'b') -}) - -Deno.test('Parser#parse each block', () => { - const nodes = Parser.parse('{{#each items as item}}{{item}}{{/each}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'each') -}) - -Deno.test('Parser#parse each with as clause using underscore identifier', () => { - const nodes = Parser.parse('{{#each items as _item}}{{_item}}{{/each}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'each') - assertEquals((nodes[0] as { itemName: string }).itemName, '_item') -}) - -Deno.test('Parser#parse each without as clause defaults to item', () => { - const nodes = Parser.parse('{{#each items}}{{item}}{{/each}}') - assertEquals(nodes.length, 1) - assertEquals((nodes[0] as { itemName: string }).itemName, 'item') -}) - -Deno.test('Parser#parse else inside each block throws', () => { - assertThrows( - () => Parser.parse('{{#each items}}{{else}}{{/each}}'), - Error, - 'Unexpected {{else}} without matching {{#if}}' - ) -}) - -Deno.test('Parser#parse else without if throws', () => { - assertThrows(() => Parser.parse('{{else}}'), Error, 'Unexpected {{else}} without matching') -}) - -Deno.test('Parser#parse empty raw tag is ignored', () => { - const nodes = Parser.parse('a{{{}}}b') - assertEquals(nodes.length, 2) -}) - -Deno.test('Parser#parse empty string returns empty array', () => { - assertEquals(Parser.parse(''), []) -}) - -Deno.test('Parser#parse empty tag is ignored', () => { - const nodes = Parser.parse('a{{}}b') - assertEquals(nodes.length, 2) - assertEquals(nodes[0]?.type, 'text') - assertEquals(nodes[1]?.type, 'text') -}) - -Deno.test('Parser#parse if block', () => { - const nodes = Parser.parse('{{#if ok}}yes{{/if}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'if') -}) - -Deno.test('Parser#parse if/else block', () => { - const nodes = Parser.parse('{{#if ok}}yes{{else}}no{{/if}}') - assertEquals(nodes.length, 1) - const ifNode = nodes[0] as { thenNodes: unknown[]; elseNodes: unknown[] } - assertEquals(ifNode.thenNodes.length, 1) - assertEquals(ifNode.elseNodes.length, 1) -}) - -Deno.test('Parser#parse include tag', () => { - const nodes = Parser.parse('{{> partial.dve}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'include') -}) - -Deno.test('Parser#parse include with empty path is ignored', () => { - const nodes = Parser.parse('a{{> }}b') - assertEquals(nodes.length, 2) - assertEquals(nodes[0]?.type, 'text') - assertEquals(nodes[1]?.type, 'text') -}) - -Deno.test('Parser#parse mixed text and tags', () => { - const nodes = Parser.parse('Hello {{name}}!') - assertEquals(nodes.length, 3) - assertEquals(nodes[0]?.type, 'text') - assertEquals(nodes[1]?.type, 'var') - assertEquals(nodes[2]?.type, 'text') -}) - -Deno.test('Parser#parse multiple else in if block pushes to elseNodes', () => { - const nodes = Parser.parse('{{#if ok}}A{{else}}B{{else}}C{{/if}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'if') - const ifNode = nodes[0] as { thenNodes: unknown[]; elseNodes: unknown[] } - assertEquals(ifNode.thenNodes.length, 1) - assertEquals(ifNode.elseNodes.length, 2) -}) - -Deno.test('Parser#parse nested each blocks', () => { - const nodes = Parser.parse('{{#each outer as o}}{{#each inner as i}}{{i}}{{/each}}{{/each}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'each') -}) - -Deno.test('Parser#parse nested if blocks', () => { - const nodes = Parser.parse('{{#if a}}{{#if b}}inner{{/if}}{{/if}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'if') - const inner = (nodes[0] as { thenNodes: Array<{ type: string }> }).thenNodes - assertEquals(inner.length, 1) - assertEquals(inner[0]?.type, 'if') -}) - -Deno.test('Parser#parse plain text returns text node', () => { - const nodes = Parser.parse('Hello World') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'text') -}) - -Deno.test('Parser#parse raw variable tag', () => { - const nodes = Parser.parse('{{{html}}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'var') - assertEquals((nodes[0] as { raw: boolean }).raw, true) -}) - -Deno.test('Parser#parse tags with extra whitespace', () => { - const nodes = Parser.parse('{{ name }}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'var') - assertEquals((nodes[0] as { path: string }).path, 'name') -}) - -Deno.test('Parser#parse unclosed each block throws', () => { - assertThrows(() => Parser.parse('{{#each items}}{{item}}'), Error, 'Unclosed {{#each}} block') -}) - -Deno.test('Parser#parse unclosed if block throws', () => { - assertThrows(() => Parser.parse('{{#if ok}}yes'), Error, 'Unclosed {{#if}} block') -}) - -Deno.test('Parser#parse variable tag', () => { - const nodes = Parser.parse('{{name}}') - assertEquals(nodes.length, 1) - assertEquals(nodes[0]?.type, 'var') - assertEquals((nodes[0] as { raw: boolean }).raw, false) -}) diff --git a/tests/rendering/engine/Tokenizer.test.ts b/tests/rendering/engine/Tokenizer.test.ts deleted file mode 100644 index 1e533d9..0000000 --- a/tests/rendering/engine/Tokenizer.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { assertEquals, assertThrows } from '@std/assert' -import { Tokenizer } from '@rendering/engine/Tokenizer.ts' - -Deno.test('Tokenizer#tokenize complex expression', () => { - const tokens = Tokenizer.tokenize('a > 1 ? "yes" : "no"') - assertEquals(tokens.length, 7) - assertEquals(tokens[0]?.kind, 'ident') - assertEquals(tokens[1]?.value, '>') - assertEquals(tokens[2]?.kind, 'number') - assertEquals(tokens[3]?.value, '?') - assertEquals(tokens[4]?.kind, 'string') - assertEquals(tokens[5]?.value, ':') - assertEquals(tokens[6]?.kind, 'string') -}) - -Deno.test('Tokenizer#tokenize consecutive unary operators', () => { - const tokens = Tokenizer.tokenize('!!!x') - assertEquals(tokens.length, 4) - assertEquals(tokens[0]?.kind, 'op') - assertEquals(tokens[0]?.value, '!') - assertEquals(tokens[1]?.kind, 'op') - assertEquals(tokens[1]?.value, '!') - assertEquals(tokens[2]?.kind, 'op') - assertEquals(tokens[2]?.value, '!') - assertEquals(tokens[3]?.kind, 'ident') - assertEquals(tokens[3]?.value, 'x') -}) - -Deno.test('Tokenizer#tokenize double-quoted string', () => { - const tokens = Tokenizer.tokenize('"world"') - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'string') - assertEquals(tokens[0]?.value, 'world') -}) - -Deno.test('Tokenizer#tokenize double-quoted string with escape sequences', () => { - const tokens = Tokenizer.tokenize('"line\\nbreak"') - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'string') - assertEquals(tokens[0]?.value, 'line\nbreak') -}) - -Deno.test('Tokenizer#tokenize empty string literal double quotes', () => { - const tokens = Tokenizer.tokenize('""') - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'string') - assertEquals(tokens[0]?.value, '') -}) - -Deno.test('Tokenizer#tokenize empty string literal single quotes', () => { - const tokens = Tokenizer.tokenize("''") - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'string') - assertEquals(tokens[0]?.value, '') -}) - -Deno.test('Tokenizer#tokenize empty string returns empty array', () => { - assertEquals(Tokenizer.tokenize(''), []) -}) - -Deno.test('Tokenizer#tokenize escaped backslash in string', () => { - const tokens = Tokenizer.tokenize("'\\\\'") - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'string') - assertEquals(tokens[0]?.value, '\\') -}) - -Deno.test('Tokenizer#tokenize float number', () => { - const tokens = Tokenizer.tokenize('3.14') - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'number') - assertEquals(tokens[0]?.value, 3.14) -}) - -Deno.test('Tokenizer#tokenize identifier', () => { - const tokens = Tokenizer.tokenize('foo') - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'ident') - assertEquals(tokens[0]?.value, 'foo') -}) - -Deno.test('Tokenizer#tokenize identifier starting with $ or _ or @', () => { - for (const name of ['$var', '_private', '@index']) { - const tokens = Tokenizer.tokenize(name) - assertEquals(tokens[0]?.kind, 'ident') - assertEquals(tokens[0]?.value, name) - } -}) - -Deno.test('Tokenizer#tokenize integer number', () => { - const tokens = Tokenizer.tokenize('42') - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'number') - assertEquals(tokens[0]?.value, 42) -}) - -Deno.test('Tokenizer#tokenize invalid character throws', () => { - assertThrows(() => Tokenizer.tokenize('a # b'), Error, 'Invalid DVE expression token') -}) - -Deno.test('Tokenizer#tokenize number followed by dot with no digits', () => { - const tokens = Tokenizer.tokenize('5.') - assertEquals(tokens[0]?.kind, 'number') - assertEquals(tokens[0]?.value, 5) -}) - -Deno.test('Tokenizer#tokenize number followed by identifier', () => { - const tokens = Tokenizer.tokenize('42abc') - assertEquals(tokens.length, 2) - assertEquals(tokens[0]?.kind, 'number') - assertEquals(tokens[0]?.value, 42) - assertEquals(tokens[1]?.kind, 'ident') - assertEquals(tokens[1]?.value, 'abc') -}) - -Deno.test('Tokenizer#tokenize number with leading zeros', () => { - const tokens = Tokenizer.tokenize('007') - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'number') - assertEquals(tokens[0]?.value, 7) -}) - -Deno.test('Tokenizer#tokenize single-char operators', () => { - for (const op of ['(', ')', '?', ':', '.', '!', '+', '-', '*', '/', '%', '>', '<']) { - const tokens = Tokenizer.tokenize(op) - assertEquals(tokens[0]?.kind, 'op') - assertEquals(tokens[0]?.value, op) - } -}) - -Deno.test('Tokenizer#tokenize single-quoted string', () => { - const tokens = Tokenizer.tokenize("'hello'") - assertEquals(tokens.length, 1) - assertEquals(tokens[0]?.kind, 'string') - assertEquals(tokens[0]?.value, 'hello') -}) - -Deno.test('Tokenizer#tokenize string with escape sequences', () => { - const tokens = Tokenizer.tokenize("'line1\\nline2\\ttab\\rret'") - assertEquals(tokens[0]?.value, 'line1\nline2\ttab\rret') -}) - -Deno.test('Tokenizer#tokenize string with escaped quote', () => { - const tokens = Tokenizer.tokenize("'it\\'s'") - assertEquals(tokens[0]?.value, "it's") -}) - -Deno.test('Tokenizer#tokenize three-char operators === and !==', () => { - const tokens = Tokenizer.tokenize('a === b !== c') - assertEquals(tokens[1]?.value, '===') - assertEquals(tokens[3]?.value, '!==') -}) - -Deno.test('Tokenizer#tokenize tokens without whitespace', () => { - const tokens = Tokenizer.tokenize('"a"+"b"') - assertEquals(tokens.length, 3) - assertEquals(tokens[0]?.kind, 'string') - assertEquals(tokens[0]?.value, 'a') - assertEquals(tokens[1]?.kind, 'op') - assertEquals(tokens[1]?.value, '+') - assertEquals(tokens[2]?.kind, 'string') - assertEquals(tokens[2]?.value, 'b') -}) - -Deno.test('Tokenizer#tokenize two-char operators', () => { - for (const op of ['&&', '||', '??', '>=', '<=', '==', '!=', '?.']) { - const tokens = Tokenizer.tokenize(`a ${op} b`) - assertEquals(tokens[1]?.value, op) - } -}) - -Deno.test('Tokenizer#tokenize unterminated string throws', () => { - assertThrows(() => Tokenizer.tokenize("'no end"), Error, 'Unterminated string literal') -}) - -Deno.test('Tokenizer#tokenize whitespace only returns empty array', () => { - assertEquals(Tokenizer.tokenize(' \t\n '), []) -}) diff --git a/tests/rendering/engine/Utils.test.ts b/tests/rendering/engine/Utils.test.ts deleted file mode 100644 index df59ef1..0000000 --- a/tests/rendering/engine/Utils.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { assertEquals } from '@std/assert' -import { Utils } from '@rendering/engine/Utils.ts' - -Deno.test('Utils#escape escapes ampersand', () => { - assertEquals(Utils.escape('a & b'), 'a & b') -}) - -Deno.test('Utils#escape escapes double quotes', () => { - assertEquals(Utils.escape('"quoted"'), '"quoted"') -}) - -Deno.test('Utils#escape escapes less-than', () => { - assertEquals(Utils.escape(''), '<tag>') -}) - -Deno.test('Utils#escape escapes single quotes', () => { - assertEquals(Utils.escape("it's"), 'it's') -}) - -Deno.test('Utils#escape handles all special chars together', () => { - assertEquals(Utils.escape('&<>"\' '), '&<>"' ') -}) - -Deno.test('Utils#escape passes through plain text unchanged', () => { - assertEquals(Utils.escape('hello world'), 'hello world') -}) - -Deno.test('Utils#escape returns empty string for empty input', () => { - assertEquals(Utils.escape(''), '') -}) - -Deno.test('Utils#join joins root and relative path', () => { - assertEquals(Utils.join('/views', 'hello.dve'), '/views/hello.dve') -}) - -Deno.test('Utils#join normalizes backslashes in relative', () => { - assertEquals(Utils.join('/views', 'sub\\hello.dve'), '/views/sub/hello.dve') -}) - -Deno.test('Utils#join strips leading slashes from relative', () => { - assertEquals(Utils.join('/views', '/hello.dve'), '/views/hello.dve') -}) - -Deno.test('Utils#join strips trailing slashes from root', () => { - assertEquals(Utils.join('/views///', 'hello.dve'), '/views/hello.dve') -}) - -Deno.test('Utils#join with empty relative', () => { - assertEquals(Utils.join('/views', ''), '/views/') -}) - -Deno.test('Utils#join with empty root', () => { - assertEquals(Utils.join('', 'file.dve'), '/file.dve') -}) - -Deno.test('Utils#lookup does not resolve inherited __proto__', () => { - assertEquals(Utils.lookup({}, '__proto__'), undefined) -}) - -Deno.test('Utils#lookup does not resolve inherited constructor', () => { - assertEquals(Utils.lookup({}, 'constructor'), undefined) -}) - -Deno.test('Utils#lookup does not resolve inherited member mid-path', () => { - assertEquals(Utils.lookup({ a: { x: 1 } }, 'a.constructor'), undefined) -}) - -Deno.test('Utils#lookup does not resolve inherited toString', () => { - assertEquals(Utils.lookup({}, 'toString'), undefined) -}) - -Deno.test('Utils#lookup does not resolve string prototype __proto__', () => { - assertEquals(Utils.lookup({ s: 'abc' }, 's.__proto__'), undefined) -}) - -Deno.test('Utils#lookup does not resolve string prototype constructor', () => { - assertEquals(Utils.lookup({ s: 'abc' }, 's.constructor'), undefined) -}) - -Deno.test('Utils#lookup does not resolve string prototype method', () => { - assertEquals(Utils.lookup({ s: 'abc' }, 's.toUpperCase'), undefined) -}) - -Deno.test('Utils#lookup handles empty segments in path', () => { - assertEquals(Utils.lookup({ a: { b: 1 } }, 'a..b'), 1) -}) - -Deno.test('Utils#lookup reads a nested string length through a multi-segment path', () => { - assertEquals(Utils.lookup({ obj: { s: 'hello' } }, 'obj.s.length'), 5) -}) - -Deno.test('Utils#lookup reads own char index of a string primitive', () => { - assertEquals(Utils.lookup({ s: 'hi' }, 's.1'), 'i') -}) - -Deno.test('Utils#lookup reads own length of a string primitive', () => { - assertEquals(Utils.lookup({ s: 'hello' }, 's.length'), 5) -}) - -Deno.test('Utils#lookup resolves an own key that shadows a builtin name', () => { - assertEquals(Utils.lookup({ toString: 'mine' }, 'toString'), 'mine') -}) - -Deno.test('Utils#lookup resolves dotted path', () => { - assertEquals(Utils.lookup({ a: { b: { c: 42 } } }, 'a.b.c'), 42) -}) - -Deno.test('Utils#lookup resolves simple key', () => { - assertEquals(Utils.lookup({ name: 'Alice' }, 'name'), 'Alice') -}) - -Deno.test('Utils#lookup returns root object for empty path', () => { - const obj = { x: 1 } - assertEquals(Utils.lookup(obj, ''), obj) -}) - -Deno.test('Utils#lookup returns undefined for an out-of-range string char index', () => { - assertEquals(Utils.lookup({ s: 'hi' }, 's.5'), undefined) -}) - -Deno.test('Utils#lookup returns undefined for missing path', () => { - assertEquals(Utils.lookup({ a: 1 }, 'b'), undefined) -}) - -Deno.test('Utils#lookup returns undefined for null object', () => { - assertEquals(Utils.lookup(null, 'a'), undefined) -}) - -Deno.test('Utils#lookup returns undefined for undefined object', () => { - assertEquals(Utils.lookup(undefined, 'a'), undefined) -}) - -Deno.test('Utils#lookup returns undefined when traversing non-object', () => { - assertEquals(Utils.lookup({ a: 42 }, 'a.b'), undefined) -}) - -Deno.test('Utils#lookup with array data and numeric string key', () => { - assertEquals(Utils.lookup([10, 20, 30], '1'), 20) -}) - -Deno.test('Utils#lookup with mid-chain null', () => { - assertEquals(Utils.lookup({ a: { b: null } }, 'a.b.c'), undefined) -}) - -Deno.test('Utils#lookup with path containing spaces around dots', () => { - assertEquals(Utils.lookup({ a: { b: 1 } }, 'a . b'), 1) -}) diff --git a/tests/routing/Handler.test.ts b/tests/routing/Handler.test.ts index 005dabd..2d01bad 100644 --- a/tests/routing/Handler.test.ts +++ b/tests/routing/Handler.test.ts @@ -1,1254 +1,151 @@ -import type * as Types from '@interfaces/index.ts' import { assertEquals } from '@std/assert' import { fileURLToPath } from 'node:url' -import { Handler } from '@core/Handler.ts' -import * as Core from '@core/index.ts' import * as Routing from '@routing/index.ts' +import * as Core from '@core/index.ts' const echoWorkerUrl = import.meta.resolve('@tests/fixtures/echo_worker.ts') +const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(/[/\\]$/, '') -function createTestContext(url: string, requestInit?: RequestInit): Core.Context { - const request = new Request(url, requestInit) - return new Core.Context(request, new URL(url), {}) -} - -Deno.test('Handler 404 for unmatched route returns HTML without Accept json', async () => { +Deno.test('Handler addMiddleware runs registered middleware on a request', async () => { const handler = new Routing.Handler() - const res = await handler.createHandler()(new Request('http://localhost/unknown')) - assertEquals(res.status, 404) - assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8') - const html = await res.text() - assertEquals(html.includes('404'), true) -}) - -Deno.test('Handler 404 for unmatched route returns JSON when Accept json', async () => { - const handler = new Routing.Handler() - const res = await handler.createHandler()( - new Request('http://localhost/unknown', { - headers: new Headers({ Accept: 'application/json' }) - }) - ) - assertEquals(res.status, 404) - assertEquals(res.headers.get('Content-Type'), 'application/problem+json') - const body = (await res.json()) as { title: string } - assertEquals(body.title, 'Not Found') -}) - -Deno.test('Handler 405 Allow advertises HEAD for a GET-only route (RFC 7231 §4.3.2)', async () => { - const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/only-get', { - handler: () => new Response('ok'), - pattern: '/only-get' - }) + handler.addMiddleware('', [(ctx) => Promise.resolve(ctx.send.text('from-mw'))]) const serve = handler.createHandler() - for (const method of ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) { - const res = await serve(new Request('http://localhost/only-get', { method })) - assertEquals(res.status, 405) - assertEquals(res.headers.get('Allow'), 'GET, HEAD') - await res.body?.cancel() - } + const res = await serve(new Request('http://localhost/anything')) + assertEquals(await res.text(), 'from-mw') }) -Deno.test('Handler 405 response lists valid methods in the Allow header', async () => { +Deno.test('Handler addMiddleware throws when a handler is not a function', () => { const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/items', { - handler: () => new Response('ok'), - pattern: '/items' - }) - routerInstance.add('POST', '/items', { - handler: () => new Response('created'), - pattern: '/items' - }) - const res = await handler.createHandler()( - new Request('http://localhost/items', { method: 'DELETE' }) - ) - assertEquals(res.status, 405) - assertEquals(res.headers.get('Allow'), 'GET, HEAD, POST') - await res.body?.cancel() -}) - -Deno.test('Handler 414 HTML response includes charset', async () => { - const handler = new Routing.Handler({ maxUrlLength: 30 }) - const serve = handler.createHandler() - const longUrl = 'http://localhost/' + 'x'.repeat(50) - const res = await serve(new Request(longUrl)) - assertEquals(res.status, 414) - assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8') -}) - -Deno.test('Handler HEAD omits Content-Length rather than buffering an unset-length body', async () => { - const handler = new Routing.Handler() - const routeModule = { - GET: (ctx: Core.Context) => ctx.send.json({ hello: 'world' }) - } - Routing.Scanner.registerHandlers( - (handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } }) - .routerInstance as Parameters[0], - routeModule, - '/cl', - ['GET'] - ) - const serve = handler.createHandler() - const headRes = await serve(new Request('http://localhost/cl', { method: 'HEAD' })) - assertEquals(headRes.headers.get('Content-Length'), null) - assertEquals(headRes.body, null) - assertEquals(headRes.status, 200) -}) - -Deno.test('Handler HEAD preserves a Content-Length the handler set explicitly', async () => { - const handler = new Routing.Handler() - const routeModule = { - GET: () => new Response('hello', { headers: { 'Content-Length': '5' } }) - } - Routing.Scanner.registerHandlers( - (handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } }) - .routerInstance as Parameters[0], - routeModule, - '/cl-explicit', - ['GET'] - ) - const serve = handler.createHandler() - const headRes = await serve(new Request('http://localhost/cl-explicit', { method: 'HEAD' })) - assertEquals(headRes.headers.get('Content-Length'), '5') - assertEquals(headRes.body, null) - assertEquals(headRes.status, 200) -}) - -Deno.test('Handler HEAD request returns null body', async () => { - const handler = new Routing.Handler() - const routeModule = { - GET: (ctx: Core.Context) => ctx.send.text('hello world') - } - Routing.Scanner.registerHandlers( - (handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } }) - .routerInstance as Parameters[0], - routeModule, - '/test', - ['GET'] - ) - const serve = handler.createHandler() - const res = await serve(new Request('http://localhost/test', { method: 'HEAD' })) - assertEquals(res.body, null) - assertEquals(res.status, 200) -}) - -Deno.test('Handler a throwing trustProxy predicate is funneled to a masked 500 with security headers', async () => { - const handler = new Routing.Handler({ - trustProxy: (ip) => { - if (ip === '10.0.0.9') { - throw new Error('trustProxy boom') - } - return true - } - }) - handler.addMiddleware('', () => new Response('ok')) - const handle = handler.createHandler() - const info = { - remoteAddr: { transport: 'tcp', hostname: '10.0.0.9', port: 1 } - } as Deno.ServeHandlerInfo - const res = await handle(new Request('http://localhost/'), info) - assertEquals(res.status, 500) - assertEquals(res.headers.get('X-Content-Type-Options'), 'nosniff') -}) - -Deno.test('Handler a trustProxy that throws on an attacker XFF hop does not escape', async () => { - const handler = new Routing.Handler({ - trustProxy: (ip) => { - if (ip === '1.2.3.4') { - throw new Error('hop boom') - } - return true - } - }) - handler.addMiddleware('', () => new Response('ok')) - const handle = handler.createHandler() - const info = { - remoteAddr: { transport: 'tcp', hostname: '127.0.0.1', port: 1 } - } as Deno.ServeHandlerInfo - const res = await handle( - new Request('http://localhost/', { headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' } }), - info - ) - assertEquals(res.status, 500) - assertEquals(res.headers.get('X-Content-Type-Options'), 'nosniff') -}) - -Deno.test('Handler addMiddleware path prefix only applies to matching routes', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('/api', async (ctx, next) => { - ctx.setHeader('X-Matched', 'yes') - return await next() - }) - handler.addMiddleware( - '', - async (ctx) => new Response(ctx[Core.InternalContext].responseHeadersMap['X-Matched'] ?? 'no') - ) - const handle = handler.createHandler() - - const resApi = await handle(new Request('http://localhost/api/users')) - assertEquals(await resApi.text(), 'yes') - - const resOther = await handle(new Request('http://localhost/assets')) - assertEquals(await resOther.text(), 'no') -}) - -Deno.test('Handler applies middleware-set headers and cookies to a raw Response return', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('', (ctx: Core.Context, next) => { - ctx.setHeader('X-Sec', 'on') - ctx.setHeader('Set-Cookie', 'sid=abc; Path=/') - return next() - }) - ;( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance.add('GET', '/raw', { - handler: () => new Response('raw', { headers: { 'Content-Type': 'text/plain' } }) - }) - const res = await handler.createHandler()(new Request('http://localhost/raw')) - assertEquals(res.status, 200) - assertEquals(res.headers.get('X-Sec'), 'on') - assertEquals(res.headers.get('Set-Cookie'), 'sid=abc; Path=/') - assertEquals(res.headers.get('Content-Type'), 'text/plain') - assertEquals(await res.text(), 'raw') -}) - -Deno.test('Handler constructor accepts omitted and valid numeric options', () => { - new Routing.Handler({}) - new Routing.Handler({ requestTimeoutMs: 5000, maxParamLength: 100, maxUrlLength: 2048 }) -}) - -Deno.test('Handler constructor throws InvalidData on non-positive maxParamLength/maxUrlLength', () => { - let a = false + let threw = false try { - new Routing.Handler({ maxParamLength: -1 }) + handler.addMiddleware('', [null as never]) } catch (e) { - a = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(a, true) - let b = false - try { - new Routing.Handler({ maxUrlLength: 0 }) - } catch (e) { - b = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(b, true) -}) - -Deno.test('Handler constructor throws InvalidData on non-positive requestTimeoutMs', () => { - for (const bad of [-5, 0, NaN, Infinity]) { - let thrown = false - try { - new Routing.Handler({ requestTimeoutMs: bad }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) + threw = true + assertEquals(e instanceof TypeError, true) } + assertEquals(threw, true) }) -Deno.test('Handler constructor with no options uses defaults', async () => { - const handler = new Routing.Handler() - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/')) - assertEquals(res.status, 404) - await res.body?.cancel() -}) - -Deno.test('Handler continues to the next middleware when a middleware returns undefined', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('', () => undefined) - handler.addMiddleware('', () => new Response('reached', { status: 201 })) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 201) - assertEquals(await res.text(), 'reached') -}) - -Deno.test('Handler createPattern with .cjs extension', () => { +Deno.test('Handler addStatic serves a static file', async () => { const handler = new Routing.Handler() - assertEquals(handler.createPattern('items/create.cjs'), '/items/create') -}) - -Deno.test('Handler createPattern with .jsx extension', () => { - const handler = new Routing.Handler() - assertEquals(handler.createPattern('items/create.jsx'), '/items/create') -}) - -Deno.test('Handler createPattern with .mjs extension', () => { - const handler = new Routing.Handler() - assertEquals(handler.createPattern('items/create.mjs'), '/items/create') -}) - -Deno.test('Handler does not double-apply cookies to a ctx.send response', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('', (ctx: Core.Context, next) => { - ctx.setHeader('Set-Cookie', 'sid=abc; Path=/') - return next() - }) - ;( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance.add('GET', '/sendc', { - handler: (ctx: Core.Context) => ctx.send.json({ ok: true }) - }) - const res = await handler.createHandler()(new Request('http://localhost/sendc')) - assertEquals(res.status, 200) - assertEquals(res.headers.getSetCookie().length, 1) - await res.body?.cancel() -}) - -Deno.test('Handler emits a request:complete event for every served request including errors', async () => { - const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/fail', { - handler: () => { - throw new Error('internal boom') - }, - pattern: '/fail' - }) - const kinds: string[] = [] - handler.onEvent((event) => kinds.push(event.kind)) - const res = await handler.createHandler()(new Request('http://localhost/fail')) - assertEquals(res.status, 500) - await res.body?.cancel() - assertEquals(kinds.includes('request:complete'), true) - assertEquals(kinds.includes('request:error'), true) -}) - -Deno.test('Handler emits external request:error for a developer-built 4xx response', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - handler.addMiddleware('', () => new Response('nope', { status: 404 })) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 404) - await res.body?.cancel() - const errorEvent = events.find((e) => e.kind === 'request:error') - assertEquals(errorEvent?.kind === 'request:error' && errorEvent.metadata.statusCode, 404) - assertEquals(errorEvent?.type, 'external') - assertEquals(errorEvent?.kind === 'request:error' && errorEvent.metadata.error, undefined) - const complete = events.find((e) => e.kind === 'request:complete') - assertEquals(complete?.type, 'external') -}) - -Deno.test('Handler emits full request metadata when a listener is registered', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - const unsub = handler.onEvent((event) => events.push(event)) - const response = await handler.createHandler()( - new Request('http://localhost/unknown', { headers: { 'user-agent': 'probe/1.0' } }) - ) - unsub() - assertEquals(response.status, 404) - const complete = events.find((event) => event.kind === 'request:complete') - assertEquals(complete !== undefined, true) - const metadata = complete?.metadata as { - method: string - statusCode: number - url: string - durationMs: number - userAgent?: string - serverAddress?: string - } - assertEquals(metadata.method, 'GET') - assertEquals(metadata.statusCode, 404) - assertEquals(metadata.userAgent, 'probe/1.0') - assertEquals(metadata.serverAddress, 'localhost') -}) - -Deno.test('Handler emits internal request:error with Error for an unmatched route', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - const res = await handler.createHandler()(new Request('http://localhost/no-such-route')) - assertEquals(res.status, 404) - await res.body?.cancel() - const errorEvents = events.filter((e) => e.kind === 'request:error') - assertEquals(errorEvents.length, 1) - const onlyError = errorEvents[0] - assertEquals(onlyError?.type, 'internal') - assertEquals( - onlyError?.kind === 'request:error' && onlyError.metadata.error !== undefined, - true - ) - assertEquals(events.some((e) => e.kind === 'request:complete'), true) -}) - -Deno.test('Handler emits no observability event when no listener is registered', async () => { - const handler = new Routing.Handler() - const response = await handler.createHandler()(new Request('http://localhost/unknown')) - assertEquals(response.status, 404) -}) - -Deno.test('Handler emits request:complete for a successful developer response', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - handler.addMiddleware('', () => new Response('ok', { status: 200 })) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 200) - await res.body?.cancel() - const complete = events.find((e) => e.kind === 'request:complete') - assertEquals(complete?.kind === 'request:complete' && complete.metadata.statusCode, 200) - assertEquals(complete?.type, 'external') - assertEquals( - complete?.kind === 'request:complete' && typeof complete.metadata.durationMs, - 'number' - ) - assertEquals(events.some((e) => e.kind === 'request:error'), false) -}) - -Deno.test('Handler fake Response on a HEAD request does not escape the error funnel', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('', () => Object.create(Response.prototype) as Response) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/', { method: 'HEAD' })) - assertEquals(res.status, 500) -}) - -Deno.test('Handler handleResponse catches builder exception', async () => { - const handler = new Routing.Handler({ - errorResponseBuilder: { - build: () => { - throw new Error('builder exploded') - } - } - }) - const routeModule = { - GET: () => { - throw new Error('route error') - } - } - Routing.Scanner.registerHandlers( - (handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } }) - .routerInstance as Parameters[0], - routeModule, - '/fail', - ['GET'] + const staticBase = fileURLToPath(import.meta.resolve('@tests/fixtures/static/')).replace( + /[/\\]$/, + '' ) + handler.addStatic('/assets', { path: staticBase }) const serve = handler.createHandler() - const res = await serve(new Request('http://localhost/fail')) - assertEquals(res.status, 500) + const res = await serve(new Request('http://localhost/assets/index.html')) + assertEquals(res.status, 200) + assertEquals((await res.text()).includes('static fixture'), true) }) -Deno.test('Handler masks a non-Response return as JSON Internal Server Error', async () => { +Deno.test('Handler addStatic throws when options path is empty', () => { const handler = new Routing.Handler() - handler.addMiddleware('', () => ({ foo: 'bar' }) as unknown as Response) - const req = new Request('http://localhost/', { - headers: new Headers({ Accept: 'application/json' }) - }) - const res = await handler.createHandler()(req) - assertEquals(res.status, 500) - assertEquals(res.headers.get('Content-Type'), 'application/problem+json') - const body = (await res.json()) as { title: string } - assertEquals(body.title, 'Internal Server Error') -}) - -Deno.test('Handler maxRouteParamLength returns 414 when exceeded', async () => { - const handler = new Routing.Handler({ maxParamLength: 10 }) - ;( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance.add('GET', '/items/:id', { - handler: (ctx: Core.Context) => new Response(ctx.param('id') ?? '') - }) - const longId = 'a'.repeat(50) - const res = await handler.createHandler()(new Request(`http://localhost/items/${longId}`)) - assertEquals(res.status, 414) -}) - -Deno.test('Handler maxRouteParamLength with Infinity throws InvalidData', () => { - let thrown = false - try { - new Routing.Handler({ maxParamLength: Infinity }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Handler maxRouteParamLength with NaN throws InvalidData', () => { - let thrown = false - try { - new Routing.Handler({ maxParamLength: NaN }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Handler maxRouteParamLength with negative throws InvalidData', () => { - let thrown = false - try { - new Routing.Handler({ maxParamLength: -5 }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Handler maxRouteParamLength with zero throws InvalidData', () => { - let thrown = false - try { - new Routing.Handler({ maxParamLength: 0 }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Handler maxUrlLength 414 returns HTML without Accept json', async () => { - const handler = new Routing.Handler({ maxUrlLength: 30 }) - const longPath = 'a'.repeat(50) - const res = await handler.createHandler()(new Request(`http://localhost/${longPath}`)) - assertEquals(res.status, 414) - assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8') -}) - -Deno.test('Handler maxUrlLength 414 returns JSON when Accept json', async () => { - const handler = new Routing.Handler({ maxUrlLength: 30 }) - const longPath = 'a'.repeat(50) - const res = await handler.createHandler()( - new Request(`http://localhost/${longPath}`, { - headers: new Headers({ Accept: 'application/json' }) - }) - ) - assertEquals(res.status, 414) - const body = (await res.json()) as { title: string } - assertEquals(body.title, 'URI Too Long') -}) - -Deno.test('Handler maxUrlLength returns 414 when exceeded', async () => { - const handler = new Routing.Handler({ maxUrlLength: 50 }) - const longPath = 'a'.repeat(200) - const res = await handler.createHandler()(new Request(`http://localhost/${longPath}`)) - assertEquals(res.status, 414) -}) - -Deno.test('Handler maxUrlLength with Infinity throws InvalidData', () => { - let thrown = false - try { - new Routing.Handler({ maxUrlLength: Infinity }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Handler maxUrlLength with NaN throws InvalidData', () => { - let thrown = false + let threw = false try { - new Routing.Handler({ maxUrlLength: NaN }) + handler.addStatic('/assets', { path: '' }) } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) + threw = true + assertEquals(e instanceof TypeError, true) } - assertEquals(thrown, true) + assertEquals(threw, true) }) -Deno.test('Handler maxUrlLength with negative throws InvalidData', () => { - let thrown = false - try { - new Routing.Handler({ maxUrlLength: -100 }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Handler maxUrlLength with zero throws InvalidData', () => { - let thrown = false - try { - new Routing.Handler({ maxUrlLength: 0 }) - } catch (e) { - thrown = true - assertEquals(e instanceof Deno.errors.InvalidData, true) - } - assertEquals(thrown, true) -}) - -Deno.test('Handler middleware * matches all paths', async () => { +Deno.test('Handler constructor defaults routesDir to ./routes', () => { const handler = new Routing.Handler() - handler.addMiddleware('*', async (ctx, next) => { - ctx.setHeader('X-Global', 'yes') - return await next() - }) - handler.addMiddleware( - '', - async (ctx) => new Response(ctx[Core.InternalContext].responseHeadersMap['X-Global'] ?? 'no') - ) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/anything')) - assertEquals(await res.text(), 'yes') + assertEquals(handler.routesDir, './routes') }) -Deno.test('Handler middleware calling next() twice surfaces a 500 instead of silently swallowing', async () => { - const handler = new Routing.Handler() - let downstreamRuns = 0 - handler.addMiddleware('', async (_ctx, next) => { - await next() - return await next() - }) - handler.addMiddleware('', (_ctx, next) => { - downstreamRuns++ - return next() - }) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/')) - assertEquals(res.status, 500) - assertEquals(downstreamRuns, 1) -}) - -Deno.test('Handler middleware chain runs in order', async () => { - const handler = new Routing.Handler() - const order: number[] = [] - handler.addMiddleware('', async (_ctx, next) => { - order.push(1) - return await next() - }) - handler.addMiddleware('', async (_ctx, next) => { - order.push(2) - return await next() - }) - handler.addMiddleware('', async () => { - order.push(3) - return new Response('done') - }) - const handle = handler.createHandler() - await handle(new Request('http://localhost/')) - assertEquals(order, [1, 2, 3]) -}) - -Deno.test('Handler middleware returning a prototype-only fake Response yields a masked 500', async () => { - const handler = new Routing.Handler() - const fake = Object.create(Response.prototype) as Response - handler.addMiddleware('', () => fake) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/')) - assertEquals(res.status, 500) - assertEquals(await res.text() !== '', true) +Deno.test('Handler constructor honors a custom routes directory', () => { + const handler = new Routing.Handler({ routes: { directory: './my-routes' } }) + assertEquals(handler.routesDir, './my-routes') }) -Deno.test('Handler middleware returning undefined after next() does not double-dispatch downstream', async () => { - const handler = new Routing.Handler() - let secondRuns = 0 - handler.addMiddleware('', async (_ctx, next) => { - await next() - return undefined - }) - handler.addMiddleware('', (_ctx, next) => { - secondRuns++ - return next() - }) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/')) - assertEquals(res.status, 404) - assertEquals(secondRuns, 1) -}) - -Deno.test('Handler middleware returns response stops chain', async () => { - const handler = new Routing.Handler() - let secondCalled = false - handler.addMiddleware('', async () => { - return new Response('stopped') - }) - handler.addMiddleware('', async (_ctx, next) => { - secondCalled = true - return await next() - }) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/')) - assertEquals(await res.text(), 'stopped') - assertEquals(secondCalled, false) -}) - -Deno.test('Handler middleware wildcard /** matches deep paths', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('/api/**', async (ctx, next) => { - ctx.setHeader('X-API', 'true') - return await next() - }) - handler.addMiddleware( - '', - async (ctx) => new Response(ctx[Core.InternalContext].responseHeadersMap['X-API'] ?? 'no') - ) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/api/v1/users/123')) - assertEquals(await res.text(), 'true') -}) - -Deno.test('Handler path-scoped middleware still applies after dot-segment normalization', async () => { - const handler = new Routing.Handler() - let guarded = false - handler.addMiddleware('/api', async (ctx) => { - guarded = true - return await ctx.handleError(403, new Deno.errors.PermissionDenied('blocked')) - }) - ;( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance.add('GET', '/api/secret', { - handler: () => new Response('secret-data') - }) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/foo/../api/secret')) - assertEquals(res.status, 403) - assertEquals(guarded, true) - assertEquals((await res.text()).includes('secret-data'), false) -}) - -Deno.test('Handler raw Response keeps its own header over a middleware default', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('', (ctx: Core.Context, next) => { - ctx.setHeader('Content-Type', 'application/xml') - return next() - }) - ;( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance.add('GET', '/raw2', { - handler: () => new Response('hi', { headers: { 'Content-Type': 'text/plain' } }) - }) - const res = await handler.createHandler()(new Request('http://localhost/raw2')) - assertEquals(res.headers.get('Content-Type'), 'text/plain') - await res.body?.cancel() -}) - -Deno.test('Handler request events carry the direct peer IP in metadata', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - handler.addMiddleware('', () => { - throw new Error('boom') - }) - const info = { - remoteAddr: { transport: 'tcp', hostname: '198.51.100.7', port: 1 } - } as Deno.ServeHandlerInfo - const res = await handler.createHandler()(new Request('http://localhost/'), info) - await res.body?.cancel() - const errorEvent = events.find((e) => e.kind === 'request:error') - assertEquals(errorEvent?.kind === 'request:error' && errorEvent.metadata.ip, '198.51.100.7') -}) - -Deno.test('Handler request events carry the matched route pattern, server authority, and user agent', async () => { - const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/users/:id', { - kind: 'handler', - pattern: '/users/:id', - handler: () => new Response('u', { headers: { 'content-length': '1' } }) - }) - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - const res = await handler.createHandler()( - new Request('http://api.example.com:8080/users/42', { - headers: { 'user-agent': 'curl/8', 'content-length': '0' } - }) - ) - await res.body?.cancel() - const complete = events.find((e) => e.kind === 'request:complete') - if (complete?.kind !== 'request:complete') { - throw new Error('missing request:complete event') - } - assertEquals(complete.metadata.route, '/users/:id') - assertEquals(complete.metadata.serverAddress, 'api.example.com') - assertEquals(complete.metadata.serverPort, 8080) - assertEquals(complete.metadata.userAgent, 'curl/8') - assertEquals(complete.metadata.requestSize, 0) - assertEquals(complete.metadata.responseSize, 1) -}) - -Deno.test('Handler request events carry the trusted-proxy-resolved client IP, not the proxy', async () => { - const handler = new Routing.Handler({ trustProxy: ['loopback'] }) - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - handler.addMiddleware('', () => new Response('ok')) - const info = { - remoteAddr: { transport: 'tcp', hostname: '127.0.0.1', port: 1 } - } as Deno.ServeHandlerInfo - const res = await handler.createHandler()( - new Request('http://localhost/', { headers: { 'x-forwarded-for': '203.0.113.99' } }), - info - ) - await res.body?.cancel() - const complete = events.find((e) => e.kind === 'request:complete') - assertEquals(complete?.kind === 'request:complete' && complete.metadata.ip, '203.0.113.99') -}) - -Deno.test('Handler request events omit ip when the peer is unknown', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - handler.addMiddleware('', () => new Response('ok')) - const res = await handler.createHandler()(new Request('http://localhost/')) - await res.body?.cancel() - const complete = events.find((e) => e.kind === 'request:complete') - assertEquals(complete?.kind === 'request:complete' && 'ip' in complete.metadata, false) -}) - -Deno.test('Handler request events omit responseSize and userAgent when unknown', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - handler.addMiddleware( - '', - () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1])) - controller.close() - } - }) - ) - ) - const res = await handler.createHandler()(new Request('http://localhost/stream')) - await res.body?.cancel() - const complete = events.find((e) => e.kind === 'request:complete') - if (complete?.kind !== 'request:complete') { - throw new Error('missing request:complete event') - } - assertEquals('responseSize' in complete.metadata, false) - assertEquals('userAgent' in complete.metadata, false) -}) - -Deno.test('Handler request events omit the route pattern when no route matches', async () => { - const handler = new Routing.Handler() - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - const res = await handler.createHandler()(new Request('http://localhost/nope')) - await res.body?.cancel() - const complete = events.find((e) => e.kind === 'request:complete') - assertEquals(complete?.kind === 'request:complete' && 'route' in complete.metadata, false) -}) - -Deno.test('Handler requestTimeoutMs returns 503 when exceeded', async () => { - const handler = new Routing.Handler({ requestTimeoutMs: 5 }) - handler.addMiddleware('', async () => { - await new Promise((r) => setTimeout(r, 20)) - return new Response('late') - }) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 503) - await res.body?.cancel() - await new Promise((r) => setTimeout(r, 30)) -}) - -Deno.test('Handler returns 404 (not 405) for a path that exists under no method', async () => { - const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/items', { - handler: () => new Response('ok'), - pattern: '/items' - }) - const res = await handler.createHandler()( - new Request('http://localhost/unknown', { method: 'DELETE' }) +Deno.test('Handler createHandler negotiates a JSON 404', async () => { + const serve = new Routing.Handler().createHandler() + const res = await serve( + new Request('http://localhost/missing', { headers: { accept: 'application/json' } }) ) assertEquals(res.status, 404) + assertEquals(res.headers.get('content-type'), 'application/problem+json') await res.body?.cancel() }) -Deno.test('Handler returns 405 when path exists under a different method', async () => { - const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/items', { - handler: () => new Response('ok'), - pattern: '/items' - }) - const res = await handler.createHandler()( - new Request('http://localhost/items', { method: 'DELETE' }) - ) - assertEquals(res.status, 405) - await res.body?.cancel() -}) - -Deno.test('Handler route error with statusCode uses thrown statusCode', async () => { - const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/fail', { - handler: () => { - const err = new Error('bad request') as Types.StatusError - err.statusCode = 400 - throw err - }, - pattern: '/fail' - }) - const res = await handler.createHandler()(new Request('http://localhost/fail')) - assertEquals(res.status, 400) - await res.body?.cancel() -}) - -Deno.test('Handler route error without statusCode defaults to 500', async () => { - const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/fail', { - handler: () => { - throw new Error('internal') - }, - pattern: '/fail' - }) - const res = await handler.createHandler()(new Request('http://localhost/fail')) - assertEquals(res.status, 500) - await res.body?.cancel() -}) - -Deno.test('Handler routes a non-Response middleware return through the error pipeline', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('', () => 'not a response' as unknown as Response) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 500) - await res.body?.cancel() -}) - -Deno.test('Handler setErrorBuilder overrides error response', async () => { - const handler = new Routing.Handler() - handler.setErrorBuilder({ - build: async (_ctx, statusCode) => new Response('custom error', { status: statusCode }) - }) - const res = await handler.createHandler()(new Request('http://localhost/nonexistent')) +Deno.test('Handler createHandler returns 404 for an unknown route', async () => { + const serve = new Routing.Handler().createHandler() + const res = await serve(new Request('http://localhost/missing')) assertEquals(res.status, 404) - assertEquals(await res.text(), 'custom error') -}) - -Deno.test('Handler setErrorMiddleware sets custom error handler', async () => { - const handler = new Routing.Handler() - let errorMiddlewareCalled = false - handler.setErrorMiddleware(async (_ctx, errorInfo) => { - errorMiddlewareCalled = true - return new Response('custom not found', { status: errorInfo.statusCode }) - }) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/nonexistent')) - assertEquals(res.status, 404) - assertEquals(errorMiddlewareCalled, true) - assertEquals(await res.text(), 'custom not found') -}) - -Deno.test('Handler stops the chain and masks 500 when a chained middleware returns a non-Response value', async () => { - const handler = new Routing.Handler() - let secondRan = false - handler.addMiddleware('', () => 42 as unknown as Response) - handler.addMiddleware('', () => { - secondRan = true - return new Response('ok') - }) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 500) - assertEquals(secondRan, false) await res.body?.cancel() }) -Deno.test('Handler timeout emits request:error event with status 503', async () => { - const handler = new Routing.Handler({ requestTimeoutMs: 5 }) - const events: Types.EventBase[] = [] - handler.onEvent((event) => events.push(event)) - handler.addMiddleware('', async () => { - await new Promise((r) => setTimeout(r, 20)) - return new Response('late') - }) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 503) +Deno.test('Handler createHandler returns 414 for an over-long URL', async () => { + const handler = new Routing.Handler({ maxUrlLength: 64 }) + const serve = handler.createHandler() + const res = await serve(new Request(`http://localhost/${'a'.repeat(200)}`)) + assertEquals(res.status, 414) await res.body?.cancel() - await new Promise((r) => setTimeout(r, 30)) - const timeoutEvent = events.find((e) => e.kind === 'request:error') - assertEquals(timeoutEvent !== undefined, true) - assertEquals(timeoutEvent?.kind === 'request:error' && timeoutEvent.metadata.statusCode, 503) - assertEquals(timeoutEvent?.type, 'internal') - assertEquals( - timeoutEvent?.kind === 'request:error' && - timeoutEvent.metadata.error instanceof Deno.errors.TimedOut, - true - ) - assertEquals( - timeoutEvent?.kind === 'request:error' && typeof timeoutEvent.metadata.durationMs, - 'number' - ) -}) - -Deno.test('Handler viewsDir sets ctx.state.view and can render', async () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const handler = new Routing.Handler({ viewsDir }) - handler.addMiddleware('', async (ctx) => { - const engine = ctx.getState(Handler.stateKeys.view) as { - render: (p: string, d?: unknown) => Promise - } - const html = await engine.render('hello.dve', { name: 'DX' } as Record) - return new Response(html) - }) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(await res.text(), 'Hello DX.\n') -}) - -Deno.test('Handler without timeout does not return 503', async () => { - const handler = new Routing.Handler() - handler.addMiddleware('', async () => { - await new Promise((r) => setTimeout(r, 5)) - return new Response('ok') - }) - const res = await handler.createHandler()(new Request('http://localhost/')) - assertEquals(res.status, 200) - assertEquals(await res.text(), 'ok') -}) - -Deno.test('Handler#addMiddleware throws a TypeError when the handler is not a function', () => { - const handler = new Routing.Handler() - const invalidHandlers: unknown[] = ['notfn', undefined, null, 123, {}] - for (const invalid of invalidHandlers) { - let caught: unknown = null - try { - handler.addMiddleware('/a', invalid as never) - } catch (e) { - caught = e - } - assertEquals(caught instanceof TypeError, true) - assertEquals((caught as Error).message.includes('must be a function'), true) - } -}) - -Deno.test('Handler#addStaticRoute returns 405 for non-GET methods on a static path', async () => { - const staticBasePath = fileURLToPath(import.meta.resolve('@tests/fixtures/static/')).replace( - /[\\/]$/, - '' - ) - const handler = new Routing.Handler() - handler.addStaticRoute('/static', { path: staticBasePath }) - const handle = handler.createHandler() - const post = await handle( - new Request('http://localhost/static/index.html', { method: 'POST' }) - ) - assertEquals(post.status, 405) - assertEquals(post.headers.get('Allow'), 'GET, HEAD') - await post.body?.cancel() - const del = await handle( - new Request('http://localhost/static/index.html', { method: 'DELETE' }) - ) - assertEquals(del.status, 405) - await del.body?.cancel() -}) - -Deno.test('Handler#addStaticRoute serves static files', async () => { - const staticBasePath = fileURLToPath(import.meta.resolve('@tests/fixtures/static/')).replace( - /[\\/]$/, - '' - ) - const handler = new Routing.Handler() - handler.addStaticRoute('/static', { path: staticBasePath }) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/static/index.html')) - assertEquals(res.status, 200) - const text = await res.text() - assertEquals(text.includes('static fixture'), true) -}) - -Deno.test('Handler#addStaticRoute throws a TypeError when path option is not a non-empty string', () => { - const handler = new Routing.Handler() - const invalidPaths: unknown[] = [123, '', undefined, null] - for (const invalid of invalidPaths) { - let caught: unknown = null - try { - handler.addStaticRoute('/s', { path: invalid as string }) - } catch (e) { - caught = e - } - assertEquals(caught instanceof TypeError, true) - assertEquals((caught as Error).message.includes('non-empty string'), true) - } -}) - -Deno.test('Handler#createHandler with worker option sets ctx.state.worker', async () => { - const handler = new Routing.Handler({ - worker: { scriptURL: echoWorkerUrl, poolSize: 1 } - }) - handler.addMiddleware('', async (ctx, next) => { - const workerHandle = ctx.getState(Handler.stateKeys.worker) - if ( - workerHandle && - typeof (workerHandle as { run?: unknown }).run === 'function' - ) { - return new Response('ok', { headers: { 'X-Worker': 'set' } }) - } - return await next() - }) - const handle = handler.createHandler() - const res = await handle(new Request('http://localhost/')) - assertEquals(res.status, 200) - assertEquals(res.headers.get('X-Worker'), 'set') - assertEquals(await res.text(), 'ok') -}) - -Deno.test('Handler#createPattern [id] -> :id', () => { - const handler = new Routing.Handler() - assertEquals(handler.createPattern('items/[id].ts'), '/items/:id') - assertEquals(handler.createPattern('items/[id]/edit.tsx'), '/items/:id/edit') -}) - -Deno.test('Handler#createPattern index -> /', () => { - const handler = new Routing.Handler() - assertEquals(handler.createPattern('index.ts'), '/') - assertEquals(handler.createPattern('index.tsx'), '/') - assertEquals(handler.createPattern('Index.TS'), '/') - assertEquals(handler.createPattern('items/Index.tsx'), '/items') -}) - -Deno.test('Handler#createPattern invalid extension returns null', () => { - const handler = new Routing.Handler() - assertEquals(handler.createPattern('readme.md'), null) -}) - -Deno.test('Handler#createPattern nested index', () => { - const handler = new Routing.Handler() - assertEquals(handler.createPattern('items/index.ts'), '/items') }) -Deno.test('Handler#createPattern skips @ and _ segments', () => { +Deno.test('Handler createHandler returns a request handler function', () => { const handler = new Routing.Handler() - assertEquals(handler.createPattern('@components/foo.ts'), null) - assertEquals(handler.createPattern('_layout.ts'), null) + assertEquals(typeof handler.createHandler(), 'function') }) -Deno.test('Handler#getViewEngine returns engine when viewsDir set', () => { - const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace( - /[\\/]$/, - '' - ) - const handler = new Routing.Handler({ viewsDir }) - const engine = handler.getViewEngine() - assertEquals(engine !== undefined, true) -}) - -Deno.test('Handler#getViewEngine returns undefined when viewsDir not set', () => { +Deno.test('Handler emitEvent reaches a subscribed listener', () => { const handler = new Routing.Handler() - assertEquals(handler.getViewEngine(), undefined) + const kinds: string[] = [] + const unsub = handler.onEvent((event) => kinds.push(event.kind)) + handler.emitEvent(Core.Observability.internalEvent('server:stopped', {})) + unsub() + assertEquals(kinds.includes('server:stopped'), true) }) -Deno.test('Handler#handleResponse when errorMiddleware returns custom uses it', async () => { +Deno.test('Handler onEvent returns an unsubscribe function', () => { const handler = new Routing.Handler() - handler.setErrorMiddleware(async (_ctx, errorInfo) => { - return new Response(`custom ${errorInfo.statusCode}`, { - status: errorInfo.statusCode, - headers: new Headers({ 'X-Custom': 'yes' }) - }) - }) - const ctx = createTestContext('http://localhost/', { - headers: new Headers({ Accept: 'application/json' }) - }) - const res = await handler.handleResponse(ctx, 404, new Error('gone')) - assertEquals(res.status, 404) - assertEquals(res.headers.get('X-Custom'), 'yes') - assertEquals(await res.text(), 'custom 404') + const unsub = handler.onEvent(() => {}) + assertEquals(typeof unsub, 'function') + unsub() }) -Deno.test('Handler#handleResponse when errorMiddleware returns non-Response falls through to safe default', async () => { +Deno.test('Handler removeRoute is safe when route is absent', () => { const handler = new Routing.Handler() - handler.setErrorMiddleware( - (() => 'broke') as unknown as Types.ErrorMiddleware - ) - const ctx = createTestContext('http://localhost/oops', { - headers: new Headers({ Accept: 'application/json' }) - }) - const res = await handler.handleResponse(ctx, 500, new Error('boom')) - assertEquals(res.status, 500) - const body = (await res.json()) as { title: string; instance: string; status: number } - assertEquals(body.status, 500) - assertEquals(body.instance, '/oops') + handler.removeRoute('/none') + assertEquals(true, true) }) -Deno.test('Handler#handleResponse when errorMiddleware returns null uses default', async () => { - const handler = new Routing.Handler() - handler.setErrorMiddleware(async () => null) - const ctx = createTestContext('http://localhost/bar', { - headers: new Headers({ Accept: 'application/json' }) - }) - const res = await handler.handleResponse(ctx, 404, new Error('Not found')) - assertEquals(res.status, 404) - const body = (await res.json()) as { title: string; instance: string; status: number } - assertEquals(body.status, 404) - assertEquals(body.instance, '/bar') +Deno.test('Handler scanRoutes resolves for a missing directory', async () => { + const handler = new Routing.Handler({ routes: { directory: './does-not-exist-routes-xyz' } }) + await handler.scanRoutes() + assertEquals(true, true) }) -Deno.test('Handler#handleResponse with Accept application/json returns JSON', async () => { +Deno.test('Handler setErrorMiddleware is used for error responses', async () => { const handler = new Routing.Handler() - const ctx = createTestContext('http://localhost/foo', { - headers: new Headers({ Accept: 'application/json' }) - }) - const res = await handler.handleResponse(ctx, 404, new Error('Not found')) + handler.setErrorMiddleware((ctx) => ctx.send.json({ custom: true }, { status: 404 })) + const serve = handler.createHandler() + const res = await serve(new Request('http://localhost/missing')) assertEquals(res.status, 404) - assertEquals(res.headers.get('Content-Type'), 'application/problem+json') - const responseBody = (await res.json()) as { title: string; instance: string; status: number } - assertEquals(responseBody.title, 'Not Found') - assertEquals(responseBody.instance, '/foo') - assertEquals(responseBody.status, 404) + assertEquals(await res.json(), { custom: true }) }) -Deno.test('Handler#removeRoute for non-existent pattern does not throw', () => { - const handler = new Routing.Handler() - handler.removeRoute('/never-added') +Deno.test('Handler terminate disposes a configured worker pool', () => { + const handler = new Routing.Handler({ worker: { scriptURL: echoWorkerUrl, poolSize: 1 } }) + handler.terminate() + assertEquals(true, true) }) -Deno.test('Handler#removeRoute removes route so it returns 404', async () => { +Deno.test('Handler terminate is safe without a worker pool', () => { const handler = new Routing.Handler() - const routerInstance = ( - handler as unknown as { routerInstance: { add: (m: string, p: string, d: unknown) => void } } - ).routerInstance - routerInstance.add('GET', '/test', { - handler: () => new Response('ok'), - pattern: '/test' - }) - const handle = handler.createHandler() - const res1 = await handle(new Request('http://localhost/test')) - assertEquals(res1.status, 200) - await res1.body?.cancel() - handler.removeRoute('/test') - const res2 = await handle(new Request('http://localhost/test')) - assertEquals(res2.status, 404) - await res2.body?.cancel() + handler.terminate() + assertEquals(true, true) }) -Deno.test('Handler#validateModule throws when method is not function', () => { +Deno.test('Handler viewEngine is null without views option', () => { const handler = new Routing.Handler() - let thrown = false - try { - handler.validateModule({ GET: 'not a function' }, 'routes/foo.ts') - } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('must be a function'), true) - } - assertEquals(thrown, true) + assertEquals(handler.viewEngine, null) }) -Deno.test('Handler#validateModule throws when no HTTP method exported', () => { - const handler = new Routing.Handler() - let thrown = false - try { - handler.validateModule({ default: () => {} }, 'routes/foo.ts') - } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('must export at least one HTTP method'), true) - } - assertEquals(thrown, true) +Deno.test('Handler viewEngine is set when views option provided', () => { + const handler = new Routing.Handler({ views: { directory: viewsDir } }) + assertEquals(handler.viewEngine instanceof Core.Rendering, true) }) diff --git a/tests/routing/Report.test.ts b/tests/routing/Report.test.ts new file mode 100644 index 0000000..e9d90bb --- /dev/null +++ b/tests/routing/Report.test.ts @@ -0,0 +1,88 @@ +import { assertEquals } from '@std/assert' +import type * as Types from '@interfaces/index.ts' +import * as Routing from '@routing/index.ts' + +function emptyHolder(): Types.RequestHolder { + return { ctx: null, frameworkError: null, parsedUrl: undefined, routePattern: undefined } +} + +Deno.test('Report reportRequest carries method, status and url metadata', () => { + const events: Types.EventBase[] = [] + const req = new Request('http://localhost/path', { method: 'POST' }) + const res = new Response('ok', { status: 201 }) + Routing.Report.reportRequest( + (e) => events.push(e), + req, + res, + performance.now(), + emptyHolder(), + false + ) + const metadata = events[0]!.metadata as Record + assertEquals(metadata['method'], 'POST') + assertEquals(metadata['statusCode'], 201) + assertEquals(metadata['url'], 'http://localhost/path') +}) + +Deno.test('Report reportRequest collects user-agent metric', () => { + const events: Types.EventBase[] = [] + const req = new Request('http://localhost/x', { headers: { 'user-agent': 'test-agent' } }) + const res = new Response('ok', { status: 200 }) + Routing.Report.reportRequest( + (e) => events.push(e), + req, + res, + performance.now(), + emptyHolder(), + false + ) + const metadata = events[0]!.metadata as Record + assertEquals(metadata['userAgent'], 'test-agent') +}) + +Deno.test('Report reportRequest emits request:completed', () => { + const kinds: string[] = [] + const req = new Request('http://localhost/ok') + const res = new Response('ok', { status: 200 }) + Routing.Report.reportRequest( + (e) => kinds.push(e.kind), + req, + res, + performance.now(), + emptyHolder(), + false + ) + assertEquals(kinds.includes('request:completed'), true) + assertEquals(kinds.includes('request:failed'), false) +}) + +Deno.test('Report reportRequest emits request:failed for 4xx and 5xx', () => { + const kinds: string[] = [] + const req = new Request('http://localhost/missing') + const res = new Response('no', { status: 404 }) + Routing.Report.reportRequest( + (e) => kinds.push(e.kind), + req, + res, + performance.now(), + emptyHolder(), + false + ) + assertEquals(kinds.includes('request:completed'), true) + assertEquals(kinds.includes('request:failed'), true) +}) + +Deno.test('Report reportRequest uses internal channel when timed out', () => { + const events: Types.EventBase[] = [] + const req = new Request('http://localhost/slow') + const res = new Response(null, { status: 503 }) + Routing.Report.reportRequest( + (e) => events.push(e), + req, + res, + performance.now(), + emptyHolder(), + true + ) + assertEquals(events[0]!.type, 'internal') +}) diff --git a/tests/routing/Respond.test.ts b/tests/routing/Respond.test.ts new file mode 100644 index 0000000..12c8aba --- /dev/null +++ b/tests/routing/Respond.test.ts @@ -0,0 +1,43 @@ +import { assertEquals } from '@std/assert' +import * as Routing from '@routing/index.ts' + +Deno.test('Respond isGenuine accepts a real Response', () => { + assertEquals(Routing.Respond.isGenuine(new Response('x')), true) +}) + +Deno.test('Respond isGenuine rejects non-Response values', () => { + assertEquals(Routing.Respond.isGenuine({}), false) + assertEquals(Routing.Respond.isGenuine(null), false) + assertEquals(Routing.Respond.isGenuine('x'), false) +}) + +Deno.test('Respond negotiatedError builds a JSON error when accepted', async () => { + const req = new Request('http://localhost/', { headers: { accept: 'application/json' } }) + const res = Routing.Respond.negotiatedError(req, 400, 'Bad Request') + assertEquals(res.status, 400) + assertEquals(res.headers.get('content-type'), 'application/problem+json') + await res.body?.cancel() +}) + +Deno.test('Respond negotiatedError builds an HTML error by default', async () => { + const req = new Request('http://localhost/') + const res = Routing.Respond.negotiatedError(req, 404, 'Not Found') + assertEquals(res.status, 404) + assertEquals(res.headers.get('content-type'), 'text/html; charset=utf-8') + await res.body?.cancel() +}) + +Deno.test('Respond safeServerError uses the safe status message', async () => { + const req = new Request('http://localhost/') + const res = Routing.Respond.safeServerError(req, 500) + assertEquals(res.status, 500) + assertEquals((await res.text()).includes('Internal Server Error'), true) +}) + +Deno.test('Respond toHeadResponse drops the body and keeps status', async () => { + const source = new Response('hello', { status: 200, headers: { 'x-a': '1' } }) + const head = await Routing.Respond.toHeadResponse(source) + assertEquals(head.status, 200) + assertEquals(head.body, null) + assertEquals(head.headers.get('x-a'), '1') +}) diff --git a/tests/routing/Router.test.ts b/tests/routing/Router.test.ts index 1fc90c4..26c4cb9 100644 --- a/tests/routing/Router.test.ts +++ b/tests/routing/Router.test.ts @@ -4,133 +4,94 @@ import * as Middleware from '@middleware/index.ts' const echoWorkerUrl = import.meta.resolve('@tests/fixtures/echo_worker.ts') -Deno.test('Handler#dispose is safe when no worker pool exists', () => { - const handler = new Routing.Handler() - ;(handler as unknown as { dispose(): void }).dispose() - assertEquals(true, true) -}) - -Deno.test('Handler#dispose terminates the worker pool and clears it', () => { - const router = new Routing.Router({ - routesDir: './routes', - worker: { scriptURL: echoWorkerUrl, poolSize: 1 } +function freePort(): number { + const listener = Deno.listen({ port: 0, hostname: '127.0.0.1' }) + const port = (listener.addr as Deno.NetAddr).port + listener.close() + return port +} + +Deno.test('Router applies default headers on an error response', async () => { + const router = new Routing.Router() + router.use(Middleware.Mware.securityHeaders()) + router.use(() => { + throw new Error('handler boom') }) - const handler = (router as unknown as { handler: unknown }).handler as { - workerPool?: unknown - dispose(): void - } - assertEquals(handler.workerPool !== undefined, true) - handler.dispose() - assertEquals(handler.workerPool, undefined) -}) - -Deno.test('Router options accepts HandlerOptions fields', () => { - const router = new Routing.Router({ - routesDir: './routes', - maxUrlLength: 4096, - maxParamLength: 512, - requestTimeoutMs: 5000 + const listening = Promise.withResolvers() + router.on((event) => { + if (event.kind === 'server:started') { + listening.resolve() + } }) - const handler = (router as unknown as { handler: unknown }).handler as { - maxUrlLength?: number - maxParamLength?: number - requestTimeoutMs?: number - } - assertEquals(handler.requestTimeoutMs, 5000) -}) - -Deno.test('Router options propagate to underlying handler (DX config)', () => { - const router = new Routing.Router({ - routesDir: './routes', - requestTimeoutMs: 123, - viewsDir: '/tmp/views', - worker: { scriptURL: echoWorkerUrl, poolSize: 1 } + const port = freePort() + const controller = new AbortController() + const serving = router.serve(port, '127.0.0.1', controller.signal) + await listening.promise + const response = await fetch(`http://127.0.0.1:${port}/boom`, { + signal: AbortSignal.timeout(5000) }) - const handler = (router as unknown as { handler: unknown }).handler as { - requestTimeoutMs?: number - workerPool?: unknown - viewEngine?: unknown - } - assertEquals(handler.requestTimeoutMs, 123) - assertEquals(handler.workerPool !== undefined, true) - assertEquals(handler.viewEngine !== undefined, true) -}) - -Deno.test('Router#catch does not throw', () => { - const router = new Routing.Router({ routesDir: './routes' }) - router.catch(async () => null) + await response.body?.cancel() + assertEquals(response.status, 500) + assertEquals(response.headers.get('x-content-type-options'), 'nosniff') + assertEquals(response.headers.get('x-frame-options'), 'SAMEORIGIN') + controller.abort() + await serving }) -Deno.test('Router#constructor defaults routesDir to ./routes', () => { +Deno.test('Router catch registers an error handler without throwing', () => { const router = new Routing.Router() - const routesDir = (router as unknown as { routesDir: string }).routesDir - assertEquals(routesDir, './routes') + router.catch(() => Promise.resolve(null)) }) -Deno.test('Router#constructor with empty options uses defaults', () => { - const router = new Routing.Router({}) +Deno.test('Router constructor with a worker option creates an instance', () => { + const router = new Routing.Router({ worker: { scriptURL: echoWorkerUrl, poolSize: 1 } }) assertEquals(router instanceof Routing.Router, true) }) -Deno.test('Router#constructor with only routesDir passes undefined to Handler', () => { - const router = new Routing.Router({ routesDir: './my-routes' }) - const handler = (router as unknown as { handler: Routing.Handler }).handler - assertEquals(handler instanceof Routing.Handler, true) +Deno.test('Router constructor with empty options creates an instance', () => { + assertEquals(new Routing.Router({}) instanceof Routing.Router, true) }) -Deno.test('Router#constructor with options creates instance', () => { - const router = new Routing.Router({ routesDir: './my-routes' }) - assertEquals(router instanceof Routing.Router, true) +Deno.test('Router constructor without options creates an instance', () => { + assertEquals(new Routing.Router() instanceof Routing.Router, true) }) -Deno.test('Router#constructor with worker option creates instance', () => { - const router = new Routing.Router({ - routesDir: './routes', - worker: { scriptURL: echoWorkerUrl, poolSize: 1 } - }) - assertEquals(router instanceof Routing.Router, true) -}) - -Deno.test('Router#constructor without options uses defaults', () => { +Deno.test('Router instance is frozen', () => { const router = new Routing.Router() - assertEquals(router instanceof Routing.Router, true) + assertEquals(Object.isFrozen(router), true) }) -Deno.test('Router#on receives request:error events from the pipeline', async () => { - const router = new Routing.Router({ routesDir: './routes' }) +Deno.test('Router on receives request:failed events from the pipeline', async () => { + const router = new Routing.Router() const events: string[] = [] router.on((event) => events.push(event.kind)) - const handler = (router as unknown as { handler: Routing.Handler }).handler - const serve = handler.createHandler() - const res = await serve(new Request('http://localhost/missing-route')) + const listening = Promise.withResolvers() + router.on((event) => { + if (event.kind === 'server:started') { + listening.resolve() + } + }) + const port = freePort() + const controller = new AbortController() + const serving = router.serve(port, '127.0.0.1', controller.signal) + await listening.promise + const res = await fetch(`http://127.0.0.1:${port}/missing`, { signal: AbortSignal.timeout(5000) }) await res.body?.cancel() assertEquals(res.status, 404) - assertEquals(events.includes('request:error'), true) + controller.abort() + await serving + assertEquals(events.includes('request:failed'), true) }) -Deno.test('Router#on returns an unsubscribe function', () => { - const router = new Routing.Router({ routesDir: './routes' }) +Deno.test('Router on returns an unsubscribe function', () => { + const router = new Routing.Router() const unsub = router.on(() => {}) assertEquals(typeof unsub, 'function') unsub() }) -Deno.test('Router#on unsubscribe stops receiving events', async () => { - const router = new Routing.Router({ routesDir: './routes' }) - let count = 0 - const unsub = router.on(() => count++) - const handler = (router as unknown as { handler: Routing.Handler }).handler - const serve = handler.createHandler() - await serve(new Request('http://localhost/missing-one')).then((r) => r.body?.cancel()) - const afterFirst = count - unsub() - await serve(new Request('http://localhost/missing-two')).then((r) => r.body?.cancel()) - assertEquals(afterFirst > 0, true) - assertEquals(count, afterFirst) -}) - -Deno.test('Router#serve drains an in-flight request and emits server:shutdown', async () => { - const router = new Routing.Router({ routesDir: './does-not-exist-routes-dir-xyz' }) +Deno.test('Router serve drains an in-flight request before shutdown', async () => { + const router = new Routing.Router() let drained = false const handlerStarted = Promise.withResolvers() router.use(async (ctx) => { @@ -139,22 +100,17 @@ Deno.test('Router#serve drains an in-flight request and emits server:shutdown', drained = true return ctx.send.text('done') }) - let shutdownEmitted = false - const listening = Promise.withResolvers() + const listening = Promise.withResolvers() router.on((event) => { - if (event.kind === 'server:listening') { - listening.resolve(event.metadata.port) - } - if (event.kind === 'server:shutdown') { - shutdownEmitted = true + if (event.kind === 'server:started') { + listening.resolve() } }) + const port = freePort() const controller = new AbortController() - const serving = router.serve(0, '127.0.0.1', controller.signal) - const port = await listening.promise - const inFlight = fetch(`http://127.0.0.1:${port}/drain-test`, { - signal: AbortSignal.timeout(5000) - }) + const serving = router.serve(port, '127.0.0.1', controller.signal) + await listening.promise + const inFlight = fetch(`http://127.0.0.1:${port}/drain`, { signal: AbortSignal.timeout(5000) }) await handlerStarted.promise controller.abort() const response = await inFlight @@ -162,28 +118,36 @@ Deno.test('Router#serve drains an in-flight request and emits server:shutdown', assertEquals(await response.text(), 'done') assertEquals(drained, true) await serving - assertEquals(shutdownEmitted, true) }) -Deno.test('Router#shutdownSignals includes SIGTERM on POSIX and only SIGINT on Windows', () => { - const signals = (Routing.Router as unknown as { - shutdownSignals(): readonly string[] - }).shutdownSignals() - assertEquals(signals.includes('SIGINT'), true) - if (Deno.build.os === 'windows') { - assertEquals(signals.includes('SIGTERM'), false) - } else { - assertEquals(signals.includes('SIGTERM'), true) - } +Deno.test('Router serve emits server:started and server:stopped', async () => { + const router = new Routing.Router() + let shutdownEmitted = false + const listening = Promise.withResolvers() + router.on((event) => { + if (event.kind === 'server:started') { + listening.resolve() + } + if (event.kind === 'server:stopped') { + shutdownEmitted = true + } + }) + const port = freePort() + const controller = new AbortController() + const serving = router.serve(port, '127.0.0.1', controller.signal) + await listening.promise + controller.abort() + await serving + assertEquals(shutdownEmitted, true) }) -Deno.test('Router#static does not throw', () => { - const router = new Routing.Router({ routesDir: './routes' }) +Deno.test('Router static mounts without throwing', () => { + const router = new Routing.Router() router.static('/assets', { path: './public' }) }) -Deno.test('Router#static throws when path option is missing or not a string', () => { - const router = new Routing.Router({ routesDir: './routes' }) +Deno.test('Router static throws when the path option is invalid', () => { + const router = new Routing.Router() for (const bad of [{}, { path: 123 }, { path: '' }]) { let threw = false try { @@ -196,35 +160,11 @@ Deno.test('Router#static throws when path option is missing or not a string', () } }) -Deno.test('Router#use throws when called with a path string and no middleware', () => { - const router = new Routing.Router({ routesDir: './routes' }) - let threw = false - try { - router.use('/api' as unknown as () => Response) - } catch (e) { - threw = true - assertEquals(e instanceof TypeError, true) - } - assertEquals(threw, true) -}) - -Deno.test('Router#use throws when middleware is not a function', () => { - const router = new Routing.Router({ routesDir: './routes' }) - let threw = false - try { - router.use(null as unknown as () => Response) - } catch (e) { - threw = true - assertEquals(e instanceof TypeError, true) - } - assertEquals(threw, true) -}) - -Deno.test('Router#use throws when path-scoped middleware is not a function', () => { - const router = new Routing.Router({ routesDir: './routes' }) +Deno.test('Router use throws when a path is given without middleware', () => { + const router = new Routing.Router() let threw = false try { - router.use('/api', undefined as unknown as () => Response) + router.use('/api') } catch (e) { threw = true assertEquals(e instanceof TypeError, true) @@ -232,59 +172,12 @@ Deno.test('Router#use throws when path-scoped middleware is not a function', () assertEquals(threw, true) }) -Deno.test('Router#use with middleware only (no path) does not throw', () => { - const router = new Routing.Router({ routesDir: './routes' }) +Deno.test('Router use with a middleware function does not throw', () => { + const router = new Routing.Router() router.use(async (_ctx, next) => await next()) }) -Deno.test('Router#use with multiple middleware functions does not throw', () => { - const router = new Routing.Router({ routesDir: './routes' }) - router.use( - async (_ctx, next) => await next(), - async (_ctx, next) => await next() - ) -}) - -Deno.test('Router#use with path and middleware does not throw', () => { - const router = new Routing.Router({ routesDir: './routes' }) +Deno.test('Router use with a path and middleware does not throw', () => { + const router = new Routing.Router() router.use('/api', async (_ctx, next) => await next()) }) - -Deno.test('Watcher#watch returns a callable stop handle for a missing directory', () => { - const handler = new Routing.Handler() - const stop = Routing.Watcher.watch(handler, './does-not-exist-routes-dir-xyz') - assertEquals(typeof stop, 'function') - stop() -}) - -Deno.test('Watcher#watch skips a non-existent routes directory without throwing', () => { - const handler = new Routing.Handler() - Routing.Watcher.watch(handler, './does-not-exist-routes-dir-xyz') - assertEquals(true, true) -}) - -Deno.test('security headers still ride a masked error response so defenses survive faults', async () => { - const router = new Routing.Router({ routesDir: './does-not-exist-routes-dir-sec' }) - router.use(Middleware.Mware.securityHeaders()) - router.use(() => { - throw new Error('handler boom') - }) - const listening = Promise.withResolvers() - router.on((event) => { - if (event.kind === 'server:listening') { - listening.resolve(event.metadata.port) - } - }) - const controller = new AbortController() - const serving = router.serve(0, '127.0.0.1', controller.signal) - const port = await listening.promise - const response = await fetch(`http://127.0.0.1:${port}/boom`, { - signal: AbortSignal.timeout(5000) - }) - await response.body?.cancel() - assertEquals(response.status, 500) - assertEquals(response.headers.get('x-content-type-options'), 'nosniff') - assertEquals(response.headers.get('x-frame-options'), 'SAMEORIGIN') - controller.abort() - await serving -}) diff --git a/tests/routing/Scanner.test.ts b/tests/routing/Scanner.test.ts index b5fb1ca..040058d 100644 --- a/tests/routing/Scanner.test.ts +++ b/tests/routing/Scanner.test.ts @@ -1,229 +1,74 @@ -import type * as Types from '@interfaces/index.ts' import { assertEquals } from '@std/assert' +import type * as Types from '@interfaces/index.ts' import * as Core from '@core/index.ts' import * as Routing from '@routing/index.ts' import { FastRouter } from '@neabyte/fast-router' -Deno.test('Scanner#createPattern [id] to :id', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/[id].ts', ext), '/items/:id') - assertEquals(Routing.Scanner.createPattern('users/[id]/edit.tsx', ext), '/users/:id/edit') -}) - -Deno.test('Scanner#createPattern accepts single-dot filenames', () => { - assertEquals(Routing.Scanner.createPattern('users.ts', ['ts', 'js']), '/users') - assertEquals(Routing.Scanner.createPattern('api/index.ts', ['ts', 'js']), '/api') -}) - -Deno.test('Scanner#createPattern case-insensitive index detection', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('INDEX.TS', ext), '/') -}) - -Deno.test('Scanner#createPattern index to /', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('index.ts', ext), '/') - assertEquals(Routing.Scanner.createPattern('items/index.ts', ext), '/items') -}) - -Deno.test('Scanner#createPattern invalid extension returns null', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('readme.md', ext), null) -}) - -Deno.test('Scanner#createPattern nested deep path', () => { - const ext = Core.Constant.allowedExtensions - assertEquals( - Routing.Scanner.createPattern('api/v1/users/[id]/posts/[postId].ts', ext), - '/api/v1/users/:id/posts/:postId' - ) -}) - -Deno.test('Scanner#createPattern rejects invalid last segment chars', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('users/na me.ts', ext), null) - assertEquals(Routing.Scanner.createPattern('users/na?me.ts', ext), null) -}) - -Deno.test('Scanner#createPattern rejects multi-dot filenames', () => { - assertEquals(Routing.Scanner.createPattern('users.test.ts', ['ts', 'js']), null) - assertEquals(Routing.Scanner.createPattern('config.local.ts', ['ts', 'js']), null) - assertEquals(Routing.Scanner.createPattern('api.spec.js', ['ts', 'js']), null) -}) - -Deno.test('Scanner#createPattern skips _ and @ segments', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('_layout.ts', ext), null) - assertEquals(Routing.Scanner.createPattern('@components/foo.ts', ext), null) -}) - -Deno.test('Scanner#createPattern with .cjs extension', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/create.cjs', ext), '/items/create') -}) +const extensions = Core.Constant.allowedExtensions +const methods = Core.Constant.httpMethods -Deno.test('Scanner#createPattern with .js extension', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/create.js', ext), '/items/create') +Deno.test('Scanner createPattern builds a route pattern', () => { + assertEquals(Routing.Scanner.createPattern('users.ts', extensions), '/users') }) -Deno.test('Scanner#createPattern with .jsx extension', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/create.jsx', ext), '/items/create') +Deno.test('Scanner createPattern collapses index to parent path', () => { + assertEquals(Routing.Scanner.createPattern('users/index.ts', extensions), '/users') + assertEquals(Routing.Scanner.createPattern('index.ts', extensions), '/') }) -Deno.test('Scanner#createPattern with .mjs extension', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/create.mjs', ext), '/items/create') +Deno.test('Scanner createPattern converts bracket params to colon params', () => { + assertEquals(Routing.Scanner.createPattern('users/[id].ts', extensions), '/users/:id') }) -Deno.test('Scanner#createPattern with .tsx extension', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/create.tsx', ext), '/items/create') +Deno.test('Scanner createPattern returns null for an unknown extension', () => { + assertEquals(Routing.Scanner.createPattern('notes.md', extensions), null) }) -Deno.test('Scanner#createPattern with empty string returns null', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('', ext), null) +Deno.test('Scanner createPattern skips underscore and at segments', () => { + assertEquals(Routing.Scanner.createPattern('_private.ts', extensions), null) + assertEquals(Routing.Scanner.createPattern('@layout.ts', extensions), null) }) -Deno.test('Scanner#createPattern with hyphen in name', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/my-file.ts', ext), '/items/my-file') -}) - -Deno.test('Scanner#createPattern with multiple segments and param', () => { - const ext = Core.Constant.allowedExtensions - assertEquals( - Routing.Scanner.createPattern('api/users/[userId]/posts.ts', ext), - '/api/users/:userId/posts' - ) -}) - -Deno.test('Scanner#createPattern with no extension returns null', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('Makefile', ext), null) -}) - -Deno.test('Scanner#createPattern with tilde and plus in name', () => { - const ext = Core.Constant.allowedExtensions - assertEquals(Routing.Scanner.createPattern('items/my~file.ts', ext), '/items/my~file') - assertEquals(Routing.Scanner.createPattern('items/my+file.ts', ext), '/items/my+file') -}) - -Deno.test('Scanner#registerHandlers adds function exports to router', () => { +Deno.test('Scanner explore skips a non-existent directory without throwing', async () => { const router = new FastRouter() - const getHandler = () => new Response('get') - const postHandler = () => new Response('post') - Routing.Scanner.registerHandlers( - router, - { GET: getHandler, POST: postHandler }, - '/items', - Core.Constant.httpMethods - ) - const getResult = router.find('GET', '/items') - assertEquals(getResult !== null, true) - const postResult = router.find('POST', '/items') - assertEquals(postResult !== null, true) + await Routing.Scanner.explore(router, './does-not-exist-routes-xyz', '', methods, extensions) + assertEquals(router.find('GET', '/anything'), undefined) }) -Deno.test('Scanner#registerHandlers skips non-function exports', () => { +Deno.test('Scanner registerHandlers registers exported method handlers', () => { const router = new FastRouter() Routing.Scanner.registerHandlers( router, - { GET: () => new Response('ok'), config: { timeout: 5000 } }, - '/items', - Core.Constant.httpMethods + { GET: () => new Response('ok') } as never, + '/users', + methods ) - const getResult = router.find('GET', '/items') - assertEquals(getResult !== null, true) + assertEquals(router.find('GET', '/users') !== undefined, true) + assertEquals(router.find('POST', '/users'), undefined) }) -Deno.test('Scanner#registerHandlers with empty module adds nothing', () => { - const router = new FastRouter() - Routing.Scanner.registerHandlers(router, {}, '/items', Core.Constant.httpMethods) - const result = router.find('GET', '/items') - assertEquals(result == null, true) -}) - -Deno.test('Scanner#validateModule throws Deno.errors.InvalidData for no method', () => { - let caughtError: unknown = null - try { - Routing.Scanner.validateModule({ foo: 1 }, 'routes/foo.ts', Core.Constant.httpMethods) - } catch (e) { - caughtError = e - } - assertEquals(caughtError instanceof Deno.errors.InvalidData, true) -}) - -Deno.test('Scanner#validateModule throws TypeError for non-function method', () => { - let caughtError: unknown = null - try { - Routing.Scanner.validateModule({ GET: 123 }, 'routes/foo.ts', Core.Constant.httpMethods) - } catch (e) { - caughtError = e - } - assertEquals(caughtError instanceof TypeError, true) +Deno.test('Scanner validateModule accepts a module with a method export', () => { + Routing.Scanner.validateModule({ GET: () => new Response('ok') } as never, 'users.ts', methods) }) -Deno.test('Scanner#validateModule throws when method export not function', () => { - let thrown = false +Deno.test('Scanner validateModule throws when a method export is not a function', () => { + let threw = false try { - Routing.Scanner.validateModule({ GET: 123 }, 'routes/foo.ts', Core.Constant.httpMethods) + Routing.Scanner.validateModule({ GET: 'nope' } as never, 'users.ts', methods) } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('must be a function'), true) + threw = true + assertEquals(e instanceof TypeError, true) } - assertEquals(thrown, true) + assertEquals(threw, true) }) -Deno.test('Scanner#validateModule throws when no method exported', () => { - let thrown = false +Deno.test('Scanner validateModule throws when no method is exported', () => { + let threw = false try { - Routing.Scanner.validateModule({ foo: 1 }, 'routes/foo.ts', Core.Constant.httpMethods) + Routing.Scanner.validateModule({} as never, 'users.ts', methods) } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('must export at least one HTTP method'), true) - } - assertEquals(thrown, true) -}) - -Deno.test('Scanner#validateModule throws when non-function method alongside valid', () => { - let thrown = false - try { - Routing.Scanner.validateModule( - { GET: () => {}, POST: 'not-a-function' }, - 'routes/items.ts', - Core.Constant.httpMethods - ) - } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('must be a function'), true) - } - assertEquals(thrown, true) -}) - -Deno.test('Scanner#validateModule with empty module object throws', () => { - let thrown = false - try { - Routing.Scanner.validateModule({}, 'routes/empty.ts', Core.Constant.httpMethods) - } catch (e) { - thrown = true - assertEquals((e as Error).message.includes('must export at least one HTTP method'), true) - } - assertEquals(thrown, true) -}) - -Deno.test('Scanner#validateModule with multiple valid methods', () => { - let thrown = false - try { - Routing.Scanner.validateModule( - { GET: () => {}, POST: () => {}, DELETE: () => {} }, - 'routes/items.ts', - Core.Constant.httpMethods - ) - } catch { - thrown = true + threw = true + assertEquals(e instanceof Deno.errors.InvalidData, true) } - assertEquals(thrown, false) + assertEquals(threw, true) }) diff --git a/tests/routing/Watcher.test.ts b/tests/routing/Watcher.test.ts index d2c0e65..9066b55 100644 --- a/tests/routing/Watcher.test.ts +++ b/tests/routing/Watcher.test.ts @@ -1,210 +1,22 @@ -import type * as Types from '@interfaces/index.ts' import { assertEquals } from '@std/assert' import * as Routing from '@routing/index.ts' -const writeGranted = (await Deno.permissions.query({ name: 'write' })).state === 'granted' - -const ROUTE_SOURCE = `export function GET(ctx) { - return ctx.send.text('watched') -} -` - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function makeRoutesDir(): Promise { - const dir = await Deno.makeTempDir({ prefix: 'deserve-routes-' }) - return await Deno.realPath(dir) -} - -function collectEvents(handler: Routing.Handler): { kinds: string[]; stop: () => void } { - const kinds: string[] = [] - const stop = handler.onEvent((event: Types.EventBase) => { - kinds.push(event.kind) - }) - return { kinds, stop } -} - -Deno.test({ - name: 'Watcher#watch hot-reloads a newly added route file', - ignore: !writeGranted, - fn: async () => { - const dir = await makeRoutesDir() - const handler = new Routing.Handler() - const events = collectEvents(handler) - const stop = Routing.Watcher.watch(handler, dir) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/hello.ts`, ROUTE_SOURCE) - await delay(600) - assertEquals(events.kinds.includes('route:reloaded'), true) - const res = await handler.createHandler()(new Request('http://localhost/hello')) - assertEquals(res.status, 200) - assertEquals(await res.text(), 'watched') - } finally { - events.stop() - stop() - await Deno.remove(dir, { recursive: true }) - } - } -}) - -Deno.test({ - name: 'Watcher#watch ignores files with non-route extensions', - ignore: !writeGranted, - fn: async () => { - const dir = await makeRoutesDir() - const handler = new Routing.Handler() - const events = collectEvents(handler) - const stop = Routing.Watcher.watch(handler, dir) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/readme.md`, '# not a route') - await delay(500) - assertEquals(events.kinds.includes('route:reloaded'), false) - } finally { - events.stop() - stop() - await Deno.remove(dir, { recursive: true }) - } - } -}) - -Deno.test({ - name: 'Watcher#watch keeps the last-good route serving when a reload drops all method exports', - ignore: !writeGranted, - fn: async () => { - const dir = await makeRoutesDir() - const handler = new Routing.Handler() - const events = collectEvents(handler) - const stop = Routing.Watcher.watch(handler, dir) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/exports.ts`, ROUTE_SOURCE) - await delay(600) - assertEquals(events.kinds.includes('route:reloaded'), true) - await Deno.writeTextFile(`${dir}/exports.ts`, 'export const note = "no http methods"\n') - await delay(600) - const res = await handler.createHandler()(new Request('http://localhost/exports')) - assertEquals(res.status, 200) - assertEquals(await res.text(), 'watched') - assertEquals(events.kinds.includes('reload:error'), true) - } finally { - events.stop() - stop() - await Deno.remove(dir, { recursive: true }) - } - } -}) - -Deno.test({ - name: 'Watcher#watch keeps the last-good route serving when a reload has a syntax error', - ignore: !writeGranted, - fn: async () => { - const dir = await makeRoutesDir() - const handler = new Routing.Handler() - const events = collectEvents(handler) - const stop = Routing.Watcher.watch(handler, dir) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/keep.ts`, ROUTE_SOURCE) - await delay(600) - assertEquals(events.kinds.includes('route:reloaded'), true) - await Deno.writeTextFile(`${dir}/keep.ts`, 'export function GET( {\n return new Response(') - await delay(600) - const res = await handler.createHandler()(new Request('http://localhost/keep')) - assertEquals(res.status, 200) - assertEquals(await res.text(), 'watched') - assertEquals(events.kinds.includes('reload:error'), true) - } finally { - events.stop() - stop() - await Deno.remove(dir, { recursive: true }) - } - } -}) - -Deno.test({ - name: 'Watcher#watch removes a route when its file is deleted', - ignore: !writeGranted, - fn: async () => { - const dir = await makeRoutesDir() - const handler = new Routing.Handler() - const stop = Routing.Watcher.watch(handler, dir) - const events = collectEvents(handler) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/gone.ts`, ROUTE_SOURCE) - await delay(600) - assertEquals(events.kinds.includes('route:reloaded'), true) - await Deno.remove(`${dir}/gone.ts`) - await delay(600) - assertEquals(events.kinds.includes('route:removed'), true) - const res = await handler.createHandler()(new Request('http://localhost/gone')) - assertEquals(res.status, 404) - await res.body?.cancel() - } finally { - events.stop() - stop() - await Deno.remove(dir, { recursive: true }) - } - } -}) - -Deno.test('Watcher#watch returns a no-op stop handle for a non-existent directory', () => { +Deno.test('Watcher watch returns a callable disposer for a real directory', () => { const handler = new Routing.Handler() - const stop = Routing.Watcher.watch(handler, './does-not-exist-routes-dir-' + Date.now()) + const stop = Routing.Watcher.watch(handler, './tests/fixtures') assertEquals(typeof stop, 'function') stop() }) -Deno.test({ - name: 'Watcher#watch stop handle halts further reloads', - ignore: !writeGranted, - fn: async () => { - const dir = await makeRoutesDir() - const handler = new Routing.Handler() - const events = collectEvents(handler) - const stop = Routing.Watcher.watch(handler, dir) - await delay(50) - stop() - await delay(50) - try { - await Deno.writeTextFile(`${dir}/late.ts`, ROUTE_SOURCE) - await delay(500) - assertEquals(events.kinds.includes('route:reloaded'), false) - } finally { - events.stop() - await Deno.remove(dir, { recursive: true }) - } - } +Deno.test('Watcher watch returns a noop disposer for a missing directory', () => { + const handler = new Routing.Handler() + const stop = Routing.Watcher.watch(handler, './does-not-exist-routes-xyz') + assertEquals(typeof stop, 'function') + stop() }) -Deno.test({ - name: 'Watcher#watch swaps in the new handler after a good save follows a failed reload', - ignore: !writeGranted, - fn: async () => { - const dir = await makeRoutesDir() - const handler = new Routing.Handler() - const stop = Routing.Watcher.watch(handler, dir) - try { - await delay(50) - await Deno.writeTextFile(`${dir}/recover.ts`, ROUTE_SOURCE) - await delay(600) - await Deno.writeTextFile(`${dir}/recover.ts`, 'export function GET( {') - await delay(600) - await Deno.writeTextFile( - `${dir}/recover.ts`, - 'export function GET(ctx) {\n return ctx.send.text("v2")\n}\n' - ) - await delay(600) - const res = await handler.createHandler()(new Request('http://localhost/recover')) - assertEquals(res.status, 200) - assertEquals(await res.text(), 'v2') - } finally { - stop() - await Deno.remove(dir, { recursive: true }) - } - } +Deno.test('Watcher watch skips a non-existent directory without throwing', () => { + const handler = new Routing.Handler() + Routing.Watcher.watch(handler, './does-not-exist-routes-xyz') + assertEquals(true, true) }) diff --git a/tests/validation/Reason.test.ts b/tests/validation/Reason.test.ts deleted file mode 100644 index b18f560..0000000 --- a/tests/validation/Reason.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { assertEquals } from '@std/assert' -import * as Core from '@core/index.ts' -import * as Validation from '@validation/index.ts' - -Deno.test('Reason.toStatusError empty cause array uses generic message', () => { - const error = new Error('original') - error.cause = [] - const statusError = Validation.Reason.toStatusError(error) - assertEquals(statusError.statusCode, 422) - assertEquals(statusError.message, 'Validation failed') - assertEquals(statusError.cause, []) -}) - -Deno.test('Reason.toStatusError filters non-string cause members', () => { - const error = new Error('original') - error.cause = ['valid', 1, null, 'also valid'] - const statusError = Validation.Reason.toStatusError(error) - assertEquals(statusError.cause, ['valid', 'also valid']) - assertEquals(statusError.message, 'valid; also valid') -}) - -Deno.test('Reason.toStatusError joins string reasons into message', () => { - const error = new Error('original') - error.cause = ['name required', 'age too low'] - const statusError = Validation.Reason.toStatusError(error) - assertEquals(statusError.statusCode, 422) - assertEquals(statusError.message, 'name required; age too low') - assertEquals(statusError.cause, ['name required', 'age too low']) -}) - -Deno.test('Reason.toStatusError maps non-error value to generic 422', () => { - const statusError = Validation.Reason.toStatusError('not an error') - assertEquals(statusError.statusCode, 422) - assertEquals(statusError.message, 'Unprocessable request input') -}) - -Deno.test('Reason.toStatusError maps plain error to generic 422 without cause', () => { - const error = new Error('Cannot read properties of null') - const statusError = Validation.Reason.toStatusError(error) - assertEquals(statusError.statusCode, 422) - assertEquals(statusError.message, 'Unprocessable request input') - assertEquals('cause' in statusError && Array.isArray(statusError.cause), false) -}) - -Deno.test('Reason.toStatusError passes through an error that already carries a status', () => { - const original = Core.Handler.createStatusError(400, 'Malformed or unreadable request body') - const statusError = Validation.Reason.toStatusError(original) - assertEquals(statusError, original) - assertEquals(statusError.statusCode, 400) -}) - -Deno.test('Reason.toStatusError sets non-enumerable cause', () => { - const error = new Error('original') - error.cause = ['reason'] - const statusError = Validation.Reason.toStatusError(error) - assertEquals(Object.prototype.propertyIsEnumerable.call(statusError, 'cause'), false) -}) - -Deno.test('Reason.toStatusError uses error pipeline status carrier', () => { - const error = new Error('original') - error.cause = ['reason'] - const statusError = Validation.Reason.toStatusError(error) - assertEquals(Core.Handler.isErrorWithStatus(statusError), true) -}) diff --git a/tests/validation/Source.test.ts b/tests/validation/Source.test.ts deleted file mode 100644 index 69d3703..0000000 --- a/tests/validation/Source.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { assertEquals } from '@std/assert' -import * as Core from '@core/index.ts' -import * as Validation from '@validation/index.ts' - -function createTestContext(url = 'http://localhost/', requestInit?: RequestInit): Core.Context { - const request = new Request(url, requestInit) - return new Core.Context(request, new URL(url), { id: '42' }) -} - -Deno.test('Source.extract body returns parsed text body', async () => { - const ctx = createTestContext('http://localhost/', { method: 'POST', body: 'hello' }) - const result = await Validation.Source.extract('body', ctx) - assertEquals(result, 'hello') -}) - -Deno.test('Source.extract cookies returns parsed cookie record', async () => { - const ctx = createTestContext('http://localhost/', { - headers: new Headers({ cookie: 'a=1; b=2' }) - }) - const result = await Validation.Source.extract('cookies', ctx) - assertEquals(result, { a: '1', b: '2' }) -}) - -Deno.test('Source.extract headers returns header record', async () => { - const ctx = createTestContext('http://localhost/', { - headers: new Headers({ 'x-test': 'value' }) - }) - const result = (await Validation.Source.extract('headers', ctx)) as Record - assertEquals(result['x-test'], 'value') -}) - -Deno.test('Source.extract json returns parsed json body', async () => { - const ctx = createTestContext('http://localhost/', { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ name: 'neo' }) - }) - const result = await Validation.Source.extract('json', ctx) - assertEquals(result, { name: 'neo' }) -}) - -Deno.test('Source.extract params returns route params', async () => { - const ctx = createTestContext() - const result = await Validation.Source.extract('params', ctx) - assertEquals(result, { id: '42' }) -}) - -Deno.test('Source.extract query returns query record', async () => { - const ctx = createTestContext('http://localhost/?page=1&size=10') - const result = await Validation.Source.extract('query', ctx) - assertEquals(result, { page: '1', size: '10' }) -}) diff --git a/tests/validation/Validator.test.ts b/tests/validation/Validator.test.ts deleted file mode 100644 index fe031ca..0000000 --- a/tests/validation/Validator.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { assertEquals, assertThrows } from '@std/assert' -import * as Core from '@core/index.ts' -import * as Validation from '@validation/index.ts' -import { Define } from '@neabyte/typebox' - -function createTestContext(): Core.Context { - const request = new Request('http://localhost/') - return new Core.Context(request, new URL('http://localhost/'), {}) -} - -const schema = { - json: Define((input: { name: string }) => input) -} - -Deno.test('Validator.check returns contract output on pass', () => { - const contract = Define( - (input: { id: string }) => ({ id: Number(input.id) }), - (input) => (/^\d+$/.test(input.id) ? true : 'id must be numeric') - ) - const result = Validation.Validator.check(contract, { id: '42' }) - assertEquals(result.id, 42) -}) - -Deno.test('Validator.check throws 422 on guard failure', () => { - const contract = Define( - (input: { id: string }) => ({ id: Number(input.id) }), - (input) => (/^\d+$/.test(input.id) ? true : 'id must be numeric') - ) - try { - Validation.Validator.check(contract, { id: 'abc' }) - throw new Error('expected throw') - } catch (error) { - assertEquals(Core.Handler.isErrorWithStatus(error), true) - if (Core.Handler.isErrorWithStatus(error)) { - assertEquals(error.statusCode, 422) - } - } -}) - -Deno.test('Validator.read returns stored validated data', () => { - const ctx = createTestContext() - ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.validated, { - json: { name: 'neo' } - }) - const result = Validation.Validator.read(ctx) - assertEquals(result.json.name, 'neo') -}) - -Deno.test('Validator.read throws 500 when no validation ran', () => { - const ctx = createTestContext() - assertThrows(() => Validation.Validator.read(ctx), Error, 'No validated data found') -}) - -Deno.test('Validator.read throws carries 500 status code', () => { - const ctx = createTestContext() - try { - Validation.Validator.read(ctx) - throw new Error('expected throw') - } catch (error) { - assertEquals(Core.Handler.isErrorWithStatus(error), true) - if (Core.Handler.isErrorWithStatus(error)) { - assertEquals(error.statusCode, 500) - } - } -})