@zappdev/runtime 0.1.0 → 0.5.0-alpha.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/worker.ts CHANGED
@@ -1,287 +1,143 @@
1
- /** Options for creating a Worker or SharedWorker. */
2
- export interface WorkerOptions {
3
- /** If true, create a shared worker that can be accessed from multiple contexts. */
4
- shared?: boolean;
5
- /** The `import.meta.url` of the calling module, used to resolve relative script paths. */
6
- importMetaUrl?: string | URL;
1
+ /**
2
+ * Worker — Zapp Workers with postMessage + named channels.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { Worker } from "@zappdev/runtime";
7
+ *
8
+ * const w = new Worker("./worker.ts");
9
+ *
10
+ * // Standard postMessage
11
+ * w.postMessage({ task: "compute" });
12
+ * w.onmessage = (e) => console.log(e.data);
13
+ *
14
+ * // Named channels (typed routing)
15
+ * w.send("compute", { n: 42 });
16
+ * w.receive("result", (data) => console.log(data));
17
+ *
18
+ * w.terminate();
19
+ * ```
20
+ */
21
+
22
+ import { getBridge, type ZappBridge } from "./bridge";
23
+
24
+ // Channel message envelope
25
+ const CHANNEL_KEY = "__zc";
26
+ const DATA_KEY = "d";
27
+
28
+ export interface WorkerMessageEvent {
29
+ data: unknown;
7
30
  }
8
31
 
9
- type ZappBridge = {
10
- createWorker: (scriptUrl: string, options?: { shared?: boolean }) => string;
11
- postToWorker: (workerId: string, data: unknown) => void;
12
- terminateWorker: (workerId: string) => void;
13
- subscribeWorker: (
14
- workerId: string,
15
- onMessage?: (data: unknown) => void,
16
- onError?: (data: unknown) => void,
17
- onClose?: (data: unknown) => void,
18
- ) => () => void;
19
- resolveWorkerScriptURL?: (scriptURL: string | URL) => string;
20
- };
21
-
22
- function getBridge(): ZappBridge {
23
- const b = (globalThis as unknown as Record<symbol, unknown>)[
24
- Symbol.for("zapp.bridge")
25
- ] as ZappBridge | undefined;
26
- if (!b?.createWorker) {
27
- throw new Error(
28
- "Zapp worker bridge unavailable. " +
29
- "Make sure the webview bootstrap has loaded before importing @zapp/runtime.",
30
- );
31
- }
32
- return b;
33
- }
34
-
35
- function rewriteToBundledWorker(url: URL): string {
36
- const ext = url.pathname.split(".").pop()?.toLowerCase();
37
- if (ext !== "ts" && ext !== "tsx") return url.toString();
38
- const toBundledUrl = (mappedPath: string): string => {
39
- const normalized = mappedPath.startsWith("/") ? mappedPath : `/${mappedPath}`;
40
- if (url.protocol === "zapp:") {
41
- return `zapp://app${normalized}`;
42
- }
43
- return new URL(normalized, url).toString();
44
- };
45
- const manifest = (globalThis as unknown as Record<symbol, unknown>)[
46
- Symbol.for("zapp.workerManifest")
47
- ] as Record<string, string> | undefined;
48
- const fileName = url.pathname.split("/").pop() ?? "worker.ts";
49
- const spec = `./${fileName}`;
50
- if (manifest) {
51
- const mapped = manifest[spec] ?? manifest[fileName];
52
- if (typeof mapped === "string" && mapped.length > 0) {
53
- return toBundledUrl(mapped);
54
- }
55
- }
56
- const pathParts = url.pathname.split("/");
57
- const fallbackName = pathParts[pathParts.length - 1] ?? "worker.ts";
58
- const stem = fallbackName.replace(/\.[^.]+$/, "");
59
- const bundledPath = `/zapp-workers/${stem}.mjs`;
60
- return toBundledUrl(bundledPath);
61
- }
62
-
63
- function resolveWorkerScriptURL(
64
- scriptURL: string | URL,
65
- importMetaUrl?: string | URL,
66
- ): string {
67
- const bridge = getBridge();
68
- if (bridge.resolveWorkerScriptURL) {
69
- return bridge.resolveWorkerScriptURL(scriptURL);
70
- }
71
- if (scriptURL instanceof URL) {
72
- return rewriteToBundledWorker(scriptURL);
73
- }
74
- if (importMetaUrl) {
75
- return rewriteToBundledWorker(new URL(scriptURL, String(importMetaUrl)));
76
- }
77
- return scriptURL;
78
- }
79
-
80
- type ListenerEntry = { listener: EventListenerOrEventListenerObject; once: boolean };
81
-
82
- /** A Zapp worker that runs a script in a separate native thread. */
83
32
  export class Worker {
84
33
  readonly id: string;
85
- readonly scriptURL: string;
34
+ private _bridge: ZappBridge;
35
+ onmessage: ((event: WorkerMessageEvent) => void) | null = null;
36
+ onerror: ((error: unknown) => void) | null = null;
86
37
 
87
- onmessage: ((event: MessageEvent) => void) | null = null;
88
- onerror: ((event: ErrorEvent) => void) | null = null;
89
- onclose: ((event: Event) => void) | null = null;
38
+ /** @internal */
39
+ _messageHandlers: Array<(event: WorkerMessageEvent) => void> = [];
90
40
 
91
- #listeners: Record<"message" | "error" | "close", ListenerEntry[]> = {
92
- message: [],
93
- error: [],
94
- close: [],
95
- };
96
- #pendingErrors: ErrorEvent[] = [];
97
- #unsubscribe: (() => void) | null = null;
41
+ constructor(scriptUrl: string) {
42
+ this._bridge = getBridge();
43
+ this.id = (this._bridge as any).createWorker(scriptUrl);
98
44
 
99
- constructor(scriptURL: string | URL, options?: WorkerOptions) {
100
- const bridge = getBridge();
101
- const resolved = resolveWorkerScriptURL(scriptURL, options?.importMetaUrl);
102
- this.scriptURL = resolved;
103
- this.id = bridge.createWorker(resolved, {
104
- shared: options?.shared === true,
105
- });
106
- this.#unsubscribe = bridge.subscribeWorker(
107
- this.id,
108
- (payload) => {
109
- if (payload && typeof payload === "object" && "__zapp_channel" in (payload as Record<string, unknown>)) {
110
- const event = new MessageEvent("message", { data: payload });
111
- this.#emit("message", event);
112
- return;
113
- }
114
- const event = new MessageEvent("message", { data: payload });
115
- this.onmessage?.(event);
116
- this.#emit("message", event);
117
- },
118
- (payload) => {
119
- const errorPayload = payload as {
120
- message?: string;
121
- filename?: string;
122
- lineno?: number;
123
- colno?: number;
124
- };
125
- const event = new ErrorEvent("error", {
126
- message: errorPayload?.message ?? "Worker error",
127
- filename: errorPayload?.filename ?? "",
128
- lineno: errorPayload?.lineno ?? 0,
129
- colno: errorPayload?.colno ?? 0,
130
- });
131
- this.#dispatchError(event);
132
- },
133
- () => {
134
- const event = new Event("close");
135
- this.onclose?.(event);
136
- this.#emit("close", event);
137
- },
138
- );
45
+ // Register this instance for message dispatch
46
+ (this._bridge as any)._workers[this.id] = this;
139
47
  }
140
48
 
49
+ /** Send a raw message to the worker. */
141
50
  postMessage(data: unknown): void {
142
- getBridge().postToWorker(this.id, data);
51
+ (this._bridge as any).postToWorker(this.id, data);
143
52
  }
144
53
 
54
+ /** Send a message on a named channel. */
145
55
  send(channel: string, data: unknown): void {
146
- this.postMessage({ __zapp_channel: channel, data });
56
+ this.postMessage({ [CHANNEL_KEY]: channel, [DATA_KEY]: data });
147
57
  }
148
58
 
59
+ /** Listen for messages on a named channel. Returns unsubscribe function. */
149
60
  receive(channel: string, handler: (data: unknown) => void): () => void {
150
- const cb = (e: Event) => {
151
- const payload = (e as MessageEvent).data as Record<string, unknown>;
152
- if (payload && typeof payload === "object" && payload.__zapp_channel === channel) {
153
- handler(payload.data);
61
+ const listener = (event: WorkerMessageEvent) => {
62
+ const msg = event.data as Record<string, unknown>;
63
+ if (msg && msg[CHANNEL_KEY] === channel) {
64
+ handler(msg[DATA_KEY]);
154
65
  }
155
66
  };
156
- this.addEventListener("message", cb);
157
- return () => this.removeEventListener("message", cb);
67
+ this._messageHandlers.push(listener);
68
+ return () => {
69
+ this._messageHandlers = this._messageHandlers.filter(h => h !== listener);
70
+ };
158
71
  }
159
72
 
73
+ /** Terminate the worker. */
160
74
  terminate(): void {
161
- if (this.#unsubscribe === null) return;
162
- const unsubscribe = this.#unsubscribe;
163
- this.#unsubscribe = null;
164
- getBridge().terminateWorker(this.id);
165
- queueMicrotask(() => unsubscribe?.());
75
+ (this._bridge as any).terminateWorker(this.id);
76
+ delete (this._bridge as any)._workers[this.id];
166
77
  }
78
+ }
167
79
 
168
- addEventListener(
169
- type: string,
170
- listener: EventListenerOrEventListenerObject | null,
171
- options?: boolean | AddEventListenerOptions,
172
- ): void {
173
- if (!listener) return;
174
- if (type !== "message" && type !== "error" && type !== "close") return;
175
- const list = this.#listeners[type as "message" | "error" | "close"];
176
- for (const entry of list) {
177
- if (entry.listener === listener) return;
178
- }
179
- const once = typeof options === "object" && options?.once === true;
180
- const signal = typeof options === "object" ? options.signal : undefined;
181
- if (signal?.aborted) return;
182
- list.push({ listener, once });
183
- if (type === "error") this.#flushPendingErrors();
184
- if (signal) {
185
- const onAbort = () => this.removeEventListener(type, listener);
186
- signal.addEventListener("abort", onAbort, { once: true });
187
- }
188
- }
80
+ /** Port for SharedWorker communication — mirrors Worker API. */
81
+ export class SharedWorkerPort {
82
+ readonly id: string;
83
+ private _bridge: ZappBridge;
84
+ onmessage: ((event: WorkerMessageEvent) => void) | null = null;
189
85
 
190
- removeEventListener(
191
- type: string,
192
- listener: EventListenerOrEventListenerObject | null,
193
- ): void {
194
- if (!listener) return;
195
- if (type !== "message" && type !== "error" && type !== "close") return;
196
- const list = this.#listeners[type as "message" | "error" | "close"];
197
- for (let i = list.length - 1; i >= 0; i -= 1) {
198
- if (list[i]?.listener === listener) list.splice(i, 1);
199
- }
200
- }
86
+ /** @internal */
87
+ _messageHandlers: Array<(event: WorkerMessageEvent) => void> = [];
201
88
 
202
- dispatchEvent(event: Event): boolean {
203
- if (!event || (event.type !== "message" && event.type !== "error" && event.type !== "close")) return true;
204
- this.#emit(event.type as "message" | "error" | "close", event);
205
- return !event.defaultPrevented;
89
+ /** @internal */
90
+ constructor(workerId: string, bridge: ZappBridge) {
91
+ this.id = workerId;
92
+ this._bridge = bridge;
206
93
  }
207
94
 
208
- #emit(type: "message" | "error" | "close", event: Event): void {
209
- const list = this.#listeners[type].slice();
210
- for (const entry of list) {
211
- if (typeof entry.listener === "function") {
212
- (entry.listener as (e: Event) => void)(event);
213
- } else {
214
- entry.listener.handleEvent(event);
215
- }
216
- if (entry.once) this.removeEventListener(type, entry.listener);
217
- }
95
+ /** Send a raw message to the shared worker. */
96
+ postMessage(data: unknown): void {
97
+ (this._bridge as any).postToWorker(this.id, data);
218
98
  }
219
99
 
220
- #dispatchError(event: ErrorEvent): void {
221
- if (this.#listeners.error.length === 0 && this.onerror == null) {
222
- this.#pendingErrors.push(event);
223
- return;
224
- }
225
- this.onerror?.(event);
226
- this.#emit("error", event);
100
+ /** Send a message on a named channel. */
101
+ send(channel: string, data: unknown): void {
102
+ this.postMessage({ [CHANNEL_KEY]: channel, [DATA_KEY]: data });
227
103
  }
228
104
 
229
- #flushPendingErrors(): void {
230
- if (this.#pendingErrors.length === 0) return;
231
- if (this.#listeners.error.length === 0 && this.onerror == null) return;
232
- const pending = this.#pendingErrors.splice(0);
233
- for (const event of pending) {
234
- this.onerror?.(event);
235
- this.#emit("error", event);
236
- }
105
+ /** Listen for messages on a named channel. Returns unsubscribe function. */
106
+ receive(channel: string, handler: (data: unknown) => void): () => void {
107
+ const listener = (event: WorkerMessageEvent) => {
108
+ const msg = event.data as Record<string, unknown>;
109
+ if (msg && msg[CHANNEL_KEY] === channel) {
110
+ handler(msg[DATA_KEY]);
111
+ }
112
+ };
113
+ this._messageHandlers.push(listener);
114
+ return () => {
115
+ this._messageHandlers = this._messageHandlers.filter(h => h !== listener);
116
+ };
237
117
  }
238
118
  }
239
119
 
240
- /** A shared worker accessible from multiple windows via a MessagePort-like interface. */
120
+ /**
121
+ * SharedWorker — persists as long as any window holds a reference.
122
+ * Multiple windows creating SharedWorker with the same URL connect to the same native worker.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * const sw = new SharedWorker("./shared-worker.ts");
127
+ * sw.port.postMessage({ task: "sync" });
128
+ * sw.port.onmessage = (e) => console.log(e.data);
129
+ * sw.port.send("channel", data);
130
+ * ```
131
+ */
241
132
  export class SharedWorker {
242
- readonly port: {
243
- postMessage: (data: unknown) => void;
244
- onmessage: ((event: MessageEvent) => void) | null;
245
- start: () => void;
246
- close: () => void;
247
- addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
248
- removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
249
- };
250
- onerror: ((event: ErrorEvent) => void) | null = null;
251
-
252
- readonly #inner: Worker;
253
-
254
- constructor(scriptURL: string | URL, options?: WorkerOptions) {
255
- this.#inner = new Worker(scriptURL, {
256
- ...options,
257
- shared: true,
258
- });
133
+ readonly port: SharedWorkerPort;
259
134
 
260
- this.port = {
261
- postMessage: (data: unknown) => this.#inner.postMessage(data),
262
- onmessage: null,
263
- start: () => {},
264
- close: () => this.#inner.terminate(),
265
- addEventListener: (type: string, listener: EventListenerOrEventListenerObject) =>
266
- this.#inner.addEventListener(type, listener),
267
- removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) =>
268
- this.#inner.removeEventListener(type, listener),
269
- };
270
-
271
- this.#inner.addEventListener("message", ((e: MessageEvent) => {
272
- this.port.onmessage?.(e);
273
- }) as EventListener);
274
-
275
- this.#inner.addEventListener("error", ((e: ErrorEvent) => {
276
- this.onerror?.(e);
277
- }) as EventListener);
278
- }
279
-
280
- send(channel: string, data: unknown): void {
281
- this.#inner.send(channel, data);
282
- }
135
+ constructor(scriptUrl: string) {
136
+ const bridge = getBridge();
137
+ const workerId = (bridge as any).createSharedWorker(scriptUrl);
138
+ this.port = new SharedWorkerPort(workerId, bridge);
283
139
 
284
- receive(channel: string, handler: (data: unknown) => void): () => void {
285
- return this.#inner.receive(channel, handler);
140
+ // Register port for message dispatch
141
+ (bridge as any)._workers[workerId] = this.port;
286
142
  }
287
143
  }
package/README.md DELETED
@@ -1,51 +0,0 @@
1
- # @zapp/runtime
2
-
3
- Frontend runtime API for Zapp desktop apps. Provides type-safe access to native window management, events, dialogs, menus, services, and more from your web frontend.
4
-
5
- ## Install
6
-
7
- ```sh
8
- bun add @zapp/runtime
9
- ```
10
-
11
- ```sh
12
- npm install @zapp/runtime
13
- ```
14
-
15
- ## Usage
16
-
17
- ```ts
18
- import { App, Window, Events, Dialog, Menu } from "@zapp/runtime";
19
-
20
- // Listen for window events
21
- Events.on("window:resize", (payload) => {
22
- console.log("Window resized:", payload.width, payload.height);
23
- });
24
-
25
- // Open a file dialog
26
- const result = await Dialog.openFile({
27
- title: "Select a file",
28
- filters: [{ name: "Images", extensions: ["png", "jpg"] }],
29
- });
30
-
31
- // Create a new window
32
- await Window.open({ title: "My Window", width: 800, height: 600 });
33
- ```
34
-
35
- ## Features
36
-
37
- - **Window management** -- create, resize, move, and close native windows
38
- - **Event system** -- subscribe to window and app lifecycle events
39
- - **Dialogs** -- native open/save file dialogs and message boxes
40
- - **Menus** -- build native application and context menus
41
- - **Services** -- communicate with backend services
42
- - **Workers** -- spawn Web Workers and Shared Workers with native integration
43
- - **Sync primitives** -- cross-thread synchronization utilities
44
-
45
- ## Docs
46
-
47
- See the full documentation in [`../../docs/`](../../docs/).
48
-
49
- ## License
50
-
51
- MIT
@@ -1,31 +0,0 @@
1
- /** Current version of the bindings schema format. */
2
- export const ZAPP_BINDINGS_SCHEMA_VERSION = 1;
3
-
4
- /** Describes a single method exposed by a service binding. */
5
- export type ZappServiceBindingMethod = {
6
- name: string;
7
- requestType?: string;
8
- responseType?: string;
9
- capability?: string;
10
- };
11
-
12
- /** Describes a service and the methods it exposes. */
13
- export type ZappServiceBinding = {
14
- name: string;
15
- namespace?: string;
16
- methods: ZappServiceBindingMethod[];
17
- };
18
-
19
- /** Top-level manifest listing all available service bindings. */
20
- export type ZappBindingsManifest = {
21
- v: typeof ZAPP_BINDINGS_SCHEMA_VERSION;
22
- generatedAt: string;
23
- services: ZappServiceBinding[];
24
- };
25
-
26
- /** Create an empty bindings manifest with the current schema version. */
27
- export const emptyBindingsManifest = (): ZappBindingsManifest => ({
28
- v: ZAPP_BINDINGS_SCHEMA_VERSION,
29
- generatedAt: new Date().toISOString(),
30
- services: [],
31
- });
package/protocol.ts DELETED
@@ -1,94 +0,0 @@
1
- /** Current version of the worker wire protocol. */
2
- export const ZAPP_WORKER_PROTOCOL_VERSION = 1;
3
- /** Current version of the service invocation protocol. */
4
- export const ZAPP_SERVICE_PROTOCOL_VERSION = 1;
5
-
6
- /** Discriminator strings for worker control messages. */
7
- export type ZappWorkerControlType =
8
- | "zapp:worker:init"
9
- | "zapp:worker:post"
10
- | "zapp:worker:message"
11
- | "zapp:worker:error"
12
- | "zapp:worker:terminate";
13
-
14
- /** Envelope wrapping all worker control messages on the wire. */
15
- export interface ZappWorkerEnvelope<T = unknown> {
16
- v: typeof ZAPP_WORKER_PROTOCOL_VERSION;
17
- t: ZappWorkerControlType;
18
- workerId: string;
19
- payload?: T;
20
- }
21
-
22
- /** Payload for the worker init control message. */
23
- export interface ZappWorkerInitPayload {
24
- scriptUrl: string;
25
- shared?: boolean;
26
- }
27
-
28
- /** Payload for posting data to a worker. */
29
- export interface ZappWorkerPostPayload {
30
- data: unknown;
31
- }
32
-
33
- /** Payload for a message received from a worker. */
34
- export interface ZappWorkerMessagePayload {
35
- data: unknown;
36
- }
37
-
38
- /** Payload describing a worker error. */
39
- export interface ZappWorkerErrorPayload {
40
- message: string;
41
- stack?: string;
42
- filename?: string;
43
- lineno?: number;
44
- colno?: number;
45
- }
46
-
47
- /** Host-side bridge interface for managing workers from the native layer. */
48
- export type ZappWorkerHostBridge = {
49
- createWorker(scriptUrl: string, options?: { shared?: boolean }): string;
50
- postToWorker(workerId: string, data: unknown): void;
51
- terminateWorker(workerId: string): void;
52
- subscribe(
53
- workerId: string,
54
- onMessage: (data: unknown) => void,
55
- onError: (error: ZappWorkerErrorPayload) => void,
56
- ): () => void;
57
- };
58
-
59
- /** Standard error codes returned by service invocations. */
60
- export type ZappServiceErrorCode =
61
- | "BAD_REQUEST"
62
- | "INVALID_METHOD"
63
- | "UNAUTHORIZED"
64
- | "NOT_FOUND"
65
- | "INTERNAL_ERROR"
66
- | "TIMEOUT";
67
-
68
- /** A request to invoke a named service method. */
69
- export interface ZappServiceInvokeRequest {
70
- v: typeof ZAPP_SERVICE_PROTOCOL_VERSION;
71
- id: string;
72
- method: string;
73
- args: unknown;
74
- meta: {
75
- sourceCtxId: string;
76
- capability?: string;
77
- };
78
- }
79
-
80
- /** Structured error returned from a failed service invocation. */
81
- export interface ZappServiceInvokeError {
82
- code: ZappServiceErrorCode;
83
- message: string;
84
- details?: unknown;
85
- }
86
-
87
- /** Response envelope from a service invocation. */
88
- export interface ZappServiceInvokeResponse {
89
- v: typeof ZAPP_SERVICE_PROTOCOL_VERSION;
90
- id: string;
91
- ok: boolean;
92
- result?: unknown;
93
- error?: ZappServiceInvokeError;
94
- }