@mandujs/core 0.21.0 → 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.
Files changed (122) hide show
  1. package/package.json +94 -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,158 @@
1
+ /**
2
+ * @mandujs/core/desktop — type contracts
3
+ *
4
+ * Shared types for Mandu's desktop integration. These intentionally mirror
5
+ * but do NOT import `webview-bun`, so consumers that never touch desktop
6
+ * features (e.g. web-only apps, CI type-checking without the optional peer
7
+ * installed) don't pay the cost of loading the FFI module graph.
8
+ *
9
+ * The concrete runtime lives in `./window.ts` behind a lazy import.
10
+ */
11
+
12
+ /**
13
+ * Size hint applied to the window. Mirrors `webview-bun` `SizeHint` enum but
14
+ * exposed here as a string union so callers don't need to import the FFI
15
+ * module just to pass an option.
16
+ *
17
+ * - `"none"` → window is freely resizable, no constraints.
18
+ * - `"min"` → the supplied width/height are treated as minimums.
19
+ * - `"max"` → the supplied width/height are treated as maximums.
20
+ * - `"fixed"` → window cannot be resized by the user.
21
+ *
22
+ * Default: `"none"`.
23
+ */
24
+ export type WindowSizeHint = "none" | "min" | "max" | "fixed";
25
+
26
+ /**
27
+ * Options passed to {@link createWindow}. Only `url` is required — everything
28
+ * else has a sane default.
29
+ */
30
+ export interface WindowOptions {
31
+ /**
32
+ * URL to navigate to. Mandu's recommended pattern is to start a local HTTP
33
+ * server on `127.0.0.1:0` and pass `http://127.0.0.1:<port>`. Remote URLs
34
+ * are technically possible but bypass the security boundary — don't.
35
+ */
36
+ url: string;
37
+ /** Window title. Default: `"Mandu Desktop"`. */
38
+ title?: string;
39
+ /** Width in logical pixels. Default: `1024`. */
40
+ width?: number;
41
+ /** Height in logical pixels. Default: `768`. */
42
+ height?: number;
43
+ /**
44
+ * Size hint — controls resizability. Default: `"none"` (freely resizable).
45
+ * Pass `"fixed"` for kiosk-style windows.
46
+ */
47
+ hint?: WindowSizeHint;
48
+ /**
49
+ * Open DevTools / enable WebInspector. Default: `false`.
50
+ *
51
+ * Note: On macOS this requires an entitlement; on Linux GTK builds, DevTools
52
+ * is baked into WebKitGTK. On Windows WebView2, pressing F12 inside the
53
+ * running window also works.
54
+ */
55
+ debug?: boolean;
56
+ /**
57
+ * Pre-register request/response handlers exposed to the page's JavaScript
58
+ * as global async functions. Equivalent to calling `handle.bind(name, fn)`
59
+ * for each entry, but registered BEFORE the first navigation — safer
60
+ * against race conditions where the page calls an unbound global.
61
+ */
62
+ handlers?: Record<string, (...args: unknown[]) => unknown>;
63
+ /**
64
+ * Invoked once after the window is shown and the first navigation begins.
65
+ * Runs on the main thread (or Worker, whichever owns the Webview instance).
66
+ * Exceptions bubble up to the caller's promise.
67
+ */
68
+ onReady?: () => void | Promise<void>;
69
+ /**
70
+ * Invoked when the user closes the window (title-bar X or Cmd+Q / Alt+F4).
71
+ * Use this to tear down the HTTP server, flush caches, etc.
72
+ */
73
+ onClose?: () => void | Promise<void>;
74
+ }
75
+
76
+ /**
77
+ * Opaque handle returned by {@link createWindow}. Provides a minimal, stable
78
+ * surface over the underlying `webview-bun` instance. Consumers should not
79
+ * cast this to `Webview` — future Mandu versions may wrap a different backend
80
+ * (direct FFI, `Bun.WebView`, etc.).
81
+ */
82
+ export interface WindowHandle {
83
+ /**
84
+ * Close the window and release native resources. Idempotent — calling
85
+ * twice is safe.
86
+ */
87
+ close(): Promise<void>;
88
+ /**
89
+ * Register a callback for when the window is closed by the user or by
90
+ * `close()`. Callback fires exactly once.
91
+ */
92
+ onClose(cb: () => void): void;
93
+ /**
94
+ * Inject JavaScript into the current page. Does NOT return a value — use
95
+ * `bind()` for host↔page request/response.
96
+ */
97
+ eval(js: string): Promise<void>;
98
+ /**
99
+ * Expose a host function as a global async function on the page. The
100
+ * returned value (or its JSON serialization) is resolved by the caller's
101
+ * awaited `window.<name>(...)` in the page.
102
+ *
103
+ * Arguments from the page are JSON-deserialized before being passed in;
104
+ * returns are JSON-serialized back. Non-serializable values (undefined,
105
+ * functions, circular refs) will either be dropped or throw.
106
+ */
107
+ bind(name: string, fn: (...args: unknown[]) => unknown): void;
108
+ /**
109
+ * Resolves when the user closes the window (title-bar X or equivalent),
110
+ * OR when `close()` is called. Does NOT reject on error — errors are
111
+ * surfaced via the synchronous throw from `createWindow()` / `eval()`.
112
+ *
113
+ * Useful for the common pattern:
114
+ * ```ts
115
+ * const win = await createWindow(opts);
116
+ * await win.closed;
117
+ * await server.stop();
118
+ * ```
119
+ */
120
+ readonly closed: Promise<void>;
121
+ /**
122
+ * Start the platform event loop. **Blocking.** Returns when the window
123
+ * closes. On the main thread, this will freeze the Bun server — launch
124
+ * a Worker for the HTTP server or wrap the window in a Worker itself.
125
+ *
126
+ * Most callers should use {@link closed} instead of managing `run()`
127
+ * manually; {@link createWindow} auto-invokes `run()` under the hood when
128
+ * `autoRun: true` (the default).
129
+ */
130
+ run(): void;
131
+ }
132
+
133
+ /**
134
+ * Messages flowing from the parent thread → Worker that hosts the window.
135
+ * Exported so callers authoring custom Worker entries have a stable shape.
136
+ */
137
+ export type WorkerInbound =
138
+ | {
139
+ type: "open";
140
+ /** Serialized {@link WindowOptions}. Functions are stripped — bind handlers must be registered via worker code. */
141
+ options: Omit<WindowOptions, "handlers" | "onReady" | "onClose">;
142
+ }
143
+ | {
144
+ type: "close";
145
+ }
146
+ | {
147
+ type: "eval";
148
+ js: string;
149
+ };
150
+
151
+ /**
152
+ * Messages flowing from the Worker → parent thread.
153
+ */
154
+ export type WorkerOutbound =
155
+ | { type: "ready" }
156
+ | { type: "closed" }
157
+ | { type: "error"; message: string }
158
+ | { type: "bind-call"; name: string; args: unknown[]; seq: string };
@@ -0,0 +1,492 @@
1
+ /**
2
+ * @mandujs/core/desktop — window factory
3
+ *
4
+ * Wraps `webview-bun` (optional peer dependency, MIT, tr1ckydev/webview-bun
5
+ * 2.4.0+). Phase 9c R0 diagnostic:
6
+ * - docs/bun/phase-9-diagnostics/webview-bun-ffi.md
7
+ *
8
+ * Design rules:
9
+ * 1. **Lazy import** — `webview-bun` must NOT be loaded when this module is
10
+ * merely imported. A web-only project running `bun test` should pass
11
+ * even if the peer is absent. The import happens on the first
12
+ * `createWindow()` call, with a clear install-me error on failure.
13
+ * 2. **No side-channel globals** — each handle is self-contained; multiple
14
+ * windows are allowed in a single process (though not a common use
15
+ * case).
16
+ * 3. **Never surface the `Webview` instance** — consumers only see
17
+ * {@link WindowHandle}. Backend swaps (Bun.WebView native, direct FFI)
18
+ * stay transparent.
19
+ *
20
+ * Threading model: `webview-bun`'s `run()` is blocking and must be on the
21
+ * thread that owns the window. For use with `Bun.serve()`, the standard
22
+ * pattern is **Worker-based**: launch the server on the main thread, spawn
23
+ * a Worker, and call `createWindow()` inside it. See `./worker.ts` for the
24
+ * canonical entry. When `autoRun: false`, callers who control their own
25
+ * event loop (e.g. running the window on the main thread while the HTTP
26
+ * server sits in a Worker) can call `handle.run()` themselves.
27
+ */
28
+
29
+ import type {
30
+ WindowHandle,
31
+ WindowOptions,
32
+ WindowSizeHint,
33
+ } from "./types.js";
34
+
35
+ // ─── Optional-peer loader ───────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Cached module once loaded. We do NOT pre-load at module evaluation — if
39
+ * `webview-bun` is missing, `import @mandujs/core/desktop` must still succeed
40
+ * (so `bun test` in a CI without the peer passes cleanly).
41
+ */
42
+ type WebviewBunModule = {
43
+ // We intentionally type the imported module as `any` here because
44
+ // `webview-bun` publishes types that depend on its FFI pointers. A tighter
45
+ // type contract is not worth pulling the peer's type graph into `core`.
46
+ // Consumers never see this — they work against WindowHandle.
47
+ Webview: new (
48
+ debug?: boolean,
49
+ size?: { width: number; height: number; hint: number } | null,
50
+ window?: unknown,
51
+ ) => {
52
+ title: string;
53
+ size: { width: number; height: number; hint: number };
54
+ navigate(url: string): void;
55
+ setHTML(html: string): void;
56
+ init(source: string): void;
57
+ eval(source: string): void;
58
+ bind(name: string, cb: (...args: unknown[]) => unknown): void;
59
+ unbind(name: string): void;
60
+ run(): void;
61
+ destroy(): void;
62
+ };
63
+ SizeHint: { NONE: number; MIN: number; MAX: number; FIXED: number };
64
+ };
65
+
66
+ let webviewBunCache: WebviewBunModule | null = null;
67
+
68
+ /**
69
+ * Lazy-load `webview-bun`. Throws with an actionable error message when the
70
+ * peer is missing — the only surface on which end users hit this is desktop
71
+ * launch, so we can afford a long-form hint.
72
+ *
73
+ * @internal
74
+ */
75
+ export async function _loadWebviewBun(): Promise<WebviewBunModule> {
76
+ if (webviewBunCache) return webviewBunCache;
77
+ try {
78
+ // Dynamic import so `bun test` in a CI without `webview-bun` installed
79
+ // still passes. The import specifier is a bare module — no file-path
80
+ // probing — so bundlers can tree-shake the whole desktop subtree in a
81
+ // web-only build.
82
+ //
83
+ // `@ts-ignore` is used because `webview-bun` is an OPTIONAL peer — tsc
84
+ // must not hard-fail module resolution when the peer is absent. The
85
+ // runtime behaviour is guarded: the try/catch below rethrows a clean
86
+ // "please install" error if the import itself rejects at runtime.
87
+ // @ts-ignore -- optional peer, may not be resolvable at typecheck time
88
+ const mod = (await import("webview-bun")) as unknown as WebviewBunModule;
89
+ webviewBunCache = mod;
90
+ return mod;
91
+ } catch (cause) {
92
+ throw new Error(
93
+ [
94
+ "[@mandujs/core/desktop] Failed to load the optional peer 'webview-bun'.",
95
+ "Install it alongside Mandu for desktop targets:",
96
+ "",
97
+ " bun add webview-bun",
98
+ "",
99
+ "Then pin the version in package.json. Tested: ^2.4.0 (MIT).",
100
+ "Docs: https://github.com/tr1ckydev/webview-bun",
101
+ ].join("\n"),
102
+ { cause: cause as Error },
103
+ );
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Reset the lazy-load cache. Tests only.
109
+ *
110
+ * @internal
111
+ */
112
+ export function _resetWebviewBunCache(): void {
113
+ webviewBunCache = null;
114
+ }
115
+
116
+ // ─── Size hint mapping ──────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Map the string hint to `webview-bun`'s `SizeHint` numeric enum. We accept
120
+ * the string because (a) the string survives Worker `postMessage` cleanly
121
+ * and (b) it doesn't pin our public API to the peer's enum numbering.
122
+ *
123
+ * @internal
124
+ */
125
+ export function _mapSizeHint(
126
+ hint: WindowSizeHint | undefined,
127
+ enumRef: WebviewBunModule["SizeHint"],
128
+ ): number {
129
+ switch (hint) {
130
+ case "fixed":
131
+ return enumRef.FIXED;
132
+ case "min":
133
+ return enumRef.MIN;
134
+ case "max":
135
+ return enumRef.MAX;
136
+ case "none":
137
+ case undefined:
138
+ default:
139
+ return enumRef.NONE;
140
+ }
141
+ }
142
+
143
+ // ─── Option validation ──────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Validates `options` before we touch the FFI peer. Throws `TypeError` on
147
+ * the first problem found.
148
+ *
149
+ * @internal
150
+ */
151
+ export function _validateOptions(options: WindowOptions): void {
152
+ if (!options || typeof options !== "object") {
153
+ throw new TypeError(
154
+ "[@mandujs/core/desktop] createWindow: options must be an object.",
155
+ );
156
+ }
157
+ if (typeof options.url !== "string" || options.url.length === 0) {
158
+ throw new TypeError(
159
+ "[@mandujs/core/desktop] createWindow: 'url' must be a non-empty string.",
160
+ );
161
+ }
162
+ // Accept http/https/file/data — reject everything else. Remote URLs work
163
+ // but are actively discouraged; document that elsewhere.
164
+ const allowedProtocols = ["http:", "https:", "file:", "data:"];
165
+ let parsed: URL;
166
+ try {
167
+ parsed = new URL(options.url);
168
+ } catch {
169
+ throw new TypeError(
170
+ `[@mandujs/core/desktop] createWindow: 'url' is not a valid URL: ${JSON.stringify(
171
+ options.url,
172
+ )}.`,
173
+ );
174
+ }
175
+ if (!allowedProtocols.includes(parsed.protocol)) {
176
+ throw new TypeError(
177
+ `[@mandujs/core/desktop] createWindow: 'url' protocol ${parsed.protocol} is not allowed (use http/https/file/data).`,
178
+ );
179
+ }
180
+ if (options.width !== undefined) {
181
+ if (
182
+ typeof options.width !== "number" ||
183
+ !Number.isFinite(options.width) ||
184
+ options.width <= 0
185
+ ) {
186
+ throw new TypeError(
187
+ "[@mandujs/core/desktop] createWindow: 'width' must be a positive finite number.",
188
+ );
189
+ }
190
+ }
191
+ if (options.height !== undefined) {
192
+ if (
193
+ typeof options.height !== "number" ||
194
+ !Number.isFinite(options.height) ||
195
+ options.height <= 0
196
+ ) {
197
+ throw new TypeError(
198
+ "[@mandujs/core/desktop] createWindow: 'height' must be a positive finite number.",
199
+ );
200
+ }
201
+ }
202
+ if (
203
+ options.hint !== undefined &&
204
+ !["none", "min", "max", "fixed"].includes(options.hint)
205
+ ) {
206
+ throw new TypeError(
207
+ `[@mandujs/core/desktop] createWindow: 'hint' must be one of none|min|max|fixed (got ${JSON.stringify(
208
+ options.hint,
209
+ )}).`,
210
+ );
211
+ }
212
+ if (options.handlers !== undefined) {
213
+ if (typeof options.handlers !== "object" || options.handlers === null) {
214
+ throw new TypeError(
215
+ "[@mandujs/core/desktop] createWindow: 'handlers' must be an object of functions.",
216
+ );
217
+ }
218
+ for (const [name, fn] of Object.entries(options.handlers)) {
219
+ if (typeof fn !== "function") {
220
+ throw new TypeError(
221
+ `[@mandujs/core/desktop] createWindow: handlers.${name} must be a function.`,
222
+ );
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ // ─── Defaults ───────────────────────────────────────────────────────────────
229
+
230
+ /** @internal */
231
+ export const _DEFAULTS: Required<Pick<WindowOptions, "title" | "width" | "height" | "hint" | "debug">> = {
232
+ title: "Mandu Desktop",
233
+ width: 1024,
234
+ height: 768,
235
+ hint: "none",
236
+ debug: false,
237
+ };
238
+
239
+ // ─── createWindow ───────────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Create a desktop window backed by the system WebView (WebView2 on Windows,
243
+ * WKWebView on macOS, WebKitGTK on Linux). Optional peer `webview-bun` must
244
+ * be installed.
245
+ *
246
+ * The returned {@link WindowHandle} does NOT auto-start the platform event
247
+ * loop — callers must either call `handle.run()` (blocking) or await
248
+ * `handle.closed`. In Worker-based setups the loop is typically started by
249
+ * the Worker host (see `./worker.ts`).
250
+ *
251
+ * @example Main-thread use (window only, no HTTP server):
252
+ * ```ts
253
+ * import { createWindow } from "@mandujs/core/desktop";
254
+ *
255
+ * const win = await createWindow({
256
+ * url: "https://example.com",
257
+ * title: "Read later",
258
+ * width: 1200,
259
+ * height: 800,
260
+ * });
261
+ * win.run(); // blocks until user closes
262
+ * ```
263
+ *
264
+ * @example With a Mandu server (Worker pattern — recommended):
265
+ * ```ts
266
+ * // main.ts
267
+ * import { startServer } from "@mandujs/core";
268
+ * import manifest from "../../.mandu/manifest.json" with { type: "json" };
269
+ *
270
+ * const server = startServer(manifest, { port: 0, hostname: "127.0.0.1" });
271
+ * const worker = new Worker(new URL("./worker.ts", import.meta.url));
272
+ * worker.postMessage({
273
+ * type: "open",
274
+ * options: { url: `http://127.0.0.1:${server.server.port}`, title: "My App" },
275
+ * });
276
+ * ```
277
+ */
278
+ export async function createWindow(
279
+ options: WindowOptions,
280
+ ): Promise<WindowHandle> {
281
+ _validateOptions(options);
282
+
283
+ const { Webview, SizeHint } = await _loadWebviewBun();
284
+
285
+ const merged = {
286
+ ..._DEFAULTS,
287
+ ...options,
288
+ };
289
+ const hintNum = _mapSizeHint(merged.hint, SizeHint);
290
+
291
+ // Construct the webview. `webview-bun` uses constructor args for size+hint
292
+ // and exposes setters for title/size post-construction.
293
+ const wv = new Webview(merged.debug, {
294
+ width: merged.width,
295
+ height: merged.height,
296
+ hint: hintNum,
297
+ });
298
+
299
+ // Title must be set post-ctor — webview-bun API shape.
300
+ try {
301
+ wv.title = merged.title;
302
+ } catch (error) {
303
+ // Some libwebview builds throw if the window hasn't been realized yet;
304
+ // best-effort, not fatal.
305
+ if (merged.debug) {
306
+ console.warn("[@mandujs/core/desktop] title set warning:", error);
307
+ }
308
+ }
309
+
310
+ // Pre-register handlers BEFORE navigation so the page's first script doesn't
311
+ // see an undefined global.
312
+ if (options.handlers) {
313
+ for (const [name, fn] of Object.entries(options.handlers)) {
314
+ try {
315
+ wv.bind(name, fn);
316
+ } catch (error) {
317
+ throw new Error(
318
+ `[@mandujs/core/desktop] Failed to bind handler "${name}": ${
319
+ error instanceof Error ? error.message : String(error)
320
+ }`,
321
+ );
322
+ }
323
+ }
324
+ }
325
+
326
+ // Set up closed-signal wiring. `webview-bun` does not expose a native
327
+ // close event, so we rely on `run()` returning OR an explicit `destroy()`
328
+ // call to flip the flag.
329
+ let closed = false;
330
+ let resolveClosed: (() => void) | null = null;
331
+ const closedPromise = new Promise<void>((resolve) => {
332
+ resolveClosed = resolve;
333
+ });
334
+ const closeCallbacks: Array<() => void> = [];
335
+
336
+ function markClosed(): void {
337
+ if (closed) return;
338
+ closed = true;
339
+ // Run user callbacks first so their exceptions don't prevent Promise
340
+ // resolution. We swallow exceptions to match `setTimeout` semantics.
341
+ for (const cb of closeCallbacks) {
342
+ try {
343
+ cb();
344
+ } catch (error) {
345
+ console.error(
346
+ "[@mandujs/core/desktop] onClose callback threw:",
347
+ error,
348
+ );
349
+ }
350
+ }
351
+ if (options.onClose) {
352
+ try {
353
+ const result = options.onClose();
354
+ if (result instanceof Promise) {
355
+ result.catch((error) =>
356
+ console.error(
357
+ "[@mandujs/core/desktop] onClose (options) threw:",
358
+ error,
359
+ ),
360
+ );
361
+ }
362
+ } catch (error) {
363
+ console.error(
364
+ "[@mandujs/core/desktop] onClose (options) threw:",
365
+ error,
366
+ );
367
+ }
368
+ }
369
+ resolveClosed?.();
370
+ }
371
+
372
+ // Navigate AFTER handlers are registered, so the first page load can
373
+ // already call any bound globals.
374
+ try {
375
+ wv.navigate(merged.url);
376
+ } catch (error) {
377
+ // Navigation failure is fatal — tear down and rethrow.
378
+ try {
379
+ wv.destroy();
380
+ } catch {
381
+ /* ignore cleanup errors */
382
+ }
383
+ throw new Error(
384
+ `[@mandujs/core/desktop] navigate() failed: ${
385
+ error instanceof Error ? error.message : String(error)
386
+ }`,
387
+ );
388
+ }
389
+
390
+ // Fire onReady on the next microtask so callers that chain `await
391
+ // createWindow(...)` can attach listeners first.
392
+ if (options.onReady) {
393
+ queueMicrotask(() => {
394
+ try {
395
+ const result = options.onReady!();
396
+ if (result instanceof Promise) {
397
+ result.catch((error) =>
398
+ console.error(
399
+ "[@mandujs/core/desktop] onReady threw:",
400
+ error,
401
+ ),
402
+ );
403
+ }
404
+ } catch (error) {
405
+ console.error("[@mandujs/core/desktop] onReady threw:", error);
406
+ }
407
+ });
408
+ }
409
+
410
+ const handle: WindowHandle = {
411
+ async close() {
412
+ if (closed) return;
413
+ try {
414
+ wv.destroy();
415
+ } catch (error) {
416
+ // `webview-bun` #35: destroy() from a timer doesn't always interrupt
417
+ // run(). We still mark closed so the `closed` promise resolves — the
418
+ // native run() will exit on its own once the user closes the shell.
419
+ if (merged.debug) {
420
+ console.warn("[@mandujs/core/desktop] destroy() warning:", error);
421
+ }
422
+ }
423
+ markClosed();
424
+ },
425
+ onClose(cb: () => void) {
426
+ if (closed) {
427
+ // Match `addEventListener('load')` semantics on a ready document —
428
+ // fire on the next microtask so ordering is deterministic.
429
+ queueMicrotask(() => {
430
+ try {
431
+ cb();
432
+ } catch (error) {
433
+ console.error(
434
+ "[@mandujs/core/desktop] onClose callback threw:",
435
+ error,
436
+ );
437
+ }
438
+ });
439
+ return;
440
+ }
441
+ closeCallbacks.push(cb);
442
+ },
443
+ async eval(js: string) {
444
+ if (closed) {
445
+ throw new Error(
446
+ "[@mandujs/core/desktop] eval() called on closed window.",
447
+ );
448
+ }
449
+ if (typeof js !== "string" || js.length === 0) {
450
+ throw new TypeError(
451
+ "[@mandujs/core/desktop] eval: 'js' must be a non-empty string.",
452
+ );
453
+ }
454
+ wv.eval(js);
455
+ },
456
+ bind(name: string, fn: (...args: unknown[]) => unknown) {
457
+ if (closed) {
458
+ throw new Error(
459
+ "[@mandujs/core/desktop] bind() called on closed window.",
460
+ );
461
+ }
462
+ if (typeof name !== "string" || name.length === 0) {
463
+ throw new TypeError(
464
+ "[@mandujs/core/desktop] bind: 'name' must be a non-empty string.",
465
+ );
466
+ }
467
+ if (typeof fn !== "function") {
468
+ throw new TypeError(
469
+ "[@mandujs/core/desktop] bind: 'fn' must be a function.",
470
+ );
471
+ }
472
+ wv.bind(name, fn);
473
+ },
474
+ closed: closedPromise,
475
+ run() {
476
+ if (closed) {
477
+ // No-op — already closed. `webview-bun`'s run() on a destroyed
478
+ // instance would crash; avoid that class of footgun.
479
+ return;
480
+ }
481
+ try {
482
+ wv.run();
483
+ } finally {
484
+ // run() returned → the window was closed (either natively or via
485
+ // destroy()). Flip the flag.
486
+ markClosed();
487
+ }
488
+ },
489
+ };
490
+
491
+ return handle;
492
+ }