@mandujs/core 0.21.0 → 0.22.1

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 (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Phase 7.0 — Browser-side `import.meta.hot` runtime (Mandu subset of the Vite API).
3
+ *
4
+ * This module runs in the browser. It is the moral equivalent of Vite's
5
+ * `packages/vite/src/client/client.ts` HMR context factory, but only
6
+ * implements the surface Mandu commits to in Phase 7.0 v0.1:
7
+ *
8
+ * - `accept()` — self-accept, no callback
9
+ * - `accept(cb)` — self-accept with new-module callback
10
+ * - `accept(dep, cb)` — accept updates to a specific dependency
11
+ * - `dispose(cb)` — register a pre-replacement cleanup
12
+ * - `data` — per-module persist object (carried across updates)
13
+ * - `invalidate(msg?)` — bail out, propagate update to importers
14
+ * - `on(event, cb)` — 4 built-in Vite events
15
+ *
16
+ * Design notes:
17
+ *
18
+ * 1. **Registry identity key**: the *module URL*. Each call to
19
+ * `createManduHot("/src/foo.ts")` returns a context whose `data`
20
+ * object is preserved across calls for the same URL — this is the
21
+ * guarantee Vite's HMR API makes. We reach into the registry so the
22
+ * same identity is also preserved across a hot-replace that tears
23
+ * down the old module and evaluates a fresh one.
24
+ *
25
+ * 2. **No global WebSocket ownership**: this module *does not* own the
26
+ * HMR WebSocket. The dev-time HMR client script (built by Agent C's
27
+ * `createHMRClientScript` in `bundler/dev.ts`) owns the socket and
28
+ * calls `dispatchReplacement` / `dispatchDependencyUpdate` /
29
+ * `dispatchEvent` from the message handler. Tests pass in a mock
30
+ * `send` via `setInvalidateTransport()` so `invalidate()` is
31
+ * observable without spinning up a real socket.
32
+ *
33
+ * 3. **`accept` overload dispatch**: both overloads are a single
34
+ * function internally; we branch on `typeof depOrCb === "function"`
35
+ * so TypeScript narrowing aligns with runtime behavior.
36
+ *
37
+ * References:
38
+ * docs/bun/phase-7-diagnostics/industry-benchmark.md §2 (Vite spec)
39
+ * packages/core/src/bundler/hmr-types.ts (`ManduHot`, `HMREventName`)
40
+ */
41
+
42
+ import type { ManduHot, HMREventName, HDRPayload } from "../bundler/hmr-types";
43
+
44
+ // ============================================
45
+ // Internal registry shape
46
+ // ============================================
47
+
48
+ /**
49
+ * A module's HMR context record — the backing store behind every
50
+ * `ManduHot` returned for a given module URL. Shared across hot
51
+ * replaces so `data` survives and listeners aren't orphaned.
52
+ */
53
+ interface ModuleRecord {
54
+ /** Per-module state preserved across HMR updates. */
55
+ data: Record<string, unknown>;
56
+ /**
57
+ * Map key: `""` for self-accept, `<dep url>` for dep-accept.
58
+ * Value: the user-supplied callback. Only the most recent callback
59
+ * per key is retained (Vite's semantics — re-calling `accept(dep)`
60
+ * overwrites the prior registration).
61
+ */
62
+ acceptCallbacks: Map<string, (mod: unknown) => void>;
63
+ /** Ordered list of dispose cleanups, oldest first. */
64
+ disposeCallbacks: Array<(data: Record<string, unknown>) => void>;
65
+ /** Vite-compat event listeners. */
66
+ eventListeners: Map<HMREventName, Set<(payload: unknown) => void>>;
67
+ }
68
+
69
+ function freshRecord(): ModuleRecord {
70
+ return {
71
+ data: {},
72
+ acceptCallbacks: new Map(),
73
+ disposeCallbacks: [],
74
+ eventListeners: new Map(),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Shared registry. Exported via `_getRegistryForTests` so unit tests
80
+ * can reset between cases; production code has no legitimate reason
81
+ * to touch it directly.
82
+ */
83
+ const registry = new Map<string, ModuleRecord>();
84
+
85
+ // ============================================
86
+ // Transport seam — for `invalidate()`
87
+ // ============================================
88
+
89
+ /**
90
+ * When a module calls `invalidate()`, we cannot hot-replace in place;
91
+ * the update must escalate to the server. In production this transport
92
+ * is `ws.send(...)` wired by `createHMRClientScript`. In tests it is a
93
+ * jest-style mock so the payload is observable.
94
+ */
95
+ type InvalidateTransport = (payload: {
96
+ type: "invalidate";
97
+ moduleUrl: string;
98
+ message?: string;
99
+ }) => void;
100
+
101
+ let invalidateTransport: InvalidateTransport = (_payload) => {
102
+ // Default no-op so tests that don't care about `invalidate` wiring
103
+ // don't blow up. Real usage must call `setInvalidateTransport`.
104
+ };
105
+
106
+ /**
107
+ * Install the transport used by `ManduHot.invalidate()`. Call this
108
+ * once at client boot from the HMR client script. Idempotent — the
109
+ * last installed transport wins.
110
+ */
111
+ export function setInvalidateTransport(fn: InvalidateTransport): void {
112
+ invalidateTransport = fn;
113
+ }
114
+
115
+ // ============================================
116
+ // Public factory
117
+ // ============================================
118
+
119
+ /**
120
+ * Return a `ManduHot` instance for the given module URL. Calling this
121
+ * twice for the same URL yields two separate `ManduHot` objects, but
122
+ * both are views onto the same underlying `ModuleRecord` — in
123
+ * particular their `data` is the same object identity, their accept
124
+ * registrations collide, and `dispose` callbacks accumulate.
125
+ */
126
+ export function createManduHot(moduleUrl: string): ManduHot {
127
+ let rec = registry.get(moduleUrl);
128
+ if (rec === undefined) {
129
+ rec = freshRecord();
130
+ registry.set(moduleUrl, rec);
131
+ }
132
+ const record = rec;
133
+
134
+ // Using `function` rather than an arrow so overload narrowing works
135
+ // identically in the synthesized types — TypeScript treats both
136
+ // forms the same here, but `function` reads more naturally.
137
+ function accept(
138
+ depOrCb?: string | ((newModule: unknown) => void),
139
+ cb?: (newDep: unknown) => void,
140
+ ): void {
141
+ if (typeof depOrCb === "function") {
142
+ // accept(cb) — self-accept with callback
143
+ record.acceptCallbacks.set("", depOrCb);
144
+ return;
145
+ }
146
+ if (typeof depOrCb === "string") {
147
+ // accept(dep, cb) — dep-accept. Require cb present (Vite allows
148
+ // it to be omitted for the batch overload, which we defer).
149
+ if (typeof cb !== "function") {
150
+ throw new TypeError(
151
+ `import.meta.hot.accept("${depOrCb}", cb): callback is required`,
152
+ );
153
+ }
154
+ record.acceptCallbacks.set(depOrCb, cb as (mod: unknown) => void);
155
+ return;
156
+ }
157
+ // accept() — no callback. Mark the module as accepting its own
158
+ // updates by recording an entry whose callback is a no-op. The
159
+ // presence of a "" key is what `hasSelfAccept()` tests.
160
+ if (!record.acceptCallbacks.has("")) {
161
+ record.acceptCallbacks.set("", () => undefined);
162
+ }
163
+ }
164
+
165
+ function dispose(cb: (data: Record<string, unknown>) => void): void {
166
+ record.disposeCallbacks.push(cb);
167
+ }
168
+
169
+ function invalidate(message?: string): void {
170
+ invalidateTransport({ type: "invalidate", moduleUrl, message });
171
+ }
172
+
173
+ function on(event: HMREventName, cb: (payload: unknown) => void): void {
174
+ let set = record.eventListeners.get(event);
175
+ if (set === undefined) {
176
+ set = new Set();
177
+ record.eventListeners.set(event, set);
178
+ }
179
+ set.add(cb);
180
+ }
181
+
182
+ return {
183
+ get data() {
184
+ return record.data;
185
+ },
186
+ accept: accept as ManduHot["accept"],
187
+ dispose,
188
+ invalidate,
189
+ on,
190
+ };
191
+ }
192
+
193
+ // ============================================
194
+ // Replacement dispatch — called by the HMR client script
195
+ // ============================================
196
+
197
+ /**
198
+ * Fire `dispose` callbacks then the matching `accept` callback for a
199
+ * self-accept. Called when the server says "module X was replaced" and
200
+ * X was registered as self-accepting.
201
+ *
202
+ * Phase 7.1 B-4 extension: after the user-supplied accept callback
203
+ * runs, if the module URL is registered with the browser-side Fast
204
+ * Refresh registry (`window.__MANDU_HMR__.isBoundary`), queue a
205
+ * `performReactRefresh()`. This is how Mandu composes Bun's source
206
+ * transform (`$RefreshReg$` / `$RefreshSig$` injection) with React's
207
+ * component-tree swap logic — without it, the new module loads but
208
+ * React doesn't know to re-render. Coalescing is handled inside
209
+ * `performReactRefresh` so multiple dispatches in the same tick yield a
210
+ * single refresh pass.
211
+ *
212
+ * Returns `true` if the module had a self-accept registration and the
213
+ * callback ran (possibly a no-op), `false` if there was no handler
214
+ * (meaning the caller should escalate to a full reload).
215
+ */
216
+ export function dispatchReplacement(
217
+ moduleUrl: string,
218
+ newModule: unknown,
219
+ ): boolean {
220
+ const rec = registry.get(moduleUrl);
221
+ if (rec === undefined) return false;
222
+
223
+ const cb = rec.acceptCallbacks.get("");
224
+ if (cb === undefined) return false;
225
+
226
+ // Run dispose first, preserving `data` so the new module sees the
227
+ // old state.
228
+ for (const d of rec.disposeCallbacks) {
229
+ try {
230
+ d(rec.data);
231
+ } catch (err) {
232
+ // A buggy dispose must not prevent the replacement from
233
+ // happening — that would wedge HMR. Log and continue.
234
+ console.error(`[Mandu HMR] dispose() threw for ${moduleUrl}:`, err);
235
+ }
236
+ }
237
+ // After dispose runs, the stored callbacks for the *old* module body
238
+ // are consumed. The replacement will re-register any it still wants.
239
+ rec.disposeCallbacks = [];
240
+
241
+ cb(newModule);
242
+
243
+ // Phase 7.1 B-4: trigger React Fast Refresh if this module was
244
+ // registered as a boundary by the bundler-emitted onLoad epilogue.
245
+ // The guard is defensive: SSR (no window), missing preamble, and
246
+ // production builds (no `__MANDU_HMR__` installed) all short-circuit
247
+ // without throwing. Wrapped in try/catch because the refresh runtime
248
+ // is third-party code and we must not wedge the HMR client if it
249
+ // throws.
250
+ try {
251
+ const w =
252
+ typeof globalThis !== "undefined"
253
+ ? (globalThis as unknown as {
254
+ __MANDU_HMR__?: {
255
+ isBoundary(url: string): boolean;
256
+ performReactRefresh(): void;
257
+ };
258
+ })
259
+ : null;
260
+ const hmr = w?.__MANDU_HMR__;
261
+ if (hmr && hmr.isBoundary(moduleUrl)) {
262
+ hmr.performReactRefresh();
263
+ }
264
+ } catch (err) {
265
+ console.error(
266
+ `[Mandu HMR] Fast Refresh dispatch for ${moduleUrl} threw:`,
267
+ err,
268
+ );
269
+ }
270
+
271
+ return true;
272
+ }
273
+
274
+ /**
275
+ * Fire the dep-accept callback for a specific `(importer, dep)` pair.
276
+ * The server decides which importer handles the update (it walks the
277
+ * import graph); from the client's perspective we just look up the
278
+ * callback and fire.
279
+ */
280
+ export function dispatchDependencyUpdate(
281
+ importerUrl: string,
282
+ depUrl: string,
283
+ newDep: unknown,
284
+ ): boolean {
285
+ const rec = registry.get(importerUrl);
286
+ if (rec === undefined) return false;
287
+ const cb = rec.acceptCallbacks.get(depUrl);
288
+ if (cb === undefined) return false;
289
+ cb(newDep);
290
+ return true;
291
+ }
292
+
293
+ /**
294
+ * Emit a Vite-compat lifecycle event to any module that called `on()`
295
+ * with that event name. Broadcasts across the entire registry — Vite
296
+ * does the same; events are not per-module.
297
+ */
298
+ export function dispatchEvent(event: HMREventName, payload: unknown): void {
299
+ for (const rec of registry.values()) {
300
+ const set = rec.eventListeners.get(event);
301
+ if (set === undefined) continue;
302
+ for (const listener of set) {
303
+ try {
304
+ listener(payload);
305
+ } catch (err) {
306
+ console.error(`[Mandu HMR] ${event} listener threw:`, err);
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ // ============================================
313
+ // Phase 7.2 — HDR (Hot Data Revalidation)
314
+ // ============================================
315
+
316
+ /**
317
+ * A transport capable of re-invoking a route's loader *without* a
318
+ * React tree remount. The real implementation is a `fetch` against
319
+ * the current URL with the `X-Mandu-HDR: 1` header; the server
320
+ * returns the loader JSON and a router hook applies it inside
321
+ * `React.startTransition` so form inputs, scroll position, and
322
+ * focused elements survive.
323
+ *
324
+ * The transport is a seam (not wired inline) for three reasons:
325
+ * 1. Tests need to observe / mock the fetch + transition path
326
+ * without spinning up a browser or a router.
327
+ * 2. The actual fetch + `startTransition` call lives in the HMR
328
+ * client script that the bundler emits (dev.ts), which has
329
+ * access to `window.__MANDU_ROUTER_REVALIDATE__`. This runtime
330
+ * module stays framework-agnostic.
331
+ * 3. Projects that opt out (env `MANDU_HDR=0` evaluated by the
332
+ * bundler) install a transport that immediately returns
333
+ * `{ ok: false }` so the client falls back to a full reload.
334
+ */
335
+ export type HDRTransport = (payload: HDRPayload) => Promise<{
336
+ /** True when the loader fetch succeeded and props were applied. */
337
+ ok: boolean;
338
+ /** When `ok` is false, the caller falls back to `location.reload()`. */
339
+ reason?: "no-route" | "fetch-failed" | "status" | "no-router" | "disabled";
340
+ }>;
341
+
342
+ /**
343
+ * Default: log + fall-back signal. Production installs a real
344
+ * transport via `setHDRTransport` at client boot from the HMR client
345
+ * script. Tests install mock transports.
346
+ */
347
+ let hdrTransport: HDRTransport = async () => ({
348
+ ok: false,
349
+ reason: "no-router" as const,
350
+ });
351
+
352
+ export function setHDRTransport(fn: HDRTransport): void {
353
+ hdrTransport = fn;
354
+ }
355
+
356
+ /**
357
+ * Dispatch a `slot-refetch` payload received over the HMR websocket.
358
+ * Returns `true` when the transport applied the update cleanly;
359
+ * `false` when the caller should fall back to a full reload.
360
+ *
361
+ * The dispatch is wrapped in try/catch because the transport is
362
+ * third-party code (from the caller's perspective) and a thrown
363
+ * error must NOT wedge the HMR client. A throw is treated as
364
+ * `{ ok: false }` and the caller falls back to full reload.
365
+ */
366
+ export async function dispatchSlotRefetch(
367
+ payload: HDRPayload,
368
+ ): Promise<boolean> {
369
+ try {
370
+ const result = await hdrTransport(payload);
371
+ return result.ok === true;
372
+ } catch (err) {
373
+ // Preserve the same "don't wedge the client" discipline as
374
+ // dispatchReplacement: log and signal fallback, never propagate.
375
+ // eslint-disable-next-line no-console
376
+ console.error(
377
+ `[Mandu HDR] slot-refetch for route ${payload.routeId} threw:`,
378
+ err,
379
+ );
380
+ return false;
381
+ }
382
+ }
383
+
384
+ // ============================================
385
+ // Test helpers — NOT part of the public API
386
+ // ============================================
387
+
388
+ /**
389
+ * Test-only: clear the shared registry. Call in `beforeEach` to
390
+ * guarantee clean slate between cases. Production code must not call
391
+ * this — it would orphan live HMR state.
392
+ *
393
+ * @internal
394
+ */
395
+ export function _resetRegistryForTests(): void {
396
+ registry.clear();
397
+ invalidateTransport = () => undefined;
398
+ hdrTransport = async () => ({ ok: false, reason: "no-router" as const });
399
+ }
400
+
401
+ /**
402
+ * Test-only: inspect the registry size without exposing the internal
403
+ * map shape. Useful for "no leaks" assertions.
404
+ *
405
+ * @internal
406
+ */
407
+ export function _getRegistrySizeForTests(): number {
408
+ return registry.size;
409
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Module-level HTTP error helpers.
3
+ *
4
+ * These are the standalone counterparts to `ctx.unauthorized()`,
5
+ * `ctx.forbidden()`, and `ctx.error()` on {@link ManduContext}. When a
6
+ * caller is inside a filling handler the ctx methods are ergonomic —
7
+ * they thread pending cookies through automatically. Outside the ctx
8
+ * (e.g. plain utilities, middleware composition, SSR loaders that
9
+ * prefer `return` over `ctx.json`), these module-level functions mint
10
+ * the same shape directly.
11
+ *
12
+ * ## Design notes
13
+ *
14
+ * - `unauthorized()` sets `WWW-Authenticate: Bearer` per RFC 7235 so
15
+ * browsers/proxies know a scheme. Opt out by passing a custom
16
+ * `WWW-Authenticate` header (or `null` via `{ headers }`).
17
+ * - `forbidden()` is JSON by default (same shape as `ctx.forbidden()`).
18
+ * - `badRequest()` accepts either a string (→ simple error body) or a
19
+ * `{ message, errors }` object for structured validation failures.
20
+ * - All three merge caller-provided headers via `new Headers(init)`
21
+ * semantics — later keys win, which matches Response constructor.
22
+ *
23
+ * The returned Response is plain — no brand symbol. Unlike `redirect()`
24
+ * / `notFound()`, these do not short-circuit the SSR pipeline; they are
25
+ * terminal API responses and the caller is expected to return them up.
26
+ */
27
+
28
+ /** Shape of the JSON body for `badRequest()` when given an object. */
29
+ export interface BadRequestBody {
30
+ /** Human-readable message. Required. */
31
+ message: string;
32
+ /** Optional structured validation detail (e.g. per-field errors). */
33
+ errors?: unknown;
34
+ }
35
+
36
+ /**
37
+ * 401 Unauthorized. Sets `WWW-Authenticate: Bearer` by default so clients
38
+ * know the expected auth scheme. To override, pass a custom header via
39
+ * `options.headers`:
40
+ *
41
+ * ```ts
42
+ * unauthorized("Token expired", { headers: { "WWW-Authenticate": 'Basic realm="app"' } });
43
+ * ```
44
+ *
45
+ * Body is JSON: `{ error: "Unauthorized" }` (or the provided `message`).
46
+ */
47
+ export function unauthorized(message?: string, options: ResponseInit = {}): Response {
48
+ const body = JSON.stringify({ error: message ?? "Unauthorized" });
49
+ const headers = new Headers(options.headers);
50
+ if (!headers.has("WWW-Authenticate")) {
51
+ headers.set("WWW-Authenticate", "Bearer");
52
+ }
53
+ if (!headers.has("Content-Type")) {
54
+ headers.set("Content-Type", "application/json; charset=utf-8");
55
+ }
56
+ return new Response(body, {
57
+ status: 401,
58
+ statusText: options.statusText,
59
+ headers,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * 403 Forbidden. JSON body: `{ error: string }`.
65
+ *
66
+ * Use this when the client is authenticated but not authorised for the
67
+ * resource (contrast with {@link unauthorized}, which signals a missing
68
+ * or invalid credential).
69
+ */
70
+ export function forbidden(message?: string, options: ResponseInit = {}): Response {
71
+ const body = JSON.stringify({ error: message ?? "Forbidden" });
72
+ const headers = new Headers(options.headers);
73
+ if (!headers.has("Content-Type")) {
74
+ headers.set("Content-Type", "application/json; charset=utf-8");
75
+ }
76
+ return new Response(body, {
77
+ status: 403,
78
+ statusText: options.statusText,
79
+ headers,
80
+ });
81
+ }
82
+
83
+ /**
84
+ * 400 Bad Request. Accepts either a plain string or a `{ message, errors }`
85
+ * object. In both cases the response body is JSON.
86
+ *
87
+ * - `badRequest("invalid id")` → `{ "error": "invalid id" }`
88
+ * - `badRequest({ message: "validation failed", errors: { email: ["required"] } })`
89
+ * → `{ "error": "validation failed", "errors": { "email": ["required"] } }`
90
+ *
91
+ * The top-level key is always `error` (string), matching `ctx.error()` in
92
+ * `filling/context.ts`. Structured `errors` is passed through untouched —
93
+ * callers decide the shape (Zod flatten, per-field map, etc.).
94
+ */
95
+ export function badRequest(
96
+ input: string | BadRequestBody = "Bad Request",
97
+ options: ResponseInit = {}
98
+ ): Response {
99
+ const body =
100
+ typeof input === "string"
101
+ ? { error: input }
102
+ : { error: input.message, ...(input.errors !== undefined ? { errors: input.errors } : {}) };
103
+
104
+ const headers = new Headers(options.headers);
105
+ if (!headers.has("Content-Type")) {
106
+ headers.set("Content-Type", "application/json; charset=utf-8");
107
+ }
108
+ return new Response(JSON.stringify(body), {
109
+ status: 400,
110
+ statusText: options.statusText,
111
+ headers,
112
+ });
113
+ }
@@ -3,6 +3,12 @@ export * from "./streaming-ssr";
3
3
  export { extractShellHtml, createPPRResponse, PPR_SHELL_MARKER } from "./ppr";
4
4
  export * from "./router";
5
5
  export * from "./server";
6
+ export { redirect, isManduRedirect, isRedirectResponse, REDIRECT_BRAND } from "./redirect";
7
+ export type { RedirectStatus, RedirectOptions } from "./redirect";
8
+ export { notFound, isNotFoundResponse, NOT_FOUND_BRAND } from "./not-found";
9
+ export type { NotFoundOptions } from "./not-found";
10
+ export { unauthorized, forbidden, badRequest } from "./http-errors";
11
+ export type { BadRequestBody } from "./http-errors";
6
12
  export * from "./cors";
7
13
  export * from "./env";
8
14
  export * from "./compose";