@mandujs/core 0.20.10 → 0.22.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/README.md +2 -1
- package/package.json +28 -3
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +495 -37
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1671 -80
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +19 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/config/validate.ts +1 -0
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/devtools/ai/mcp-connector.ts +18 -16
- package/src/devtools/client/components/mandu-character.tsx +4 -1
- package/src/devtools/client/components/panel/panel-container.tsx +20 -5
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +679 -23
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +247 -189
|
@@ -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
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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";
|