@warlock.js/core 5.10.0 → 5.12.0
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 +33 -0
- package/esm/application/app.d.mts +3 -3
- package/esm/application/app.mjs +6 -6
- package/esm/application/app.mjs.map +1 -1
- package/esm/cli/cli-commands.utils.mjs +7 -3
- package/esm/cli/cli-commands.utils.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/health.check.mjs +1 -1
- package/esm/cli/commands/doctor/checks/health.check.mjs.map +1 -1
- package/esm/connectors/socket-connector.mjs +13 -16
- package/esm/connectors/socket-connector.mjs.map +1 -1
- package/esm/container/index.d.mts +20 -7
- package/esm/container/index.d.mts.map +1 -1
- package/esm/container/index.mjs +8 -5
- package/esm/container/index.mjs.map +1 -1
- package/esm/dev-server/dev-logger.mjs +15 -3
- package/esm/dev-server/dev-logger.mjs.map +1 -1
- package/esm/dev-server/development-server.mjs +1 -1
- package/esm/dev-server/development-server.mjs.map +1 -1
- package/esm/dev-server/file-event-handler.mjs +50 -9
- package/esm/dev-server/file-event-handler.mjs.map +1 -1
- package/esm/dev-server/file-manager.d.mts +8 -0
- package/esm/dev-server/file-manager.d.mts.map +1 -1
- package/esm/dev-server/file-manager.mjs +26 -4
- package/esm/dev-server/file-manager.mjs.map +1 -1
- package/esm/dev-server/files-orchestrator.mjs +3 -3
- package/esm/dev-server/files-orchestrator.mjs.map +1 -1
- package/esm/dev-server/files-watcher.mjs +16 -2
- package/esm/dev-server/files-watcher.mjs.map +1 -1
- package/esm/dev-server/flags.mjs +16 -1
- package/esm/dev-server/flags.mjs.map +1 -1
- package/esm/dev-server/layer-executor.mjs +27 -3
- package/esm/dev-server/layer-executor.mjs.map +1 -1
- package/esm/errors/container-key-missing-error.mjs +18 -6
- package/esm/errors/container-key-missing-error.mjs.map +1 -1
- package/esm/generations/stubs.mjs +10 -10
- package/esm/generations/stubs.mjs.map +1 -1
- package/esm/http/context/request-context.d.mts +14 -11
- package/esm/http/context/request-context.d.mts.map +1 -1
- package/esm/http/context/request-context.mjs +13 -9
- package/esm/http/context/request-context.mjs.map +1 -1
- package/esm/http/createHttpApplication.d.mts.map +1 -1
- package/esm/http/createHttpApplication.mjs +2 -0
- package/esm/http/createHttpApplication.mjs.map +1 -1
- package/esm/http/csp.d.mts +139 -0
- package/esm/http/csp.d.mts.map +1 -0
- package/esm/http/csp.mjs +134 -0
- package/esm/http/csp.mjs.map +1 -0
- package/esm/http/errors/errors.d.mts +17 -1
- package/esm/http/errors/errors.d.mts.map +1 -1
- package/esm/http/errors/errors.mjs +20 -1
- package/esm/http/errors/errors.mjs.map +1 -1
- package/esm/http/index.d.mts +8 -3
- package/esm/http/index.mjs +6 -1
- package/esm/http/middleware/concurrency-limit.middleware.d.mts +2 -2
- package/esm/http/middleware/concurrency-limit.middleware.mjs +1 -1
- package/esm/http/middleware/concurrency-limit.middleware.mjs.map +1 -1
- package/esm/http/middleware/inject-request-context.d.mts +1 -1
- package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
- package/esm/http/middleware/inject-request-context.mjs +10 -0
- package/esm/http/middleware/inject-request-context.mjs.map +1 -1
- package/esm/http/middleware/rate-limit.middleware.d.mts +1 -1
- package/esm/http/middleware/rate-limit.middleware.mjs.map +1 -1
- package/esm/http/middleware/utils/idempotency-key.d.mts.map +1 -1
- package/esm/http/middleware/utils/idempotency-key.mjs +13 -13
- package/esm/http/middleware/utils/idempotency-key.mjs.map +1 -1
- package/esm/http/request-controller.d.mts +1 -1
- package/esm/http/request.d.mts +32 -44
- package/esm/http/request.d.mts.map +1 -1
- package/esm/http/request.mjs +52 -44
- package/esm/http/request.mjs.map +1 -1
- package/esm/http/response.d.mts +18 -0
- package/esm/http/response.d.mts.map +1 -1
- package/esm/http/response.mjs +31 -0
- package/esm/http/response.mjs.map +1 -1
- package/esm/http/stream-react-response.d.mts +65 -0
- package/esm/http/stream-react-response.d.mts.map +1 -0
- package/esm/http/stream-react-response.mjs +46 -0
- package/esm/http/stream-react-response.mjs.map +1 -0
- package/esm/http/tracing/index.mjs +4 -0
- package/esm/http/tracing/trace-id.d.mts +14 -0
- package/esm/http/tracing/trace-id.d.mts.map +1 -0
- package/esm/http/tracing/trace-id.mjs +37 -0
- package/esm/http/tracing/trace-id.mjs.map +1 -0
- package/esm/http/tracing/tracing-dispatcher.d.mts +42 -0
- package/esm/http/tracing/tracing-dispatcher.d.mts.map +1 -0
- package/esm/http/tracing/tracing-dispatcher.mjs +94 -0
- package/esm/http/tracing/tracing-dispatcher.mjs.map +1 -0
- package/esm/http/tracing/tracing.type.d.mts +66 -0
- package/esm/http/tracing/tracing.type.d.mts.map +1 -0
- package/esm/http/types.d.mts +27 -21
- package/esm/http/types.d.mts.map +1 -1
- package/esm/index.d.mts +9 -4
- package/esm/index.mjs +6 -2
- package/esm/production/esbuild-preflight.mjs +47 -0
- package/esm/production/esbuild-preflight.mjs.map +1 -0
- package/esm/production/production-builder.mjs +19 -15
- package/esm/production/production-builder.mjs.map +1 -1
- package/esm/restful/restful.d.mts +1 -1
- package/esm/router/log-request-lifecycle.mjs +12 -2
- package/esm/router/log-request-lifecycle.mjs.map +1 -1
- package/esm/router/router.d.mts.map +1 -1
- package/esm/router/router.mjs +10 -0
- package/esm/router/router.mjs.map +1 -1
- package/esm/router/types.d.mts +1 -1
- package/esm/socket/utils.mjs +1 -2
- package/esm/socket/utils.mjs.map +1 -1
- package/esm/storage/drivers/cloud-driver.d.mts.map +1 -1
- package/esm/storage/drivers/cloud-driver.mjs +2 -5
- package/esm/storage/drivers/cloud-driver.mjs.map +1 -1
- package/esm/warlock-config/types.d.mts +12 -0
- package/esm/warlock-config/types.d.mts.map +1 -1
- package/llms-full.txt +315 -25
- package/llms.txt +1 -0
- package/package.json +12 -12
- package/skills/add-connector/SKILL.md +1 -1
- package/skills/build-restful/SKILL.md +2 -2
- package/skills/configure-app/SKILL.md +31 -0
- package/skills/create-controller/SKILL.md +4 -4
- package/skills/request-tracing/SKILL.md +208 -0
- package/skills/send-response/SKILL.md +16 -0
- package/skills/store-file/SKILL.md +1 -1
- package/skills/upload-file/SKILL.md +14 -2
- package/skills/use-app-context/SKILL.md +2 -2
- package/skills/use-middleware/SKILL.md +2 -2
- package/skills/use-repository/SKILL.md +1 -1
- package/skills/use-request-locals/SKILL.md +1 -1
- package/skills/validate-input/SKILL.md +13 -0
- package/skills/wire-socket/SKILL.md +3 -3
- package/skills/write-middleware/SKILL.md +11 -5
- package/skills/write-use-case/SKILL.md +1 -1
|
@@ -59,11 +59,42 @@ const httpConfigurations: HttpConfigurations = {
|
|
|
59
59
|
path: "/",
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
|
+
csp: {
|
|
63
|
+
enabled: true,
|
|
64
|
+
directives: {
|
|
65
|
+
"img-src": ["'self'", "data:", "https://cdn.example.com"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
62
68
|
};
|
|
63
69
|
|
|
64
70
|
export default httpConfigurations;
|
|
65
71
|
```
|
|
66
72
|
|
|
73
|
+
### `csp` — Content-Security-Policy (5.12.0)
|
|
74
|
+
|
|
75
|
+
Opt-in (`enabled: true`) `Content-Security-Policy` header, built per-request
|
|
76
|
+
from a documented default policy plus your `directives`, with the
|
|
77
|
+
framework's own per-request nonce always added to `script-src`:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
default-src 'self'; script-src 'self' 'nonce-<request nonce>'; style-src 'self';
|
|
81
|
+
img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A directive you name under `directives` **replaces** the default list for
|
|
85
|
+
that directive (never merged element-wise) — declare the full value list you
|
|
86
|
+
want. Set `reportOnly: true` to emit `Content-Security-Policy-Report-Only`
|
|
87
|
+
instead while you observe violations without blocking anything. Absent or
|
|
88
|
+
`enabled: false` (the default) emits no header at all — no behaviour change
|
|
89
|
+
for apps that don't opt in.
|
|
90
|
+
|
|
91
|
+
A malformed directive value (containing `;`, or with an unbalanced `'`
|
|
92
|
+
count) throws `InvalidCspDirectiveError` at boot, not silently — fix the
|
|
93
|
+
value in `src/config/http.ts` rather than expecting the framework to repair
|
|
94
|
+
it. See [`send-response/SKILL.md`](../send-response/SKILL.md) for how the
|
|
95
|
+
header lands on the response, and `@warlock.js/web`'s docs for how the
|
|
96
|
+
same nonce reaches the page's `<script>` tags.
|
|
97
|
+
|
|
67
98
|
## Which layer holds what
|
|
68
99
|
|
|
69
100
|
| Setting | Layer |
|
|
@@ -34,7 +34,7 @@ Scaffold with: `npx warlock generate.controller <module>/<action>` (add `--with-
|
|
|
34
34
|
| `request.input("key", default?)` | one field | reading a single param/body field by name |
|
|
35
35
|
| `request.all()` | full input object | passing the whole input straight to a service |
|
|
36
36
|
| `request.validated()` | schema-typed object (only after schema attached) | controllers with a schema — preferred over `.all()` |
|
|
37
|
-
| `request.user`
|
|
37
|
+
| `request.locals.user` | authenticated user | guarded routes (see "Typing a guarded handler") |
|
|
38
38
|
| `request.file("key")` | `UploadedFile` | multipart uploads |
|
|
39
39
|
| `request.header("X-Foo")` | header value | reading request metadata |
|
|
40
40
|
| `request.ip`, `request.userAgent` | strings | logging, device info |
|
|
@@ -90,7 +90,7 @@ If validation fails, the framework returns a 400 with an `errors` payload and yo
|
|
|
90
90
|
|
|
91
91
|
### Typing a guarded handler
|
|
92
92
|
|
|
93
|
-
Routes behind `authMiddleware` need `request.user` typed. Project conventions add a `GuardedRequest<TSchema>` (
|
|
93
|
+
Routes behind `authMiddleware` need `request.locals.user` typed. Project conventions add a `GuardedRequest<TSchema>` (overriding `locals` to `RequestLocals & { user: User }`) and a paired `GuardedRequestHandler<TSchema>` alias in `app/auth/requests/guarded.request`:
|
|
94
94
|
|
|
95
95
|
```ts
|
|
96
96
|
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
@@ -100,7 +100,7 @@ export const createProductController: GuardedRequestHandler<CreateProductSchema>
|
|
|
100
100
|
request,
|
|
101
101
|
response,
|
|
102
102
|
}) => {
|
|
103
|
-
// request.user is typed
|
|
103
|
+
// request.locals.user is typed
|
|
104
104
|
const product = await createProductService(request.validated());
|
|
105
105
|
return response.successCreate({ product });
|
|
106
106
|
};
|
|
@@ -149,7 +149,7 @@ import { listProductsService } from "../services/list-products.service";
|
|
|
149
149
|
export const listProductsController: RequestHandler = async ({ request, response }) => {
|
|
150
150
|
const { data: products, pagination } = await listProductsService({
|
|
151
151
|
...request.all(),
|
|
152
|
-
organization_id: request.user.organizationId,
|
|
152
|
+
organization_id: request.locals.user.organizationId,
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
return response.success({ products, pagination });
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: request-tracing
|
|
3
|
+
description: 'Vendor-neutral request tracing hooks (`http.tracing`) — observe request start/end and named phase spans (`route.match`, `middleware`, `validation`, `handler`, `response.write`) without adopting an OTel/vendor dependency in `core`. Covers the `TracingHooks` shape, trace id derivation from an inbound W3C `traceparent` vs `request.id`, that a throwing hook is caught and reported once (never breaks the request), and zero overhead when disabled. Triggers: `http.tracing`, `TracingHooks`, `onRequestStart`, `onRequestEnd`, `onPhase`, `traceparent`, `traceId`, `dispatchPhase`, "instrument requests", "trace a request", "slow phase logging", "APM / OTel bridge for Warlock". Skip: request-id header echo/inheritance (`X-Request-Id`) — `@warlock.js/core/use-middleware/SKILL.md`; app-level structured logging — `@warlock.js/logger/logger-basics/SKILL.md`; competing libs `@opentelemetry/api` direct instrumentation, `express-request-id`, hand-rolled `X-Trace-Id` middleware.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Warlock — request tracing hooks
|
|
7
|
+
|
|
8
|
+
`http.tracing` gives every request a set of named phase spans and start/end
|
|
9
|
+
events, delivered to plain callbacks you register in config — no
|
|
10
|
+
`@opentelemetry/api` dependency in `core`, in this release or later (an OTel
|
|
11
|
+
bridge is a separate, optional package that subscribes to these hooks; it
|
|
12
|
+
does not exist yet). OFF by default, and a disabled app pays exactly one
|
|
13
|
+
cached boolean check per call site — no context object is built, no
|
|
14
|
+
`performance.now()` is called, nothing is allocated.
|
|
15
|
+
|
|
16
|
+
## Enabling it
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// src/config/http.ts
|
|
20
|
+
export default {
|
|
21
|
+
tracing: {
|
|
22
|
+
enabled: true, // default false
|
|
23
|
+
hooks: [
|
|
24
|
+
{
|
|
25
|
+
onRequestStart(ctx) {
|
|
26
|
+
/* ctx: { traceId, requestId, method, route, path } */
|
|
27
|
+
},
|
|
28
|
+
onPhase(ctx, phase) {
|
|
29
|
+
/* phase: { name, durationMs, attrs? } */
|
|
30
|
+
},
|
|
31
|
+
onRequestEnd(ctx, result) {
|
|
32
|
+
/* result: { status?, durationMs, error? } */
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`enabled` is resolved once at first read and cached for the process — flipping
|
|
41
|
+
it requires a restart, the same trade-off as `http.maintenance.enabled` and
|
|
42
|
+
`http.requestId.enabled`.
|
|
43
|
+
|
|
44
|
+
## The `TracingHooks` shape
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
type TracingContext = {
|
|
48
|
+
traceId: string; // see "Trace id derivation" below
|
|
49
|
+
requestId: string; // request.id
|
|
50
|
+
method: string;
|
|
51
|
+
route?: string; // matched pattern, e.g. "/users/:id" — undefined before routing
|
|
52
|
+
path: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type TracingPhaseInfo = {
|
|
56
|
+
name: string;
|
|
57
|
+
durationMs: number;
|
|
58
|
+
attrs?: Record<string, unknown>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type TracingRequestEndInfo = {
|
|
62
|
+
status?: number;
|
|
63
|
+
durationMs: number;
|
|
64
|
+
error?: unknown;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type TracingHooks = {
|
|
68
|
+
onRequestStart?(ctx: TracingContext): void;
|
|
69
|
+
onRequestEnd?(ctx: TracingContext, result: TracingRequestEndInfo): void;
|
|
70
|
+
onPhase?(ctx: TracingContext, phase: TracingPhaseInfo): void;
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Every verb is optional — a hook that only wants phase spans need not implement
|
|
75
|
+
`onRequestStart`/`onRequestEnd`. Register as many hooks as you like via
|
|
76
|
+
`hooks: TracingHooks[]`; each fires independently.
|
|
77
|
+
|
|
78
|
+
## Phase names
|
|
79
|
+
|
|
80
|
+
`core` wires five phases into the request lifecycle, in this order, for every
|
|
81
|
+
HTTP request:
|
|
82
|
+
|
|
83
|
+
| Phase | Fires around |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `route.match` | Resolving the incoming path/method to a registered route |
|
|
86
|
+
| `middleware` | Each middleware in the route's chain — one `onPhase` call per middleware, with `attrs: { name, index }` |
|
|
87
|
+
| `validation` | The route's input validation (`v.object(...)` / RESTful resource validation) |
|
|
88
|
+
| `handler` | The route handler itself |
|
|
89
|
+
| `response.write` | The overall request span, closed once the response has settled (success or thrown error) — this is also where `onRequestEnd` fires |
|
|
90
|
+
|
|
91
|
+
`@warlock.js/web` page requests report through this same `onPhase` surface
|
|
92
|
+
instead of adding a separate hook API. They add three phases:
|
|
93
|
+
|
|
94
|
+
| Phase | Fires around |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `loader` | Each loader level, once per app/layout/page, with `attrs: { level, layoutPath? }` |
|
|
97
|
+
| `render.shell` | Time from render start until React's shell is ready to stream |
|
|
98
|
+
| `stream.end` | The whole streamed response, including every `defer()` value settling |
|
|
99
|
+
|
|
100
|
+
## Trace id derivation
|
|
101
|
+
|
|
102
|
+
`ctx.traceId` is derived once per request:
|
|
103
|
+
|
|
104
|
+
1. If the inbound `traceparent` header is a valid W3C version-`00` header
|
|
105
|
+
(`00-<32 hex>-<16 hex>-<2 hex>`, trace id not all-zero), `traceId` is that
|
|
106
|
+
header's trace id — so a request already inside someone else's distributed
|
|
107
|
+
trace keeps the same id through Warlock.
|
|
108
|
+
2. Otherwise `traceId` falls back to `request.id` (the framework's own
|
|
109
|
+
per-request correlation id — see
|
|
110
|
+
[`use-middleware/SKILL.md`](../use-middleware/SKILL.md#request-id-correlation)
|
|
111
|
+
for how that id is generated/inherited/echoed).
|
|
112
|
+
|
|
113
|
+
`ctx.requestId` is always `request.id`, regardless of which branch produced
|
|
114
|
+
`traceId` — so a hook can always join back to the same id the framework logs
|
|
115
|
+
and echoes on `X-Request-Id`, even when `traceId` came from an inbound header.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { deriveTraceId, parseTraceparentTraceId } from "@warlock.js/core";
|
|
119
|
+
|
|
120
|
+
parseTraceparentTraceId("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
|
121
|
+
// -> "4bf92f3577b34da6a3ce929d0e0e4736"
|
|
122
|
+
|
|
123
|
+
deriveTraceId(undefined, "req-abc123");
|
|
124
|
+
// -> "req-abc123" (no traceparent, falls back to requestId)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Response header: no new one
|
|
128
|
+
|
|
129
|
+
Tracing does **not** add a response header of its own. `core` already echoes
|
|
130
|
+
`request.id` back as `X-Request-Id` on every response (see
|
|
131
|
+
[`use-middleware/SKILL.md`](../use-middleware/SKILL.md#request-id-correlation)) —
|
|
132
|
+
apps correlate through that existing header. When a valid inbound
|
|
133
|
+
`traceparent` is present, `ctx.traceId` inside your hooks carries that trace
|
|
134
|
+
id even though the response header still reflects `request.id`; if you need
|
|
135
|
+
the resolved `traceId` on the wire (e.g. to hand back to a caller that sent
|
|
136
|
+
`traceparent`), read it from your own `onRequestStart`/`onPhase` hook and set
|
|
137
|
+
it yourself — `core` deliberately doesn't duplicate it into a second header.
|
|
138
|
+
|
|
139
|
+
## A throwing hook never breaks a request
|
|
140
|
+
|
|
141
|
+
Every hook call is wrapped: if a hook throws, the dispatcher catches it,
|
|
142
|
+
reports it once per `(hook, verb)` pair per process to the error sink, and
|
|
143
|
+
continues with the next hook. A hook that throws on every request does not
|
|
144
|
+
flood your logs and never turns an observability bug into a 500 for real
|
|
145
|
+
traffic.
|
|
146
|
+
|
|
147
|
+
## Zero overhead when disabled
|
|
148
|
+
|
|
149
|
+
`http.tracing.enabled` is resolved once (lazily, on first read) and cached —
|
|
150
|
+
never re-read per request. Every instrumented call site checks that cached
|
|
151
|
+
boolean **before** building a context object or calling `performance.now()`,
|
|
152
|
+
so a disabled app pays exactly one boolean check per phase and allocates
|
|
153
|
+
nothing extra. Don't wrap `dispatchPhase`/`buildTracingContext` calls in your
|
|
154
|
+
own extra guard — the check is already there.
|
|
155
|
+
|
|
156
|
+
## Example: log phases slower than a threshold
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// src/config/http.ts
|
|
160
|
+
import { log } from "@warlock.js/logger";
|
|
161
|
+
|
|
162
|
+
const SLOW_MS = 200;
|
|
163
|
+
|
|
164
|
+
export default {
|
|
165
|
+
tracing: {
|
|
166
|
+
enabled: env("TRACING_ENABLED") === "true",
|
|
167
|
+
hooks: [
|
|
168
|
+
{
|
|
169
|
+
onPhase(ctx, phase) {
|
|
170
|
+
if (phase.durationMs < SLOW_MS) return;
|
|
171
|
+
|
|
172
|
+
log.warn("http", "slow-phase", {
|
|
173
|
+
traceId: ctx.traceId,
|
|
174
|
+
requestId: ctx.requestId,
|
|
175
|
+
route: ctx.route,
|
|
176
|
+
phase: phase.name,
|
|
177
|
+
durationMs: phase.durationMs,
|
|
178
|
+
attrs: phase.attrs,
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Gotchas
|
|
188
|
+
|
|
189
|
+
- **No OTel dependency, and none planned for `core`.** An OTel (or other
|
|
190
|
+
vendor) bridge is a separate, optional package that subscribes to these
|
|
191
|
+
hooks — never add `@opentelemetry/api` to `core` itself.
|
|
192
|
+
- **`web`'s phases aren't live yet.** Don't register a hook expecting
|
|
193
|
+
`loader`/`render.shell`/`stream.end` calls today; only the five `core`
|
|
194
|
+
phases fire in this release.
|
|
195
|
+
- **`route` is `undefined` until routing has matched.** There's no path where
|
|
196
|
+
a hook fires before that, but code branching on `ctx.route` for an
|
|
197
|
+
early-failing request (e.g. a 404 with no match) must handle `undefined`.
|
|
198
|
+
- **`traceId` is not a second correlation id to store separately by default.**
|
|
199
|
+
It equals `requestId` unless the caller sent a valid `traceparent` — don't
|
|
200
|
+
assume it's always a 32-hex OTel-shaped value.
|
|
201
|
+
- **Toggling `enabled` needs a restart.** It's resolved once per process, the
|
|
202
|
+
same trade-off as `http.maintenance.enabled`.
|
|
203
|
+
|
|
204
|
+
## See also
|
|
205
|
+
|
|
206
|
+
- [`use-middleware/SKILL.md`](../use-middleware/SKILL.md#request-id-correlation) — `X-Request-Id` inheritance/echo, the header tracing correlates through.
|
|
207
|
+
- [`use-app-context/SKILL.md`](../use-app-context/SKILL.md) — request-scoped ALS context (`request`/`response`) tracing hooks run inside.
|
|
208
|
+
- [`@warlock.js/logger/logger-basics/SKILL.md`](../../../logger/skills/logger-basics/SKILL.md) — structured logging; tracing hooks are the place to bridge phase timing into your log channel.
|
|
@@ -206,6 +206,22 @@ These are the flags whose absence never fails a test and is fatal in production:
|
|
|
206
206
|
|
|
207
207
|
These mutate the response in place; chain or call before the final `return response.<helper>()`.
|
|
208
208
|
|
|
209
|
+
### `Content-Security-Policy` (5.12.0)
|
|
210
|
+
|
|
211
|
+
Opt in via `http.csp.enabled` in `src/config/http.ts` (see
|
|
212
|
+
[`configure-app/SKILL.md`](../configure-app/SKILL.md) for the full shape).
|
|
213
|
+
When enabled, every response through the shared request funnel
|
|
214
|
+
(`http/middleware/inject-request-context.ts`, the same seam that stamps
|
|
215
|
+
`X-Request-Id`) gets a `Content-Security-Policy` header — `-Report-Only`
|
|
216
|
+
instead when `reportOnly: true` — built from the framework's default policy
|
|
217
|
+
merged with your `directives`, with the current request's CSP nonce always
|
|
218
|
+
appended to `script-src`. It is a no-op, and adds no header, while `http.csp`
|
|
219
|
+
is unset — every existing app is unaffected until it opts in.
|
|
220
|
+
|
|
221
|
+
A route can still override it — call `response.header("Content-Security-Policy", ...)`
|
|
222
|
+
from a controller or middleware that runs after the funnel, and your value
|
|
223
|
+
wins, same as any other header.
|
|
224
|
+
|
|
209
225
|
## Common patterns
|
|
210
226
|
|
|
211
227
|
### Localized error
|
|
@@ -355,7 +355,7 @@ export const uploadAvatarController: GuardedRequestHandler = async ({ request, r
|
|
|
355
355
|
return response.badRequest({ error: "missing file" });
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
-
const file = await storage.put(upload, `avatars/${request.user.id}/${upload.name}`);
|
|
358
|
+
const file = await storage.put(upload, `avatars/${request.locals.user.id}/${upload.name}`);
|
|
359
359
|
|
|
360
360
|
return response.successCreate({ url: file.url, hash: file.hash });
|
|
361
361
|
};
|
|
@@ -37,7 +37,7 @@ export const uploadAvatarController: GuardedRequestHandler<UploadAvatarSchema> =
|
|
|
37
37
|
.resize(400, 400)
|
|
38
38
|
.format("webp")
|
|
39
39
|
.quality(85)
|
|
40
|
-
.save(`avatars/${request.user.id}`);
|
|
40
|
+
.save(`avatars/${request.locals.user.id}`);
|
|
41
41
|
|
|
42
42
|
return response.successCreate({ path: file.path, url: file.url });
|
|
43
43
|
};
|
|
@@ -210,6 +210,18 @@ v.file() // must be UploadedFile
|
|
|
210
210
|
|
|
211
211
|
Size accepts either bytes (`.maxSize(5_242_880)`) or `{ unit, size }` (`{ unit: "MB", size: 5 }`). See [`validate-input`](../validate-input/SKILL.md) for the full validation pattern.
|
|
212
212
|
|
|
213
|
+
### Optional file field
|
|
214
|
+
|
|
215
|
+
Chain `.optional()` in front of the file rules for a field the caller may or may not send — no hand-rolled "was a file attached" check needed:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
const updateAvatarSchema = v.object({
|
|
219
|
+
avatar: v.file().optional().image(),
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
An absent (or `null`) `avatar` key passes validation with `avatar` coming back `undefined`. A present `avatar` value that isn't a file fails with a normal, structured `avatar` error — the framework never throws for it.
|
|
224
|
+
|
|
213
225
|
For ad-hoc validation outside a schema:
|
|
214
226
|
|
|
215
227
|
```ts
|
|
@@ -243,7 +255,7 @@ export const createUploadController: RequestHandler = async ({ request, response
|
|
|
243
255
|
|
|
244
256
|
const saved = await Promise.all(
|
|
245
257
|
files.map((file) =>
|
|
246
|
-
file.save(`uploads/${request.user.organizationId}`, {
|
|
258
|
+
file.save(`uploads/${request.locals.user.organizationId}`, {
|
|
247
259
|
prefix: { as: "directory", format: "DD-MM-YYYY" },
|
|
248
260
|
}),
|
|
249
261
|
),
|
|
@@ -181,7 +181,7 @@ app.router // the Router singleton
|
|
|
181
181
|
app.database // Cascade's DataSource
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
Each property is a getter backed by the framework's DI container (`container.
|
|
184
|
+
Each property is a getter backed by the framework's DI container (`container.tryGet("http.server")` etc. — `router` uses `container.get(...)`, which throws instead, since it is never legitimately absent). The container is populated by connectors during their `boot()`/`start()` phase — read these accessors only after the relevant connector has run. `http`, `socket`, and `database` are thin `container.tryGet(...)` reads, so before the connector boots they return `undefined` (they do **not** throw); chaining off an `undefined` accessor is what blows up. `http` and `socket` are *late*-phase connectors — they boot **after** app code is imported, so these accessors are not populated at the top level of a module's `main.ts`. From inside controllers, services, use-cases, or any code that runs while a request is in flight, every accessor is safe.
|
|
185
185
|
|
|
186
186
|
Typical uses (note: from runtime code, after bootstrap — not at module-import time):
|
|
187
187
|
|
|
@@ -288,7 +288,7 @@ cors: {
|
|
|
288
288
|
|
|
289
289
|
- **`version` is `null` until the first `await`.** The version loader is async (it reads `package.json`). On a cold start before any framework code has run `getWarlockVersion()`, `Application.version` returns `null`. The framework does load it during bootstrap, so anywhere downstream of bootstrap is fine — controllers, services, connectors after `start()`. CLI commands without `preload.bootstrap` may see `null`.
|
|
290
290
|
- **`Application` is static, not a DI registration.** Don't try to inject it. There's nothing to inject — it's a class with only static members.
|
|
291
|
-
- **`app.*` accessors return `undefined` before their connector boots — they don't throw.** `app.socket` / `app.database` / `app.http` are populated by their respective connectors during boot; until then each getter returns `undefined` (a bare `container.
|
|
291
|
+
- **`app.*` accessors return `undefined` before their connector boots — they don't throw.** `app.socket` / `app.database` / `app.http` are populated by their respective connectors during boot; until then each getter returns `undefined` (a bare `container.tryGet(...)`). Reading them earlier (eager module-load code, the top level of a `main.ts` for the late-phase `http`/`socket`, certain CLI commands without the right `preload.connectors`) hands you `undefined`, and chaining off it throws. Safe everywhere downstream of bootstrap — including inside `Application.onceBooted(...)`. `app.router` is the exception: it reads via `container.get(...)`, which throws a named `ContainerKeyMissingError` instead of handing back `undefined`, because the router is never a legitimate absence.
|
|
292
292
|
- **`onceBooted` is a latch, not `events.on`.** A callback registered *after* boot completed still runs (next microtask) instead of silently missing the signal — so register it wherever it reads best, including module top-level in `main.ts`. A listener that throws is caught and logged; it can't break boot or the other listeners.
|
|
293
293
|
- **`onShutdown` runs before connectors close, not after.** That ordering is deliberate so cleanup can still use db/cache/http — but it means a hook that hangs delays connector teardown (bounded only by your process manager's kill timeout). Keep teardown fast; for HTTP draining the framework already bounds it via `http.gracefulShutdown.timeout`.
|
|
294
294
|
|
|
@@ -54,7 +54,7 @@ router.post("/ai/summarize", summarizeController, {
|
|
|
54
54
|
|
|
55
55
|
## `idempotency` — must run after auth
|
|
56
56
|
|
|
57
|
-
The cache key is `idem:{userType}:{userId|ip}:{key}` so user A can't replay user B's key. That requires `request.user` to be populated, so order it **after** `authMiddleware`:
|
|
57
|
+
The cache key is `idem:{userType}:{userId|ip}:{key}` so user A can't replay user B's key. That requires `request.locals.user` to be populated, so order it **after** `authMiddleware`:
|
|
58
58
|
|
|
59
59
|
```ts
|
|
60
60
|
import { authMiddleware } from "@warlock.js/auth";
|
|
@@ -230,7 +230,7 @@ export default {
|
|
|
230
230
|
## Gotchas
|
|
231
231
|
|
|
232
232
|
- **Bare factory names are not exported.** Always reach for them via `middleware` (`middleware.rateLimit`, not `rateLimitMiddleware`). The internal `*Middleware`-suffixed names are an in-package code-organization detail.
|
|
233
|
-
- **Idempotency must run after auth.** The cache key includes `request.user` for scope-isolation. Putting it before auth silently falls back to IP-scope for every request.
|
|
233
|
+
- **Idempotency must run after auth.** The cache key includes `request.locals.user` for scope-isolation. Putting it before auth silently falls back to IP-scope for every request.
|
|
234
234
|
- **In-process counters lose state on restart.** `middleware.rateLimit` and `middleware.concurrencyLimit` use module-scoped `Map`s. A redeploy resets every window/counter. For globally-shared limits, use `@fastify/rate-limit` with a Redis store.
|
|
235
235
|
- **Idempotency clients must reuse the key across retries.** If your client generates a new UUID on every attempt, idempotency is a no-op. Generate once at "intent" time.
|
|
236
236
|
- **`ipFilter` fail-closed.** Empty / unparseable IP = denied. Internal callers (Unix sockets, local processes) need explicit allowlisting.
|
|
@@ -282,7 +282,7 @@ import { listFaqsService } from "../services/list-faqs.service";
|
|
|
282
282
|
export const listFaqsController: GuardedRequestHandler = async ({ request, response }) => {
|
|
283
283
|
const { data, pagination } = await listFaqsService({
|
|
284
284
|
...request.all(),
|
|
285
|
-
organization_id: request.user.organizationId,
|
|
285
|
+
organization_id: request.locals.user.organizationId,
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
return response.success({ data, pagination });
|
|
@@ -150,7 +150,7 @@ Writing `request.locals.organization` does not affect `request.all()`, `request.
|
|
|
150
150
|
- Use `request.locals` for per-request data written by middleware and read downstream.
|
|
151
151
|
- Use `requestMemo(key, fn)` for lazily computed, single-flight work scoped to the active request.
|
|
152
152
|
- Augment `Request` itself only for a genuine new typed member that also has a runtime implementation.
|
|
153
|
-
-
|
|
153
|
+
- The authenticated user itself already lives at `request.locals.user` — `@warlock.js/auth` augments `RequestLocals` with a `user?: RequestUser` key and its middleware writes it. Core's own `request.user` was removed in 5.12.0; do not re-add a separate `user` local outside that key.
|
|
154
154
|
|
|
155
155
|
## Gotchas
|
|
156
156
|
|
|
@@ -170,6 +170,19 @@ const uploadAvatarSchema = v.object({
|
|
|
170
170
|
|
|
171
171
|
Full file chain: `.image()`, `.accept(extensions)`, `.mimeType(types)`, `.pdf()`, `.excel()`, `.word()`, `.minSize(n)`, `.maxSize(n)`, `.minWidth(px)`, `.maxWidth(px)`, `.minHeight(px)`, `.maxHeight(px)`. See [`upload-file`](../upload-file/SKILL.md) for the full upload flow.
|
|
172
172
|
|
|
173
|
+
### Optional file field
|
|
174
|
+
|
|
175
|
+
`.optional()` composes with `v.file()` the same as any other validator — no need to hand-roll an "if a file was sent" guard in the controller:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const updateAvatarSchema = v.object({
|
|
179
|
+
avatar: v.file().optional().image(),
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- Key absent (or `null`) → valid, `avatar` comes back `undefined`.
|
|
184
|
+
- Key present but not a file (e.g. a stray string) → invalid, with a normal `avatar` error in the response — it never throws.
|
|
185
|
+
|
|
173
186
|
## What the framework sends on failure
|
|
174
187
|
|
|
175
188
|
The framework calls `response.failedSchema(result)` which sends `400` with the shape configured under `validation.response` (defaults shown):
|
|
@@ -71,7 +71,7 @@ const socketOptions: SocketOptions = {
|
|
|
71
71
|
Two ways to get the `socket.io` `Server` instance:
|
|
72
72
|
|
|
73
73
|
```ts
|
|
74
|
-
// 1. Via the app runtime accessor — reads container.
|
|
74
|
+
// 1. Via the app runtime accessor — reads container.tryGet("socket")
|
|
75
75
|
import { app } from "@warlock.js/core";
|
|
76
76
|
|
|
77
77
|
const io = app.socket; // → Server, or undefined before the socket connector boots
|
|
@@ -82,7 +82,7 @@ import { getSocketServer } from "@warlock.js/core";
|
|
|
82
82
|
const io = getSocketServer(); // → Server | null
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
Both read the same DI container slot. `app.socket` is a getter that returns `container.
|
|
85
|
+
Both read the same DI container slot. `app.socket` is a getter that returns `container.tryGet("socket")` — that's `undefined` (it does **not** throw) until the socket connector has booted. `getSocketServer()` reads the same `container.tryGet("socket")` and returns `null` if absent. Prefer `getSocketServer()` plus a null-guard at any call site that *might* run before the connector boots (module-load code, `main.ts` top level, scripts that skip bootstrap). From a controller, a service, a job — anything downstream of a completed bootstrap — `app.socket` is populated and safe to read directly.
|
|
86
86
|
|
|
87
87
|
## Wiring `connection` handlers
|
|
88
88
|
|
|
@@ -127,7 +127,7 @@ import type { GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
|
127
127
|
import { notifyUserService } from "../services/notify-user.service";
|
|
128
128
|
|
|
129
129
|
export const sendNotificationController: GuardedRequestHandler = async ({ request, response }) => {
|
|
130
|
-
await notifyUserService(request.user, request.input("payload"));
|
|
130
|
+
await notifyUserService(request.locals.user, request.input("payload"));
|
|
131
131
|
return response.success({ delivered: true });
|
|
132
132
|
};
|
|
133
133
|
```
|
|
@@ -58,7 +58,8 @@ If you short-circuit, the controller never runs. The response helper you pick (`
|
|
|
58
58
|
You can attach arbitrary fields to `request` from a middleware, and they survive into the controller. The cleanest pattern is to extend `Request` via module augmentation in a `.d.ts` and assign in the middleware:
|
|
59
59
|
|
|
60
60
|
```ts title="src/app/feature-flags/middleware/load-feature-flag.middleware.ts"
|
|
61
|
-
import type { Middleware, Request
|
|
61
|
+
import type { Middleware, Request } from "@warlock.js/core";
|
|
62
|
+
import type { RequestUser } from "@warlock.js/auth";
|
|
62
63
|
import { FeatureFlag } from "../models/feature-flag";
|
|
63
64
|
|
|
64
65
|
declare module "@warlock.js/core" {
|
|
@@ -67,12 +68,17 @@ declare module "@warlock.js/core" {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
export const loadFeatureFlag: Middleware<Request & { user: RequestUser }> = async ({
|
|
71
|
-
request
|
|
71
|
+
export const loadFeatureFlag: Middleware<Request & { locals: { user: RequestUser } }> = async ({
|
|
72
|
+
request,
|
|
73
|
+
}) => {
|
|
74
|
+
request.featureFlag = await FeatureFlag.findBy(
|
|
75
|
+
"organization_id",
|
|
76
|
+
request.locals.user.organizationId,
|
|
77
|
+
);
|
|
72
78
|
};
|
|
73
79
|
```
|
|
74
80
|
|
|
75
|
-
After this middleware runs, `request.featureFlag` is typed inside any downstream middleware or controller. The same pattern is how `@warlock.js/auth`'s `authMiddleware` attaches `request.user` and `request.decodedAccessToken`.
|
|
81
|
+
After this middleware runs, `request.featureFlag` is typed inside any downstream middleware or controller. The same pattern is how `@warlock.js/auth`'s `authMiddleware` attaches `request.locals.user` and `request.decodedAccessToken`.
|
|
76
82
|
|
|
77
83
|
## Registration — three scopes
|
|
78
84
|
|
|
@@ -213,6 +219,6 @@ export const optionalAuth: Middleware = async ({ request, response }) => {
|
|
|
213
219
|
|
|
214
220
|
- [`use-middleware/SKILL.md`](../use-middleware/SKILL.md) — the built-in middleware catalog (`rateLimit`, `idempotency`, `maxBodySize`, etc.) + request-id correlation.
|
|
215
221
|
- [`register-route/SKILL.md`](../register-route/SKILL.md) — where middleware attaches: `router.group` and route-options.
|
|
216
|
-
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — how the controller picks up `request.user`, `request.validated()`, etc., set by upstream middleware.
|
|
222
|
+
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — how the controller picks up `request.locals.user`, `request.validated()`, etc., set by upstream middleware.
|
|
217
223
|
- [`send-response/SKILL.md`](../send-response/SKILL.md) — the response helpers used to short-circuit.
|
|
218
224
|
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — the `guarded()` / `guardedAdmin()` / `publicRoutes()` convention.
|