@zappdev/runtime 0.1.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 ADDED
@@ -0,0 +1,287 @@
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;
7
+ }
8
+
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
+ export class Worker {
84
+ readonly id: string;
85
+ readonly scriptURL: string;
86
+
87
+ onmessage: ((event: MessageEvent) => void) | null = null;
88
+ onerror: ((event: ErrorEvent) => void) | null = null;
89
+ onclose: ((event: Event) => void) | null = null;
90
+
91
+ #listeners: Record<"message" | "error" | "close", ListenerEntry[]> = {
92
+ message: [],
93
+ error: [],
94
+ close: [],
95
+ };
96
+ #pendingErrors: ErrorEvent[] = [];
97
+ #unsubscribe: (() => void) | null = null;
98
+
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
+ );
139
+ }
140
+
141
+ postMessage(data: unknown): void {
142
+ getBridge().postToWorker(this.id, data);
143
+ }
144
+
145
+ send(channel: string, data: unknown): void {
146
+ this.postMessage({ __zapp_channel: channel, data });
147
+ }
148
+
149
+ 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);
154
+ }
155
+ };
156
+ this.addEventListener("message", cb);
157
+ return () => this.removeEventListener("message", cb);
158
+ }
159
+
160
+ 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?.());
166
+ }
167
+
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
+ }
189
+
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
+ }
201
+
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;
206
+ }
207
+
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
+ }
218
+ }
219
+
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);
227
+ }
228
+
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
+ }
237
+ }
238
+ }
239
+
240
+ /** A shared worker accessible from multiple windows via a MessagePort-like interface. */
241
+ 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
+ });
259
+
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
+ }
283
+
284
+ receive(channel: string, handler: (data: unknown) => void): () => void {
285
+ return this.#inner.receive(channel, handler);
286
+ }
287
+ }