@mandujs/core 0.22.1 → 0.23.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/package.json +1 -1
- package/src/config/mandu.ts +172 -110
- package/src/config/validate.ts +357 -290
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -0
- package/src/desktop/__tests__/window.test.ts +79 -3
- package/src/desktop/webview-fallback.ts +583 -0
- package/src/desktop/window.ts +527 -492
- package/src/perf/hmr-markers.ts +12 -0
- package/src/runtime/server.ts +133 -8
- package/src/testing/db.ts +157 -0
- package/src/testing/index.ts +59 -1
- package/src/testing/mocks.ts +203 -0
- package/src/testing/server.ts +196 -0
- package/src/testing/session.ts +190 -0
- package/src/testing/snapshot.ts +444 -0
package/src/desktop/window.ts
CHANGED
|
@@ -1,492 +1,527 @@
|
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
console.error(
|
|
434
|
-
"[@mandujs/core/desktop]
|
|
435
|
-
error,
|
|
436
|
-
)
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
// Phase 11 C / M-02 — FFI fallback path. When `MANDU_DESKTOP_INLINE_FFI=1`
|
|
284
|
+
// is set, OR when `webview-bun` dynamic import fails at runtime, we try
|
|
285
|
+
// the `bun:ffi` fallback that binds directly to the upstream
|
|
286
|
+
// `webview/webview` C library. The fallback is a supply-chain mitigation
|
|
287
|
+
// for the webview-bun single-maintainer risk — see
|
|
288
|
+
// `docs/bun/phase-9-diagnostics/webview-bun-ffi.md` §8.
|
|
289
|
+
//
|
|
290
|
+
// Behaviour matrix:
|
|
291
|
+
// MANDU_DESKTOP_INLINE_FFI=1:
|
|
292
|
+
// → SKIP webview-bun, go straight to FFI fallback.
|
|
293
|
+
// webview-bun resolves cleanly:
|
|
294
|
+
// → primary path (normal flow below).
|
|
295
|
+
// webview-bun rejects with a module-not-found error AND fallback
|
|
296
|
+
// succeeds: log a one-time hint, use fallback.
|
|
297
|
+
// Both fail: rethrow the webview-bun "install me" error (the original
|
|
298
|
+
// actionable hint).
|
|
299
|
+
const forceFFI = process.env.MANDU_DESKTOP_INLINE_FFI === "1";
|
|
300
|
+
if (forceFFI) {
|
|
301
|
+
const { createFallbackWebview } = await import("./webview-fallback.js");
|
|
302
|
+
return createFallbackWebview(options);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let peer: WebviewBunModule;
|
|
306
|
+
try {
|
|
307
|
+
peer = await _loadWebviewBun();
|
|
308
|
+
} catch (primaryError) {
|
|
309
|
+
// Try the fallback. If it also fails, surface the PRIMARY error since
|
|
310
|
+
// it carries the actionable "bun add webview-bun" hint users expect.
|
|
311
|
+
try {
|
|
312
|
+
const { createFallbackWebview } = await import("./webview-fallback.js");
|
|
313
|
+
return await createFallbackWebview(options);
|
|
314
|
+
} catch {
|
|
315
|
+
throw primaryError;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const { Webview, SizeHint } = peer;
|
|
319
|
+
|
|
320
|
+
const merged = {
|
|
321
|
+
..._DEFAULTS,
|
|
322
|
+
...options,
|
|
323
|
+
};
|
|
324
|
+
const hintNum = _mapSizeHint(merged.hint, SizeHint);
|
|
325
|
+
|
|
326
|
+
// Construct the webview. `webview-bun` uses constructor args for size+hint
|
|
327
|
+
// and exposes setters for title/size post-construction.
|
|
328
|
+
const wv = new Webview(merged.debug, {
|
|
329
|
+
width: merged.width,
|
|
330
|
+
height: merged.height,
|
|
331
|
+
hint: hintNum,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Title must be set post-ctor — webview-bun API shape.
|
|
335
|
+
try {
|
|
336
|
+
wv.title = merged.title;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
// Some libwebview builds throw if the window hasn't been realized yet;
|
|
339
|
+
// best-effort, not fatal.
|
|
340
|
+
if (merged.debug) {
|
|
341
|
+
console.warn("[@mandujs/core/desktop] title set warning:", error);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Pre-register handlers BEFORE navigation so the page's first script doesn't
|
|
346
|
+
// see an undefined global.
|
|
347
|
+
if (options.handlers) {
|
|
348
|
+
for (const [name, fn] of Object.entries(options.handlers)) {
|
|
349
|
+
try {
|
|
350
|
+
wv.bind(name, fn);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`[@mandujs/core/desktop] Failed to bind handler "${name}": ${
|
|
354
|
+
error instanceof Error ? error.message : String(error)
|
|
355
|
+
}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Set up closed-signal wiring. `webview-bun` does not expose a native
|
|
362
|
+
// close event, so we rely on `run()` returning OR an explicit `destroy()`
|
|
363
|
+
// call to flip the flag.
|
|
364
|
+
let closed = false;
|
|
365
|
+
let resolveClosed: (() => void) | null = null;
|
|
366
|
+
const closedPromise = new Promise<void>((resolve) => {
|
|
367
|
+
resolveClosed = resolve;
|
|
368
|
+
});
|
|
369
|
+
const closeCallbacks: Array<() => void> = [];
|
|
370
|
+
|
|
371
|
+
function markClosed(): void {
|
|
372
|
+
if (closed) return;
|
|
373
|
+
closed = true;
|
|
374
|
+
// Run user callbacks first so their exceptions don't prevent Promise
|
|
375
|
+
// resolution. We swallow exceptions to match `setTimeout` semantics.
|
|
376
|
+
for (const cb of closeCallbacks) {
|
|
377
|
+
try {
|
|
378
|
+
cb();
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error(
|
|
381
|
+
"[@mandujs/core/desktop] onClose callback threw:",
|
|
382
|
+
error,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (options.onClose) {
|
|
387
|
+
try {
|
|
388
|
+
const result = options.onClose();
|
|
389
|
+
if (result instanceof Promise) {
|
|
390
|
+
result.catch((error) =>
|
|
391
|
+
console.error(
|
|
392
|
+
"[@mandujs/core/desktop] onClose (options) threw:",
|
|
393
|
+
error,
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.error(
|
|
399
|
+
"[@mandujs/core/desktop] onClose (options) threw:",
|
|
400
|
+
error,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
resolveClosed?.();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Navigate AFTER handlers are registered, so the first page load can
|
|
408
|
+
// already call any bound globals.
|
|
409
|
+
try {
|
|
410
|
+
wv.navigate(merged.url);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
// Navigation failure is fatal — tear down and rethrow.
|
|
413
|
+
try {
|
|
414
|
+
wv.destroy();
|
|
415
|
+
} catch {
|
|
416
|
+
/* ignore cleanup errors */
|
|
417
|
+
}
|
|
418
|
+
throw new Error(
|
|
419
|
+
`[@mandujs/core/desktop] navigate() failed: ${
|
|
420
|
+
error instanceof Error ? error.message : String(error)
|
|
421
|
+
}`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Fire onReady on the next microtask so callers that chain `await
|
|
426
|
+
// createWindow(...)` can attach listeners first.
|
|
427
|
+
if (options.onReady) {
|
|
428
|
+
queueMicrotask(() => {
|
|
429
|
+
try {
|
|
430
|
+
const result = options.onReady!();
|
|
431
|
+
if (result instanceof Promise) {
|
|
432
|
+
result.catch((error) =>
|
|
433
|
+
console.error(
|
|
434
|
+
"[@mandujs/core/desktop] onReady threw:",
|
|
435
|
+
error,
|
|
436
|
+
),
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.error("[@mandujs/core/desktop] onReady threw:", error);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const handle: WindowHandle = {
|
|
446
|
+
async close() {
|
|
447
|
+
if (closed) return;
|
|
448
|
+
try {
|
|
449
|
+
wv.destroy();
|
|
450
|
+
} catch (error) {
|
|
451
|
+
// `webview-bun` #35: destroy() from a timer doesn't always interrupt
|
|
452
|
+
// run(). We still mark closed so the `closed` promise resolves — the
|
|
453
|
+
// native run() will exit on its own once the user closes the shell.
|
|
454
|
+
if (merged.debug) {
|
|
455
|
+
console.warn("[@mandujs/core/desktop] destroy() warning:", error);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
markClosed();
|
|
459
|
+
},
|
|
460
|
+
onClose(cb: () => void) {
|
|
461
|
+
if (closed) {
|
|
462
|
+
// Match `addEventListener('load')` semantics on a ready document —
|
|
463
|
+
// fire on the next microtask so ordering is deterministic.
|
|
464
|
+
queueMicrotask(() => {
|
|
465
|
+
try {
|
|
466
|
+
cb();
|
|
467
|
+
} catch (error) {
|
|
468
|
+
console.error(
|
|
469
|
+
"[@mandujs/core/desktop] onClose callback threw:",
|
|
470
|
+
error,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
closeCallbacks.push(cb);
|
|
477
|
+
},
|
|
478
|
+
async eval(js: string) {
|
|
479
|
+
if (closed) {
|
|
480
|
+
throw new Error(
|
|
481
|
+
"[@mandujs/core/desktop] eval() called on closed window.",
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (typeof js !== "string" || js.length === 0) {
|
|
485
|
+
throw new TypeError(
|
|
486
|
+
"[@mandujs/core/desktop] eval: 'js' must be a non-empty string.",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
wv.eval(js);
|
|
490
|
+
},
|
|
491
|
+
bind(name: string, fn: (...args: unknown[]) => unknown) {
|
|
492
|
+
if (closed) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
"[@mandujs/core/desktop] bind() called on closed window.",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
498
|
+
throw new TypeError(
|
|
499
|
+
"[@mandujs/core/desktop] bind: 'name' must be a non-empty string.",
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (typeof fn !== "function") {
|
|
503
|
+
throw new TypeError(
|
|
504
|
+
"[@mandujs/core/desktop] bind: 'fn' must be a function.",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
wv.bind(name, fn);
|
|
508
|
+
},
|
|
509
|
+
closed: closedPromise,
|
|
510
|
+
run() {
|
|
511
|
+
if (closed) {
|
|
512
|
+
// No-op — already closed. `webview-bun`'s run() on a destroyed
|
|
513
|
+
// instance would crash; avoid that class of footgun.
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
wv.run();
|
|
518
|
+
} finally {
|
|
519
|
+
// run() returned → the window was closed (either natively or via
|
|
520
|
+
// destroy()). Flip the flag.
|
|
521
|
+
markClosed();
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
return handle;
|
|
527
|
+
}
|