@swiftbrowser/native 0.12.0 → 0.13.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/README.md +36 -1
- package/bin/swiftbrowser-native.mjs +23 -7
- package/dist/bridge.d.ts +7 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/build.d.ts +22 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +25 -8
- package/dist/build.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +7 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -3
- package/dist/runtime.js.map +1 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +18 -3
- package/dist/site.js.map +1 -1
- package/dist/worker.d.ts +87 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +213 -0
- package/dist/worker.js.map +1 -0
- package/package.json +3 -3
- package/src/bridge.ts +12 -0
- package/src/build.ts +40 -8
- package/src/index.ts +1 -0
- package/src/runtime.ts +16 -3
- package/src/site.ts +20 -3
- package/src/worker.ts +236 -0
- package/swift/Package.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +46 -2
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +2 -1
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +279 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
package/src/worker.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workers for a JavaScriptCore app: a second context on its own thread, as
|
|
3
|
+
* a page has Web Workers. The host (Native/Sources/SwiftBrowserNative/Host/WorkerHost.swift)
|
|
4
|
+
* runs each one in a `JSVirtualMachine` of its own with a run loop, the
|
|
5
|
+
* runtime's globals (timers, `fetch`, `console`, `TextDecoder`) and the
|
|
6
|
+
* bundle `swiftbrowser-native build --worker <name>=<entry>` wrote next to
|
|
7
|
+
* app.js (`<name>.worker.js`). Messages cross as text: JSON, with typed
|
|
8
|
+
* arrays, `ArrayBuffer`s and `Error`s encoded (`encodeMessage`), since
|
|
9
|
+
* values cannot be shared between virtual machines. Drawn in a page
|
|
10
|
+
* (`swiftbrowser-native site`), the same app code gets the page's own
|
|
11
|
+
* `Worker` over the same bundle, published with the site.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { createWorker } from '@swiftbrowser/native';
|
|
15
|
+
* const worker = createWorker('render'); // render.worker.js
|
|
16
|
+
* worker.onmessage = (event) => console.log(event.data);
|
|
17
|
+
* worker.postMessage({ draw: 'something' });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { nativeBridge, type NativeBridge } from './bridge';
|
|
22
|
+
import { base64Decode, base64Encode } from './encoding';
|
|
23
|
+
|
|
24
|
+
/** The site's published path for worker bundles (`/native/bundle/App/`), defined by `swiftbrowser-native site`; undefined in an Xcode bundle. */
|
|
25
|
+
declare const __SB_WORKER_BASE__: string | undefined;
|
|
26
|
+
|
|
27
|
+
/** What `Worker` and a worker's `self` have in common, as an app uses them. */
|
|
28
|
+
export interface WorkerLike {
|
|
29
|
+
postMessage(data: unknown, transfer?: unknown[]): void;
|
|
30
|
+
terminate(): void;
|
|
31
|
+
onmessage: ((event: WorkerMessageEvent) => void) | null;
|
|
32
|
+
onerror: ((event: WorkerErrorEvent) => void) | null;
|
|
33
|
+
addEventListener(type: 'message', listener: (event: WorkerMessageEvent) => void): void;
|
|
34
|
+
addEventListener(type: 'error', listener: (event: WorkerErrorEvent) => void): void;
|
|
35
|
+
removeEventListener(type: 'message' | 'error', listener: (event: never) => void): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorkerMessageEvent {
|
|
39
|
+
type: 'message';
|
|
40
|
+
data: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WorkerErrorEvent {
|
|
44
|
+
type: 'error';
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// MARK: Messages
|
|
49
|
+
|
|
50
|
+
const TYPED_ARRAYS = {
|
|
51
|
+
Uint8Array,
|
|
52
|
+
Uint8ClampedArray,
|
|
53
|
+
Int8Array,
|
|
54
|
+
Uint16Array,
|
|
55
|
+
Int16Array,
|
|
56
|
+
Uint32Array,
|
|
57
|
+
Int32Array,
|
|
58
|
+
Float32Array,
|
|
59
|
+
Float64Array,
|
|
60
|
+
} as const;
|
|
61
|
+
type TypedArrayName = keyof typeof TYPED_ARRAYS;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A message as text: JSON, with a typed array or `ArrayBuffer` as
|
|
65
|
+
* `{ $sbBytes, $sbType }` (base64) and an `Error` as `{ $sbError }`. What
|
|
66
|
+
* JSON drops (functions, `undefined` in an object, a `Map`) is dropped here
|
|
67
|
+
* too; a `Date` arrives as its ISO string.
|
|
68
|
+
*/
|
|
69
|
+
export function encodeMessage(data: unknown): string {
|
|
70
|
+
return JSON.stringify(data === undefined ? null : data, (_key, value: unknown) => {
|
|
71
|
+
if (value instanceof ArrayBuffer) return { $sbBytes: base64Encode(new Uint8Array(value)), $sbType: 'ArrayBuffer' };
|
|
72
|
+
if (ArrayBuffer.isView(value)) {
|
|
73
|
+
const name = (value.constructor as { name?: string }).name;
|
|
74
|
+
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
75
|
+
return { $sbBytes: base64Encode(bytes), $sbType: name && name in TYPED_ARRAYS ? name : 'Uint8Array' };
|
|
76
|
+
}
|
|
77
|
+
if (value instanceof Error) return { $sbError: { name: value.name, message: value.message, stack: value.stack ?? null } };
|
|
78
|
+
return value;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The value `encodeMessage` encoded. */
|
|
83
|
+
export function decodeMessage(text: string): unknown {
|
|
84
|
+
return JSON.parse(text, (_key, value: unknown) => {
|
|
85
|
+
if (value && typeof value === 'object') {
|
|
86
|
+
const record = value as Record<string, unknown>;
|
|
87
|
+
if (typeof record['$sbBytes'] === 'string') {
|
|
88
|
+
const bytes = base64Decode(record['$sbBytes']);
|
|
89
|
+
const type = record['$sbType'];
|
|
90
|
+
if (type === 'ArrayBuffer') return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
91
|
+
const Constructor = TYPED_ARRAYS[(type as TypedArrayName) in TYPED_ARRAYS ? (type as TypedArrayName) : 'Uint8Array'];
|
|
92
|
+
// A view over the decoded bytes, aligned for the element size.
|
|
93
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
94
|
+
return new Constructor(buffer);
|
|
95
|
+
}
|
|
96
|
+
if (record['$sbError'] && typeof record['$sbError'] === 'object') {
|
|
97
|
+
const spec = record['$sbError'] as { name?: string; message?: string; stack?: string | null };
|
|
98
|
+
const error = new Error(spec.message ?? '');
|
|
99
|
+
if (spec.name) error.name = spec.name;
|
|
100
|
+
if (spec.stack) error.stack = spec.stack;
|
|
101
|
+
return error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// MARK: The app's side
|
|
109
|
+
|
|
110
|
+
type Listener<E> = (event: E) => void;
|
|
111
|
+
|
|
112
|
+
/** `Worker` over the bridge, in the app's context. */
|
|
113
|
+
export class NativeWorker implements WorkerLike {
|
|
114
|
+
private static nextId = 1;
|
|
115
|
+
private static readonly live = new Map<number, NativeWorker>();
|
|
116
|
+
readonly #id: number;
|
|
117
|
+
readonly #native: NativeBridge;
|
|
118
|
+
#terminated = false;
|
|
119
|
+
readonly #messageListeners = new Set<Listener<WorkerMessageEvent>>();
|
|
120
|
+
readonly #errorListeners = new Set<Listener<WorkerErrorEvent>>();
|
|
121
|
+
onmessage: Listener<WorkerMessageEvent> | null = null;
|
|
122
|
+
onerror: Listener<WorkerErrorEvent> | null = null;
|
|
123
|
+
|
|
124
|
+
/** `name` is the bundle's: `<name>.worker.js` next to app.js. A URL's last path segment serves as the name (`render.worker.js` → `render`). */
|
|
125
|
+
constructor(name: string | { href: string } | URL, native: NativeBridge | undefined = nativeBridge()) {
|
|
126
|
+
if (!native?.workerStart || !native.workerPost || !native.workerTerminate) {
|
|
127
|
+
throw new Error('Worker: this host has no workers (SwiftBrowser Native 0.13 or later)');
|
|
128
|
+
}
|
|
129
|
+
this.#native = native;
|
|
130
|
+
this.#id = NativeWorker.nextId++;
|
|
131
|
+
NativeWorker.live.set(this.#id, this);
|
|
132
|
+
const failure = native.workerStart(this.#id, workerName(name));
|
|
133
|
+
if (failure) {
|
|
134
|
+
NativeWorker.live.delete(this.#id);
|
|
135
|
+
throw new Error(failure);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
postMessage(data: unknown): void {
|
|
140
|
+
if (this.#terminated) return;
|
|
141
|
+
this.#native.workerPost!(this.#id, encodeMessage(data));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
terminate(): void {
|
|
145
|
+
if (this.#terminated) return;
|
|
146
|
+
this.#terminated = true;
|
|
147
|
+
NativeWorker.live.delete(this.#id);
|
|
148
|
+
this.#native.workerTerminate!(this.#id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
addEventListener(type: 'message', listener: Listener<WorkerMessageEvent>): void;
|
|
152
|
+
addEventListener(type: 'error', listener: Listener<WorkerErrorEvent>): void;
|
|
153
|
+
addEventListener(type: 'message' | 'error', listener: Listener<never>): void {
|
|
154
|
+
if (type === 'message') this.#messageListeners.add(listener as Listener<WorkerMessageEvent>);
|
|
155
|
+
else this.#errorListeners.add(listener as Listener<WorkerErrorEvent>);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
removeEventListener(type: 'message' | 'error', listener: Listener<never>): void {
|
|
159
|
+
if (type === 'message') this.#messageListeners.delete(listener as Listener<WorkerMessageEvent>);
|
|
160
|
+
else this.#errorListeners.delete(listener as Listener<WorkerErrorEvent>);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The bridge's `__sbWorkerMessage(id, text)`. */
|
|
164
|
+
static receive(id: number, text: string): void {
|
|
165
|
+
const worker = NativeWorker.live.get(id);
|
|
166
|
+
if (!worker) return;
|
|
167
|
+
const event: WorkerMessageEvent = { type: 'message', data: decodeMessage(text) };
|
|
168
|
+
worker.onmessage?.(event);
|
|
169
|
+
for (const listener of worker.#messageListeners) listener(event);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The bridge's `__sbWorkerError(id, message)`: an uncaught exception in the worker. */
|
|
173
|
+
static fail(id: number, message: string): void {
|
|
174
|
+
const worker = NativeWorker.live.get(id);
|
|
175
|
+
if (!worker) return;
|
|
176
|
+
const event: WorkerErrorEvent = { type: 'error', message };
|
|
177
|
+
worker.onerror?.(event);
|
|
178
|
+
for (const listener of worker.#errorListeners) listener(event);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The bundle name a worker was asked for: `render`, or the `render` of `.../render.worker.js`. */
|
|
183
|
+
export function workerName(name: string | { href: string } | URL): string {
|
|
184
|
+
const text = typeof name === 'string' ? name : name.href;
|
|
185
|
+
const last = text.split('/').pop() ?? text;
|
|
186
|
+
return last.replace(/\.(m?[jt]sx?)$/, '').replace(/\.worker$/, '');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A worker from the bundle `<name>.worker.js`: the page's own `Worker` when
|
|
191
|
+
* the app is drawn in a page (the bundle published with the site), else the
|
|
192
|
+
* host's over the bridge.
|
|
193
|
+
*/
|
|
194
|
+
export function createWorker(name: string): WorkerLike {
|
|
195
|
+
const base = typeof __SB_WORKER_BASE__ === 'string' ? __SB_WORKER_BASE__ : undefined;
|
|
196
|
+
const PageWorker = (globalThis as { Worker?: new (url: string) => WorkerLike }).Worker;
|
|
197
|
+
if (typeof PageWorker === 'function' && base !== undefined) return new PageWorker(`${base}${workerName(name)}.worker.js`);
|
|
198
|
+
if (nativeBridge()?.workerStart) return new NativeWorker(name);
|
|
199
|
+
if (typeof PageWorker === 'function') return new PageWorker(`${workerName(name)}.worker.js`);
|
|
200
|
+
throw new Error(`createWorker: no host for "${name}" (not in a SwiftBrowser Native app or a page)`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// MARK: The worker's side
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A worker's own globals over its bridge: `self`, `postMessage`, `onmessage`
|
|
207
|
+
* and `onerror` as properties the script assigns, `addEventListener`,
|
|
208
|
+
* `close`, and `__sbWorkerReceive`, which the host calls with each message.
|
|
209
|
+
*/
|
|
210
|
+
export function installWorkerScope(native: NativeBridge, target: Record<string, unknown>): string[] {
|
|
211
|
+
const installed: string[] = [];
|
|
212
|
+
const define = (name: string, value: unknown): void => {
|
|
213
|
+
Object.defineProperty(target, name, { value, writable: true, configurable: true, enumerable: false });
|
|
214
|
+
installed.push(name);
|
|
215
|
+
};
|
|
216
|
+
const messageListeners = new Set<Listener<WorkerMessageEvent>>();
|
|
217
|
+
const errorListeners = new Set<Listener<WorkerErrorEvent>>();
|
|
218
|
+
define('self', target);
|
|
219
|
+
define('postMessage', (data: unknown) => native.workerPostMessage!(encodeMessage(data)));
|
|
220
|
+
define('addEventListener', (type: string, listener: Listener<never>) => {
|
|
221
|
+
if (type === 'message') messageListeners.add(listener as Listener<WorkerMessageEvent>);
|
|
222
|
+
else if (type === 'error') errorListeners.add(listener as Listener<WorkerErrorEvent>);
|
|
223
|
+
});
|
|
224
|
+
define('removeEventListener', (type: string, listener: Listener<never>) => {
|
|
225
|
+
if (type === 'message') messageListeners.delete(listener as Listener<WorkerMessageEvent>);
|
|
226
|
+
else if (type === 'error') errorListeners.delete(listener as Listener<WorkerErrorEvent>);
|
|
227
|
+
});
|
|
228
|
+
define('close', () => native.log('warn', 'close(): a worker ends when the app terminates it'));
|
|
229
|
+
define('__sbWorkerReceive', (text: string) => {
|
|
230
|
+
const event: WorkerMessageEvent = { type: 'message', data: decodeMessage(text) };
|
|
231
|
+
const handler = target['onmessage'];
|
|
232
|
+
if (typeof handler === 'function') (handler as Listener<WorkerMessageEvent>)(event);
|
|
233
|
+
for (const listener of messageListeners) listener(event);
|
|
234
|
+
});
|
|
235
|
+
return installed;
|
|
236
|
+
}
|
package/swift/Package.swift
CHANGED
|
@@ -27,5 +27,6 @@ let package = Package(
|
|
|
27
27
|
.target(name: "SwiftBrowserProtocol"),
|
|
28
28
|
.target(name: "SwiftBrowserTree", dependencies: ["SwiftBrowserProtocol"]),
|
|
29
29
|
.target(name: "SwiftBrowserNative", dependencies: ["SwiftBrowserProtocol", "SwiftBrowserTree"]),
|
|
30
|
+
// Apple platforms only (JavaScriptCore): the workers; empty elsewhere.
|
|
30
31
|
]
|
|
31
32
|
)
|
|
@@ -27,14 +27,18 @@ import FoundationBridge // the shim's Foundation stand-ins (ShimAliases.swift)
|
|
|
27
27
|
/// | `openURL(url)` | the system opener |
|
|
28
28
|
/// | `locale()` | the viewer's locale identifier |
|
|
29
29
|
/// | `launchArguments()` | the process's arguments (`-screen list` from a scheme or `simctl launch`) |
|
|
30
|
+
/// | `workerStart(id, name)`, `workerPost(id, text)`, `workerTerminate(id)` | workers (WorkerHost.swift, JavaScriptCore only); their messages come to `__sbWorkerMessage(id, text)`, their errors to `__sbWorkerError(id, message)` |
|
|
30
31
|
///
|
|
31
32
|
/// Everything JavaScript-facing runs on the main thread; work that is not
|
|
32
|
-
/// (a fetch) hops back before calling in.
|
|
33
|
+
/// (a fetch, a worker) hops back before calling in.
|
|
33
34
|
@MainActor
|
|
34
35
|
final class HostBridge {
|
|
35
36
|
private let host: JSHost
|
|
36
37
|
private let tree: OpTree
|
|
37
38
|
private var timers: [Int: Timer] = [:]
|
|
39
|
+
#if canImport(JavaScriptCore)
|
|
40
|
+
private var workers: [Int: WorkerHost] = [:]
|
|
41
|
+
#endif
|
|
38
42
|
private let logger = Logger(subsystem: "swiftbrowser", category: "js")
|
|
39
43
|
private let session = URLSession(configuration: .default)
|
|
40
44
|
/// Keys of `localStorage` live under this prefix in `UserDefaults`.
|
|
@@ -46,10 +50,14 @@ final class HostBridge {
|
|
|
46
50
|
installAll()
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
/// Stops every timer; the app that owned the bridge is going away.
|
|
53
|
+
/// Stops every timer and worker; the app that owned the bridge is going away.
|
|
50
54
|
func invalidate() {
|
|
51
55
|
for timer in timers.values { timer.invalidate() }
|
|
52
56
|
timers.removeAll()
|
|
57
|
+
#if canImport(JavaScriptCore)
|
|
58
|
+
for worker in workers.values { worker.terminate() }
|
|
59
|
+
workers.removeAll()
|
|
60
|
+
#endif
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
private func installAll() {
|
|
@@ -166,6 +174,42 @@ final class HostBridge {
|
|
|
166
174
|
host.install("locale") { _ in .string(Locale.current.identifier) }
|
|
167
175
|
|
|
168
176
|
host.install("launchArguments") { _ in .array(ProcessInfo.processInfo.arguments.map(JSONValue.string)) }
|
|
177
|
+
|
|
178
|
+
#if canImport(JavaScriptCore)
|
|
179
|
+
host.install("workerStart") { [weak self] arguments in
|
|
180
|
+
guard let self, let id = arguments[safe: 0]?.int, let name = arguments[safe: 1]?.string else {
|
|
181
|
+
return .string("workerStart: expected an id and a name")
|
|
182
|
+
}
|
|
183
|
+
do {
|
|
184
|
+
let worker = try WorkerHost(name: name, onMessage: { [weak self] text in
|
|
185
|
+
DispatchQueue.main.async {
|
|
186
|
+
MainActor.assumeIsolated { self?.callIn("__sbWorkerMessage", [.number(Double(id)), .string(text)]) }
|
|
187
|
+
}
|
|
188
|
+
}, onError: { [weak self] message in
|
|
189
|
+
DispatchQueue.main.async {
|
|
190
|
+
MainActor.assumeIsolated { self?.callIn("__sbWorkerError", [.number(Double(id)), .string(message)]) }
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
self.workers[id]?.terminate()
|
|
194
|
+
self.workers[id] = worker
|
|
195
|
+
worker.start()
|
|
196
|
+
return nil
|
|
197
|
+
} catch {
|
|
198
|
+
return .string("\(error)")
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
host.install("workerPost") { [weak self] arguments in
|
|
202
|
+
guard let self, let id = arguments[safe: 0]?.int, let text = arguments[safe: 1]?.string else { return nil }
|
|
203
|
+
self.workers[id]?.post(text)
|
|
204
|
+
return nil
|
|
205
|
+
}
|
|
206
|
+
host.install("workerTerminate") { [weak self] arguments in
|
|
207
|
+
guard let self, let id = arguments[safe: 0]?.int else { return nil }
|
|
208
|
+
self.workers[id]?.terminate()
|
|
209
|
+
self.workers[id] = nil
|
|
210
|
+
return nil
|
|
211
|
+
}
|
|
212
|
+
#endif
|
|
169
213
|
}
|
|
170
214
|
|
|
171
215
|
/// Calls into JavaScript from a timer or a completion, where a throw has nowhere to go but the log.
|
|
@@ -91,7 +91,8 @@ public final class JavaScriptCoreHost: JSHost {
|
|
|
91
91
|
|
|
92
92
|
/// A JavaScript value as JSON. Strings stay strings without a copy
|
|
93
93
|
/// through `toObject`, the common case (the ops of a pass, an event).
|
|
94
|
-
|
|
94
|
+
/// Any thread's: a worker's context (WorkerHost.swift) uses it too.
|
|
95
|
+
nonisolated static func json(from value: JSValue) -> JSONValue {
|
|
95
96
|
if value.isUndefined || value.isNull { return .null }
|
|
96
97
|
if value.isString { return .string(value.toString()) }
|
|
97
98
|
if value.isBoolean { return .bool(value.toBool()) }
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && canImport(JavaScriptCore)
|
|
2
|
+
import Foundation
|
|
3
|
+
import JavaScriptCore
|
|
4
|
+
import os
|
|
5
|
+
import SwiftBrowserProtocol
|
|
6
|
+
|
|
7
|
+
/// A worker of a JavaScriptCore app (packages/native/src/worker.ts): a
|
|
8
|
+
/// second `JSContext` in a `JSVirtualMachine` of its own, on a thread of
|
|
9
|
+
/// its own, so it runs beside the app's context rather than in its turns.
|
|
10
|
+
/// The thread runs a run loop: the worker's timers and `fetch` answers come
|
|
11
|
+
/// through it, and so does the engine's own deferred work (an async
|
|
12
|
+
/// `WebAssembly.instantiate` settles its promise from it, which is what an
|
|
13
|
+
/// emscripten module's glue awaits).
|
|
14
|
+
///
|
|
15
|
+
/// The worker's `__sbNative` has the primitives the runtime builds its
|
|
16
|
+
/// globals from, minus the tree (`applyOps`), plus `workerPostMessage(text)`.
|
|
17
|
+
/// Messages are text both ways (the runtime encodes typed arrays into it):
|
|
18
|
+
/// values cannot cross virtual machines. The app's side (HostBridge.swift)
|
|
19
|
+
/// delivers the worker's messages and errors on the main thread.
|
|
20
|
+
final class WorkerHost: @unchecked Sendable {
|
|
21
|
+
let name: String
|
|
22
|
+
private let script: String
|
|
23
|
+
private let thread: WorkerThread
|
|
24
|
+
private let onMessage: @Sendable (String) -> Void
|
|
25
|
+
private let onError: @Sendable (String) -> Void
|
|
26
|
+
private let logger = Logger(subsystem: "swiftbrowser", category: "worker")
|
|
27
|
+
private let session = URLSession(configuration: .default)
|
|
28
|
+
// Touched on the worker's thread only.
|
|
29
|
+
private var context: JSContext?
|
|
30
|
+
private var timers: [Int: Timer] = [:]
|
|
31
|
+
|
|
32
|
+
/// The script is `<name>.worker.js` in `bundle` (what `swiftbrowser-native
|
|
33
|
+
/// build --worker <name>=<entry>` writes beside app.js); throws when it is not there.
|
|
34
|
+
init(name: String, bundle: Bundle = .main, onMessage: @escaping @Sendable (String) -> Void, onError: @escaping @Sendable (String) -> Void) throws {
|
|
35
|
+
guard let url = bundle.url(forResource: "\(name).worker", withExtension: "js") else {
|
|
36
|
+
throw JSError(message: "\(name).worker.js is not in the app bundle (build it with `swiftbrowser-native build --worker \(name)=<entry>`)")
|
|
37
|
+
}
|
|
38
|
+
self.name = name
|
|
39
|
+
script = try String(contentsOf: url, encoding: .utf8)
|
|
40
|
+
self.onMessage = onMessage
|
|
41
|
+
self.onError = onError
|
|
42
|
+
thread = WorkerThread(label: "swiftbrowser.worker.\(name)")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Starts the thread: it creates the context, runs the script, then its
|
|
46
|
+
/// run loop. Messages posted before the script ran wait for it.
|
|
47
|
+
func start() {
|
|
48
|
+
thread.start { [self] in setUp() }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Delivers an encoded message to the worker's `__sbWorkerReceive`.
|
|
52
|
+
func post(_ text: String) {
|
|
53
|
+
thread.perform { [self] in call("__sbWorkerReceive", [.string(text)]) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Ends the worker: its timers, context and thread go.
|
|
57
|
+
func terminate() {
|
|
58
|
+
thread.perform { [self] in tearDown() }
|
|
59
|
+
thread.stop()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: On the worker's thread
|
|
63
|
+
|
|
64
|
+
private func setUp() {
|
|
65
|
+
let context = JSContext(virtualMachine: JSVirtualMachine())!
|
|
66
|
+
context.name = "SwiftBrowser worker \(name)"
|
|
67
|
+
context.exceptionHandler = { [weak self] _, exception in
|
|
68
|
+
guard let self else { return }
|
|
69
|
+
let message = exception?.toString() ?? "unknown error"
|
|
70
|
+
let stack = exception?.objectForKeyedSubscript("stack")?.toString()
|
|
71
|
+
let text = stack.flatMap { $0 == "undefined" ? nil : "\(message)\n\($0)" } ?? message
|
|
72
|
+
self.logger.error("\(self.name): \(text, privacy: .public)")
|
|
73
|
+
self.onError(text)
|
|
74
|
+
}
|
|
75
|
+
self.context = context
|
|
76
|
+
let native = JSValue(newObjectIn: context)!
|
|
77
|
+
context.setObject(native, forKeyedSubscript: "__sbNative" as NSString)
|
|
78
|
+
installPrimitives(on: native)
|
|
79
|
+
context.evaluateScript(script, withSourceURL: URL(string: "sb://\(name).worker.js"))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func tearDown() {
|
|
83
|
+
for timer in timers.values { timer.invalidate() }
|
|
84
|
+
timers.removeAll()
|
|
85
|
+
context?.exceptionHandler = nil
|
|
86
|
+
context = nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func install(_ native: JSValue, _ name: String, _ body: @escaping ([JSONValue]) -> JSONValue?) {
|
|
90
|
+
let block: @convention(block) () -> Any? = {
|
|
91
|
+
let arguments = (JSContext.currentArguments() as? [JSValue]) ?? []
|
|
92
|
+
return body(arguments.map(JavaScriptCoreHost.json(from:)))?.anyObject
|
|
93
|
+
}
|
|
94
|
+
native.setObject(unsafeBitCast(block, to: AnyObject.self), forKeyedSubscript: name as NSString)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Calls a global function of the worker's; an exception reaches `onError` through the handler.
|
|
98
|
+
@discardableResult
|
|
99
|
+
private func call(_ function: String, _ arguments: [JSONValue]) -> JSONValue? {
|
|
100
|
+
guard let context, let callee = context.objectForKeyedSubscript(function), !callee.isUndefined else { return nil }
|
|
101
|
+
return callee.call(withArguments: arguments.map(\.anyObject)).map(JavaScriptCoreHost.json(from:))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func installPrimitives(on native: JSValue) {
|
|
105
|
+
install(native, "workerPostMessage") { [onMessage] arguments in
|
|
106
|
+
if let text = arguments.first?.string { onMessage(text) }
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
install(native, "log") { [logger, name] arguments in
|
|
111
|
+
let level = arguments.first?.string ?? "log"
|
|
112
|
+
let message = arguments.dropFirst().map { $0.string ?? $0.json }.joined(separator: " ")
|
|
113
|
+
switch level {
|
|
114
|
+
case "error": logger.error("\(name): \(message, privacy: .public)")
|
|
115
|
+
case "warn": logger.warning("\(name): \(message, privacy: .public)")
|
|
116
|
+
case "debug": logger.debug("\(name): \(message, privacy: .public)")
|
|
117
|
+
default: logger.info("\(name): \(message, privacy: .public)")
|
|
118
|
+
}
|
|
119
|
+
#if DEBUG
|
|
120
|
+
print("[\(name):\(level)] \(message)")
|
|
121
|
+
#endif
|
|
122
|
+
return nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
install(native, "now") { _ in .number(ProcessInfo.processInfo.systemUptime * 1000) }
|
|
126
|
+
|
|
127
|
+
install(native, "setTimer") { [weak self] arguments in
|
|
128
|
+
guard let self, let id = arguments[safe: 0]?.int else { return nil }
|
|
129
|
+
let delay = max(0, (arguments[safe: 1]?.double ?? 0) / 1000)
|
|
130
|
+
let repeats = arguments[safe: 2]?.bool ?? false
|
|
131
|
+
self.timers[id]?.invalidate()
|
|
132
|
+
// On this thread's run loop, so it fires on this thread.
|
|
133
|
+
let timer = Timer(timeInterval: delay, repeats: repeats) { [weak self] _ in
|
|
134
|
+
guard let self else { return }
|
|
135
|
+
if !repeats { self.timers[id] = nil }
|
|
136
|
+
self.call("__sbTimerFired", [.number(Double(id))])
|
|
137
|
+
}
|
|
138
|
+
RunLoop.current.add(timer, forMode: .default)
|
|
139
|
+
self.timers[id] = timer
|
|
140
|
+
return nil
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
install(native, "clearTimer") { [weak self] arguments in
|
|
144
|
+
guard let self, let id = arguments[safe: 0]?.int else { return nil }
|
|
145
|
+
self.timers[id]?.invalidate()
|
|
146
|
+
self.timers[id] = nil
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
install(native, "fetch") { [weak self] arguments in
|
|
151
|
+
guard let self, let id = arguments[safe: 0]?.int else { return nil }
|
|
152
|
+
self.fetch(id: id, request: arguments[safe: 1] ?? .null)
|
|
153
|
+
return nil
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// No storage of its own: a worker keeps state in its scope.
|
|
157
|
+
install(native, "storageGet") { _ in .null }
|
|
158
|
+
install(native, "storageSet") { _ in nil }
|
|
159
|
+
install(native, "storageRemove") { _ in nil }
|
|
160
|
+
install(native, "storageKeys") { _ in .array([]) }
|
|
161
|
+
install(native, "storageClear") { _ in nil }
|
|
162
|
+
|
|
163
|
+
install(native, "randomBytes") { arguments in
|
|
164
|
+
let count = max(0, min(arguments.first?.int ?? 0, 65536))
|
|
165
|
+
var generator = SystemRandomNumberGenerator()
|
|
166
|
+
let bytes = (0..<count).map { _ in UInt8.random(in: .min ... .max, using: &generator) }
|
|
167
|
+
return .string(Data(bytes).base64EncodedString())
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
install(native, "openURL") { _ in .bool(false) }
|
|
171
|
+
install(native, "locale") { _ in .string(Locale.current.identifier) }
|
|
172
|
+
install(native, "launchArguments") { _ in .array(ProcessInfo.processInfo.arguments.map(JSONValue.string)) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// As HostBridge's, answering on the worker's thread.
|
|
176
|
+
private func fetch(id: Int, request: JSONValue) {
|
|
177
|
+
guard let urlString = request["url"].string, let url = URL(string: urlString) else {
|
|
178
|
+
call("__sbFetchResult", [.number(Double(id)), .object(["error": .string("invalid URL")])])
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
var urlRequest = URLRequest(url: url)
|
|
182
|
+
urlRequest.httpMethod = request["method"].string ?? "GET"
|
|
183
|
+
for (name, value) in request["headers"].object ?? [:] {
|
|
184
|
+
if let value = value.string { urlRequest.setValue(value, forHTTPHeaderField: name) }
|
|
185
|
+
}
|
|
186
|
+
if let body = request["body"].string, let data = Data(base64Encoded: body) {
|
|
187
|
+
urlRequest.httpBody = data
|
|
188
|
+
}
|
|
189
|
+
let task = session.dataTask(with: urlRequest) { [weak self] data, response, error in
|
|
190
|
+
let result: JSONValue
|
|
191
|
+
if let error {
|
|
192
|
+
result = .object(["error": .string(error.localizedDescription)])
|
|
193
|
+
} else {
|
|
194
|
+
let http = response as? HTTPURLResponse
|
|
195
|
+
var headers: JSONObject = [:]
|
|
196
|
+
for (name, value) in http?.allHeaderFields ?? [:] {
|
|
197
|
+
headers[String(describing: name).lowercased()] = .string(String(describing: value))
|
|
198
|
+
}
|
|
199
|
+
result = .object([
|
|
200
|
+
"status": .number(Double(http?.statusCode ?? 200)),
|
|
201
|
+
"statusText": .string(http.map { HTTPURLResponse.localizedString(forStatusCode: $0.statusCode) } ?? ""),
|
|
202
|
+
"headers": .object(headers),
|
|
203
|
+
"url": .string(response?.url?.absoluteString ?? urlString),
|
|
204
|
+
"body": .string((data ?? Data()).base64EncodedString()),
|
|
205
|
+
])
|
|
206
|
+
}
|
|
207
|
+
guard let self else { return }
|
|
208
|
+
self.thread.perform { [self] in self.call("__sbFetchResult", [.number(Double(id)), result]) }
|
|
209
|
+
}
|
|
210
|
+
task.resume()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// A thread with a run loop that runs blocks sent to it, in order, after
|
|
215
|
+
/// the block it started with.
|
|
216
|
+
final class WorkerThread: @unchecked Sendable {
|
|
217
|
+
private let label: String
|
|
218
|
+
private let lock = NSLock()
|
|
219
|
+
private var runLoop: CFRunLoop?
|
|
220
|
+
private var pending: [@Sendable () -> Void] = []
|
|
221
|
+
private var stopped = false
|
|
222
|
+
|
|
223
|
+
init(label: String) {
|
|
224
|
+
self.label = label
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func start(_ setUp: @escaping @Sendable () -> Void) {
|
|
228
|
+
let thread = Thread { [self] in
|
|
229
|
+
let loop = RunLoop.current
|
|
230
|
+
// A port keeps the loop running while nothing else is scheduled.
|
|
231
|
+
loop.add(Port(), forMode: .default)
|
|
232
|
+
setUp()
|
|
233
|
+
lock.lock()
|
|
234
|
+
runLoop = CFRunLoopGetCurrent()
|
|
235
|
+
let queued = pending
|
|
236
|
+
pending = []
|
|
237
|
+
lock.unlock()
|
|
238
|
+
for block in queued { block() }
|
|
239
|
+
while !isStopped {
|
|
240
|
+
loop.run(mode: .default, before: .distantFuture)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
thread.name = label
|
|
244
|
+
thread.qualityOfService = .userInitiated
|
|
245
|
+
thread.start()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/// Runs `block` on the thread: at once if it is idle, else after what it is doing.
|
|
249
|
+
func perform(_ block: @escaping @Sendable () -> Void) {
|
|
250
|
+
lock.lock()
|
|
251
|
+
guard let runLoop else {
|
|
252
|
+
pending.append(block)
|
|
253
|
+
lock.unlock()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
lock.unlock()
|
|
257
|
+
CFRunLoopPerformBlock(runLoop, CFRunLoopMode.defaultMode.rawValue, block)
|
|
258
|
+
CFRunLoopWakeUp(runLoop)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private var isStopped: Bool {
|
|
262
|
+
lock.lock()
|
|
263
|
+
defer { lock.unlock() }
|
|
264
|
+
return stopped
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Ends the loop after the blocks already sent ran.
|
|
268
|
+
func stop() {
|
|
269
|
+
lock.lock()
|
|
270
|
+
stopped = true
|
|
271
|
+
let loop = runLoop
|
|
272
|
+
lock.unlock()
|
|
273
|
+
if let loop {
|
|
274
|
+
CFRunLoopPerformBlock(loop, CFRunLoopMode.defaultMode.rawValue) { CFRunLoopStop(loop) }
|
|
275
|
+
CFRunLoopWakeUp(loop)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
#endif
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/wasm/SwiftBrowserNative.wasm
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 49299538 bytes
|
|
4
|
+
sha256: 4d0446e4f7414a5c80780f1206c3d3b1d8577840d60b07efbb1e925b00caea88
|