@swiftbrowser/web 0.9.0 → 0.9.2
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/app/index.html +3 -0
- package/node/dist/plugins/playground.d.ts +6 -3
- package/node/dist/plugins/playground.d.ts.map +1 -1
- package/node/dist/plugins/playground.js +32 -12
- package/node/dist/plugins/playground.js.map +1 -1
- package/node/dist/src/playground/files.d.ts +6 -0
- package/node/dist/src/playground/files.d.ts.map +1 -1
- package/node/dist/src/playground/files.js.map +1 -1
- package/node/dist/src/playground/sections.d.ts +5 -0
- package/node/dist/src/playground/sections.d.ts.map +1 -0
- package/node/dist/src/playground/sections.js +88 -0
- package/node/dist/src/playground/sections.js.map +1 -0
- package/package.json +6 -1
- package/plugins/playground.ts +30 -12
- package/src/main.ts +6 -2
- package/src/page.css +16 -0
- package/src/playground/app.tsx +311 -0
- package/src/playground/assets.ts +53 -134
- package/src/playground/build.ts +30 -19
- package/src/playground/download.ts +125 -0
- package/src/playground/files.ts +6 -0
- package/src/playground/freeze.ts +105 -0
- package/src/playground/main.tsx +45 -0
- package/src/playground/playground.css +192 -29
- package/src/playground/read-once.ts +75 -0
- package/src/playground/sections.ts +77 -0
- package/src/playground/session.ts +117 -0
- package/src/playground/tool-host.ts +163 -0
- package/src/playground/tool-worker.ts +67 -0
- package/src/playground/worker.ts +41 -17
- package/src/playground/main.ts +0 -107
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// The playground's window, drawn with @swiftbrowser/react's SwiftUI views
|
|
2
|
+
// as Xcode lays one out: the project's files in a sidebar, then the editor
|
|
3
|
+
// beside the simulator. In a compact width (a phone) the code is a stack's
|
|
4
|
+
// root, the simulator a screen it pushes, and the files an inspector. The
|
|
5
|
+
// editor and the frame the app runs in are ordinary DOM in `Host`s.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Button,
|
|
9
|
+
Divider,
|
|
10
|
+
HStack,
|
|
11
|
+
Host,
|
|
12
|
+
Label,
|
|
13
|
+
List,
|
|
14
|
+
NavigationSplitView,
|
|
15
|
+
NavigationStack,
|
|
16
|
+
Section,
|
|
17
|
+
ToolbarItem,
|
|
18
|
+
ZStack,
|
|
19
|
+
useHorizontalSizeClass,
|
|
20
|
+
} from '@swiftbrowser/react';
|
|
21
|
+
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent } from 'react';
|
|
22
|
+
import type { Session, SessionState } from './session';
|
|
23
|
+
|
|
24
|
+
/** The value a phone's stack pushes for the simulator's screen. */
|
|
25
|
+
const SIMULATOR = 'simulator';
|
|
26
|
+
|
|
27
|
+
export interface PlaygroundProps {
|
|
28
|
+
session: Session;
|
|
29
|
+
appName: string;
|
|
30
|
+
/** Path relative to the app's Sources/ → text. */
|
|
31
|
+
files: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Playground({ session, appName, files: initialFiles }: PlaygroundProps) {
|
|
35
|
+
const state = useSyncExternalStore(session.subscribe, () => session.state);
|
|
36
|
+
const [files, setFiles] = useState(initialFiles);
|
|
37
|
+
const names = Object.keys(files).sort();
|
|
38
|
+
const [selection, setSelection] = useState<string | null>(names[0] ?? null);
|
|
39
|
+
// The file the editor shows: the sidebar's selection, or the last one
|
|
40
|
+
// when it has none.
|
|
41
|
+
const [shown, setShown] = useState(names[0] ?? '');
|
|
42
|
+
const select = (file: string | null): void => {
|
|
43
|
+
setSelection(file);
|
|
44
|
+
if (file) setShown(file);
|
|
45
|
+
};
|
|
46
|
+
const compact = useHorizontalSizeClass() === 'compact';
|
|
47
|
+
// A phone's stack: empty, or the simulator pushed over the code.
|
|
48
|
+
const [path, setPath] = useState<readonly unknown[]>([]);
|
|
49
|
+
const [filesShown, setFilesShown] = useState(false);
|
|
50
|
+
const latest = useRef(files);
|
|
51
|
+
latest.current = files;
|
|
52
|
+
const run = (): void => session.compile(latest.current);
|
|
53
|
+
|
|
54
|
+
// The first run loads the compiler and the SDK too.
|
|
55
|
+
useEffect(() => run(), []);
|
|
56
|
+
// On a phone a linked app comes into view.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (state.linked > 0 && compact) setPath((stack) => (stack.length > 0 ? stack : [SIMULATOR]));
|
|
59
|
+
}, [state.linked]);
|
|
60
|
+
|
|
61
|
+
const editor = (
|
|
62
|
+
<Host sizing="fill" className="pg-pane" frame={{ maxWidth: Infinity, maxHeight: Infinity }}>
|
|
63
|
+
<Editor
|
|
64
|
+
file={shown}
|
|
65
|
+
text={files[shown] ?? ''}
|
|
66
|
+
onChange={(text) => setFiles((all) => ({ ...all, [shown]: text }))}
|
|
67
|
+
onRun={run}
|
|
68
|
+
state={state}
|
|
69
|
+
/>
|
|
70
|
+
</Host>
|
|
71
|
+
);
|
|
72
|
+
const preview = (
|
|
73
|
+
<Host sizing="fill" className="pg-pane" frame={compact ? { maxWidth: Infinity, maxHeight: Infinity } : { width: 430, maxHeight: Infinity }}>
|
|
74
|
+
<Simulator session={session} state={state} />
|
|
75
|
+
</Host>
|
|
76
|
+
);
|
|
77
|
+
const runButton = (
|
|
78
|
+
<ToolbarItem placement="primaryAction">
|
|
79
|
+
<Button action={run} disabled={state.busy}>
|
|
80
|
+
<Label title="Run" systemImage="play.fill" labelStyle={compact ? 'iconOnly' : 'titleAndIcon'} />
|
|
81
|
+
</Button>
|
|
82
|
+
</ToolbarItem>
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const sources = (
|
|
86
|
+
<Section header="Sources">
|
|
87
|
+
{names.map((name) => (
|
|
88
|
+
<Label key={name} tag={name} title={name} systemImage="swift" />
|
|
89
|
+
))}
|
|
90
|
+
</Section>
|
|
91
|
+
);
|
|
92
|
+
// Picking a file in the phone's inspector opens it and closes the inspector.
|
|
93
|
+
const pick = (file: string | null): void => {
|
|
94
|
+
if (!file) return;
|
|
95
|
+
select(file);
|
|
96
|
+
setFilesShown(false);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (compact) {
|
|
100
|
+
// A phone: one stack. The code is its root, with the files in an
|
|
101
|
+
// inspector (a sheet in a compact width) and the simulator a screen the
|
|
102
|
+
// bar's iPhone button pushes, as does a build that links. Popping back
|
|
103
|
+
// unloads the app, which leaves its memory to the compiler.
|
|
104
|
+
return (
|
|
105
|
+
<NavigationStack
|
|
106
|
+
path={[path, setPath]}
|
|
107
|
+
destination={() => (
|
|
108
|
+
<ZStack navigationTitle={appName} navigationBarTitleDisplayMode="inline" toolbar={runButton}>
|
|
109
|
+
{preview}
|
|
110
|
+
</ZStack>
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
<ZStack
|
|
114
|
+
navigationTitle={shown}
|
|
115
|
+
navigationBarTitleDisplayMode="inline"
|
|
116
|
+
toolbar={
|
|
117
|
+
<>
|
|
118
|
+
<ToolbarItem placement="topBarLeading">
|
|
119
|
+
<Button action={() => setFilesShown(true)}>
|
|
120
|
+
<Label title="Files" systemImage="folder" labelStyle="iconOnly" />
|
|
121
|
+
</Button>
|
|
122
|
+
</ToolbarItem>
|
|
123
|
+
{runButton}
|
|
124
|
+
<ToolbarItem placement="primaryAction">
|
|
125
|
+
<Button action={() => setPath([SIMULATOR])}>
|
|
126
|
+
<Label title="Simulator" systemImage="iphone" labelStyle="iconOnly" />
|
|
127
|
+
</Button>
|
|
128
|
+
</ToolbarItem>
|
|
129
|
+
</>
|
|
130
|
+
}
|
|
131
|
+
inspector={{
|
|
132
|
+
isPresented: [filesShown, setFilesShown],
|
|
133
|
+
content: (
|
|
134
|
+
<NavigationStack>
|
|
135
|
+
<List
|
|
136
|
+
selection={[shown, pick]}
|
|
137
|
+
navigationTitle="Files"
|
|
138
|
+
navigationBarTitleDisplayMode="inline"
|
|
139
|
+
presentationDetents={['medium', 'large']}
|
|
140
|
+
toolbar={
|
|
141
|
+
<ToolbarItem placement="confirmationAction">
|
|
142
|
+
<Button title="Done" action={() => setFilesShown(false)} />
|
|
143
|
+
</ToolbarItem>
|
|
144
|
+
}
|
|
145
|
+
>
|
|
146
|
+
{sources}
|
|
147
|
+
</List>
|
|
148
|
+
</NavigationStack>
|
|
149
|
+
),
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
{editor}
|
|
153
|
+
</ZStack>
|
|
154
|
+
</NavigationStack>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<NavigationSplitView
|
|
160
|
+
sidebar={
|
|
161
|
+
<List selection={[selection, select]} navigationTitle={appName}>
|
|
162
|
+
{sources}
|
|
163
|
+
</List>
|
|
164
|
+
}
|
|
165
|
+
detail={
|
|
166
|
+
<HStack spacing={0} navigationTitle={shown} navigationBarTitleDisplayMode="inline" toolbar={runButton}>
|
|
167
|
+
{editor}
|
|
168
|
+
<Divider />
|
|
169
|
+
{preview}
|
|
170
|
+
</HStack>
|
|
171
|
+
}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface EditorProps {
|
|
177
|
+
file: string;
|
|
178
|
+
text: string;
|
|
179
|
+
onChange: (text: string) => void;
|
|
180
|
+
onRun: () => void;
|
|
181
|
+
state: SessionState;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The source editor: a textarea with a line-number gutter, the issues below it, and a status bar. */
|
|
185
|
+
function Editor({ file, text, onChange, onRun, state }: EditorProps) {
|
|
186
|
+
const gutter = useRef<HTMLPreElement>(null);
|
|
187
|
+
const area = useRef<HTMLTextAreaElement>(null);
|
|
188
|
+
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
|
189
|
+
const lines = text.split('\n').length;
|
|
190
|
+
const syncScroll = (): void => {
|
|
191
|
+
if (gutter.current && area.current) gutter.current.scrollTop = area.current.scrollTop;
|
|
192
|
+
};
|
|
193
|
+
useLayoutEffect(syncScroll, [text, file]);
|
|
194
|
+
const track = (): void => {
|
|
195
|
+
const element = area.current;
|
|
196
|
+
if (!element) return;
|
|
197
|
+
const before = element.value.slice(0, element.selectionStart).split('\n');
|
|
198
|
+
setCursor({ line: before.length, column: before.at(-1)!.length + 1 });
|
|
199
|
+
};
|
|
200
|
+
const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
201
|
+
// ⌘R or ⌘↩ (Ctrl on other systems) runs, as in Xcode.
|
|
202
|
+
if ((event.metaKey || event.ctrlKey) && (event.key === 'r' || event.key === 'Enter')) {
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
onRun();
|
|
205
|
+
} else if (event.key === 'Tab' && !event.shiftKey) {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
const element = event.currentTarget;
|
|
208
|
+
const { selectionStart, selectionEnd, value } = element;
|
|
209
|
+
onChange(`${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`);
|
|
210
|
+
requestAnimationFrame(() => element.setSelectionRange(selectionStart + 4, selectionStart + 4));
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const issues = state.output.trim();
|
|
214
|
+
const count = (kind: string): number => (issues.match(new RegExp(`^[^\\s:][^:]*:\\d+:\\d+: ${kind}:`, 'gm')) ?? []).length;
|
|
215
|
+
const errors = count('error');
|
|
216
|
+
const warnings = count('warning');
|
|
217
|
+
// Issues open by themselves when a build fails; warnings wait to be asked for.
|
|
218
|
+
const [open, setOpen] = useState(false);
|
|
219
|
+
useEffect(() => setOpen(state.failed), [state.output, state.failed]);
|
|
220
|
+
const summary = [errors && `${errors} error${errors === 1 ? '' : 's'}`, warnings && `${warnings} warning${warnings === 1 ? '' : 's'}`].filter(Boolean).join(', ') || (state.failed ? 'Failed' : 'Output');
|
|
221
|
+
return (
|
|
222
|
+
<div className="pg-editor">
|
|
223
|
+
{state.warning && (
|
|
224
|
+
<p id="warning" role="alert">
|
|
225
|
+
{state.warning}
|
|
226
|
+
</p>
|
|
227
|
+
)}
|
|
228
|
+
<div className="pg-code">
|
|
229
|
+
<pre ref={gutter} className="pg-gutter" aria-hidden="true">
|
|
230
|
+
{Array.from({ length: lines }, (_, index) => `${index + 1}\n`).join('')}
|
|
231
|
+
</pre>
|
|
232
|
+
<textarea
|
|
233
|
+
ref={area}
|
|
234
|
+
id="source"
|
|
235
|
+
aria-label={file}
|
|
236
|
+
spellCheck={false}
|
|
237
|
+
autoCapitalize="off"
|
|
238
|
+
autoComplete="off"
|
|
239
|
+
autoCorrect="off"
|
|
240
|
+
wrap="off"
|
|
241
|
+
value={text}
|
|
242
|
+
onChange={(event) => onChange(event.target.value)}
|
|
243
|
+
onScroll={syncScroll}
|
|
244
|
+
onKeyDown={keyDown}
|
|
245
|
+
onKeyUp={track}
|
|
246
|
+
onClick={track}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
<pre id="diagnostics" className={state.failed ? 'pg-errors' : ''} hidden={issues === '' || !open}>
|
|
250
|
+
{issues}
|
|
251
|
+
</pre>
|
|
252
|
+
<div className="pg-statusbar">
|
|
253
|
+
<span id="status" role="status" className={state.busy ? 'pg-busy' : ''}>
|
|
254
|
+
{state.status}
|
|
255
|
+
</span>
|
|
256
|
+
{issues !== '' && (
|
|
257
|
+
<button type="button" className={`pg-issues${errors || state.failed ? ' pg-has-errors' : ''}`} aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
258
|
+
{summary}
|
|
259
|
+
</button>
|
|
260
|
+
)}
|
|
261
|
+
<span className="pg-position">{`Line ${cursor.line}, Col ${cursor.column}`}</span>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Whether this is a touch device, where the app runs full screen in the simulator's pane. */
|
|
268
|
+
export const TOUCH = matchMedia('(pointer: coarse)').matches;
|
|
269
|
+
/**
|
|
270
|
+
* The embedded app page's size: its iPhone, bezel included (styles.css's
|
|
271
|
+
* --device-*), and 16 around it for the shadow (page.css).
|
|
272
|
+
*/
|
|
273
|
+
const DEVICE = { width: 417 + 32, height: 876 + 32 };
|
|
274
|
+
/** The simulator's margin around that, beyond the shadow's room. */
|
|
275
|
+
const MARGIN = 4;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The simulator: the frame the app runs in, and the compiler's progress
|
|
279
|
+
* over it while it runs. Off a touch device the frame is the iPhone's size,
|
|
280
|
+
* scaled to fit the pane; the app inside never knows, so its taps and
|
|
281
|
+
* scrolling are exact.
|
|
282
|
+
*/
|
|
283
|
+
function Simulator({ session, state }: { session: Session; state: SessionState }) {
|
|
284
|
+
const box = useRef<HTMLDivElement>(null);
|
|
285
|
+
const [fit, setFit] = useState({ scale: 1, top: MARGIN });
|
|
286
|
+
useLayoutEffect(() => {
|
|
287
|
+
const element = box.current;
|
|
288
|
+
if (TOUCH || !element) return;
|
|
289
|
+
const observer = new ResizeObserver(() => {
|
|
290
|
+
const { clientWidth: width, clientHeight: height } = element;
|
|
291
|
+
const scale = Math.max(0.2, Math.min(1, (width - 2 * MARGIN) / DEVICE.width, (height - 2 * MARGIN) / DEVICE.height));
|
|
292
|
+
setFit({ scale, top: Math.max(MARGIN, (height - DEVICE.height * scale) / 2) });
|
|
293
|
+
});
|
|
294
|
+
observer.observe(element);
|
|
295
|
+
return () => observer.disconnect();
|
|
296
|
+
}, []);
|
|
297
|
+
const style = TOUCH
|
|
298
|
+
? undefined
|
|
299
|
+
: { width: DEVICE.width, height: DEVICE.height, top: fit.top, transform: `translateX(-50%) scale(${fit.scale})` };
|
|
300
|
+
return (
|
|
301
|
+
<div ref={box} className={TOUCH ? 'pg-simulator pg-full' : 'pg-simulator'}>
|
|
302
|
+
<iframe ref={session.attachFrame} id="app" title="The app" src="about:blank" style={style} />
|
|
303
|
+
{state.busy && (
|
|
304
|
+
<div className="pg-activity" aria-hidden="true">
|
|
305
|
+
<span className="pg-spinner" />
|
|
306
|
+
{state.status}
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
package/src/playground/assets.ts
CHANGED
|
@@ -1,122 +1,48 @@
|
|
|
1
1
|
// The playground's downloads (files.ts): its manifest from
|
|
2
2
|
// @swiftbrowser/playground, then the tools, the SDK's and the kit's files it
|
|
3
3
|
// names, the ICU data and the module cache, every one a file of an npm
|
|
4
|
-
// package, from the CDN at the source's version or from the site itself
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// come brotli-compressed, as does a file too big for the site, and no
|
|
8
|
-
// browser's DecompressionStream reads brotli: a small WebAssembly decoder
|
|
9
|
-
// unpacks them as they arrive, and the cache keeps them unpacked. Other files
|
|
10
|
-
// the server compresses in transit.
|
|
4
|
+
// package, from the CDN at the source's version or from the site itself,
|
|
5
|
+
// into the cache (download.ts). The page's worker holds only the small
|
|
6
|
+
// files: each tool's own worker reads the rest from the cache (tool-host.ts).
|
|
11
7
|
|
|
12
|
-
import { memoryDirectory } from '@swiftbrowser/wasm-compiler';
|
|
13
8
|
import type { Toolchain } from './build';
|
|
9
|
+
import { type Download, cacheKey, downloadBytes, openCache } from './download';
|
|
14
10
|
import {
|
|
15
11
|
PLAYGROUND_ICU,
|
|
16
12
|
PLAYGROUND_MANIFEST,
|
|
17
13
|
PLAYGROUND_MODULE_CACHE,
|
|
18
14
|
PLAYGROUND_PACKAGE,
|
|
19
15
|
SITE_BROTLI,
|
|
20
|
-
isBrotli,
|
|
21
16
|
type PackageFile,
|
|
22
17
|
type PlaygroundManifest,
|
|
23
18
|
type PlaygroundSource,
|
|
24
19
|
packageUrl,
|
|
25
20
|
} from './files';
|
|
26
21
|
import { readPack } from './pack';
|
|
22
|
+
import { prefetchInWorker } from './tool-host';
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
let brotli: Promise<Brotli> | null = null;
|
|
34
|
-
|
|
35
|
-
/** The brotli decoder, loaded on first use. */
|
|
36
|
-
function brotliDecoder(): Promise<Brotli> {
|
|
37
|
-
brotli ??= Promise.all([import('brotli-dec-wasm/web'), import('brotli-dec-wasm/web/bg.wasm?url')]).then(async ([decoder, wasm]) => {
|
|
38
|
-
await decoder.default({ module_or_path: wasm.default });
|
|
39
|
-
return decoder;
|
|
40
|
-
});
|
|
41
|
-
return brotli;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A stream that unpacks brotli as its input arrives. */
|
|
45
|
-
function brotliStream(decoder: Brotli): TransformStream<Uint8Array, Uint8Array> {
|
|
46
|
-
const stream = new decoder.BrotliDecStream();
|
|
47
|
-
let code: number = decoder.BrotliStreamResultCode.NeedsMoreInput;
|
|
48
|
-
return new TransformStream({
|
|
49
|
-
transform(chunk, controller) {
|
|
50
|
-
let input = chunk;
|
|
51
|
-
do {
|
|
52
|
-
const result = stream.dec(input, 1 << 22);
|
|
53
|
-
if (result.buf.length > 0) controller.enqueue(result.buf);
|
|
54
|
-
input = input.subarray(result.input_offset);
|
|
55
|
-
code = result.code;
|
|
56
|
-
} while (code === decoder.BrotliStreamResultCode.NeedsMoreOutput);
|
|
57
|
-
},
|
|
58
|
-
flush() {
|
|
59
|
-
stream.free();
|
|
60
|
-
if (code !== decoder.BrotliStreamResultCode.ResultSuccess) throw new Error('a brotli file ended early');
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const openCache = async (): Promise<Cache | null> => (typeof caches === 'undefined' ? null : caches.open(CACHE).catch(() => null));
|
|
66
|
-
|
|
67
|
-
/** The file at `url`, unpacked when it is brotli (`isBrotli`), from the cache when it has it. */
|
|
68
|
-
async function download(cache: Cache | null, url: string): Promise<Response> {
|
|
69
|
-
const hit = await cache?.match(url);
|
|
70
|
-
if (hit) return hit;
|
|
71
|
-
const response = await fetch(url);
|
|
72
|
-
if (!response.ok || !response.body) throw new Error(`${url}: HTTP ${response.status}`);
|
|
73
|
-
let file = response;
|
|
74
|
-
if (isBrotli(url)) {
|
|
75
|
-
const decoder = await brotliDecoder();
|
|
76
|
-
const type = /\.wasm\.(br|brotli)$/.test(url) ? 'application/wasm' : 'application/octet-stream';
|
|
77
|
-
file = new Response(response.body.pipeThrough(brotliStream(decoder)), { headers: { 'content-type': type } });
|
|
78
|
-
}
|
|
79
|
-
if (cache) {
|
|
80
|
-
// The page reads one copy as the cache takes the other, unpacked: of the
|
|
81
|
-
// headers only the type goes with it (compileStreaming wants it).
|
|
82
|
-
const headers = { 'content-type': file.headers.get('content-type') ?? 'application/octet-stream' };
|
|
83
|
-
const [kept, used] = file.body!.tee();
|
|
84
|
-
void cache.put(url, new Response(kept, { headers })).catch(() => {});
|
|
85
|
-
return new Response(used, { headers });
|
|
86
|
-
}
|
|
87
|
-
return file;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function compileModule(response: Response): Promise<WebAssembly.Module> {
|
|
91
|
-
if (typeof WebAssembly.compileStreaming === 'function' && (response.headers.get('content-type') ?? '').includes('application/wasm')) {
|
|
92
|
-
return WebAssembly.compileStreaming(response);
|
|
93
|
-
}
|
|
94
|
-
return WebAssembly.compile(await response.arrayBuffer());
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** `work` over `items`, at most `limit` at once, in order. */
|
|
98
|
-
async function pooled<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
|
|
99
|
-
const results: R[] = new Array(items.length);
|
|
100
|
-
let next = 0;
|
|
101
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
102
|
-
while (next < items.length) {
|
|
103
|
-
const index = next++;
|
|
104
|
-
results[index] = await work(items[index]!);
|
|
105
|
-
}
|
|
106
|
-
}));
|
|
107
|
-
return results;
|
|
108
|
-
}
|
|
109
|
-
|
|
24
|
+
/**
|
|
25
|
+
* What the playground's compiles read, downloaded into the cache. Only the
|
|
26
|
+
* small files are held here: each tool's worker takes the rest from the
|
|
27
|
+
* cache, those its tool reads (tool-host.ts).
|
|
28
|
+
*/
|
|
110
29
|
export interface LoadedToolchain {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
30
|
+
tools: Record<'swift-frontend' | 'wasm-ld', Download>;
|
|
31
|
+
/** The macro plugins by the module whose macros they expand: downloaded only by `prefetchPlugins`. */
|
|
32
|
+
plugins: Record<string, Download>;
|
|
33
|
+
/** The Swift SDK's files, relative to its `wasm32-unknown-wasip1` directory: to download, and those the manifest carries. */
|
|
34
|
+
sdk: { downloads: [string, Download][]; files: [string, Uint8Array][] };
|
|
35
|
+
/** The kit's files, relative to @swiftbrowser/kit. */
|
|
36
|
+
kit: [string, Download][];
|
|
37
|
+
kitManifest: Toolchain['kitManifest'];
|
|
38
|
+
icu: Toolchain['icu'];
|
|
118
39
|
/** The module cache built with the playground's flags. */
|
|
119
40
|
moduleCache: [string, Uint8Array][];
|
|
41
|
+
/**
|
|
42
|
+
* Downloads the plugins into the cache, keeping nothing: what the page
|
|
43
|
+
* does once a compile is done, and before a compile that expands a macro.
|
|
44
|
+
*/
|
|
45
|
+
prefetchPlugins: () => Promise<void>;
|
|
120
46
|
manifest: PlaygroundManifest;
|
|
121
47
|
/** Why the kit may not suit this page's renderer, when it may not. */
|
|
122
48
|
warning?: string;
|
|
@@ -133,8 +59,8 @@ export function kitWarning(manifest: Pick<PlaygroundManifest, 'shimSources'>, so
|
|
|
133
59
|
|
|
134
60
|
export async function loadToolchain(source: PlaygroundSource, onProgress: (text: string) => void = () => {}): Promise<LoadedToolchain> {
|
|
135
61
|
const cache = await openCache();
|
|
136
|
-
const
|
|
137
|
-
const bytes =
|
|
62
|
+
const from = ({ package: name, file }: PackageFile, module = false): Download => ({ url: packageUrl(source, name, file), ...(module ? { module } : {}) });
|
|
63
|
+
const bytes = (file: PackageFile): Promise<Uint8Array<ArrayBuffer>> => downloadBytes(cache, from(file));
|
|
138
64
|
const started = performance.now();
|
|
139
65
|
const lap = (text: string): void => onProgress(`${text} (${((performance.now() - started) / 1000).toFixed(1)} s)`);
|
|
140
66
|
const own = (file: string): PackageFile => ({ package: PLAYGROUND_PACKAGE, file });
|
|
@@ -144,54 +70,47 @@ export async function loadToolchain(source: PlaygroundSource, onProgress: (text:
|
|
|
144
70
|
// What a site stores brotli-compressed it serves under another name.
|
|
145
71
|
const brotli = new Set(manifest.brotli ?? []);
|
|
146
72
|
const served = (name: string, file: string): PackageFile => ({ package: name, file: brotli.has(`${name}/${file}`) ? `${file}${SITE_BROTLI}` : file });
|
|
147
|
-
const
|
|
148
|
-
const
|
|
73
|
+
const sdk = Object.entries(manifest.sdk).flatMap(([name, files]) => files.map((file): [string, Download] => [file, from(served(name, `wasm32-unknown-wasip1/${file}`))]));
|
|
74
|
+
const kit = manifest.kit.map((file): [string, Download] => [file, from(served(kitPackage, file))]);
|
|
75
|
+
const tools = { 'swift-frontend': from(manifest.frontend, true), 'wasm-ld': from(manifest.linker, true) };
|
|
76
|
+
const plugins = Object.fromEntries(Object.entries(manifest.plugins).map(([name, file]) => [name, from(file, true)]));
|
|
77
|
+
// The tools first, which take longest; one worker downloads them all.
|
|
78
|
+
const downloads = [tools['swift-frontend'], tools['wasm-ld'], ...sdk.map(([, file]) => file), ...kit.map(([, file]) => file)];
|
|
149
79
|
let done = 0;
|
|
150
|
-
const total =
|
|
151
|
-
const
|
|
152
|
-
|
|
80
|
+
const total = sdk.length + kit.length;
|
|
81
|
+
const fetched = (index: number): void => {
|
|
82
|
+
if (index < 2) return lap(index === 0 ? 'compiler ready' : 'linker ready');
|
|
153
83
|
if (++done % 50 === 0 || done === total) onProgress(`SDK and kit: ${done} of ${total} files`);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
download(cache, url(manifest.linker)).then(compileModule).then((module) => (lap('linker ready'), module)),
|
|
159
|
-
files.then((list) => (lap('SDK ready'), list)),
|
|
84
|
+
};
|
|
85
|
+
const [, kitJson, icu, moduleCacheBytes] = await Promise.all([
|
|
86
|
+
prefetchInWorker(downloads, fetched).then(() => lap('SDK ready')),
|
|
87
|
+
bytes(served(kitPackage, 'kit.json')),
|
|
160
88
|
bytes(served(PLAYGROUND_PACKAGE, PLAYGROUND_ICU)),
|
|
161
89
|
bytes(served(PLAYGROUND_PACKAGE, PLAYGROUND_MODULE_CACHE)),
|
|
162
90
|
]);
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const kitJson = kit.pop()![1];
|
|
91
|
+
const encoder = new TextEncoder();
|
|
92
|
+
const inline = Object.entries(manifest.inline ?? {}).map(([path, text]): [string, Uint8Array] => [path, encoder.encode(text)]);
|
|
166
93
|
|
|
167
|
-
const plugins = Promise.all(Object.entries(manifest.plugins).map(async ([name, file]) => {
|
|
168
|
-
const data = await bytes(file);
|
|
169
|
-
return [name, { module: await WebAssembly.compile(data), bytes: data }] as const;
|
|
170
|
-
})).then(Object.fromEntries<Toolchain['plugins'][string]>);
|
|
171
|
-
// A failed download surfaces when a compile needs the plugins.
|
|
172
|
-
plugins.catch(() => {});
|
|
173
94
|
// Every file is named by its version: drop what an older one left.
|
|
174
|
-
void
|
|
95
|
+
void (async () => {
|
|
175
96
|
const wanted = new Set([
|
|
176
|
-
...[
|
|
177
|
-
|
|
178
|
-
...[PLAYGROUND_ICU, PLAYGROUND_MODULE_CACHE].map((file) => url(served(PLAYGROUND_PACKAGE, file))),
|
|
97
|
+
...[...Object.values(tools), ...Object.values(plugins), ...sdk.map(([, file]) => file), ...kit.map(([, file]) => file)].map(cacheKey),
|
|
98
|
+
...[own(PLAYGROUND_MANIFEST), served(kitPackage, 'kit.json'), served(PLAYGROUND_PACKAGE, PLAYGROUND_ICU), served(PLAYGROUND_PACKAGE, PLAYGROUND_MODULE_CACHE)].map((file) => cacheKey(from(file))),
|
|
179
99
|
]);
|
|
180
100
|
for (const request of (await cache?.keys()) ?? []) if (!wanted.has(request.url)) await cache!.delete(request);
|
|
181
|
-
}).catch(() => {});
|
|
101
|
+
})().catch(() => {});
|
|
182
102
|
|
|
103
|
+
const warning = kitWarning(manifest, source);
|
|
183
104
|
return {
|
|
184
|
-
|
|
185
|
-
tools: { 'swift-frontend': frontend, 'wasm-ld': linker },
|
|
186
|
-
plugins: {},
|
|
187
|
-
sdk: memoryDirectory(sdk),
|
|
188
|
-
kit: memoryDirectory(kit),
|
|
189
|
-
kitManifest: JSON.parse(new TextDecoder().decode(kitJson)) as Toolchain['kitManifest'],
|
|
190
|
-
icu: { symbol: manifest.icuSymbol, data: icu },
|
|
191
|
-
},
|
|
105
|
+
tools,
|
|
192
106
|
plugins,
|
|
107
|
+
sdk: { downloads: sdk, files: inline },
|
|
108
|
+
kit,
|
|
109
|
+
kitManifest: JSON.parse(new TextDecoder().decode(kitJson)) as Toolchain['kitManifest'],
|
|
110
|
+
icu: { symbol: manifest.icuSymbol, data: icu },
|
|
193
111
|
moduleCache: readPack(moduleCacheBytes).files('module-cache'),
|
|
112
|
+
prefetchPlugins: () => prefetchInWorker(Object.values(plugins)),
|
|
194
113
|
manifest,
|
|
195
|
-
...(
|
|
114
|
+
...(warning ? { warning } : {}),
|
|
196
115
|
};
|
|
197
116
|
}
|
package/src/playground/build.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// An app compiled and linked in the browser: what the CLI does with
|
|
2
|
-
// SWIFTBROWSER_COMPILER=wasm (packages/cli/src/wasmCompiler.ts),
|
|
3
|
-
// in-memory
|
|
2
|
+
// SWIFTBROWSER_COMPILER=wasm (packages/cli/src/wasmCompiler.ts), over an
|
|
3
|
+
// in-memory file system instead of the real one: the toolchain makes the
|
|
4
|
+
// host (the playground's runs each tool in a worker, tool-host.ts). The
|
|
5
|
+
// app's sources are staged
|
|
4
6
|
// for the shim with the CLI's own code (its browser entry) and compiled with
|
|
5
7
|
// the CLI's flags against the kit, so a module built here is the module the
|
|
6
8
|
// CLI builds. The playground's worker runs it, and so does the Node script
|
|
@@ -20,26 +22,35 @@ import {
|
|
|
20
22
|
optionalRequirementNames,
|
|
21
23
|
transformSource,
|
|
22
24
|
} from '@swiftbrowser/cli/browser';
|
|
23
|
-
import { type CompileStep, compile, dataObject, memoryDirectory
|
|
25
|
+
import { type CompileStep, type MemoryHost, compile, dataObject, memoryDirectory } from '@swiftbrowser/wasm-compiler';
|
|
24
26
|
|
|
25
27
|
/** Where the kit's modules and archives are, as the compiler sees them. */
|
|
26
28
|
const KIT_ROOT = '/kit/wasm32-unknown-wasip1';
|
|
27
29
|
|
|
28
30
|
/** What every compile shares, loaded once. */
|
|
29
31
|
export interface Toolchain {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The host the tools run on, over the build's own directories (/app,
|
|
34
|
+
* /work) and the toolchain's: the Swift SDK's `wasm32-unknown-wasip1`
|
|
35
|
+
* directory at /sdk, the kit's (its `wasm32-unknown-wasip1/Modules` and
|
|
36
|
+
* `lib`) at /kit, and each of `plugins` at /plugins/<name>.wasm.
|
|
37
|
+
*/
|
|
38
|
+
host(options: HostOptions): MemoryHost;
|
|
39
|
+
/** The macro plugins a compile loads, by the module whose macros they expand. */
|
|
40
|
+
plugins: string[];
|
|
37
41
|
/** kit.json's target and product graph. */
|
|
38
42
|
kitManifest: { targets: Record<string, string[]>; products: Record<string, string[]> };
|
|
39
43
|
/** Foundation's ICU data, trimmed, and the symbol it is read through. */
|
|
40
44
|
icu: { symbol: string; data: Uint8Array };
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
export interface HostOptions {
|
|
48
|
+
mounts: Record<string, Directory>;
|
|
49
|
+
moduleCache?: Iterable<readonly [string, Uint8Array]>;
|
|
50
|
+
/** Every path the tools open, as a guest path. */
|
|
51
|
+
onOpen?: (path: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
export interface AppSources {
|
|
44
55
|
/** The module name (a Swift identifier). */
|
|
45
56
|
name: string;
|
|
@@ -62,6 +73,8 @@ export interface BuildOptions {
|
|
|
62
73
|
moduleCache?: Iterable<readonly [string, Uint8Array]>;
|
|
63
74
|
/** Every path the tools open, as a guest path. */
|
|
64
75
|
onOpen?: (path: string) => void;
|
|
76
|
+
/** Each tool as it starts. */
|
|
77
|
+
onStep?: (tool: string) => void;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
/** The app's sources as the CLI stages them (stage.ts): adjusted for the shim, with the generated Foundation aliases. */
|
|
@@ -105,18 +118,11 @@ export async function buildApp(toolchain: Toolchain, app: AppSources, options: B
|
|
|
105
118
|
const staged = stageSources(app);
|
|
106
119
|
const encoder = new TextEncoder();
|
|
107
120
|
const sources = Object.keys(staged).sort().map((file) => `/app/${file}`);
|
|
108
|
-
const
|
|
109
|
-
const host = memoryHost({
|
|
110
|
-
tools: toolchain.tools,
|
|
121
|
+
const host = toolchain.host({
|
|
111
122
|
mounts: {
|
|
112
|
-
'/sdk': toolchain.sdk,
|
|
113
|
-
'/kit': toolchain.kit,
|
|
114
123
|
'/app': memoryDirectory(Object.entries(staged).map(([file, text]) => [file, encoder.encode(text)])),
|
|
115
124
|
'/work': memoryDirectory([['icudt.o', dataObject(toolchain.icu.symbol, toolchain.icu.data)]]),
|
|
116
|
-
'/plugins': memoryDirectory(plugins.map(([name, plugin]) => [`${name}.wasm`, plugin.bytes])),
|
|
117
125
|
},
|
|
118
|
-
sdkRoot: '/sdk',
|
|
119
|
-
plugins: Object.fromEntries(plugins.map(([name, plugin]) => [`/plugins/${name}.wasm`, plugin.module])),
|
|
120
126
|
...(options.moduleCache ? { moduleCache: options.moduleCache } : {}),
|
|
121
127
|
...(options.onOpen ? { onOpen: options.onOpen } : {}),
|
|
122
128
|
});
|
|
@@ -125,16 +131,21 @@ export async function buildApp(toolchain: Toolchain, app: AppSources, options: B
|
|
|
125
131
|
const result = await compile(host, {
|
|
126
132
|
moduleName: app.name,
|
|
127
133
|
sources,
|
|
134
|
+
...(options.onStep ? { onStep: ({ tool }: { tool: string }) => options.onStep!(tool) } : {}),
|
|
128
135
|
objectDir: '/work',
|
|
129
136
|
output: `/work/${app.name}.wasm`,
|
|
130
137
|
wholeModule: true,
|
|
131
138
|
frontendFlags: [
|
|
132
139
|
...appFrontendFlags({ name: app.name, swiftLanguageMode: '5', defines: [] }, sources, `${KIT_ROOT}/Modules`),
|
|
133
|
-
...plugins.flatMap((
|
|
140
|
+
...toolchain.plugins.flatMap((name) => ['-load-plugin-executable', `/plugins/${name}.wasm#${name}`]),
|
|
134
141
|
],
|
|
135
142
|
objects: ['/work/icudt.o'],
|
|
136
143
|
linkerFlags: [
|
|
137
144
|
'--gc-sections',
|
|
145
|
+
// No DWARF, as the CLI's release builds have none (its wasm-opt pass
|
|
146
|
+
// drops it): the frame runs the app, nothing debugs it, and the linker
|
|
147
|
+
// needs less memory without copying it out (the names stay).
|
|
148
|
+
'--strip-debug',
|
|
138
149
|
...appLinkerFlags(),
|
|
139
150
|
...archives.flatMap((archive) => ['--whole-archive', archive, '--no-whole-archive']),
|
|
140
151
|
],
|