@warlock.js/core 5.23.2 → 5.25.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.
Files changed (207) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/bin/warlock.js +0 -0
  3. package/esm/application/application.d.mts +21 -0
  4. package/esm/application/application.d.mts.map +1 -1
  5. package/esm/application/application.mjs +29 -1
  6. package/esm/application/application.mjs.map +1 -1
  7. package/esm/application/boot-signal.d.mts +2 -1
  8. package/esm/application/boot-signal.d.mts.map +1 -1
  9. package/esm/application/boot-signal.mjs.map +1 -1
  10. package/esm/application/index.d.mts +1 -0
  11. package/esm/application/index.mjs +1 -0
  12. package/esm/application/roles.d.mts +72 -0
  13. package/esm/application/roles.d.mts.map +1 -0
  14. package/esm/application/roles.mjs +124 -0
  15. package/esm/application/roles.mjs.map +1 -0
  16. package/esm/cli/commands/add.command.mjs.map +1 -1
  17. package/esm/cli/commands/create-database.command.mjs.map +1 -1
  18. package/esm/cli/commands/dev-server.command.mjs.map +1 -1
  19. package/esm/cli/commands/doctor/boot-for-diagnostics.mjs.map +1 -1
  20. package/esm/cli/commands/drop-tables.command.mjs.map +1 -1
  21. package/esm/cli/commands/generate/utils/path-resolver.mjs.map +1 -1
  22. package/esm/cli/commands/migrate.command.mjs.map +1 -1
  23. package/esm/cli/commands/routes.command.mjs.map +1 -1
  24. package/esm/cli/commands/seed.command.mjs.map +1 -1
  25. package/esm/cli/commands/start-production.command.mjs +54 -5
  26. package/esm/cli/commands/start-production.command.mjs.map +1 -1
  27. package/esm/cli/commands/storage-put.action.mjs.map +1 -1
  28. package/esm/cli/commands/storage-put.command.mjs.map +1 -1
  29. package/esm/cli/commands/typings-generator.command.mjs +17 -1
  30. package/esm/cli/commands/typings-generator.command.mjs.map +1 -1
  31. package/esm/cli/commands/update.command.mjs.map +1 -1
  32. package/esm/cli/commands-loader.mjs.map +1 -1
  33. package/esm/config/config-loader.d.mts.map +1 -1
  34. package/esm/config/config-loader.mjs +3 -1
  35. package/esm/config/config-loader.mjs.map +1 -1
  36. package/esm/connectors/connectors-manager.mjs.map +1 -1
  37. package/esm/connectors/database-connector.mjs.map +1 -1
  38. package/esm/connectors/herald-connector.mjs.map +1 -1
  39. package/esm/connectors/http-connector.d.mts.map +1 -1
  40. package/esm/connectors/http-connector.mjs +7 -0
  41. package/esm/connectors/http-connector.mjs.map +1 -1
  42. package/esm/connectors/logger-connector.mjs.map +1 -1
  43. package/esm/connectors/mail-connector.mjs.map +1 -1
  44. package/esm/connectors/types.d.mts +8 -0
  45. package/esm/connectors/types.d.mts.map +1 -1
  46. package/esm/connectors/types.mjs.map +1 -1
  47. package/esm/database/create-database-action.mjs.map +1 -1
  48. package/esm/database/drop-tables-action.mjs.map +1 -1
  49. package/esm/database/migrate-action.mjs +2 -1
  50. package/esm/database/migrate-action.mjs.map +1 -1
  51. package/esm/database/models/database-log/database-log.mjs.map +1 -1
  52. package/esm/database/seed-command-action.mjs +2 -1
  53. package/esm/database/seed-command-action.mjs.map +1 -1
  54. package/esm/dev-server/dependency-graph.mjs.map +1 -1
  55. package/esm/dev-server/development-server.mjs +2 -0
  56. package/esm/dev-server/development-server.mjs.map +1 -1
  57. package/esm/dev-server/files-orchestrator.mjs.map +1 -1
  58. package/esm/dev-server/layer-executor.mjs.map +1 -1
  59. package/esm/dev-server/manifest-manager.mjs.map +1 -1
  60. package/esm/dev-server/module-loader.mjs +10 -5
  61. package/esm/dev-server/module-loader.mjs.map +1 -1
  62. package/esm/dev-server/register-installed-devtools.mjs +47 -0
  63. package/esm/dev-server/register-installed-devtools.mjs.map +1 -0
  64. package/esm/dev-server/run-connector-typings.mjs +28 -0
  65. package/esm/dev-server/run-connector-typings.mjs.map +1 -0
  66. package/esm/dev-server/special-file-patterns.mjs +3 -1
  67. package/esm/dev-server/special-file-patterns.mjs.map +1 -1
  68. package/esm/dev-server/special-files-collector.d.mts +2 -2
  69. package/esm/dev-server/special-files-collector.d.mts.map +1 -1
  70. package/esm/dev-server/special-files-collector.mjs +7 -4
  71. package/esm/dev-server/special-files-collector.mjs.map +1 -1
  72. package/esm/encryption/password.mjs.map +1 -1
  73. package/esm/generations/add-command.action.mjs +3 -1
  74. package/esm/generations/add-command.action.mjs.map +1 -1
  75. package/esm/generations/features/access.feature.mjs.map +1 -1
  76. package/esm/generations/features/ai-anthropic.feature.mjs.map +1 -1
  77. package/esm/generations/features/ai-bedrock.feature.mjs.map +1 -1
  78. package/esm/generations/features/ai-google.feature.mjs.map +1 -1
  79. package/esm/generations/features/ai-ollama.feature.mjs.map +1 -1
  80. package/esm/generations/features/ai-openai.feature.mjs.map +1 -1
  81. package/esm/generations/features/ai-panoptic.feature.mjs.map +1 -1
  82. package/esm/generations/features/ai-tools.feature.mjs.map +1 -1
  83. package/esm/generations/features/ai-workspace.feature.mjs.map +1 -1
  84. package/esm/generations/features/ai.feature.mjs.map +1 -1
  85. package/esm/generations/features/devtools.feature.mjs +23 -0
  86. package/esm/generations/features/devtools.feature.mjs.map +1 -0
  87. package/esm/generations/features/herald.feature.mjs.map +1 -1
  88. package/esm/generations/features/image.feature.mjs.map +1 -1
  89. package/esm/generations/features/index.mjs +2 -0
  90. package/esm/generations/features/index.mjs.map +1 -1
  91. package/esm/generations/features/mail.feature.mjs.map +1 -1
  92. package/esm/generations/features/mongodb.feature.mjs.map +1 -1
  93. package/esm/generations/features/notifications.feature.mjs.map +1 -1
  94. package/esm/generations/features/postgres.feature.mjs.map +1 -1
  95. package/esm/generations/features/react-email.feature.mjs.map +1 -1
  96. package/esm/generations/features/react.feature.mjs.map +1 -1
  97. package/esm/generations/features/redis.feature.mjs.map +1 -1
  98. package/esm/generations/features/s3.feature.mjs.map +1 -1
  99. package/esm/generations/features/scheduler.feature.mjs.map +1 -1
  100. package/esm/generations/features/ses.feature.mjs.map +1 -1
  101. package/esm/generations/features/socket.feature.mjs.map +1 -1
  102. package/esm/generations/features/test.feature.mjs +6 -2
  103. package/esm/generations/features/test.feature.mjs.map +1 -1
  104. package/esm/generations/features/types.mjs.map +1 -1
  105. package/esm/generations/features/web.feature.mjs.map +1 -1
  106. package/esm/http/database/RequestLog.mjs.map +1 -1
  107. package/esm/http/index.d.mts +2 -1
  108. package/esm/http/index.mjs +2 -1
  109. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  110. package/esm/http/middleware/inject-request-context.mjs +9 -1
  111. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  112. package/esm/http/request.d.mts +7 -0
  113. package/esm/http/request.d.mts.map +1 -1
  114. package/esm/http/request.mjs +8 -0
  115. package/esm/http/request.mjs.map +1 -1
  116. package/esm/http/response.d.mts +5 -5
  117. package/esm/http/response.d.mts.map +1 -1
  118. package/esm/http/response.mjs.map +1 -1
  119. package/esm/http/server-rebuilt.d.mts +20 -0
  120. package/esm/http/server-rebuilt.d.mts.map +1 -0
  121. package/esm/http/server-rebuilt.mjs +21 -0
  122. package/esm/http/server-rebuilt.mjs.map +1 -0
  123. package/esm/http/tracing/index.mjs +1 -1
  124. package/esm/http/tracing/tracing-dispatcher.d.mts +13 -6
  125. package/esm/http/tracing/tracing-dispatcher.d.mts.map +1 -1
  126. package/esm/http/tracing/tracing-dispatcher.mjs +46 -14
  127. package/esm/http/tracing/tracing-dispatcher.mjs.map +1 -1
  128. package/esm/http/tracing/tracing.type.d.mts +2 -1
  129. package/esm/http/tracing/tracing.type.d.mts.map +1 -1
  130. package/esm/http/types.d.mts +9 -0
  131. package/esm/http/types.d.mts.map +1 -1
  132. package/esm/image/image.mjs.map +1 -1
  133. package/esm/index.d.mts +4 -2
  134. package/esm/index.mjs +4 -2
  135. package/esm/loader/app-module-importer.d.mts +17 -0
  136. package/esm/loader/app-module-importer.d.mts.map +1 -0
  137. package/esm/loader/app-module-importer.mjs +20 -0
  138. package/esm/loader/app-module-importer.mjs.map +1 -0
  139. package/esm/logger/logger.mjs.map +1 -1
  140. package/esm/mail/events.d.mts +7 -0
  141. package/esm/mail/events.d.mts.map +1 -1
  142. package/esm/mail/events.mjs +7 -0
  143. package/esm/mail/events.mjs.map +1 -1
  144. package/esm/mail/send-mail.d.mts.map +1 -1
  145. package/esm/mail/send-mail.mjs +23 -2
  146. package/esm/mail/send-mail.mjs.map +1 -1
  147. package/esm/mail/types.d.mts +5 -1
  148. package/esm/mail/types.d.mts.map +1 -1
  149. package/esm/mail/types.mjs.map +1 -1
  150. package/esm/manifest/manifest-manager.mjs.map +1 -1
  151. package/esm/production/build-contributions.mjs +19 -3
  152. package/esm/production/build-contributions.mjs.map +1 -1
  153. package/esm/production/production-builder.mjs +28 -8
  154. package/esm/production/production-builder.mjs.map +1 -1
  155. package/esm/production/production-supervisor.mjs +6 -0
  156. package/esm/production/production-supervisor.mjs.map +1 -1
  157. package/esm/production/resolve-build-config.d.mts +1 -1
  158. package/esm/production/resolve-build-config.mjs +15 -0
  159. package/esm/production/resolve-build-config.mjs.map +1 -1
  160. package/esm/production/route-registration-child.mjs +14 -2
  161. package/esm/production/route-registration-child.mjs.map +1 -1
  162. package/esm/production/route-registration-snapshot.mjs +9 -2
  163. package/esm/production/route-registration-snapshot.mjs.map +1 -1
  164. package/esm/resource/resource-field-builder.mjs +1 -1
  165. package/esm/resource/resource-field-builder.mjs.map +1 -1
  166. package/esm/resource/resource.d.mts +5 -0
  167. package/esm/resource/resource.d.mts.map +1 -1
  168. package/esm/resource/resource.mjs +17 -6
  169. package/esm/resource/resource.mjs.map +1 -1
  170. package/esm/router/router.d.mts.map +1 -1
  171. package/esm/router/router.mjs +1 -0
  172. package/esm/router/router.mjs.map +1 -1
  173. package/esm/router/types.d.mts +2 -1
  174. package/esm/router/types.d.mts.map +1 -1
  175. package/esm/tests/index.d.mts +3 -2
  176. package/esm/tests/index.mjs +3 -2
  177. package/esm/tests/vitest-setup.d.mts +16 -1
  178. package/esm/tests/vitest-setup.d.mts.map +1 -1
  179. package/esm/tests/vitest-setup.mjs +20 -2
  180. package/esm/tests/vitest-setup.mjs.map +1 -1
  181. package/esm/use-cases/use-case-events.mjs.map +1 -1
  182. package/esm/utils/is-test-file.mjs +15 -0
  183. package/esm/utils/is-test-file.mjs.map +1 -0
  184. package/esm/utils/normalized-path.d.mts +3 -1
  185. package/esm/utils/normalized-path.d.mts.map +1 -1
  186. package/esm/utils/normalized-path.mjs +4 -2
  187. package/esm/utils/normalized-path.mjs.map +1 -1
  188. package/esm/utils/paths.mjs.map +1 -1
  189. package/esm/validation/plugins/database-plugin.mjs.map +1 -1
  190. package/esm/validation/validateAll.mjs +9 -3
  191. package/esm/validation/validateAll.mjs.map +1 -1
  192. package/esm/warlock-config/default-configurations.mjs +1 -0
  193. package/esm/warlock-config/default-configurations.mjs.map +1 -1
  194. package/esm/warlock-config/types.d.mts +7 -0
  195. package/esm/warlock-config/types.d.mts.map +1 -1
  196. package/esm/warlock-config/warlock-config.manager.mjs.map +1 -1
  197. package/llms-full.txt +264 -139
  198. package/package.json +15 -11
  199. package/skills/configure-app/SKILL.md +2 -0
  200. package/skills/define-resource/SKILL.md +4 -0
  201. package/skills/request-tracing/SKILL.md +38 -15
  202. package/skills/run-app/SKILL.md +71 -34
  203. package/skills/send-mail/SKILL.md +55 -25
  204. package/skills/test-http/SKILL.md +19 -8
  205. package/skills/test-service/SKILL.md +61 -40
  206. package/skills/warlock-conventions/SKILL.md +26 -18
  207. package/skills/write-cli-command/SKILL.md +50 -45
package/package.json CHANGED
@@ -25,12 +25,12 @@
25
25
  "@mongez/slug": "^1.0.7",
26
26
  "@mongez/supportive-is": "^2.1.5",
27
27
  "@mongez/time-wizard": "^1.0.6",
28
- "@warlock.js/cache": "5.23.2",
29
- "@warlock.js/cascade": "5.23.2",
30
- "@warlock.js/context": "5.23.2",
31
- "@warlock.js/fs": "5.23.2",
32
- "@warlock.js/logger": "5.23.2",
33
- "@warlock.js/seal": "5.23.2",
28
+ "@warlock.js/cache": "5.25.0",
29
+ "@warlock.js/cascade": "5.25.0",
30
+ "@warlock.js/context": "5.25.0",
31
+ "@warlock.js/fs": "5.25.0",
32
+ "@warlock.js/logger": "5.25.0",
33
+ "@warlock.js/seal": "5.25.0",
34
34
  "bcryptjs": "^3.0.3",
35
35
  "chokidar": "^5.0.0",
36
36
  "dayjs": "^1.11.19",
@@ -52,10 +52,11 @@
52
52
  "@aws-sdk/lib-storage": "^3.955.0",
53
53
  "@aws-sdk/s3-request-presigner": "^3.955.0",
54
54
  "@react-email/render": "^2.0.5",
55
- "@warlock.js/access": "5.23.2",
56
- "@warlock.js/ai": "5.23.2",
57
- "@warlock.js/herald": "5.23.2",
58
- "@warlock.js/notifications": "5.23.2",
55
+ "@warlock.js/access": "5.25.0",
56
+ "@warlock.js/ai": "5.25.0",
57
+ "@warlock.js/herald": "5.25.0",
58
+ "@warlock.js/notifications": "5.25.0",
59
+ "@warlock.js/devtools": "5.25.0",
59
60
  "nodemailer": "^8.0.5",
60
61
  "react": "^19.2.3",
61
62
  "react-dom": "^19.2.3",
@@ -104,6 +105,9 @@
104
105
  },
105
106
  "@warlock.js/notifications": {
106
107
  "optional": true
108
+ },
109
+ "@warlock.js/devtools": {
110
+ "optional": true
107
111
  }
108
112
  },
109
113
  "repository": {
@@ -123,7 +127,7 @@
123
127
  ],
124
128
  "author": "hassanzohdy",
125
129
  "license": "MIT",
126
- "version": "5.23.2",
130
+ "version": "5.25.0",
127
131
  "type": "module",
128
132
  "main": "./esm/index.mjs",
129
133
  "module": "./esm/index.mjs",
@@ -229,6 +229,8 @@ Same shape — TS validates the union; runtime picks the `default` key.
229
229
  - `socket.adapter` — adapter factory for cross-server broadcast.
230
230
  - `cache.silenceSingleServerWarning`, `storage.silenceSingleServerWarning`, `socket.silenceSingleServerWarning` — silence the production warnings for in-memory cache, local storage, and adapter-less sockets.
231
231
 
232
+ `http.modelValidationErrorStatus` sets the status for a Cascade model-write validation failure (a model failing its own schema on save). It defaults to `500` with an opaque body, because request input is already validated with a `422` before the model is touched. Set a `4xx` (e.g. `422`) to treat it as a client error instead; the response then carries `{ errors }`.
233
+
232
234
  ## Per-env knobs
233
235
 
234
236
  For one-off switches gated on environment, the canonical pattern is `Application.isProduction` (also `isDevelopment`, `isTest`):
@@ -221,6 +221,10 @@ export class Product extends Model {
221
221
 
222
222
  This is convention, not a framework requirement. Controllers/services that import the resource explicitly work the same way.
223
223
 
224
+ ### `id` and Mongo `_id`
225
+
226
+ The output `id` falls back to `_id` when the record has no `id`; an explicit `id` always wins. If a collection uses native ObjectIds, set `public primaryKey = "_id"` on the model so `Model.find(hex)` resolves the `id` your resource emits.
227
+
224
228
  ## Output-only rule
225
229
 
226
230
  Resources map fields. They do not:
@@ -55,6 +55,7 @@ type TracingContext = {
55
55
  type TracingPhaseInfo = {
56
56
  name: string;
57
57
  durationMs: number;
58
+ startedAt?: number; // epoch milliseconds; supplied when the phase started
58
59
  attrs?: Record<string, unknown>;
59
60
  };
60
61
 
@@ -73,29 +74,52 @@ type TracingHooks = {
73
74
 
74
75
  Every verb is optional — a hook that only wants phase spans need not implement
75
76
  `onRequestStart`/`onRequestEnd`. Register as many hooks as you like via
76
- `hooks: TracingHooks[]`; each fires independently.
77
+ `hooks: TracingHooks[]`; each fires independently. Use `registerTracingHooks()`
78
+ when the observer is installed by application code rather than static config:
79
+
80
+ ```ts
81
+ import { registerTracingHooks, type TracingHooks } from "@warlock.js/core";
82
+
83
+ const hooks: TracingHooks = {
84
+ onPhase(ctx, phase) {
85
+ console.log(ctx.traceId, phase.name, phase.startedAt);
86
+ },
87
+ };
88
+
89
+ const unregister = registerTracingHooks(hooks);
90
+ // Later, for example during plugin teardown:
91
+ unregister();
92
+ ```
93
+
94
+ `registerTracingHooks(hooks): () => void` enables tracing immediately. Runtime
95
+ hooks run after configured hooks; calling the returned unregister function more
96
+ than once is safe. When a phase did not provide `startedAt`, the dispatcher
97
+ derives it from its duration before delivering the callback.
77
98
 
78
99
  ## Phase names
79
100
 
80
101
  `core` wires five phases into the request lifecycle, in this order, for every
81
102
  HTTP request:
82
103
 
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 |
104
+ | Phase | Fires around |
105
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
106
+ | `route.match` | Resolving the incoming path/method to a registered route |
107
+ | `middleware` | Each middleware in the route's chain — one `onPhase` call per middleware, with `attrs: { name, index }` |
108
+ | `validation` | The route's input validation (`v.object(...)` / RESTful resource validation) |
109
+ | `handler` | The route handler itself |
89
110
  | `response.write` | The overall request span, closed once the response has settled (success or thrown error) — this is also where `onRequestEnd` fires |
90
111
 
91
112
  `@warlock.js/web` page requests report through this same `onPhase` surface
92
113
  instead of adding a separate hook API. They add three phases:
93
114
 
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 |
115
+ | Phase | Fires around |
116
+ | ----------------- | --------------------------------------------------------------------------------------------------- |
117
+ | `loader` | Each loader level, once per app/layout/page, with `attrs: { level, layoutPath? }` |
118
+ | `render.shell` | Time from render start until React's shell is ready to stream |
119
+ | `stream.end` | The whole streamed response, including every `defer()` value settling |
120
+ | `page.middleware` | Page middleware chain; `attrs: { count, outcome }`, where outcome is `next`, `response`, or `error` |
121
+ | `page.cache` | Page-cache lookup; `attrs: { status }`, where status is `hit`, `miss`, or `bypass` |
122
+ | `defer.settle` | A deferred key settling; `attrs: { key, status }`, where status is `fulfilled` or `rejected` |
99
123
 
100
124
  ## Trace id derivation
101
125
 
@@ -189,9 +213,8 @@ export default {
189
213
  - **No OTel dependency, and none planned for `core`.** An OTel (or other
190
214
  vendor) bridge is a separate, optional package that subscribes to these
191
215
  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.
216
+ - **Web phases share the same hook.** Do not create a second page-tracing
217
+ integration: subscribe with `TracingHooks.onPhase`.
195
218
  - **`route` is `undefined` until routing has matched.** There's no path where
196
219
  a hook fires before that, but code branching on `ctx.route` for an
197
220
  early-failing request (e.g. a 404 with no match) must handle `undefined`.
@@ -28,11 +28,11 @@ Boots the framework in dev mode: file watcher, HMR-style module reload, on-disk
28
28
 
29
29
  ### Flags
30
30
 
31
- | Flag | Default | Purpose |
32
- | --------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
31
+ | Flag | Default | Purpose |
32
+ | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
33
33
  | `--fresh, -f` | off | Delete `.warlock/manifest.json` before start — forces a full re-parse from disk. Use after odd boot states. |
34
- | `--skip-typings, -st` | off | Skip background type generation **for this run**. Overrides `devServer.generateTypings` config. |
35
- | `--skip-health, -sh` | off | Skip file health checkers **for this run**. Overrides `devServer.healthCheckers` config. |
34
+ | `--skip-typings, -st` | off | Skip background type generation **for this run**. Overrides `devServer.generateTypings` config. |
35
+ | `--skip-health, -sh` | off | Skip file health checkers **for this run**. Overrides `devServer.healthCheckers` config. |
36
36
 
37
37
  When a flag is **not** passed, the corresponding `warlock.config.ts > devServer.*` value applies. When passed, the flag wins.
38
38
 
@@ -40,14 +40,14 @@ When a flag is **not** passed, the corresponding `warlock.config.ts > devServer.
40
40
 
41
41
  Once the server is ready, `warlock dev` listens for single keypresses:
42
42
 
43
- | Key | Does |
44
- | -------- | ---------------------------------------------------------------------------- |
45
- | `r` | Restart the server on a fresh process. |
46
- | `c` | Clear the console. |
47
- | `q` | Graceful shutdown, exit `0` — the same path as `Ctrl+C`. |
48
- | `h` | Print the shortcuts that are armed right now. |
43
+ | Key | Does |
44
+ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
+ | `r` | Restart the server on a fresh process. |
46
+ | `c` | Clear the console. |
47
+ | `q` | Graceful shutdown, exit `0` — the same path as `Ctrl+C`. |
48
+ | `h` | Print the shortcuts that are armed right now. |
49
49
  | `u` | **Only while an update notice is showing** — update every `@warlock.js/*` package, install, and restart. See [`update-packages/SKILL.md`](../update-packages/SKILL.md). |
50
- | `Ctrl+C` | Graceful shutdown, unchanged. |
50
+ | `Ctrl+C` | Graceful shutdown, unchanged. |
51
51
 
52
52
  A "restart" is a fresh process (`r` and `u` both use it) — see [The supervisor](#the-supervisor) below for how that works.
53
53
 
@@ -67,7 +67,7 @@ shell
67
67
 
68
68
  The supervisor spawns the worker with `stdio: "inherit"` and mirrors its exit code. To restart, the worker shuts down cleanly — freeing the http port — and exits `75`; the supervisor spawns a replacement. **The tree never gets deeper**, however many restarts happen.
69
69
 
70
- The supervisor is chosen in the `dev` command's `preAction`, which runs *before* the preloaders — deliberately, so the supervisor never loads config or starts connectors. A supervisor holding a second database connection open for the session would be a real bug, not a cosmetic one.
70
+ The supervisor is chosen in the `dev` command's `preAction`, which runs _before_ the preloaders — deliberately, so the supervisor never loads config or starts connectors. A supervisor holding a second database connection open for the session would be a real bug, not a cosmetic one.
71
71
 
72
72
  Signals: `SIGINT` reaches the worker directly (same process group), so the supervisor ignores it and only notes that the next exit is final — acting on it would exit the parent while the worker was still draining. `SIGTERM` / `SIGHUP` are forwarded explicitly, since they don't propagate to the group on Windows. After any worker exits, the supervisor puts stdin back out of raw mode in case the worker was killed before it could.
73
73
 
@@ -81,13 +81,13 @@ A worker that dies **after running healthily for at least 5s** — OOM, a native
81
81
  14:31:02 Development server was killed by SIGSEGV — restarting.
82
82
  ```
83
83
 
84
- A worker that dies *sooner* than that failed to **boot** — a broken config, a port already taken — and it has already printed why. Restarting there would just reprint the same error and bury it, so the supervisor mirrors the exit code and stops.
84
+ A worker that dies _sooner_ than that failed to **boot** — a broken config, a port already taken — and it has already printed why. Restarting there would just reprint the same error and bury it, so the supervisor mirrors the exit code and stops.
85
85
 
86
- A busy port is now caught *before* the bind attempt: the HTTP connector calls `assertPortIsAvailable(port, host)` immediately before `listen()`, so the failure is `Port <port> is already in use on <host>. Stop the dev server (or whatever else is listening on port <port>) and run again, or start on a free port…` — not a raw `EADDRINUSE` surfacing from inside Fastify. Same connector, same preflight, for `warlock start`.
86
+ A busy port is now caught _before_ the bind attempt: the HTTP connector calls `assertPortIsAvailable(port, host)` immediately before `listen()`, so the failure is `Port <port> is already in use on <host>. Stop the dev server (or whatever else is listening on port <port>) and run again, or start on a free port…` — not a raw `EADDRINUSE` surfacing from inside Fastify. Same connector, same preflight, for `warlock start`.
87
87
 
88
88
  Flapping is capped: more than 3 crashes inside 60s and the supervisor gives up rather than restarting behind your back.
89
89
 
90
- An explicit restart (`r`, `u`, a config change) is a *request*, not a crash, so the uptime rule never swallows it.
90
+ An explicit restart (`r`, `u`, a config change) is a _request_, not a crash, so the uptime rule never swallows it.
91
91
 
92
92
  ### Restart on config change
93
93
 
@@ -103,7 +103,7 @@ Set `devServer.restartOnConfigChange: false` for the previous behaviour (a warni
103
103
 
104
104
  On first boot `warlock dev` bundles its ESM loader hook and writes it to **your project's** `.warlock/loader-hook.mjs` — the hook runs in a fresh Node worker thread with no TypeScript loader of its own, so it has to be plain, pre-bundled ESM.
105
105
 
106
- Because that file lives in your directory rather than core's, every npm import inside it is rewritten at generation time to an **absolute path resolved from core's own install**. A bare `import "esbuild"` there would resolve from *your* `node_modules`, and `esbuild` / `get-tsconfig` are core's dependencies, not yours.
106
+ Because that file lives in your directory rather than core's, every npm import inside it is rewritten at generation time to an **absolute path resolved from core's own install**. A bare `import "esbuild"` there would resolve from _your_ `node_modules`, and `esbuild` / `get-tsconfig` are core's dependencies, not yours.
107
107
 
108
108
  :::note[Fixed in 4.9.2 — pnpm users]
109
109
  Before 4.9.2 those imports were left bare. npm and yarn hoist every transitive dependency into one flat tree, so they resolved by accident; pnpm's strict layout does not, and the dev server failed with `ERR_MODULE_NOT_FOUND: Cannot find package 'esbuild'`. The workaround was declaring `esbuild` and `get-tsconfig` in your own `package.json` — no longer needed, and you can drop them.
@@ -165,12 +165,13 @@ esbuild bundle of the app down to a single JS file in `dist/`. No flags — ever
165
165
  ```ts title="warlock.config.ts"
166
166
  export default defineConfig({
167
167
  build: {
168
- outdir: "dist", // default — relative or absolute
169
- outFile: "app.js", // default — bundle filename
170
- minify: true, // default — esbuild minify
171
- sourcemap: true, // default — true | false | "inline" | "linked"
172
- singleBundle: false, // default — one runnable file, deps inlined
173
- esmShim: true, // default — require/__filename/__dirname for bundled CJS
168
+ outdir: "dist", // default — relative or absolute
169
+ outFile: "app.js", // default — bundle filename
170
+ minify: true, // default — esbuild minify
171
+ sourcemap: true, // default — true | false | "inline" | "linked"
172
+ singleBundle: false, // default — one runnable file, deps inlined
173
+ esmShim: true, // default — require/__filename/__dirname for bundled CJS
174
+ routeRegistrationTimeoutMs: 30_000, // default — route-registration child deadline
174
175
  },
175
176
  });
176
177
  ```
@@ -182,8 +183,9 @@ Defaults are sensible for the typical "Node service" deployment. Knobs to actual
182
183
  - **`minify: false`** — flip to debug a production-only bug. Larger bundle, readable stack traces.
183
184
  - **`sourcemap: "inline"`** — embed the source map in the bundle. Useful when your error reporter only captures the bundle and can't fetch a `.map` sidecar.
184
185
  - **`sourcemap: false`** — skip source maps entirely. Smaller artifact, but stack traces in production logs lose their file:line precision (and `warlock start` will not enable `--enable-source-maps` since there's nothing to map).
185
- - **`singleBundle: true`** — inline dependencies so `node dist/app.js` runs with no `node_modules` and no launcher. Sets `packages: "bundle"` + `splitting: false` as **defaults you can override**. ⚠ Native `.node` addons are still emitted alongside — "single bundle" is one JS file *plus* any native addons. Do NOT reach for it as the default: keeping deps external is right when you deploy the folder.
186
+ - **`singleBundle: true`** — inline dependencies so `node dist/app.js` runs with no `node_modules` and no launcher. Sets `packages: "bundle"` + `splitting: false` as **defaults you can override**. ⚠ Native `.node` addons are still emitted alongside — "single bundle" is one JS file _plus_ any native addons. Do NOT reach for it as the default: keeping deps external is right when you deploy the folder.
186
187
  - **`esmShim: false`** — only if you are certain nothing in the graph is CommonJS. Leave it on.
188
+ - **`routeRegistrationTimeoutMs`** — increase on busy CI runners when static route registration needs more than 30 seconds. `WARLOCK_ROUTE_REGISTRATION_TIMEOUT_MS` overrides this value for a build; invalid values warn and use 30,000ms.
187
189
 
188
190
  ⚠ **The trap this replaced.** Setting `packages: "bundle"` by hand used to produce a clean build whose process died on start with `Error: Dynamic require of "node:assert" is not supported`. The output is ESM; bundled CJS deps call `require(...)` and read `__dirname`, and neither exists in an ES module, so the bundler substitutes a throwing stub. **A green `warlock build` was not evidence the bundle ran.** `esmShim` now injects `createRequire(import.meta.url)` and friends automatically for any ESM build, so both `singleBundle` and a hand-written `packages: "bundle"` work. An existing hand-written `banner` is preserved — the shim is prepended, not substituted.
189
191
 
@@ -241,6 +243,41 @@ npx warlock start --inspect # → spawns node --enable-source-maps
241
243
  npx warlock start --max-old-space-size=4096 # → spawns node --enable-source-maps --max-old-space-size=4096 dist/app.js
242
244
  ```
243
245
 
246
+ ### Deploy one build in parts
247
+
248
+ One production build can run as API, page, and background-worker processes.
249
+ Omit `--role` to run every role (the existing default); combine roles with a
250
+ comma-separated list:
251
+
252
+ ```bash
253
+ npx warlock start --role=api
254
+ npx warlock start --role=web --sites=admin,storefront
255
+ npx warlock start --role=worker
256
+ npx warlock start --role=web,api
257
+ ```
258
+
259
+ `--sites=a,b` is valid only with the `web` role and installs the listed site
260
+ keys' pages (shared non-site pages remain installed). Unknown roles, an empty
261
+ list, `--sites` without `web`, and unknown site keys fail boot rather than
262
+ silently serving the wrong traffic.
263
+
264
+ An API or web role binds HTTP. A worker-only process binds no HTTP port; it
265
+ loads worker work, starts queue workers and the scheduler there, and its
266
+ readiness banner reports `worker ready`. `warlock dev` always runs every role.
267
+
268
+ Inside application code, inspect the roles resolved at bootstrap:
269
+
270
+ ```ts
271
+ import { Application } from "@warlock.js/core";
272
+
273
+ if (Application.hasRole("worker")) {
274
+ // worker-only integration
275
+ }
276
+
277
+ Application.roles; // ReadonlySet<"api" | "web" | "worker">
278
+ Application.sites; // ReadonlySet<string> | undefined (undefined = all sites)
279
+ ```
280
+
244
281
  Everything you pass after `start` is forwarded to the spawned Node process. Use this to attach a debugger (`--inspect`), tune memory (`--max-old-space-size`), or pass any other Node flag without editing the command.
245
282
 
246
283
  ### Source maps
@@ -283,9 +320,9 @@ Nothing else is needed for pnpm. Warlock never requires an app to declare a pack
283
320
 
284
321
  **A success line on stdout means the app is serving requests.** That is a contract, not a convention, and you can build a CI gate or a health probe on it.
285
322
 
286
- | Stream | Carries |
287
- | ---------- | -------------------------------------------------------------- |
288
- | **stdout** | the started banner, and start failures. Nothing else. |
323
+ | Stream | Carries |
324
+ | ---------- | -------------------------------------------------------------------------------------- |
325
+ | **stdout** | the started banner, and start failures. Nothing else. |
289
326
  | **stderr** | progress (`🚀 Starting production server...`), diagnostics, the application's own logs |
290
327
 
291
328
  The started banner prints **only** when the running application reports a completed boot — not when the command starts, not when the child is spawned. A child that dies before reporting is a failed start: the failure is written to **both** streams (stderr for humans and log collectors, stdout so a supervisor greping for the banner finds a failure rather than silence), and `warlock start` exits non-zero **even when the child itself exited `0`**.
@@ -321,7 +358,7 @@ else is listening on port 3000) and run again, or start on a free port — e.g.
321
358
  startHttpTestServer({ port: 3001 }).
322
359
  ```
323
360
 
324
- **The process exits `78` (`EX_CONFIG`), and `warlock dev`'s supervisor treats that as terminal — it prints once and stops rather than restarting.** That code is the counterpart of `75`, which a worker uses to ask for a fresh process. The distinction is the point: a code error is worth restarting, and a *startup precondition* — a port held by something else, a port value that is not a port, a boot validator that rejected — cannot change because we tried again six seconds later. Retrying one only reprints the diagnostic and then scrolls it away.
361
+ **The process exits `78` (`EX_CONFIG`), and `warlock dev`'s supervisor treats that as terminal — it prints once and stops rather than restarting.** That code is the counterpart of `75`, which a worker uses to ask for a fresh process. The distinction is the point: a code error is worth restarting, and a _startup precondition_ — a port held by something else, a port value that is not a port, a boot validator that rejected — cannot change because we tried again six seconds later. Retrying one only reprints the diagnostic and then scrolls it away.
325
362
 
326
363
  `EACCES` on the port is treated the same way as `EADDRINUSE` (a privileged port you may not bind is also "not available"); anything else the probe throws is rethrown untouched.
327
364
 
@@ -336,7 +373,7 @@ Configured http.port "03999" normalised to 3999.
336
373
  `warlock start` spawns the bundle with an IPC channel and sets `WARLOCK_BOOT_SIGNAL=1` on it. `Application.markBooted()` — which the production entry calls after the late-phase connectors (http, socket) are up — sends one versioned message and closes the channel:
337
374
 
338
375
  ```ts
339
- { type: "warlock:ready", version: 1, pid, at, environment, runtimeStrategy, bootDurationMs?, port? }
376
+ { type: "warlock:ready", version: 1, pid, at, environment, runtimeStrategy, bootDurationMs?, port?, roles }
340
377
  ```
341
378
 
342
379
  Three consequences worth knowing:
@@ -351,11 +388,11 @@ If you need the same fact inside the app, use `Application.onceBooted()` / `Appl
351
388
 
352
389
  `Application.environment` and `Application.runtimeStrategy` are separate axes:
353
390
 
354
- | Mode | `environment` | `runtimeStrategy` | How |
355
- | ---------------- | --------------- | ----------------- | ----------------------------------------- |
356
- | `warlock dev` | `development`* | `development` | preload force-sets `runtimeStrategy` |
357
- | `warlock build` | n/a (no app boots) | n/a | only loads warlock.config.ts |
358
- | `warlock start` | `production`* | `production`* | usually set via `NODE_ENV` in the env |
391
+ | Mode | `environment` | `runtimeStrategy` | How |
392
+ | --------------- | ------------------ | ----------------- | ------------------------------------- |
393
+ | `warlock dev` | `development`* | `development` | preload force-sets `runtimeStrategy` |
394
+ | `warlock build` | n/a (no app boots) | n/a | only loads warlock.config.ts |
395
+ | `warlock start` | `production`* | `production`* | usually set via `NODE_ENV` in the env |
359
396
 
360
397
  `*` — `environment` follows `NODE_ENV`. The dev command doesn't force it, but the default in most projects is `development`. `start` doesn't force it either; deployments set `NODE_ENV=production` themselves.
361
398
 
@@ -414,7 +451,7 @@ export default defineConfig({
414
451
 
415
452
  CI sets `BUILD_OUT=build/<sha>` per pipeline. `warlock start` reads the same config and finds the bundle without any hardcoded paths.
416
453
 
417
- **This recipe only started working in 4.11.0.** Before that, `warlock.config.ts` was evaluated *before* any `.env` file was read, so every `env()` call in it returned its default — silently, under every command. If you copied this recipe earlier and concluded that `BUILD_OUT` was ignored, it was.
454
+ **This recipe only started working in 4.11.0.** Before that, `warlock.config.ts` was evaluated _before_ any `.env` file was read, so every `env()` call in it returned its default — silently, under every command. If you copied this recipe earlier and concluded that `BUILD_OUT` was ignored, it was.
418
455
 
419
456
  **Which file the value comes from is decided by `NODE_ENV`, and no Warlock command sets it.** `env()` reads `.env.<NODE_ENV>` when that file exists and falls back to plain `.env`. So:
420
457
 
@@ -12,10 +12,7 @@ Two APIs over the same engine. The fluent `Mail` builder reads top-to-bottom in
12
12
  ```ts
13
13
  import { Mail } from "@warlock.js/core";
14
14
 
15
- await Mail.to("user@example.com")
16
- .subject("Welcome!")
17
- .text("Thanks for joining.")
18
- .send();
15
+ await Mail.to("user@example.com").subject("Welcome!").text("Thanks for joining.").send();
19
16
  ```
20
17
 
21
18
  Or:
@@ -59,10 +56,25 @@ For named mailers (multi-provider apps), export a `MailersConfig` instead:
59
56
 
60
57
  ```ts
61
58
  const config: MailersConfig = {
62
- default: { host: "smtp.sendgrid.net", port: 587, username: "apikey", password: env("SENDGRID_KEY") },
59
+ default: {
60
+ host: "smtp.sendgrid.net",
61
+ port: 587,
62
+ username: "apikey",
63
+ password: env("SENDGRID_KEY"),
64
+ },
63
65
  mailers: {
64
- marketing: { host: "smtp.mailchimp.com", port: 587, username: env("MC_USER"), password: env("MC_PASS") },
65
- transactional: { host: "smtp.postmark.com", port: 587, username: env("PM_USER"), password: env("PM_PASS") },
66
+ marketing: {
67
+ host: "smtp.mailchimp.com",
68
+ port: 587,
69
+ username: env("MC_USER"),
70
+ password: env("MC_PASS"),
71
+ },
72
+ transactional: {
73
+ host: "smtp.postmark.com",
74
+ port: 587,
75
+ username: env("PM_USER"),
76
+ password: env("PM_PASS"),
77
+ },
66
78
  },
67
79
  };
68
80
  ```
@@ -94,9 +106,9 @@ The mail pipeline switches behavior on a global mode:
94
106
  ```ts
95
107
  import { setMailMode } from "@warlock.js/core";
96
108
 
97
- setMailMode("production"); // actually send (default)
98
- setMailMode("development"); // log subject + recipient, no send
99
- setMailMode("test"); // capture to test mailbox, no send
109
+ setMailMode("production"); // actually send (default)
110
+ setMailMode("development"); // log subject + recipient, no send
111
+ setMailMode("test"); // capture to test mailbox, no send
100
112
  ```
101
113
 
102
114
  Production sends. Development logs and returns a fake-success `MailResult`. Test captures every call into an in-memory mailbox (cleared between tests) and returns success — handy for asserting "the welcome email got sent" without hitting SMTP.
@@ -109,7 +121,7 @@ Production sends. Development logs and returns a fake-success `MailResult`. Test
109
121
 
110
122
  ```ts
111
123
  await Mail.to("a@example.com")
112
- .to(["a@example.com", "b@example.com"]) // override
124
+ .to(["a@example.com", "b@example.com"]) // override
113
125
  .cc("manager@example.com")
114
126
  .bcc("audit@example.com")
115
127
  .replyTo("support@example.com")
@@ -150,15 +162,15 @@ await Mail.to("u@e.com")
150
162
 
151
163
  ### Other knobs
152
164
 
153
- | Method | Purpose |
154
- | ------------------------------------- | --------------------------------------------- |
155
- | `.priority("high" \| "normal" \| "low")` | priority header |
156
- | `.headers({ "X-Foo": "bar" })` | replace custom headers |
157
- | `.header("X-Foo", "bar")` | add one header |
158
- | `.tags(["welcome"])` / `.tag("transactional")` | categorization tags |
159
- | `.correlationId("req-123")` | tracking id (logged with sends) |
160
- | `.config(MailConfigurations)` | one-off override of the global config |
161
- | `.mailer("marketing")` | route via a named mailer from config |
165
+ | Method | Purpose |
166
+ | ---------------------------------------------- | ------------------------------------- |
167
+ | `.priority("high" \| "normal" \| "low")` | priority header |
168
+ | `.headers({ "X-Foo": "bar" })` | replace custom headers |
169
+ | `.header("X-Foo", "bar")` | add one header |
170
+ | `.tags(["welcome"])` / `.tag("transactional")` | categorization tags |
171
+ | `.correlationId("req-123")` | tracking id (logged with sends) |
172
+ | `.config(MailConfigurations)` | one-off override of the global config |
173
+ | `.mailer("marketing")` | route via a named mailer from config |
162
174
 
163
175
  ### Per-mail event handlers
164
176
 
@@ -171,9 +183,9 @@ await Mail.to("u@e.com")
171
183
  .beforeSending((mail) => {
172
184
  // mutate `mail` or return false to cancel
173
185
  })
174
- .onSent((mail, result, error) => {}) // always fires after attempt
175
- .onSuccess((mail, result) => {}) // only on success
176
- .onError((mail, error) => {}) // only on failure
186
+ .onSent((mail, result, error) => {}) // always fires after attempt
187
+ .onSuccess((mail, result) => {}) // only on success
188
+ .onError((mail, error) => {}) // only on failure
177
189
  .send();
178
190
  ```
179
191
 
@@ -243,6 +255,23 @@ mailEvents.onBeforeSending((mail) => {
243
255
 
244
256
  Global hooks fire for **every** mail. Per-mail handlers from the builder / `sendMail` fire only for that one send.
245
257
 
258
+ ### Observe captured development and test mail
259
+
260
+ Development and test sends create a `CapturedMail` and synchronously emit the
261
+ global `mail.captured` event. Subscribe with `mailEvents.onCaptured`:
262
+
263
+ ```ts
264
+ import { mailEvents, type CapturedMail } from "@warlock.js/core";
265
+
266
+ mailEvents.onCaptured((mail: CapturedMail) => {
267
+ console.log(mail.id, mail.options.subject);
268
+ });
269
+ ```
270
+
271
+ `CapturedMail.id` is the mail's generated or supplied id. This event is for
272
+ development/test tooling; production sends do not emit it. Listener failures
273
+ are logged and never interrupt mail delivery.
274
+
246
275
  For correlated tracking of a specific mail, generate an id and subscribe by id:
247
276
 
248
277
  ```ts
@@ -296,10 +325,11 @@ Captured mail shape:
296
325
 
297
326
  ```ts
298
327
  type CapturedMail = {
299
- options: MailOptions; // original payload
328
+ id: string;
329
+ options: MailOptions; // original payload
300
330
  normalized: NormalizedMail; // post-normalization (arrays, resolved from)
301
331
  timestamp: Date;
302
- result?: MailResult; // in test mode, always success
332
+ result?: MailResult; // in test mode, always success
303
333
  error?: MailError;
304
334
  };
305
335
  ```
@@ -9,7 +9,7 @@ Some tests need the full stack: route matching, middleware chain, validation, co
9
9
 
10
10
  `startHttpTestServer()` is the bootstrap. `testGet` / `testPost` / `expectJson` are the call helpers. Both ship in `@warlock.js/core/tests`.
11
11
 
12
- ⚠ **Changed in 4.13.0 — the import is a subpath now.** These helpers used to be re-exported from the package root; they are not any more, because that put the test helpers into every application's production module graph. `import { testGet } from "@warlock.js/core"` now fails with *"has no exported member"* — **add `/tests` to the specifier and nothing else changes.**
12
+ ⚠ **Changed in 4.13.0 — the import is a subpath now.** These helpers used to be re-exported from the package root; they are not any more, because that put the test helpers into every application's production module graph. `import { testGet } from "@warlock.js/core"` now fails with _"has no exported member"_ — **add `/tests` to the specifier and nothing else changes.**
13
13
 
14
14
  ## The shape
15
15
 
@@ -125,8 +125,8 @@ import { defineConfig } from "vitest/config";
125
125
  export default defineConfig({
126
126
  plugins: [lowerStage3Decorators(), mongezVite()],
127
127
  test: {
128
- globalSetup: "./src/test-global-setup.ts", // ← starts the HTTP server
129
- setupFiles: ["./src/test-setup.ts"], // ← setupTest + afterAll(teardownTest), per test file
128
+ globalSetup: "./src/test-global-setup.ts", // ← starts the HTTP server
129
+ setupFiles: ["./src/test-setup.ts"], // ← setupTest + afterAll(teardownTest), per test file
130
130
  environment: "node",
131
131
  globals: false,
132
132
  include: ["src/app/**/*.test.ts"],
@@ -142,6 +142,17 @@ Both files (and this config, with `lowerStage3Decorators()` first so decorated m
142
142
 
143
143
  **HTTP is the exception and stays in `globalSetup`**, which genuinely does run once in the main vitest process and owns a real port. **That split is the point:** one server for the whole run, one framework per test file.
144
144
 
145
+ ### Keep Vitest setup out of development
146
+
147
+ `warlock dev` (and `warlock build`) load your app's `vite.config.ts`. Anything you put there for tests runs in development too. Guard vitest-only setup:
148
+
149
+ ```ts
150
+ // vite.config.ts
151
+ if (process.env.VITEST) {
152
+ Object.assign(process.env, testEnv); // test database, test keys...
153
+ }
154
+ ```
155
+
145
156
  ## HTTP request helpers
146
157
 
147
158
  Everything is built on native `fetch` — no extra dependency, no special wire format.
@@ -151,7 +162,7 @@ Everything is built on native `fetch` — no extra dependency, no special wire f
151
162
  ```ts
152
163
  import { getTestServerUrl } from "@warlock.js/core/tests";
153
164
 
154
- const url = getTestServerUrl(); // → "http://localhost:2031" (defaults)
165
+ const url = getTestServerUrl(); // → "http://localhost:2031" (defaults)
155
166
  ```
156
167
 
157
168
  Reads `http.host` (default `"localhost"`) and `http.port` (default `2031`) from config — unless `startHttpTestServer` published a port, which wins, since a worker's own config never sees the `{ port }` option passed in `globalSetup`. If you change the HTTP config, helpers follow automatically.
@@ -189,9 +200,9 @@ import { expectJson, parseJsonResponse } from "@warlock.js/core/tests";
189
200
  const body = await parseJsonResponse<MyShape>(response);
190
201
 
191
202
  // Assert status + parse in one call
192
- const body = await expectJson<MyShape>(response); // expects 200
193
- const body = await expectJson<MyShape>(response, 201); // expects 201
194
- const body = await expectJson<MyShape>(response, 404); // expects 404 (testing error paths)
203
+ const body = await expectJson<MyShape>(response); // expects 200
204
+ const body = await expectJson<MyShape>(response, 201); // expects 201
205
+ const body = await expectJson<MyShape>(response, 404); // expects 404 (testing error paths)
195
206
  ```
196
207
 
197
208
  `expectJson` throws with the actual status + response body when the assertion fails — no chasing "got 500" messages without context.
@@ -227,7 +238,7 @@ describe("Products API — happy path", () => {
227
238
 
228
239
  ```ts
229
240
  it("rejects missing required fields with 400", async () => {
230
- const response = await testPost("/products", { name: "" }); // price missing
241
+ const response = await testPost("/products", { name: "" }); // price missing
231
242
  const body = await expectJson<{ errors: Array<{ key: string }> }>(response, 400);
232
243
 
233
244
  expect(body.errors.some((e) => e.key === "price")).toBe(true);