@mandujs/core 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @mandujs/core/desktop — smoke tests
3
+ *
4
+ * Integration-shaped assertions: the barrel re-exports what we expect, the
5
+ * optional peer is genuinely optional (tests pass with it absent), and the
6
+ * demo fixture is present.
7
+ *
8
+ * The browser-visible smoke (actual WebView window opening) runs only when
9
+ * a `MANDU_DESKTOP_E2E=1` env flag is set AND the peer is installed. CI
10
+ * skips it unconditionally.
11
+ */
12
+
13
+ import { describe, it, expect } from "bun:test";
14
+ import path from "path";
15
+ import fs from "fs";
16
+
17
+ const repoRoot = path.resolve(__dirname, "..", "..", "..", "..", "..");
18
+ const demoPath = path.join(repoRoot, "demo", "desktop-starter");
19
+
20
+ describe("@mandujs/core/desktop — smoke", () => {
21
+ it("barrel exports createWindow and the type surface", async () => {
22
+ const mod = await import("../index");
23
+ expect(typeof mod.createWindow).toBe("function");
24
+ // Type-only exports don't survive runtime introspection; the fact
25
+ // that the import succeeds without throwing is the meaningful part.
26
+ });
27
+
28
+ it("worker entry is importable without a running Worker host", async () => {
29
+ // Importing worker.ts outside a Worker context must not crash — the
30
+ // module guards on `globalThis.addEventListener` presence. In Bun main
31
+ // thread, `addEventListener` exists on globalThis (as a DOM-compat
32
+ // shim), so the installer runs but just doesn't receive messages.
33
+ const mod = await import("../worker");
34
+ expect(typeof mod._installHandler).toBe("function");
35
+ expect(typeof mod._postToParent).toBe("function");
36
+ });
37
+
38
+ it("demo/desktop-starter fixture exists", () => {
39
+ const exists = fs.existsSync(demoPath);
40
+ expect(exists).toBe(true);
41
+ });
42
+
43
+ it("demo/desktop-starter has a package.json with webview-bun peer", () => {
44
+ const pkgPath = path.join(demoPath, "package.json");
45
+ const pkgRaw = fs.readFileSync(pkgPath, "utf-8");
46
+ const pkg = JSON.parse(pkgRaw);
47
+ expect(pkg.name).toBe("mandu-desktop-starter");
48
+ // webview-bun should appear either in dependencies or peerDependencies
49
+ const combined = {
50
+ ...(pkg.dependencies ?? {}),
51
+ ...(pkg.peerDependencies ?? {}),
52
+ ...(pkg.optionalDependencies ?? {}),
53
+ };
54
+ expect("webview-bun" in combined).toBe(true);
55
+ });
56
+
57
+ it("demo/desktop-starter has a desktop entry file", () => {
58
+ const entryPath = path.join(demoPath, "src", "desktop", "main.ts");
59
+ const exists = fs.existsSync(entryPath);
60
+ expect(exists).toBe(true);
61
+ const contents = fs.readFileSync(entryPath, "utf-8");
62
+ // The entry should wire Worker + startServer — sanity-check key symbols.
63
+ expect(contents).toContain("startServer");
64
+ expect(contents).toContain("Worker");
65
+ });
66
+
67
+ it("demo/desktop-starter has minimal app/ routes", () => {
68
+ const appDir = path.join(demoPath, "app");
69
+ const exists = fs.existsSync(appDir);
70
+ expect(exists).toBe(true);
71
+ const pagePath = path.join(appDir, "page.tsx");
72
+ expect(fs.existsSync(pagePath)).toBe(true);
73
+ });
74
+ });
75
+
76
+ // Describe block that runs only when explicitly enabled. Left as a
77
+ // placeholder for local Windows smoke — no assertions on CI.
78
+ const canOpenWindow =
79
+ process.env.MANDU_DESKTOP_E2E === "1" &&
80
+ (process.platform === "win32" ||
81
+ process.platform === "darwin" ||
82
+ process.platform === "linux");
83
+
84
+ describe.skipIf(!canOpenWindow)(
85
+ "@mandujs/core/desktop — browser smoke (opt-in)",
86
+ () => {
87
+ it("opens and closes a data: URL window", async () => {
88
+ const { createWindow } = await import("../window");
89
+ const handle = await createWindow({
90
+ url: "data:text/html,<h1>Mandu smoke</h1>",
91
+ title: "Mandu E2E",
92
+ width: 400,
93
+ height: 300,
94
+ });
95
+ // Close immediately — we only assert the handle is constructible.
96
+ await handle.close();
97
+ await handle.closed;
98
+ });
99
+ },
100
+ );
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @mandujs/core/desktop — window factory tests
3
+ *
4
+ * These tests run **without actually loading `webview-bun`**: we either
5
+ * stub the optional peer via a mocked import cache or exercise option
6
+ * validation / size-hint mapping / error paths that never need the FFI
7
+ * peer at all. CI does not have WebView2 / WKWebView available, so any
8
+ * test that would open a real window is marked describe.skipIf.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach } from "bun:test";
12
+ import {
13
+ _DEFAULTS,
14
+ _mapSizeHint,
15
+ _resetWebviewBunCache,
16
+ _validateOptions,
17
+ createWindow,
18
+ } from "../window";
19
+ import type { WindowOptions } from "../types";
20
+
21
+ describe("@mandujs/core/desktop — types & validation", () => {
22
+ it("_DEFAULTS match the documented contract", () => {
23
+ expect(_DEFAULTS.title).toBe("Mandu Desktop");
24
+ expect(_DEFAULTS.width).toBe(1024);
25
+ expect(_DEFAULTS.height).toBe(768);
26
+ expect(_DEFAULTS.hint).toBe("none");
27
+ expect(_DEFAULTS.debug).toBe(false);
28
+ });
29
+
30
+ it("_mapSizeHint translates strings to peer enum values", () => {
31
+ const fakeEnum = { NONE: 0, MIN: 1, MAX: 2, FIXED: 3 };
32
+ expect(_mapSizeHint("none", fakeEnum)).toBe(0);
33
+ expect(_mapSizeHint("min", fakeEnum)).toBe(1);
34
+ expect(_mapSizeHint("max", fakeEnum)).toBe(2);
35
+ expect(_mapSizeHint("fixed", fakeEnum)).toBe(3);
36
+ expect(_mapSizeHint(undefined, fakeEnum)).toBe(0); // default → NONE
37
+ });
38
+
39
+ it("_validateOptions rejects missing url", () => {
40
+ expect(() => _validateOptions({} as WindowOptions)).toThrow(/url/);
41
+ });
42
+
43
+ it("_validateOptions rejects non-string url", () => {
44
+ expect(() =>
45
+ _validateOptions({ url: 123 as unknown as string }),
46
+ ).toThrow(/url/);
47
+ });
48
+
49
+ it("_validateOptions rejects malformed url", () => {
50
+ expect(() => _validateOptions({ url: "not a url" })).toThrow(/valid URL/);
51
+ });
52
+
53
+ it("_validateOptions rejects forbidden protocols", () => {
54
+ expect(() =>
55
+ _validateOptions({ url: "javascript:alert(1)" }),
56
+ ).toThrow(/protocol/);
57
+ expect(() =>
58
+ _validateOptions({ url: "chrome://version" }),
59
+ ).toThrow(/protocol/);
60
+ });
61
+
62
+ it("_validateOptions accepts http, https, file, and data URLs", () => {
63
+ expect(() =>
64
+ _validateOptions({ url: "http://127.0.0.1:3333/" }),
65
+ ).not.toThrow();
66
+ expect(() =>
67
+ _validateOptions({ url: "https://example.com/" }),
68
+ ).not.toThrow();
69
+ expect(() =>
70
+ _validateOptions({ url: "file:///tmp/index.html" }),
71
+ ).not.toThrow();
72
+ expect(() =>
73
+ _validateOptions({ url: "data:text/html,<h1>hi</h1>" }),
74
+ ).not.toThrow();
75
+ });
76
+
77
+ it("_validateOptions rejects non-positive width/height", () => {
78
+ expect(() =>
79
+ _validateOptions({ url: "http://x", width: 0 }),
80
+ ).toThrow(/width/);
81
+ expect(() =>
82
+ _validateOptions({ url: "http://x", height: -50 }),
83
+ ).toThrow(/height/);
84
+ expect(() =>
85
+ _validateOptions({
86
+ url: "http://x",
87
+ width: Number.NaN,
88
+ }),
89
+ ).toThrow(/width/);
90
+ });
91
+
92
+ it("_validateOptions rejects unknown hint values", () => {
93
+ expect(() =>
94
+ _validateOptions({
95
+ url: "http://x",
96
+ hint: "resizable" as never,
97
+ }),
98
+ ).toThrow(/hint/);
99
+ });
100
+
101
+ it("_validateOptions rejects non-function handlers", () => {
102
+ expect(() =>
103
+ _validateOptions({
104
+ url: "http://x",
105
+ handlers: { foo: "not a function" as unknown as () => void },
106
+ }),
107
+ ).toThrow(/handlers\.foo/);
108
+ });
109
+
110
+ it("_validateOptions accepts a fully-specified options bag", () => {
111
+ expect(() =>
112
+ _validateOptions({
113
+ url: "http://127.0.0.1:3333/",
114
+ title: "Test",
115
+ width: 1280,
116
+ height: 800,
117
+ hint: "fixed",
118
+ debug: true,
119
+ handlers: { greet: () => "hi" },
120
+ }),
121
+ ).not.toThrow();
122
+ });
123
+ });
124
+
125
+ describe("@mandujs/core/desktop — createWindow peer loading", () => {
126
+ beforeEach(() => {
127
+ _resetWebviewBunCache();
128
+ });
129
+
130
+ it("validates options before attempting to load the peer", async () => {
131
+ // Bad options should throw TypeError *before* the peer import runs, so
132
+ // this test passes even on CI without webview-bun installed.
133
+ await expect(
134
+ createWindow({ url: "" } as WindowOptions),
135
+ ).rejects.toThrow(TypeError);
136
+ await expect(
137
+ createWindow({} as WindowOptions),
138
+ ).rejects.toThrow(TypeError);
139
+ await expect(
140
+ createWindow({
141
+ url: "javascript:evil()",
142
+ } as WindowOptions),
143
+ ).rejects.toThrow(/protocol/);
144
+ });
145
+
146
+ it("throws an actionable error when webview-bun is not installed", async () => {
147
+ // Only run this when the peer is genuinely absent — if a developer has
148
+ // `webview-bun` installed locally, the import will succeed and we'd
149
+ // open a real window. Probe via dynamic import.
150
+ //
151
+ // @ts-ignore -- optional peer, resolution may fail at typecheck time
152
+ const probe = () => import("webview-bun");
153
+ let peerInstalled = false;
154
+ try {
155
+ await probe();
156
+ peerInstalled = true;
157
+ } catch {
158
+ peerInstalled = false;
159
+ }
160
+
161
+ if (peerInstalled) {
162
+ // On Windows with the peer installed, we'd end up creating a real
163
+ // window. Skip the assertion — the actionable-error guarantee is only
164
+ // meaningful when the peer is missing.
165
+ return;
166
+ }
167
+
168
+ await expect(
169
+ createWindow({ url: "http://127.0.0.1:1" }),
170
+ ).rejects.toThrow(/webview-bun/);
171
+ });
172
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * @mandujs/core/desktop — Worker protocol tests
3
+ *
4
+ * The Worker file auto-installs a global message listener on module
5
+ * evaluation, so to test the protocol we reach for the exported
6
+ * `_installHandler` helper and drive it with a mock message emitter and
7
+ * a mock `createWindow` implementation. No real Worker spawn, no FFI.
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+ import { _installHandler } from "../worker";
12
+ import type {
13
+ WindowHandle,
14
+ WindowOptions,
15
+ WorkerInbound,
16
+ WorkerOutbound,
17
+ } from "../types";
18
+
19
+ type MessageListener = (ev: { data: WorkerInbound }) => Promise<void> | void;
20
+
21
+ /**
22
+ * Stand in for `addEventListener('message', ...)` — retains the listener
23
+ * and exposes a `fire()` helper so tests can synchronously drive messages.
24
+ */
25
+ function createEmitter(): {
26
+ listen: (cb: MessageListener) => void;
27
+ fire: (data: WorkerInbound) => Promise<void>;
28
+ } {
29
+ let listener: MessageListener | null = null;
30
+ return {
31
+ listen(cb) {
32
+ listener = cb;
33
+ },
34
+ async fire(data) {
35
+ if (!listener) throw new Error("no listener installed");
36
+ await listener({ data });
37
+ },
38
+ };
39
+ }
40
+
41
+ /**
42
+ * A fake WindowHandle we can wire up to _installHandler so we don't need a
43
+ * real Webview. Tracks every call so tests can assert order.
44
+ */
45
+ function createFakeHandle(options: WindowOptions): {
46
+ handle: WindowHandle;
47
+ calls: string[];
48
+ closed: () => void;
49
+ } {
50
+ const calls: string[] = [];
51
+ calls.push(`ctor:${options.url}`);
52
+ let resolveClosed: (() => void) | null = null;
53
+ const closedPromise = new Promise<void>((r) => {
54
+ resolveClosed = r;
55
+ });
56
+ const handle: WindowHandle = {
57
+ async close() {
58
+ calls.push("close");
59
+ resolveClosed?.();
60
+ },
61
+ onClose(cb) {
62
+ closedPromise.then(cb);
63
+ },
64
+ async eval(js) {
65
+ calls.push(`eval:${js}`);
66
+ },
67
+ bind() {
68
+ calls.push("bind");
69
+ },
70
+ closed: closedPromise,
71
+ run() {
72
+ calls.push("run");
73
+ // Run returns synchronously in tests — no blocking.
74
+ },
75
+ };
76
+ return {
77
+ handle,
78
+ calls,
79
+ closed: () => resolveClosed?.(),
80
+ };
81
+ }
82
+
83
+ describe("@mandujs/core/desktop — Worker protocol", () => {
84
+ it("handles open → ready sequence and stores handle", async () => {
85
+ const emitter = createEmitter();
86
+ let fake: ReturnType<typeof createFakeHandle> | null = null;
87
+ const outbound: WorkerOutbound[] = [];
88
+
89
+ // Install message handler with a fake createWindow.
90
+ const installed = _installHandler(emitter.listen, async (opts) => {
91
+ fake = createFakeHandle(opts);
92
+ return fake.handle;
93
+ });
94
+
95
+ // Intercept `postMessage` on globalThis to capture outbound messages.
96
+ const prevPost = (globalThis as { postMessage?: unknown }).postMessage;
97
+ (globalThis as { postMessage?: unknown }).postMessage = (
98
+ msg: WorkerOutbound,
99
+ ) => {
100
+ outbound.push(msg);
101
+ };
102
+
103
+ try {
104
+ await emitter.fire({
105
+ type: "open",
106
+ options: { url: "http://127.0.0.1:3333/" },
107
+ });
108
+
109
+ // Give the microtask queue a chance to drain the deferred run() call.
110
+ await new Promise((r) => setTimeout(r, 10));
111
+
112
+ expect(fake).not.toBeNull();
113
+ expect(fake!.calls).toContain("ctor:http://127.0.0.1:3333/");
114
+ expect(fake!.calls).toContain("run");
115
+ expect(outbound.some((m) => m.type === "ready")).toBe(true);
116
+ expect(installed.getHandle()).not.toBeNull();
117
+ } finally {
118
+ (globalThis as { postMessage?: unknown }).postMessage = prevPost;
119
+ }
120
+ });
121
+
122
+ it("rejects duplicate open messages", async () => {
123
+ const emitter = createEmitter();
124
+ const outbound: WorkerOutbound[] = [];
125
+
126
+ _installHandler(emitter.listen, async (opts) =>
127
+ createFakeHandle(opts).handle,
128
+ );
129
+
130
+ const prevPost = (globalThis as { postMessage?: unknown }).postMessage;
131
+ (globalThis as { postMessage?: unknown }).postMessage = (
132
+ msg: WorkerOutbound,
133
+ ) => {
134
+ outbound.push(msg);
135
+ };
136
+
137
+ try {
138
+ await emitter.fire({
139
+ type: "open",
140
+ options: { url: "http://127.0.0.1:3333/" },
141
+ });
142
+ outbound.length = 0; // reset
143
+ await emitter.fire({
144
+ type: "open",
145
+ options: { url: "http://127.0.0.1:3333/" },
146
+ });
147
+ const error = outbound.find((m) => m.type === "error");
148
+ expect(error).toBeDefined();
149
+ if (error && error.type === "error") {
150
+ expect(error.message).toMatch(/already open/);
151
+ }
152
+ } finally {
153
+ (globalThis as { postMessage?: unknown }).postMessage = prevPost;
154
+ }
155
+ });
156
+
157
+ it("handles eval after open", async () => {
158
+ const emitter = createEmitter();
159
+ let fake: ReturnType<typeof createFakeHandle> | null = null;
160
+
161
+ _installHandler(emitter.listen, async (opts) => {
162
+ fake = createFakeHandle(opts);
163
+ return fake.handle;
164
+ });
165
+
166
+ const prevPost = (globalThis as { postMessage?: unknown }).postMessage;
167
+ (globalThis as { postMessage?: unknown }).postMessage = () => {};
168
+
169
+ try {
170
+ await emitter.fire({
171
+ type: "open",
172
+ options: { url: "http://127.0.0.1:3333/" },
173
+ });
174
+ await emitter.fire({
175
+ type: "eval",
176
+ js: "console.log('hi')",
177
+ });
178
+ expect(fake!.calls).toContain("eval:console.log('hi')");
179
+ } finally {
180
+ (globalThis as { postMessage?: unknown }).postMessage = prevPost;
181
+ }
182
+ });
183
+
184
+ it("reports error when eval is received before open", async () => {
185
+ const emitter = createEmitter();
186
+ const outbound: WorkerOutbound[] = [];
187
+
188
+ _installHandler(emitter.listen, async (opts) =>
189
+ createFakeHandle(opts).handle,
190
+ );
191
+
192
+ const prevPost = (globalThis as { postMessage?: unknown }).postMessage;
193
+ (globalThis as { postMessage?: unknown }).postMessage = (
194
+ msg: WorkerOutbound,
195
+ ) => {
196
+ outbound.push(msg);
197
+ };
198
+
199
+ try {
200
+ await emitter.fire({
201
+ type: "eval",
202
+ js: "console.log('early')",
203
+ });
204
+ const error = outbound.find((m) => m.type === "error");
205
+ expect(error).toBeDefined();
206
+ if (error && error.type === "error") {
207
+ expect(error.message).toMatch(/before 'open'/);
208
+ }
209
+ } finally {
210
+ (globalThis as { postMessage?: unknown }).postMessage = prevPost;
211
+ }
212
+ });
213
+
214
+ it("handles close message gracefully when no window is open", async () => {
215
+ const emitter = createEmitter();
216
+ const outbound: WorkerOutbound[] = [];
217
+
218
+ _installHandler(emitter.listen, async (opts) =>
219
+ createFakeHandle(opts).handle,
220
+ );
221
+
222
+ const prevPost = (globalThis as { postMessage?: unknown }).postMessage;
223
+ (globalThis as { postMessage?: unknown }).postMessage = (
224
+ msg: WorkerOutbound,
225
+ ) => {
226
+ outbound.push(msg);
227
+ };
228
+
229
+ try {
230
+ await emitter.fire({ type: "close" });
231
+ // Should not throw or emit an error — close on nothing is a no-op.
232
+ expect(outbound.filter((m) => m.type === "error").length).toBe(0);
233
+ } finally {
234
+ (globalThis as { postMessage?: unknown }).postMessage = prevPost;
235
+ }
236
+ });
237
+
238
+ it("reports errors for unknown message types", async () => {
239
+ const emitter = createEmitter();
240
+ const outbound: WorkerOutbound[] = [];
241
+
242
+ _installHandler(emitter.listen, async (opts) =>
243
+ createFakeHandle(opts).handle,
244
+ );
245
+
246
+ const prevPost = (globalThis as { postMessage?: unknown }).postMessage;
247
+ (globalThis as { postMessage?: unknown }).postMessage = (
248
+ msg: WorkerOutbound,
249
+ ) => {
250
+ outbound.push(msg);
251
+ };
252
+
253
+ try {
254
+ await emitter.fire({
255
+ type: "bogus" as unknown as "open",
256
+ } as WorkerInbound);
257
+ const error = outbound.find((m) => m.type === "error");
258
+ expect(error).toBeDefined();
259
+ if (error && error.type === "error") {
260
+ expect(error.message).toMatch(/Unknown message type/);
261
+ }
262
+ } finally {
263
+ (globalThis as { postMessage?: unknown }).postMessage = prevPost;
264
+ }
265
+ });
266
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @mandujs/core/desktop
3
+ *
4
+ * Native desktop windowing for Mandu apps via `webview-bun` (optional peer).
5
+ * Phase 9c — see `docs/bun/phase-9-diagnostics/webview-bun-ffi.md`.
6
+ *
7
+ * Supported backends (from the peer):
8
+ * - Windows: WebView2 (Chromium Evergreen; 10/11)
9
+ * - macOS: WKWebView (11+)
10
+ * - Linux: WebKitGTK 6 + GTK 4
11
+ *
12
+ * `webview-bun` is declared as an **optional peer dependency** — Mandu's core
13
+ * runtime continues to work on web-only projects that never install it.
14
+ * Importing this module is safe in any environment; loading only occurs the
15
+ * first time `createWindow()` is called, at which point a missing peer throws
16
+ * with an actionable install message.
17
+ *
18
+ * @example Minimal desktop entry
19
+ * ```ts
20
+ * import { startServer } from "@mandujs/core";
21
+ * import { createWindow } from "@mandujs/core/desktop";
22
+ * import manifest from "../../.mandu/manifest.json" with { type: "json" };
23
+ *
24
+ * const server = startServer(manifest, { port: 0, hostname: "127.0.0.1" });
25
+ * const win = await createWindow({
26
+ * url: `http://127.0.0.1:${server.server.port}`,
27
+ * title: "My App",
28
+ * width: 1280,
29
+ * height: 800,
30
+ * });
31
+ * win.run(); // blocking; returns on user close
32
+ * server.stop();
33
+ * ```
34
+ */
35
+
36
+ export { createWindow } from "./window.js";
37
+ export type {
38
+ WindowHandle,
39
+ WindowOptions,
40
+ WindowSizeHint,
41
+ WorkerInbound,
42
+ WorkerOutbound,
43
+ } from "./types.js";