@swiftbrowser/native 0.0.0-stage → 0.10.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 +143 -2
- package/bin/swiftbrowser-native.mjs +49 -0
- package/dist/bridge.d.ts +50 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +10 -0
- package/dist/bridge.js.map +1 -0
- package/dist/build.d.ts +47 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +156 -0
- package/dist/build.js.map +1 -0
- package/dist/devBuild.d.ts +34 -0
- package/dist/devBuild.d.ts.map +1 -0
- package/dist/devBuild.js +133 -0
- package/dist/devBuild.js.map +1 -0
- package/dist/encoding.d.ts +32 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +136 -0
- package/dist/encoding.js.map +1 -0
- package/dist/fetch.d.ts +117 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +272 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +19 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +17 -0
- package/dist/native.js.map +1 -0
- package/dist/refresh.d.ts +18 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +18 -0
- package/dist/refresh.js.map +1 -0
- package/dist/run.d.ts +27 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +76 -0
- package/dist/run.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +193 -0
- package/dist/runtime.js.map +1 -0
- package/dist/stubs/react-dom-client.d.ts +8 -0
- package/dist/stubs/react-dom-client.d.ts.map +1 -0
- package/dist/stubs/react-dom-client.js +11 -0
- package/dist/stubs/react-dom-client.js.map +1 -0
- package/dist/stubs/react-dom.d.ts +9 -0
- package/dist/stubs/react-dom.d.ts.map +1 -0
- package/dist/stubs/react-dom.js +13 -0
- package/dist/stubs/react-dom.js.map +1 -0
- package/dist/url.d.ts +48 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +213 -0
- package/dist/url.js.map +1 -0
- package/dist/vendor.d.ts +11 -0
- package/dist/vendor.d.ts.map +1 -0
- package/dist/vendor.js +27 -0
- package/dist/vendor.js.map +1 -0
- package/dist/wasmHost.d.ts +35 -0
- package/dist/wasmHost.d.ts.map +1 -0
- package/dist/wasmHost.js +120 -0
- package/dist/wasmHost.js.map +1 -0
- package/package.json +73 -4
- package/src/bridge.ts +58 -0
- package/src/build.ts +185 -0
- package/src/devBuild.ts +152 -0
- package/src/encoding.ts +130 -0
- package/src/fetch.ts +305 -0
- package/src/index.ts +20 -0
- package/src/native.ts +35 -0
- package/src/refresh.ts +29 -0
- package/src/run.ts +98 -0
- package/src/runtime.ts +208 -0
- package/src/stubs/react-dom-client.ts +17 -0
- package/src/stubs/react-dom.ts +15 -0
- package/src/types/react-refresh.d.ts +38 -0
- package/src/url.ts +202 -0
- package/src/vendor.ts +30 -0
- package/src/wasmHost.ts +147 -0
- package/swift/Package.swift +31 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +228 -0
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +42 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +102 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +25 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebHost.swift +138 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +251 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +53 -0
- package/swift/Sources/SwiftBrowserNative/SwiftBrowserNative.swift +6 -0
- package/swift/Sources/SwiftBrowserNative/Views/Conversions.swift +361 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +169 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +268 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +175 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +196 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +246 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +108 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +189 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +102 -0
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +190 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +182 -0
- package/swift/Sources/SwiftBrowserProtocol/JSONValue.swift +268 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +100 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +469 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +117 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +148 -0
- package/swift/Sources/SwiftBrowserProtocol/Values.swift +279 -0
- package/swift/Sources/SwiftBrowserTree/ElementNode.swift +65 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +168 -0
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch`, `Headers`, `Request`, `Response` and `AbortController` over the
|
|
3
|
+
* bridge's `fetch` primitive (`URLSession` on the Swift side). Bodies cross
|
|
4
|
+
* as base64; a `Response` reads them back as text, JSON or bytes. Streams
|
|
5
|
+
* and `Blob` are not here: a body is whole.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NativeBridge, NativeFetchResult } from './bridge';
|
|
9
|
+
import { base64Decode, base64Encode, utf8Decode, utf8Encode } from './encoding';
|
|
10
|
+
|
|
11
|
+
type HeadersInitLike = SimpleHeaders | Record<string, string> | [string, string][];
|
|
12
|
+
|
|
13
|
+
export class SimpleHeaders {
|
|
14
|
+
private map = new Map<string, string>();
|
|
15
|
+
|
|
16
|
+
constructor(init?: HeadersInitLike) {
|
|
17
|
+
if (init instanceof SimpleHeaders) {
|
|
18
|
+
for (const [k, v] of init) this.map.set(k, v);
|
|
19
|
+
} else if (Array.isArray(init)) {
|
|
20
|
+
for (const [k, v] of init) this.append(k, v);
|
|
21
|
+
} else if (init) {
|
|
22
|
+
for (const [k, v] of Object.entries(init)) this.set(k, v);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
append(name: string, value: string): void {
|
|
26
|
+
const key = name.toLowerCase();
|
|
27
|
+
const current = this.map.get(key);
|
|
28
|
+
this.map.set(key, current === undefined ? String(value) : `${current}, ${value}`);
|
|
29
|
+
}
|
|
30
|
+
delete(name: string): void {
|
|
31
|
+
this.map.delete(name.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
get(name: string): string | null {
|
|
34
|
+
return this.map.get(name.toLowerCase()) ?? null;
|
|
35
|
+
}
|
|
36
|
+
has(name: string): boolean {
|
|
37
|
+
return this.map.has(name.toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
set(name: string, value: string): void {
|
|
40
|
+
this.map.set(name.toLowerCase(), String(value));
|
|
41
|
+
}
|
|
42
|
+
forEach(callback: (value: string, key: string, parent: SimpleHeaders) => void): void {
|
|
43
|
+
for (const [k, v] of this.map) callback(v, k, this);
|
|
44
|
+
}
|
|
45
|
+
keys(): IterableIterator<string> {
|
|
46
|
+
return this.map.keys();
|
|
47
|
+
}
|
|
48
|
+
values(): IterableIterator<string> {
|
|
49
|
+
return this.map.values();
|
|
50
|
+
}
|
|
51
|
+
entries(): IterableIterator<[string, string]> {
|
|
52
|
+
return this.map.entries();
|
|
53
|
+
}
|
|
54
|
+
[Symbol.iterator](): IterableIterator<[string, string]> {
|
|
55
|
+
return this.map.entries();
|
|
56
|
+
}
|
|
57
|
+
/** The headers as a plain object, for the bridge. */
|
|
58
|
+
toObject(): Record<string, string> {
|
|
59
|
+
return Object.fromEntries(this.map);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class SimpleAbortSignal {
|
|
64
|
+
aborted = false;
|
|
65
|
+
reason: unknown = undefined;
|
|
66
|
+
onabort: ((event: { type: 'abort' }) => void) | null = null;
|
|
67
|
+
private listeners = new Set<(event: { type: 'abort' }) => void>();
|
|
68
|
+
|
|
69
|
+
addEventListener(type: string, listener: (event: { type: 'abort' }) => void): void {
|
|
70
|
+
if (type === 'abort') this.listeners.add(listener);
|
|
71
|
+
}
|
|
72
|
+
removeEventListener(type: string, listener: (event: { type: 'abort' }) => void): void {
|
|
73
|
+
if (type === 'abort') this.listeners.delete(listener);
|
|
74
|
+
}
|
|
75
|
+
throwIfAborted(): void {
|
|
76
|
+
if (this.aborted) throw this.reason;
|
|
77
|
+
}
|
|
78
|
+
/** @internal */
|
|
79
|
+
_abort(reason: unknown): void {
|
|
80
|
+
if (this.aborted) return;
|
|
81
|
+
this.aborted = true;
|
|
82
|
+
this.reason = reason;
|
|
83
|
+
const event = { type: 'abort' as const };
|
|
84
|
+
this.onabort?.(event);
|
|
85
|
+
for (const listener of this.listeners) listener(event);
|
|
86
|
+
}
|
|
87
|
+
static abort(reason?: unknown): SimpleAbortSignal {
|
|
88
|
+
const signal = new SimpleAbortSignal();
|
|
89
|
+
signal._abort(reason ?? abortError());
|
|
90
|
+
return signal;
|
|
91
|
+
}
|
|
92
|
+
static timeout(ms: number): SimpleAbortSignal {
|
|
93
|
+
const signal = new SimpleAbortSignal();
|
|
94
|
+
setTimeout(() => signal._abort(timeoutError()), ms);
|
|
95
|
+
return signal;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class SimpleAbortController {
|
|
100
|
+
readonly signal = new SimpleAbortSignal();
|
|
101
|
+
abort(reason?: unknown): void {
|
|
102
|
+
this.signal._abort(reason ?? abortError());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function abortError(): Error {
|
|
107
|
+
const error = new Error('The operation was aborted.');
|
|
108
|
+
error.name = 'AbortError';
|
|
109
|
+
return error;
|
|
110
|
+
}
|
|
111
|
+
function timeoutError(): Error {
|
|
112
|
+
const error = new Error('The operation timed out.');
|
|
113
|
+
error.name = 'TimeoutError';
|
|
114
|
+
return error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type BodyInitLike = string | ArrayBuffer | ArrayBufferView | SimpleURLSearchParamsLike | null | undefined;
|
|
118
|
+
interface SimpleURLSearchParamsLike {
|
|
119
|
+
toString(): string;
|
|
120
|
+
[Symbol.iterator](): IterableIterator<[string, string]>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Bytes of a body, and the content type it implies when the caller set none. */
|
|
124
|
+
function encodeBody(body: BodyInitLike): { bytes: Uint8Array | null; contentType: string | null } {
|
|
125
|
+
if (body === null || body === undefined) return { bytes: null, contentType: null };
|
|
126
|
+
if (typeof body === 'string') return { bytes: utf8Encode(body), contentType: 'text/plain;charset=UTF-8' };
|
|
127
|
+
if (body instanceof ArrayBuffer) return { bytes: new Uint8Array(body), contentType: null };
|
|
128
|
+
if (ArrayBuffer.isView(body)) return { bytes: new Uint8Array(body.buffer, body.byteOffset, body.byteLength), contentType: null };
|
|
129
|
+
if (typeof body === 'object' && Symbol.iterator in body && typeof body.toString === 'function') {
|
|
130
|
+
return { bytes: utf8Encode(body.toString()), contentType: 'application/x-www-form-urlencoded;charset=UTF-8' };
|
|
131
|
+
}
|
|
132
|
+
return { bytes: utf8Encode(String(body)), contentType: 'text/plain;charset=UTF-8' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class Body {
|
|
136
|
+
protected bytes: Uint8Array | null;
|
|
137
|
+
bodyUsed = false;
|
|
138
|
+
constructor(bytes: Uint8Array | null) {
|
|
139
|
+
this.bytes = bytes;
|
|
140
|
+
}
|
|
141
|
+
get body(): null {
|
|
142
|
+
// No streams; read the body whole with the methods below.
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
private take(): Uint8Array {
|
|
146
|
+
if (this.bodyUsed) throw new TypeError('body used already');
|
|
147
|
+
this.bodyUsed = true;
|
|
148
|
+
return this.bytes ?? new Uint8Array(0);
|
|
149
|
+
}
|
|
150
|
+
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
151
|
+
const bytes = this.take();
|
|
152
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
153
|
+
}
|
|
154
|
+
async bytesArray(): Promise<Uint8Array> {
|
|
155
|
+
return this.take();
|
|
156
|
+
}
|
|
157
|
+
async text(): Promise<string> {
|
|
158
|
+
return utf8Decode(this.take());
|
|
159
|
+
}
|
|
160
|
+
async json(): Promise<unknown> {
|
|
161
|
+
return JSON.parse(utf8Decode(this.take()));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface RequestInitLike {
|
|
166
|
+
method?: string;
|
|
167
|
+
headers?: HeadersInitLike;
|
|
168
|
+
body?: BodyInitLike;
|
|
169
|
+
signal?: SimpleAbortSignal | null;
|
|
170
|
+
[key: string]: unknown;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class SimpleRequest extends Body {
|
|
174
|
+
readonly url: string;
|
|
175
|
+
readonly method: string;
|
|
176
|
+
readonly headers: SimpleHeaders;
|
|
177
|
+
readonly signal: SimpleAbortSignal;
|
|
178
|
+
|
|
179
|
+
constructor(input: string | SimpleRequest | { href: string }, init: RequestInitLike = {}) {
|
|
180
|
+
const base = input instanceof SimpleRequest ? input : null;
|
|
181
|
+
const { bytes, contentType } = encodeBody(init.body);
|
|
182
|
+
super(bytes ?? base?.rawBytes() ?? null);
|
|
183
|
+
this.url = typeof input === 'string' ? input : input instanceof SimpleRequest ? input.url : input.href;
|
|
184
|
+
this.method = (init.method ?? base?.method ?? 'GET').toUpperCase();
|
|
185
|
+
this.headers = new SimpleHeaders(init.headers ?? base?.headers);
|
|
186
|
+
if (contentType && !this.headers.has('content-type')) this.headers.set('content-type', contentType);
|
|
187
|
+
this.signal = init.signal ?? base?.signal ?? new SimpleAbortSignal();
|
|
188
|
+
}
|
|
189
|
+
/** @internal */
|
|
190
|
+
rawBytes(): Uint8Array | null {
|
|
191
|
+
return this.bytes;
|
|
192
|
+
}
|
|
193
|
+
clone(): SimpleRequest {
|
|
194
|
+
return new SimpleRequest(this, { body: this.bytes });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ResponseInitLike {
|
|
199
|
+
status?: number;
|
|
200
|
+
statusText?: string;
|
|
201
|
+
headers?: HeadersInitLike;
|
|
202
|
+
url?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class SimpleResponse extends Body {
|
|
206
|
+
readonly status: number;
|
|
207
|
+
readonly statusText: string;
|
|
208
|
+
readonly headers: SimpleHeaders;
|
|
209
|
+
readonly url: string;
|
|
210
|
+
readonly type = 'basic';
|
|
211
|
+
readonly redirected = false;
|
|
212
|
+
|
|
213
|
+
constructor(body: BodyInitLike = null, init: ResponseInitLike = {}) {
|
|
214
|
+
const { bytes, contentType } = encodeBody(body);
|
|
215
|
+
super(bytes);
|
|
216
|
+
this.status = init.status ?? 200;
|
|
217
|
+
this.statusText = init.statusText ?? '';
|
|
218
|
+
this.headers = new SimpleHeaders(init.headers);
|
|
219
|
+
if (contentType && !this.headers.has('content-type')) this.headers.set('content-type', contentType);
|
|
220
|
+
this.url = init.url ?? '';
|
|
221
|
+
}
|
|
222
|
+
get ok(): boolean {
|
|
223
|
+
return this.status >= 200 && this.status < 300;
|
|
224
|
+
}
|
|
225
|
+
clone(): SimpleResponse {
|
|
226
|
+
return new SimpleResponse(this.bytes, { status: this.status, statusText: this.statusText, headers: this.headers, url: this.url });
|
|
227
|
+
}
|
|
228
|
+
static json(data: unknown, init: ResponseInitLike = {}): SimpleResponse {
|
|
229
|
+
const response = new SimpleResponse(JSON.stringify(data), init);
|
|
230
|
+
response.headers.set('content-type', 'application/json');
|
|
231
|
+
return response;
|
|
232
|
+
}
|
|
233
|
+
static error(): SimpleResponse {
|
|
234
|
+
return new SimpleResponse(null, { status: 0 });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface FetchGlobals {
|
|
239
|
+
fetch: (input: string | SimpleRequest | { href: string }, init?: RequestInitLike) => Promise<SimpleResponse>;
|
|
240
|
+
Headers: typeof SimpleHeaders;
|
|
241
|
+
Request: typeof SimpleRequest;
|
|
242
|
+
Response: typeof SimpleResponse;
|
|
243
|
+
AbortController: typeof SimpleAbortController;
|
|
244
|
+
AbortSignal: typeof SimpleAbortSignal;
|
|
245
|
+
/** The bridge's answer; Swift calls it as `__sbFetchResult(id, result)`. */
|
|
246
|
+
__sbFetchResult: (id: number, result: NativeFetchResult | string) => void;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The fetch globals over the bridge. */
|
|
250
|
+
export function createFetch(native: Pick<NativeBridge, 'fetch'>): FetchGlobals {
|
|
251
|
+
let nextId = 1;
|
|
252
|
+
const pending = new Map<number, { resolve: (response: SimpleResponse) => void; reject: (error: unknown) => void }>();
|
|
253
|
+
|
|
254
|
+
const fetch: FetchGlobals['fetch'] = (input, init = {}) => {
|
|
255
|
+
const request = new SimpleRequest(input, init);
|
|
256
|
+
return new Promise<SimpleResponse>((resolve, reject) => {
|
|
257
|
+
if (request.signal.aborted) {
|
|
258
|
+
reject(request.signal.reason);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const id = nextId++;
|
|
262
|
+
pending.set(id, { resolve, reject });
|
|
263
|
+
request.signal.addEventListener('abort', () => {
|
|
264
|
+
if (pending.delete(id)) reject(request.signal.reason);
|
|
265
|
+
});
|
|
266
|
+
const bytes = request.rawBytes();
|
|
267
|
+
native.fetch(id, {
|
|
268
|
+
url: request.url,
|
|
269
|
+
method: request.method,
|
|
270
|
+
headers: request.headers.toObject(),
|
|
271
|
+
body: bytes ? base64Encode(bytes) : null,
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const __sbFetchResult: FetchGlobals['__sbFetchResult'] = (id, raw) => {
|
|
277
|
+
const waiting = pending.get(id);
|
|
278
|
+
if (!waiting) return;
|
|
279
|
+
pending.delete(id);
|
|
280
|
+
const result: NativeFetchResult = typeof raw === 'string' ? (JSON.parse(raw) as NativeFetchResult) : raw;
|
|
281
|
+
if (result.error !== undefined) {
|
|
282
|
+
const error = new TypeError(`fetch failed: ${result.error}`);
|
|
283
|
+
waiting.reject(error);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
waiting.resolve(
|
|
287
|
+
new SimpleResponse(result.body ? base64Decode(result.body) : null, {
|
|
288
|
+
status: result.status ?? 200,
|
|
289
|
+
statusText: result.statusText ?? '',
|
|
290
|
+
headers: result.headers ?? {},
|
|
291
|
+
url: result.url ?? '',
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
fetch,
|
|
298
|
+
Headers: SimpleHeaders,
|
|
299
|
+
Request: SimpleRequest,
|
|
300
|
+
Response: SimpleResponse,
|
|
301
|
+
AbortController: SimpleAbortController,
|
|
302
|
+
AbortSignal: SimpleAbortSignal,
|
|
303
|
+
__sbFetchResult,
|
|
304
|
+
};
|
|
305
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @swiftbrowser/native: a @swiftbrowser/react app running in JavaScriptCore
|
|
3
|
+
* inside an iOS app, drawn by real SwiftUI (docs/native.md).
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* import { run } from '@swiftbrowser/native';
|
|
7
|
+
* run(<App />);
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* The bundle `swiftbrowser-native build` writes starts with the runtime
|
|
11
|
+
* (./runtime), so the web globals are there before React loads.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { run, type RunOptions, type Running } from './run';
|
|
15
|
+
export { Native, type NativeProps } from './native';
|
|
16
|
+
export { installRuntime, type InstallOptions } from './runtime';
|
|
17
|
+
export { nativeBridge, launchArguments, type NativeBridge, type NativeFetchRequest, type NativeFetchResult } from './bridge';
|
|
18
|
+
export { SimpleHeaders, SimpleRequest, SimpleResponse, SimpleAbortController, SimpleAbortSignal, createFetch } from './fetch';
|
|
19
|
+
export { SimpleURL, SimpleURLSearchParams } from './url';
|
|
20
|
+
export { SimpleTextEncoder, SimpleTextDecoder, base64Encode, base64Decode, utf8Encode, utf8Decode } from './encoding';
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<Native name props onEvent>`: the escape hatch (docs/native.md). The
|
|
3
|
+
* element kind `native`, which only this producer emits, names a SwiftUI view
|
|
4
|
+
* the app registered with `NativeRegistry.register(name)`; `props` reach it
|
|
5
|
+
* as JSON, its children as SwiftUI views, and what it sends back arrives at
|
|
6
|
+
* `onEvent`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createElement, type ReactElement, type ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
export interface NativeProps {
|
|
12
|
+
/** The name registered on the Swift side. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** JSON-serializable props for the view. */
|
|
15
|
+
props?: Record<string, unknown>;
|
|
16
|
+
/** A value the native view sent (`context.send(value)`). */
|
|
17
|
+
onEvent?: (value: unknown) => void;
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The shape of a `native` event (`RendererEvent` has no such member). */
|
|
22
|
+
interface NativeEvent {
|
|
23
|
+
type: 'native';
|
|
24
|
+
id: number;
|
|
25
|
+
name: string;
|
|
26
|
+
value: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Native({ name, props, onEvent, children }: NativeProps): ReactElement {
|
|
30
|
+
// The host element of @swiftbrowser/react: `sb` with the protocol kind and
|
|
31
|
+
// props, and the handlers by event type (packages/react/src/host/instance.ts).
|
|
32
|
+
const hostProps: Record<string, unknown> = { kind: 'native', props: { name, props: props ?? {} } };
|
|
33
|
+
if (onEvent) hostProps['on'] = { native: (event: NativeEvent) => onEvent(event.value) };
|
|
34
|
+
return children === undefined ? createElement('sb', hostProps) : createElement('sb', hostProps, children);
|
|
35
|
+
}
|
package/src/refresh.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Fast Refresh for the dev loop (docs/native.md, "Development loop").
|
|
3
|
+
* The refresh runtime must install its hook before React's reconciler loads,
|
|
4
|
+
* so the vendor bundle imports this module first. The app bundle's modules,
|
|
5
|
+
* transformed by `react-refresh/babel` (devBuild.ts), register their
|
|
6
|
+
* components through `__sbRefresh`; `run` performs the refresh when the app
|
|
7
|
+
* bundle is evaluated again.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import RefreshRuntime from 'react-refresh/runtime';
|
|
11
|
+
|
|
12
|
+
export interface RefreshGlobal {
|
|
13
|
+
register: (type: unknown, id: string) => void;
|
|
14
|
+
createSignatureFunctionForTransform: typeof RefreshRuntime.createSignatureFunctionForTransform;
|
|
15
|
+
/** Applies the registered updates to the mounted roots; true when something was refreshed. */
|
|
16
|
+
perform: () => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
RefreshRuntime.injectIntoGlobalHook(globalThis as unknown as Window);
|
|
20
|
+
|
|
21
|
+
const refresh: RefreshGlobal = {
|
|
22
|
+
register: (type, id) => RefreshRuntime.register(type, id),
|
|
23
|
+
createSignatureFunctionForTransform: RefreshRuntime.createSignatureFunctionForTransform,
|
|
24
|
+
perform: () => RefreshRuntime.performReactRefresh() !== null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
(globalThis as unknown as { __sbRefresh: RefreshGlobal }).__sbRefresh = refresh;
|
|
28
|
+
|
|
29
|
+
export { refresh };
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run(element)`: a `@swiftbrowser/react` root whose ops go to the native
|
|
3
|
+
* renderer over the bridge, and whose events come back from it. The native
|
|
4
|
+
* counterpart of `mount` in @swiftbrowser/react, without the DOM.
|
|
5
|
+
*
|
|
6
|
+
* The bridge is synchronous both ways: `__sbApplyOps` returns when the tree
|
|
7
|
+
* has the pass, and Swift's `__sbDispatch(json)` returns when the handler
|
|
8
|
+
* ran and its pass was applied (docs/native.md, "Synchronous calls").
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createSwiftUIRoot, type SwiftUIRoot } from '@swiftbrowser/react';
|
|
12
|
+
import type { Op, RendererEvent } from '@swiftbrowser/web/src/protocol';
|
|
13
|
+
import type { ReactNode } from 'react';
|
|
14
|
+
import { nativeBridge, type NativeBridge } from './bridge';
|
|
15
|
+
|
|
16
|
+
export interface RunOptions {
|
|
17
|
+
/** The bridge to render through; the global `__sbNative` by default. */
|
|
18
|
+
native?: NativeBridge;
|
|
19
|
+
/** Called with an error applying ops threw on the native side, or a handler threw. */
|
|
20
|
+
onError?: (error: unknown) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Running {
|
|
24
|
+
root: SwiftUIRoot;
|
|
25
|
+
/** Deliver an event as the native side does (tests). */
|
|
26
|
+
dispatch(event: RendererEvent | string): void;
|
|
27
|
+
unmount(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The environment fields the native side sends; the rest of the event is ignored. */
|
|
31
|
+
interface EnvironmentFields {
|
|
32
|
+
colorScheme?: 'light' | 'dark';
|
|
33
|
+
dynamicTypeSize?: string;
|
|
34
|
+
horizontalSizeClass?: 'compact' | 'regular';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The running app, when `run` was called (the dev loop calls it again on every save). */
|
|
38
|
+
let current: Running | null = null;
|
|
39
|
+
|
|
40
|
+
export function run(element: ReactNode, options: RunOptions = {}): Running {
|
|
41
|
+
const native = options.native ?? nativeBridge();
|
|
42
|
+
if (!native) throw new Error('run(): no native bridge (__sbNative); this entry runs inside a SwiftBrowser Native app');
|
|
43
|
+
const report = options.onError ?? ((error: unknown) => native.log('error', error instanceof Error ? (error.stack ?? error.message) : String(error)));
|
|
44
|
+
|
|
45
|
+
if (current) {
|
|
46
|
+
// Fast Refresh (devBuild.ts): the app bundle was evaluated again. Its
|
|
47
|
+
// modules re-registered their components; the refresh runtime updates
|
|
48
|
+
// the mounted ones in place (state kept where the hooks are unchanged),
|
|
49
|
+
// and the new tree renders into the same root.
|
|
50
|
+
const refresh = (globalThis as unknown as { __sbRefresh?: { perform: () => boolean } }).__sbRefresh;
|
|
51
|
+
try {
|
|
52
|
+
refresh?.perform();
|
|
53
|
+
current.root.render(element);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
report(error);
|
|
56
|
+
}
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const root = createSwiftUIRoot((ops: Op[]) => {
|
|
61
|
+
const failure = native.applyOps(JSON.stringify(ops));
|
|
62
|
+
if (failure) report(new Error(`applyOps: ${failure}`));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const dispatch = (event: RendererEvent | string): void => {
|
|
66
|
+
const parsed = typeof event === 'string' ? (JSON.parse(event) as RendererEvent) : event;
|
|
67
|
+
try {
|
|
68
|
+
if (parsed.type === 'environment') {
|
|
69
|
+
const fields = parsed as EnvironmentFields;
|
|
70
|
+
const next: Partial<{ colorScheme: 'light' | 'dark'; dynamicTypeSize: never; horizontalSizeClass: 'compact' | 'regular' }> = {};
|
|
71
|
+
if (fields.colorScheme) next.colorScheme = fields.colorScheme;
|
|
72
|
+
if (fields.dynamicTypeSize) (next as Record<string, unknown>)['dynamicTypeSize'] = fields.dynamicTypeSize;
|
|
73
|
+
if (fields.horizontalSizeClass) next.horizontalSizeClass = fields.horizontalSizeClass;
|
|
74
|
+
if (Object.keys(next).length > 0) root.setEnvironment(next);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
root.dispatch(parsed);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
report(error);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
84
|
+
g['__sbDispatch'] = (json: string) => dispatch(json);
|
|
85
|
+
|
|
86
|
+
root.render(element);
|
|
87
|
+
|
|
88
|
+
current = {
|
|
89
|
+
root,
|
|
90
|
+
dispatch,
|
|
91
|
+
unmount() {
|
|
92
|
+
root.unmount();
|
|
93
|
+
if (g['__sbDispatch']) delete g['__sbDispatch'];
|
|
94
|
+
current = null;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
return current;
|
|
98
|
+
}
|