expediate 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/LICENSE +16 -16
- package/README.md +330 -444
- package/dist/apis.d.ts +504 -27
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +618 -107
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +4066 -0
- package/dist/cjs/package.json +1 -0
- package/dist/git.d.ts +72 -9
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +129 -74
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +18 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +158 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +447 -207
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.d.ts +476 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +647 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +268 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +449 -168
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +433 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +624 -0
- package/dist/openapi.js.map +1 -0
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +37 -201
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +502 -244
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +3 -3
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +164 -105
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +47 -8
- package/.npmignore +0 -16
package/docs/openapi.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# OpenAPI Spec Generation
|
|
2
|
+
|
|
3
|
+
Expediate can generate an OpenAPI 3.1.0 document straight from your route definitions — no separate spec to hand-maintain. Annotate handlers with `describe()`, then call `openApiSpec()` (or `apiBuilder`'s `api.spec()` / `api.specHandler()`) to produce the document. Routes that aren't backed by a `ServiceDefinition` at all — e.g. JWT auth endpoints mounted directly with `app.post(...)` — can still be documented and merged into the same spec via a spec-only `ServiceOpenApi` source.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { apiBuilder, describe } from 'expediate';
|
|
11
|
+
import type { ServiceDefinition, ApiContext } from 'expediate';
|
|
12
|
+
|
|
13
|
+
const service: ServiceDefinition<State> = {
|
|
14
|
+
GET: {
|
|
15
|
+
'/items/:id': describe(
|
|
16
|
+
function (this: State, ctx: ApiContext) { return this.findOrThrow(ctx.params.id); },
|
|
17
|
+
{
|
|
18
|
+
summary: 'Get an item by ID',
|
|
19
|
+
tags: ['items'],
|
|
20
|
+
responses: {
|
|
21
|
+
'200': { description: 'The item' },
|
|
22
|
+
'404': { description: 'Not found' },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const api = apiBuilder(service);
|
|
30
|
+
app.use('/api', api);
|
|
31
|
+
app.get('/openapi.json', api.specHandler({ title: 'Items API', version: '1.0.0' }));
|
|
32
|
+
app.get('/openapi.yaml', api.specHandler({ title: 'Items API', version: '1.0.0' }, 'yaml'));
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## `describe(handler, meta)`
|
|
38
|
+
|
|
39
|
+
Annotates a service method handler with OpenAPI operation metadata. The returned function behaves identically to `handler` — it can be used directly in a `ServiceDefinition` route map. Metadata is attached via a non-enumerable property keyed by the `DESCRIBE_META` symbol, so it never pollutes `Object.keys()` or `JSON.stringify()` output.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
GET: {
|
|
43
|
+
'/todos/:id': describe(
|
|
44
|
+
function (this: State, ctx) { return this.findOrThrow(ctx.params.id); },
|
|
45
|
+
{
|
|
46
|
+
summary: 'Get a todo by ID',
|
|
47
|
+
tags: ['todos'],
|
|
48
|
+
permission: 'todo.read', // → auth.check + security in the spec
|
|
49
|
+
responses: {
|
|
50
|
+
'200': { description: 'The todo', content: { 'application/json': { schema: { $ref: '#/components/schemas/Todo' } } } },
|
|
51
|
+
'404': { description: 'Not found' },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`OperationMeta` fields:
|
|
59
|
+
|
|
60
|
+
| Field | Type | Description |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `summary` | `string` | Short summary shown in UI tooling |
|
|
63
|
+
| `description` | `string` | Longer description (Markdown supported) |
|
|
64
|
+
| `operationId` | `string` | Overrides the auto-generated id (e.g. `getTodosById`) |
|
|
65
|
+
| `tags` | `string[]` | UI grouping; falls back to controller `tags`, then the source's default tag |
|
|
66
|
+
| `parameters` | `ParameterObject[]` | Merged with auto-detected path params by `name`; query/header/cookie params must be listed here — never auto-inferred |
|
|
67
|
+
| `requestBody` | `RequestBodyObject` | Provide when the operation consumes a body |
|
|
68
|
+
| `responses` | `Record<string, ResponseObject>` | Replaces the default response set entirely; a `'500'` reference to the built-in `ApiError` component is injected unless you supply your own `'500'` key |
|
|
69
|
+
| `deprecated` | `boolean` | Marks the operation deprecated |
|
|
70
|
+
| `guards` | `Guard[]` | Request-pipeline guards (ignored by spec generation) |
|
|
71
|
+
| `permission` | `string \| string[]` | Overrides the controller-level `permission`; emits `security` + the permissions vendor extension |
|
|
72
|
+
| `x-*` | `unknown` | Any other vendor extension is passed straight through |
|
|
73
|
+
|
|
74
|
+
Unspecified fields are inferred: path parameters auto-detected from the route pattern, default responses assigned by HTTP verb (`POST` → `201`, others → `200`), and `operationId` generated from the verb + path.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## `openApiSpec(service, opts)`
|
|
79
|
+
|
|
80
|
+
The free function underlying `api.spec()` — use it when you have a definition but not (or not only) a router built by `apiBuilder`.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { openApiSpec } from 'expediate';
|
|
84
|
+
|
|
85
|
+
const doc = openApiSpec(todoService, {
|
|
86
|
+
title: 'Todo API',
|
|
87
|
+
version: '1.0.0',
|
|
88
|
+
basePath: '/api',
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Controllers merge into the same document as the root route map — see [API Builder](api-builder.md) for `controllers`, `guards`, and `auth`. The generated document always includes an `ApiError` schema (`{ status?, message?, data? }`) in `components.schemas` and a matching `500` reference in `components.responses`.
|
|
93
|
+
|
|
94
|
+
### `SpecOptions`
|
|
95
|
+
|
|
96
|
+
| Option | Type | Default | Description |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| `title` | `string` | **required** | API title |
|
|
99
|
+
| `version` | `string` | **required** | API version |
|
|
100
|
+
| `description` | `string` | — | API description |
|
|
101
|
+
| `basePath` | `string` | — | Prefix prepended to every path |
|
|
102
|
+
| `servers` | `{ url, description? }[]` | — | OpenAPI server objects |
|
|
103
|
+
| `schemas` | `Record<string, JsonSchema>` | — | Extra component schemas (superseded by a source's own `schemas` on name conflicts) |
|
|
104
|
+
|
|
105
|
+
### `api.spec(opts)` and `api.specHandler(opts, format?)`
|
|
106
|
+
|
|
107
|
+
The router returned by `apiBuilder` can introspect its own definition — controllers included:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const api = apiBuilder(service);
|
|
111
|
+
|
|
112
|
+
app.use('/api', api);
|
|
113
|
+
app.get('/openapi.json', api.specHandler({ title: 'Todo API', version: '1.0.0' }));
|
|
114
|
+
app.get('/openapi.yaml', api.specHandler({ title: 'Todo API', version: '1.0.0' }, 'yaml'));
|
|
115
|
+
|
|
116
|
+
// Or get the document object directly:
|
|
117
|
+
const doc = api.spec({ title: 'Todo API', version: '1.0.0', basePath: '/api' });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`specHandler` generates the spec once and caches it on the returned handler's first call. Both methods document the single `ServiceDefinition` passed to `apiBuilder` — for merging in routes from elsewhere, use `openApiSpec()` directly with multiple sources (below).
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Multiple sources
|
|
125
|
+
|
|
126
|
+
`openApiSpec()` accepts a single source **or an array of sources**, merged into one document. This is how routes with no `ServiceDefinition` at all — most notably the JWT plugin's `/auth/login`, `/auth/refresh`, `/auth/logout`, typically mounted directly with `app.post(...)` — get documented alongside a real API:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { openApiSpec } from 'expediate';
|
|
130
|
+
import type { ServiceOpenApi } from 'expediate';
|
|
131
|
+
|
|
132
|
+
const authDocs: ServiceOpenApi = {
|
|
133
|
+
openapi: { tag: 'auth' },
|
|
134
|
+
POST: {
|
|
135
|
+
'/auth/login': { summary: 'Log in', requestBody: loginBody },
|
|
136
|
+
'/auth/refresh': { summary: 'Refresh a token', requestBody: refreshBody },
|
|
137
|
+
'/auth/logout': { summary: 'Log out' },
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const spec = openApiSpec([authDocs, todoService], { title: 'Todo API', version: '1.0.0' });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`ServiceOpenApi` is a spec-only counterpart to `ServiceDefinition`: same shape (`controllers`, root route maps, `schemas`, `auth`, service-level `openapi` metadata) but route map values are `OperationMeta` objects directly instead of handler functions, since there's no handler to call. `guards` and `validate` are accepted for structural parity only — spec generation ignores both, as there's no request pipeline to run them against. `ControllerOpenApi` and `RouteOpenApi` are the equivalent spec-only counterparts to `ControllerDefinition` and a handler route map.
|
|
145
|
+
|
|
146
|
+
`OpenApiSource = ServiceDefinition<any> | ServiceOpenApi` is the type accepted by `openApiSpec()`, so a real service and a `ServiceOpenApi` mix in the same array with no casts needed.
|
|
147
|
+
|
|
148
|
+
### Merge rules
|
|
149
|
+
|
|
150
|
+
- **Routes** — every source's root route map and `controllers` are flattened into one globally-sorted route table (same specificity scoring as `apiBuilder`'s `collectRoutes`). A duplicate `(verb, path)` pair throws, even when the two routes come from different sources.
|
|
151
|
+
- **Tags and `x-required-permissions`** — each route resolves its default tag (`openapi.tag`) and permissions vendor-extension name (`auth.permissionsExtension`) from **its own** originating source, not a single global value.
|
|
152
|
+
- **`components.securitySchemes.bearerAuth`** — taken from the first source in array order that declares a custom `auth.scheme`; falls back to the default HTTP bearer/JWT scheme if none do.
|
|
153
|
+
- **`components.schemas` / `components.responses`** — merged source by source in array order, so a later source's `schemas` wins on a name conflict. For a single source this reproduces the original precedence exactly: built-ins ← `openapi.schemas` ← `opts.schemas` ← the source's own `schemas`.
|
|
154
|
+
|
|
155
|
+
Passing a single source (not wrapped in an array) behaves identically to the pre-multi-source API — `openApiSpec(service, opts)` and `openApiSpec([service], opts)` produce the same document.
|
|
156
|
+
|
|
157
|
+
This merge logic (`collectOpenApiRoutes`) is entirely separate from `apiBuilder`'s request-handling pipeline — it only inspects route shapes to build documentation and never invokes a handler, so adding spec-only sources cannot affect runtime routing.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Security output
|
|
162
|
+
|
|
163
|
+
Routes carrying a `permission` (route- or controller-level) automatically receive `security: [{ bearerAuth: [] }]` and an `x-required-permissions: [...]` vendor extension (name overridable via `auth.permissionsExtension`). `components.securitySchemes.bearerAuth` is emitted once, only when at least one operation declares a permission, defaulting to:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{ "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## `serializeSpec(doc, format?)`
|
|
172
|
+
|
|
173
|
+
Serialize an `OpenApiDocument` to a JSON or YAML string (both zero-dependency):
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { serializeSpec } from 'expediate';
|
|
177
|
+
|
|
178
|
+
const json = serializeSpec(doc); // JSON (default), 2-space indented
|
|
179
|
+
const yaml = serializeSpec(doc, 'yaml'); // block-style YAML 1.2
|
|
180
|
+
```
|
package/docs/router.md
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# Router
|
|
2
|
+
|
|
3
|
+
The core of Expediate is a zero-dependency HTTP router built on top of Node.js `http`/`https`. It supports named parameters, glob wildcards, regular expressions, nested sub-routers, signed cookies, per-request timeouts, and graceful shutdown.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Creating a router
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createRouter } from 'expediate';
|
|
11
|
+
|
|
12
|
+
const app = createRouter();
|
|
13
|
+
// or with options:
|
|
14
|
+
const app = createRouter({
|
|
15
|
+
secret: process.env.COOKIE_SECRET, // for signed cookies
|
|
16
|
+
timeout: 30_000, // ms; 0 = disabled
|
|
17
|
+
trustProxy: true, // trust X-Forwarded-* headers
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`createRouter()` returns a `Router` which is itself a middleware function (`router.listener`), so it can be nested inside any other router.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Route registration
|
|
26
|
+
|
|
27
|
+
### HTTP method routes
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
router.get(path, ...middleware)
|
|
31
|
+
router.post(path, ...middleware)
|
|
32
|
+
router.put(path, ...middleware)
|
|
33
|
+
router.delete(path, ...middleware)
|
|
34
|
+
router.patch(path, ...middleware)
|
|
35
|
+
router.all(path, ...middleware) // matches any HTTP method
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Method routes use **endpoint matching**: the pattern must match the entire path (up to an optional trailing slash). `get('/users')` matches `/users` and `/users/` but **not** `/users/42`.
|
|
39
|
+
|
|
40
|
+
### Prefix / mount routes
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
router.use(path, ...middleware)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`use()` uses **prefix matching**: the pattern is tested as a prefix, and the matched portion is **stripped from `req.path`** before the middleware is called. After the middleware calls `done()`, `req.path` is restored so sibling layers see the original path.
|
|
47
|
+
|
|
48
|
+
This makes `use()` the correct mount mechanism for sub-routers and global middleware.
|
|
49
|
+
|
|
50
|
+
### Middleware argument shapes
|
|
51
|
+
|
|
52
|
+
Each slot accepts a `Middleware` function, a `Router` instance, or an array of either:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
app.get('/admin', authGuard, adminHandler);
|
|
56
|
+
app.get('/multi', [guard, logger], handler);
|
|
57
|
+
app.use('/api', apiRouter); // Router instance
|
|
58
|
+
app.use('/api', apiRouter.listener); // equivalent
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Fluent route builder
|
|
62
|
+
|
|
63
|
+
`router.route(path)` returns a chainable `RouteBuilder` for registering several HTTP methods against the same path without repeating it:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
app.route('/users/:id')
|
|
67
|
+
.get(getUser)
|
|
68
|
+
.put(updateUser)
|
|
69
|
+
.delete(deleteUser);
|
|
70
|
+
|
|
71
|
+
// equivalent to:
|
|
72
|
+
app.get('/users/:id', getUser);
|
|
73
|
+
app.put('/users/:id', updateUser);
|
|
74
|
+
app.delete('/users/:id', deleteUser);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`path` may be a string or `RegExp`. The builder exposes `.all/.get/.put/.post/.delete/.patch/.head/.options`, each registering on the underlying router and returning the same builder for further chaining.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Path patterns
|
|
82
|
+
|
|
83
|
+
### Plain strings with `:param` segments
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
app.get('/users/:id', handler); // req.params.id
|
|
87
|
+
app.get('/orgs/:org/repos/:repo', handler); // req.params.org, .repo
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### Inline regex constraints
|
|
91
|
+
|
|
92
|
+
Append a constraint in parentheses to restrict what a parameter segment matches. Only requests whose segment passes the constraint reach the handler — others fall through:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
app.get('/items/:id(\\d+)', handler); // digits only
|
|
96
|
+
app.get('/files/:name([\\w-]+)', handler); // word chars + hyphens
|
|
97
|
+
app.get('/v:ver(\\d+)/api', handler); // literal suffix after constraint
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Named capture groups inside constraints are not allowed (they conflict with the outer `(?<name>…)` wrapper).
|
|
101
|
+
|
|
102
|
+
### Glob patterns
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
app.use('/static/**', handler); // any depth under /static/
|
|
106
|
+
app.get('/api/*', handler); // exactly one path segment
|
|
107
|
+
app.get('/**/*.php', handler); // PHP files anywhere
|
|
108
|
+
app.get('/v?/status', handler); // one character wildcard
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Glob rules: `?` → one non-slash character; `*` → any non-slash characters; `**` → any characters including slashes (cross-segment).
|
|
112
|
+
|
|
113
|
+
### Regular expressions
|
|
114
|
+
|
|
115
|
+
Named capture groups become `req.params` entries:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
app.get(/^\/users\/(?<id>\d+)/, handler);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
> **Restriction:** RegExp patterns with the `g` (global) or `y` (sticky) flag are **rejected at registration time** with a `TypeError`. These flags make `exec()` stateful, causing intermittent routing failures across requests.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Request fields
|
|
126
|
+
|
|
127
|
+
The router augments every incoming request before any middleware runs. The augmentation is idempotent — safe across nested routers sharing the same request object.
|
|
128
|
+
|
|
129
|
+
| Field | Type | Description |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `req.originalUrl` | `string` | Raw URL string, never modified |
|
|
132
|
+
| `req.path` | `string` | Pathname; rewritten by `use()` layers for sub-routers |
|
|
133
|
+
| `req.params` | `Record<string, string>` | Merged URL query params + named route params (flat, first-value for repeated keys) |
|
|
134
|
+
| `req.query` | `Record<string, string \| string[]>` | URL query parameters; repeated keys produce arrays. Alias for `req.queries.url` |
|
|
135
|
+
| `req.queries.url` | same as above | Structured query string bucket |
|
|
136
|
+
| `req.queries.route` | `Record<string, string>` | Named route parameters from the matched pattern |
|
|
137
|
+
| `req.cookies` | `Record<string, unknown>` | Parsed `Cookie` header. `j:` values are JSON-decoded; `s:` values are HMAC-verified (requires `secret`) |
|
|
138
|
+
| `req.ip` | `string` | Remote client IP. With `trustProxy: true`: first `X-Forwarded-For` value |
|
|
139
|
+
| `req.ips` | `string[]` | Full `X-Forwarded-For` chain (oldest first). Empty when `trustProxy` is disabled |
|
|
140
|
+
| `req.hostname` | `string` | `Host` header with port stripped. With `trustProxy: true`: taken from `X-Forwarded-Host` |
|
|
141
|
+
| `req.protocol` | `string` | `'http'` or `'https'`. With `trustProxy: true`: taken from `X-Forwarded-Proto` |
|
|
142
|
+
| `req.secure` | `boolean` | `true` when `req.protocol === 'https'` |
|
|
143
|
+
| `req.baseUrl` | `string` | Accumulated path prefix stripped by parent `use()` mounts. `''` at the root level |
|
|
144
|
+
| `req.json(opts?)` | `Promise<unknown\|null>` | Parse request body as JSON. Returns cached value if body already parsed |
|
|
145
|
+
| `req.text(opts?)` | `Promise<string\|null>` | Decode request body as plain text |
|
|
146
|
+
| `req.formData(opts?)` | `Promise<FormPart[]\|null>` | Parse request body as `multipart/form-data` |
|
|
147
|
+
|
|
148
|
+
### Cookies
|
|
149
|
+
|
|
150
|
+
Cookie values are decoded automatically:
|
|
151
|
+
|
|
152
|
+
- Plain strings are returned as-is.
|
|
153
|
+
- `j:<json>` prefixed values are JSON-parsed.
|
|
154
|
+
- `s:<sig>.<value>` prefixed values are HMAC-SHA256 verified; cookies that fail verification are silently dropped. Requires `secret` option on `createRouter()`.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Response helpers
|
|
159
|
+
|
|
160
|
+
Every response is augmented with convenience methods. All chainable helpers return `this`.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
res.send('Hello'); // write string body and end
|
|
164
|
+
res.send(); // end with no body
|
|
165
|
+
res.json({ ok: true }); // set Content-Type: application/json and end
|
|
166
|
+
res.status(404).send('Not Found'); // set status code (validated 100–999)
|
|
167
|
+
res.status(201, { 'X-Id': '42' }).end(); // set status + headers
|
|
168
|
+
res.redirect('/new-path'); // 302 Found
|
|
169
|
+
|
|
170
|
+
res.type('text/csv').send(data); // set Content-Type (chainable)
|
|
171
|
+
res.etag('v1').json(payload); // weak ETag W/"v1" (chainable)
|
|
172
|
+
res.etag(sha256, true).send(buf); // strong ETag "sha256"
|
|
173
|
+
|
|
174
|
+
res.cookie('session', 'abc', {
|
|
175
|
+
maxAge: 3_600_000, // ms; also sets Expires
|
|
176
|
+
path: '/api',
|
|
177
|
+
httpOnly: true,
|
|
178
|
+
secure: true,
|
|
179
|
+
sameSite: 'Strict',
|
|
180
|
+
signed: true, // HMAC-sign (requires router secret)
|
|
181
|
+
});
|
|
182
|
+
res.clearCookie('session'); // Max-Age=0, Expires=epoch
|
|
183
|
+
res.clearCookie('tok', { path: '/admin' }); // match original options
|
|
184
|
+
|
|
185
|
+
res.download('/path/file.pdf'); // Content-Disposition: attachment
|
|
186
|
+
res.download('/path/file.pdf', 'invoice.pdf'); // custom filename
|
|
187
|
+
res.attachment('report.pdf').send(buf); // set disposition + Content-Type, no file I/O
|
|
188
|
+
|
|
189
|
+
res.append('X-Custom', 'v1'); // append to header (comma-joins; Set-Cookie accumulates)
|
|
190
|
+
res.vary('Accept'); // add to Vary header, deduplicating
|
|
191
|
+
res.vary(['Accept', 'Accept-Encoding']);
|
|
192
|
+
res.location('/new-path'); // set Location header
|
|
193
|
+
res.sendStatus(200); // set status + send standard text body
|
|
194
|
+
|
|
195
|
+
res.locals['user'] = currentUser; // request-scoped storage for middleware chains
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### `res.status()` validation
|
|
199
|
+
|
|
200
|
+
The code must be an integer in the range `100–999`. Passing a non-integer or out-of-range value throws a `RangeError` at call time, which is caught by the router error handler.
|
|
201
|
+
|
|
202
|
+
### Cookies — writing
|
|
203
|
+
|
|
204
|
+
Setting a cookie with `signed: true` produces an `s:<hmac>.<value>` string. The router's `secret` is required. Objects are serialised with a `j:` prefix and JSON-decoded on read.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Error handling
|
|
209
|
+
|
|
210
|
+
→ Full reference: [errors.md](errors.md)
|
|
211
|
+
|
|
212
|
+
Synchronous throws, async rejections, and `next(err)` calls all enter the router's **error channel**, which resolves through an ordered `error()` chain, an `onError()` fallback, and finally **bubbles to parent routers** before defaulting to `500`.
|
|
213
|
+
|
|
214
|
+
### Ordered error handlers (`error()`)
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
app.error((err, _req, res, next) => {
|
|
218
|
+
if ((err as any)?.status === 404) return res.status(404).json({ error: 'Not Found' });
|
|
219
|
+
next(err); // forward to the next handler, or bubble to the parent router
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
app.error((err, _req, res, _next) =>
|
|
223
|
+
res.status((err as any)?.status ?? 500).json({ error: String(err) }));
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The error value is the **first** argument. `next()` forwards the same error; `next(err)` replaces it. Handlers run in registration order until one ends the response.
|
|
227
|
+
|
|
228
|
+
### Single terminal fallback (`onError()`)
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
app.onError((err, _req, res) => {
|
|
232
|
+
const status = (err as any)?.status ?? 500;
|
|
233
|
+
res.status(status).json({ error: String(err) });
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`onError()` is the simple, single catch-all. It runs after the `error()` chain is exhausted and, unlike `error()`, does **not** bubble — it is terminal for its router. Without any handler, the default sends `500` and logs to `console.warn`.
|
|
238
|
+
|
|
239
|
+
### Custom 404 handler
|
|
240
|
+
|
|
241
|
+
Register a catch-all as the **last** layer. Layers match in registration order, so it runs only when no earlier route handled the request. `/**` matches any path and `all()` matches any method:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
app.all('/**', (_req, res) => res.status(404).json({ error: 'Not Found' }));
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
With no catch-all, unmatched requests fall back to the built-in `Cannot METHOD /path` 404.
|
|
248
|
+
|
|
249
|
+
### Passing errors through middleware
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
app.use('/protected', (req, _res, next) => {
|
|
253
|
+
if (!req.headers.authorization) return next(new Error('Unauthorized'));
|
|
254
|
+
next();
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Calling `next(err)` skips remaining route layers and enters the error channel directly.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Sub-routers and `req.baseUrl`
|
|
263
|
+
|
|
264
|
+
Routers are fully nestable. The matched prefix is stripped from `req.path` when mounted with `use()`:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
const api = createRouter();
|
|
268
|
+
api.get('/users', listUsers);
|
|
269
|
+
api.get('/users/:id', getUser);
|
|
270
|
+
|
|
271
|
+
const app = createRouter();
|
|
272
|
+
app.use('/api/v1', api); // /api/v1/users → api sees req.path = '/users'
|
|
273
|
+
// req.baseUrl = '/api/v1'
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`req.baseUrl` accumulates the stripped prefix as the request descends through nested `use()` mounts. It is `''` at the root level and is restored for sibling layers after a sub-router calls `done()`.
|
|
277
|
+
|
|
278
|
+
### Prefix router creation
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
const v1 = createRouter('/api/v1'); // implicitly strips this prefix
|
|
282
|
+
app.use(v1);
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Starting the server
|
|
288
|
+
|
|
289
|
+
`router.listen()` returns the underlying `http.Server` (or `https.Server`, or `http2.Http2SecureServer`) instance:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// Plain HTTP
|
|
293
|
+
const server = app.listen(3000, () => console.log('Ready'));
|
|
294
|
+
|
|
295
|
+
// HTTPS
|
|
296
|
+
app.listen(443, {
|
|
297
|
+
key: readFileSync('server.key'),
|
|
298
|
+
cert: readFileSync('server.crt'),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// HTTP/2 (requires TLS)
|
|
302
|
+
app.listen(443, { key, cert, http2: true });
|
|
303
|
+
|
|
304
|
+
// Ephemeral port (useful in tests)
|
|
305
|
+
const server = app.listen(0, () => {
|
|
306
|
+
const { port } = server.address() as AddressInfo;
|
|
307
|
+
console.log(`Listening on :${port}`);
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Graceful shutdown
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
process.on('SIGTERM', () => app.shutdown(10_000)); // 10 s drain window
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Request timeout
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const app = createRouter({ timeout: 30_000 }); // 408 after 30 s of no response
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Introspecting registered routes
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
console.log(app.routes());
|
|
329
|
+
// [{ method: 'GET', path: '/users', stripPath: false }, ...]
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`method` is `null` for `use()` / `all()` layers. `stripPath: true` identifies `use()` layers.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## CookieOptions reference
|
|
337
|
+
|
|
338
|
+
| Option | Type | Default | Description |
|
|
339
|
+
|---|---|---|---|
|
|
340
|
+
| `signed` | `boolean` | `false` | HMAC-sign the value (requires router `secret`) |
|
|
341
|
+
| `expires` | `Date` | — | Expiry date |
|
|
342
|
+
| `maxAge` | `number` | — | Max age in **milliseconds** (also derives `Expires`) |
|
|
343
|
+
| `path` | `string` | `'/'` | Cookie path |
|
|
344
|
+
| `httpOnly` | `boolean` | `false` | Mark `HttpOnly` |
|
|
345
|
+
| `secure` | `boolean` | `false` | Mark `Secure` |
|
|
346
|
+
| `sameSite` | `'Strict'\|'Lax'\|'None'` | — | `SameSite` attribute |
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## RouterOptions reference
|
|
351
|
+
|
|
352
|
+
| Option | Type | Default | Description |
|
|
353
|
+
|---|---|---|---|
|
|
354
|
+
| `secret` | `string` | — | Cookie-signing secret. Required for signed cookies |
|
|
355
|
+
| `timeout` | `number` | `0` (disabled) | Request timeout in milliseconds |
|
|
356
|
+
| `trustProxy` | `boolean` | `false` | Trust `X-Forwarded-*` headers |
|
package/docs/static.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Static File Serving
|
|
2
|
+
|
|
3
|
+
Expediate includes a full static file serving implementation with ETag caching, conditional GET, MIME detection, dot-file policies, path traversal protection, and an optional directory listing.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## `serveStatic(root, opts?)`
|
|
8
|
+
|
|
9
|
+
Serve all files under a directory:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { serveStatic } from 'expediate';
|
|
13
|
+
|
|
14
|
+
app.use('/public', serveStatic('./dist'));
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### StaticOptions
|
|
18
|
+
|
|
19
|
+
| Option | Type | Default | Description |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| `fallthrough` | `boolean` | `true` | Call `next()` instead of 404 for missing files/paths |
|
|
22
|
+
| `dotfiles` | `'allow' \| 'deny' \| 'hide'` | `'hide'` | Dot-file access: `allow` serves them, `deny` sends 403, `hide` sends 404 |
|
|
23
|
+
| `redirect` | `boolean` | `true` | Redirect bare directory URLs to their trailing-slash form |
|
|
24
|
+
| `index` | `string` | `'index.html'` | Default file to serve for directory requests |
|
|
25
|
+
| `etag` | `boolean` | `true` | Send weak `ETag` header (`W/"<size_hex>-<mtime_hex>"`) |
|
|
26
|
+
| `lastModified` | `boolean` | `true` | Send `Last-Modified` header |
|
|
27
|
+
| `maxage` / `maxAge` | `number` | `0` | Cache lifetime in **milliseconds** → `Cache-Control: public, max-age=<s>` |
|
|
28
|
+
| `immutable` | `boolean` | `false` | Append `, immutable` to `Cache-Control` |
|
|
29
|
+
| `contentType` | `string \| null` | `null` | Override the auto-detected `Content-Type` |
|
|
30
|
+
| `headers` | `Record<string, string>` | security defaults | Extra response headers, merged with built-in security headers |
|
|
31
|
+
| `indexOf` | `boolean` | `false` | Enable Apache-style directory listing |
|
|
32
|
+
|
|
33
|
+
### HTTP methods
|
|
34
|
+
|
|
35
|
+
Only `GET` and `HEAD` are served. Other methods receive **405 Method Not Allowed** (or fall through if `fallthrough: true`).
|
|
36
|
+
|
|
37
|
+
### Default security headers
|
|
38
|
+
|
|
39
|
+
Every static response includes these headers by default:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Content-Security-Policy: default-src 'self'
|
|
43
|
+
X-Content-Type-Options: nosniff
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Conditional GET (caching)
|
|
47
|
+
|
|
48
|
+
`serveStatic` honours `If-None-Match` and `If-Modified-Since` request headers. When the client's cached version is still fresh, a `304 Not Modified` is returned with no body. `Cache-Control: no-cache` forces a full response even when the ETag matches.
|
|
49
|
+
|
|
50
|
+
`If-Match` and `If-Unmodified-Since` are also honoured: when the condition fails, the response is `412 Precondition Failed` instead of serving the file.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## `serveFile(filepath, opts?)`
|
|
55
|
+
|
|
56
|
+
Serve a single fixed file for every request — useful for SPA catch-all routes:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { serveFile } from 'expediate';
|
|
60
|
+
|
|
61
|
+
// Serve dist/index.html for every unmatched path
|
|
62
|
+
app.get('/**', serveFile('./dist/index.html'));
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Accepts the same options as `serveStatic()`. Returns `500 EISDIR` if the path resolves to a directory.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## `sendFile(req, res, filepath, opts?)`
|
|
70
|
+
|
|
71
|
+
Low-level utility for sending a dynamically resolved file path. Does not require middleware wrapping:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { sendFile } from 'expediate';
|
|
75
|
+
|
|
76
|
+
app.get('/downloads/:name', (req, res) => {
|
|
77
|
+
const fp = path.join('./downloads', req.params.name);
|
|
78
|
+
sendFile(req as any, res as any, fp);
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## MIME type detection
|
|
85
|
+
|
|
86
|
+
The `mime` object provides type lookup:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { mime } from 'expediate';
|
|
90
|
+
|
|
91
|
+
mime.lookup('image.png'); // 'image/png'
|
|
92
|
+
mime.lookup('archive.tar.gz'); // 'application/gzip'
|
|
93
|
+
mime.lookup('file', 'text/plain'); // fallback to 'text/plain' if unknown
|
|
94
|
+
mime.charsets('text/html'); // 'UTF-8'
|
|
95
|
+
mime.charsets('image/png'); // null
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
MIME types are loaded from a bundled `mimetypes.json` file.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Directory listing
|
|
103
|
+
|
|
104
|
+
When `indexOf: true`, requesting a directory URL generates an Apache-style HTML listing with sortable columns:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
app.use('/', serveStatic('./public', { indexOf: true }));
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Sort controls use query parameters: `C` for column (`N`=name, `M`=mtime, `S`=size) and `O` for order (`A`=ascending, `D`=descending). Directories always sort above files regardless of the active column.
|
|
111
|
+
|
|
112
|
+
All user-controlled content (URL paths, filenames) is **HTML-escaped** before being inserted into the page. Link `href` values use `encodeURIComponent`. This prevents XSS when a directory contains files with HTML-significant names.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Security
|
|
117
|
+
|
|
118
|
+
### Path traversal protection
|
|
119
|
+
|
|
120
|
+
All resolved file paths are checked against the declared root. Any path that resolves outside the root is rejected with `403 Forbidden`. This is a defense-in-depth check that runs after the `..` segment guard (`/(\/|^)(\.\.?)(\/|$)/`).
|
|
121
|
+
|
|
122
|
+
### Malformed percent-encoding
|
|
123
|
+
|
|
124
|
+
Requests with malformed percent-encoded characters in the path (e.g. `/%zz`, `/%a`) receive `400 Bad Request` instead of a 500 error.
|
|
125
|
+
|
|
126
|
+
### Dot-file policy
|
|
127
|
+
|
|
128
|
+
The default `dotfiles: 'hide'` policy responds with `404` for paths that include a dot-file segment, without revealing whether the file exists.
|