@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.
@@ -0,0 +1,117 @@
1
+ // The playground's compiles: an app's sources sent to the compiler's worker
2
+ // (worker.ts), the module it links run by the renderer's own page in the
3
+ // frame (`?module=`, a blob: URL), and what the page shows meanwhile. The
4
+ // UI (app.tsx) reads `state` and calls `compile`; it hands over the frame
5
+ // with `attachFrame` whenever it mounts one.
6
+
7
+ import { frozenCopy, restoreScroll } from './freeze';
8
+ import type { PlaygroundSource } from './files';
9
+ import type { FromWorker, ToWorker } from './worker';
10
+
11
+ export interface SessionState {
12
+ /** A compile is under way. */
13
+ busy: boolean;
14
+ /** One line on what the compiler is doing or did. */
15
+ status: string;
16
+ /** The compiler's diagnostics, or the error the worker failed with. */
17
+ output: string;
18
+ /** Whether the last compile failed (its output is errors). */
19
+ failed: boolean;
20
+ /** Why the kit may not suit this page's renderer, when it may not. */
21
+ warning: string | null;
22
+ /** How many compiles have linked an app. */
23
+ linked: number;
24
+ }
25
+
26
+ export class Session {
27
+ state: SessionState = { busy: false, status: 'Loading the compiler…', output: '', failed: false, warning: null, linked: 0 };
28
+ private readonly worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
29
+ private readonly listeners = new Set<() => void>();
30
+ private nextId = 1;
31
+ private started = 0;
32
+ private frame: HTMLIFrameElement | null = null;
33
+ /** The last linked app, and the still copy of it a compile shows instead (freeze.ts). */
34
+ private moduleUrl: string | null = null;
35
+ private frozenUrl: string | null = null;
36
+
37
+ /**
38
+ * `frame`: the app page in an embedded frame mode (its iPhone, without
39
+ * its toolbar) for the simulator to scale; `device`: full screen.
40
+ */
41
+ constructor(
42
+ private readonly source: PlaygroundSource,
43
+ private readonly appName: string,
44
+ private readonly appMode: 'frame' | 'device',
45
+ ) {
46
+ this.worker.onmessage = (event: MessageEvent<FromWorker>) => this.receive(event.data);
47
+ }
48
+
49
+ subscribe = (listener: () => void): (() => void) => {
50
+ this.listeners.add(listener);
51
+ return () => this.listeners.delete(listener);
52
+ };
53
+
54
+ private set(changes: Partial<SessionState>): void {
55
+ this.state = { ...this.state, ...changes };
56
+ for (const listener of this.listeners) listener();
57
+ }
58
+
59
+ /** The frame the app runs in, or null when the UI unmounts it; a new one shows what the last had. */
60
+ attachFrame = (frame: HTMLIFrameElement | null): void => {
61
+ this.frame = frame;
62
+ if (!frame) return;
63
+ frame.addEventListener('load', () => {
64
+ if (this.frozenUrl && frame.contentDocument && frame.contentWindow?.location.href === this.frozenUrl) restoreScroll(frame.contentDocument);
65
+ });
66
+ const url = this.frozenUrl ?? (this.moduleUrl ? this.appUrl(this.moduleUrl) : null);
67
+ if (url) frame.src = url;
68
+ };
69
+
70
+ private appUrl(moduleUrl: string): string {
71
+ const mode = this.appMode === 'frame' ? 'mode=frame&embed=1' : 'mode=device';
72
+ return `${import.meta.env.BASE_URL}app/?app=${this.appName}&${mode}&module=${encodeURIComponent(moduleUrl)}`;
73
+ }
74
+
75
+ compile(files: Record<string, string>): void {
76
+ if (this.state.busy) return;
77
+ // The last build's app goes while the compiler runs: its page, its module
78
+ // and its memory would stay beside the linker's, which a phone has not got.
79
+ // A still copy of it stays in its place.
80
+ if (this.moduleUrl) {
81
+ let copy: string | null = null;
82
+ try {
83
+ copy = this.frame?.contentDocument ? frozenCopy(this.frame.contentDocument) : null;
84
+ } catch {
85
+ // No copy: the frame goes blank.
86
+ }
87
+ if (this.frozenUrl) URL.revokeObjectURL(this.frozenUrl);
88
+ this.frozenUrl = copy ? URL.createObjectURL(new Blob([copy], { type: 'text/html' })) : null;
89
+ if (this.frame) this.frame.src = this.frozenUrl ?? 'about:blank';
90
+ URL.revokeObjectURL(this.moduleUrl);
91
+ this.moduleUrl = null;
92
+ }
93
+ this.started = performance.now();
94
+ this.set({ busy: true, status: 'Compiling…' });
95
+ const message: ToWorker = { type: 'compile', id: this.nextId++, source: this.source, app: { name: this.appName, files: { ...files } } };
96
+ this.worker.postMessage(message);
97
+ }
98
+
99
+ private receive(message: FromWorker): void {
100
+ if (message.type === 'progress') return this.set({ status: message.text });
101
+ if (message.type === 'warning') return this.set({ warning: message.text });
102
+ if (message.type === 'failure') return this.set({ busy: false, status: 'The compiler failed.', output: message.error, failed: true });
103
+ const seconds = (performance.now() - this.started) / 1000;
104
+ // Each tool's memory too: what a phone's browser runs out of first.
105
+ const steps = message.steps
106
+ .map((step) => `${step.tool} ${step.seconds.toFixed(1)} s${step.memoryBytes ? `, ${Math.round(step.memoryBytes / 1e6)} MB` : ''}`)
107
+ .join('; ');
108
+ if (!message.ok || !message.wasm) {
109
+ return this.set({ busy: false, status: `Did not compile (${steps}).`, output: message.output, failed: true });
110
+ }
111
+ if (this.frozenUrl) URL.revokeObjectURL(this.frozenUrl);
112
+ this.frozenUrl = null;
113
+ this.moduleUrl = URL.createObjectURL(new Blob([message.wasm], { type: 'application/wasm' }));
114
+ if (this.frame) this.frame.src = this.appUrl(this.moduleUrl);
115
+ this.set({ busy: false, status: `Compiled and linked in ${seconds.toFixed(1)} s (${steps}).`, output: message.output, failed: false, linked: this.state.linked + 1 });
116
+ }
117
+ }
@@ -0,0 +1,163 @@
1
+ // Each tool run in a worker of its own, which loads the tool and the SDK's
2
+ // and kit's files it reads from the cache (assets.ts), runs it over an
3
+ // in-memory file system, sends back what it wrote and is ended. A phone's
4
+ // browser runs out of memory where a desktop's does not: this way the
5
+ // compiler's code, its memory and the Swift modules are gone before the
6
+ // linker loads, with its own memory and the archives, and the page's worker
7
+ // holds only the small files. A run compiles its tool again, which a
8
+ // browser does fast enough (a WebAssembly.Module could be sent to the
9
+ // worker instead, but its code would stay as long as the page kept it).
10
+
11
+ import type { Directory } from '@bjorn3/browser_wasi_shim';
12
+ import { type MemoryHost, type RunResult, type ToolName, directoryFiles, memoryDirectory, sdkPaths } from '@swiftbrowser/wasm-compiler';
13
+ import type { Download } from './download';
14
+
15
+ /** What a tool's worker runs. */
16
+ export interface ToolJob {
17
+ /** argv, the program name first. */
18
+ args: string[];
19
+ env: Record<string, string>;
20
+ module: Download;
21
+ /** Guest directory → the files it holds, every mount the tool sees in order. */
22
+ files: Record<string, [string, Uint8Array][]>;
23
+ /** Guest directory → files of it the worker downloads. */
24
+ downloads: Record<string, [string, Download][]>;
25
+ /** The macro plugins, as guest paths of downloads. */
26
+ plugins: string[];
27
+ /** The mounts whose files the worker sends back: those a tool writes to. */
28
+ writable: string[];
29
+ /**
30
+ * Whether the downloads go once the tool has read them (read-once.ts): the
31
+ * linker's, which it keeps whole. Not those named in `keep` (file names).
32
+ */
33
+ readOnce: boolean;
34
+ keep: string[];
35
+ }
36
+
37
+ /** What a tool's worker is sent: a tool to run, or downloads to put in the cache. */
38
+ export type ToToolWorker = { type: 'run'; job: ToolJob } | { type: 'prefetch'; files: Download[] };
39
+ export type ToolReply =
40
+ /** `reopened`: a download the tool opened again after it was dropped (`readOnce`): it has to run again keeping them. */
41
+ | { type: 'done'; result: RunResult; files: Record<string, [string, Uint8Array][]>; reopened?: string }
42
+ /** One of a prefetch's files is in the cache, by its index. */
43
+ | { type: 'fetched'; index: number }
44
+ | { type: 'prefetched' }
45
+ | { type: 'error'; error: string };
46
+
47
+ /**
48
+ * Whether a tool reads a file of the SDK or the kit: the linker their
49
+ * archives and objects, the compiler the rest.
50
+ */
51
+ export const toolReads = (tool: ToolName, file: string): boolean => /\.[ao]$/.test(file) === (tool === 'wasm-ld');
52
+
53
+ export interface ToolHostOptions {
54
+ tools: Partial<Record<ToolName, Download>>;
55
+ /** Guest directory → the files held here. */
56
+ mounts: Record<string, Directory>;
57
+ /** Guest directory → files of it a tool's worker downloads, those it reads (`toolReads`). */
58
+ downloads: Record<string, [string, Download][]>;
59
+ /** Macro plugins by guest path, for the compiler: its worker downloads them too. */
60
+ plugins: Record<string, Download>;
61
+ /** The Swift SDK's `wasm32-unknown-wasip1` directory, as a guest path. */
62
+ sdkRoot: string;
63
+ /** Clang's module cache to start from (memoryHost's). */
64
+ moduleCache?: Iterable<readonly [string, Uint8Array]>;
65
+ }
66
+
67
+ /** The module cache's directory under the host's /tmp, as memoryHost has it. */
68
+ const MODULE_CACHE = 'module-cache';
69
+
70
+ /**
71
+ * Sends `message` to a new tool worker and answers with its last reply (a
72
+ * `done` or `prefetched`), ending the worker then: with it goes whatever it
73
+ * loaded, which no browser frees as surely otherwise.
74
+ */
75
+ function inWorker<Reply extends ToolReply>(message: ToToolWorker, onReply: (reply: ToolReply) => void = () => {}): Promise<Reply> {
76
+ const worker = new Worker(new URL('./tool-worker.ts', import.meta.url), { type: 'module' });
77
+ const name = message.type === 'run' ? message.job.args[0] : 'the download';
78
+ return new Promise<Reply>((resolve, reject) => {
79
+ worker.onmessage = (event: MessageEvent<ToolReply>) => {
80
+ const reply = event.data;
81
+ if (reply.type === 'error') reject(new Error(reply.error));
82
+ else if (reply.type === 'done' || reply.type === 'prefetched') resolve(reply as Reply);
83
+ else onReply(reply);
84
+ };
85
+ worker.onerror = (event) => {
86
+ event.preventDefault();
87
+ reject(new Error(`${name}'s worker failed: ${event.message || 'no message'}`));
88
+ };
89
+ worker.postMessage(message);
90
+ }).finally(() => worker.terminate());
91
+ }
92
+
93
+ /**
94
+ * Puts `files` in the cache from a worker of their own, calling `onFetched`
95
+ * with each one's index as it is in: the brotli decoder's memory, which only
96
+ * grows, goes with the worker.
97
+ */
98
+ export async function prefetchInWorker(files: Download[], onFetched: (index: number) => void = () => {}): Promise<void> {
99
+ await inWorker({ type: 'prefetch', files }, (reply) => reply.type === 'fetched' && onFetched(reply.index));
100
+ }
101
+
102
+ /**
103
+ * The archives and objects a link names more than once, by file name: the
104
+ * linker reads such a file again each time (the SDK's flags and the
105
+ * objects' autolink entries both name -lwasi-emulated-mman, for one).
106
+ */
107
+ export function namedTwice(args: readonly string[]): string[] {
108
+ const counts = new Map<string, number>();
109
+ for (const arg of args) {
110
+ const name = arg.startsWith('-l') ? `lib${arg.slice(2)}.a` : /\.[ao]$/.test(arg) ? arg.split('/').pop()! : null;
111
+ if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
112
+ }
113
+ return [...counts].filter(([, count]) => count > 1).map(([name]) => name).sort();
114
+ }
115
+
116
+ /** A host like memoryHost (wasm-compiler) that runs each tool in its own worker. */
117
+ export function toolHost(options: ToolHostOptions): MemoryHost {
118
+ const tmp = memoryDirectory(options.moduleCache ? [...options.moduleCache].map(([file, data]): [string, Uint8Array] => [`${MODULE_CACHE}/${file}`, data]) : []);
119
+ const plugins = Object.entries(options.plugins);
120
+ const pluginMounts = new Set(plugins.map(([guest]) => `/${guest.split('/')[1]}`));
121
+ const mounts: Record<string, Directory> = { '/': memoryDirectory([]), '/tmp': tmp, ...options.mounts };
122
+ for (const dir of [...Object.keys(options.downloads), ...pluginMounts]) mounts[dir] ??= memoryDirectory([]);
123
+ const downloads: Record<string, [string, Download][]> = { ...options.downloads };
124
+ for (const [guest, file] of plugins) {
125
+ const dir = `/${guest.split('/')[1]}`;
126
+ downloads[dir] = [...(downloads[dir] ?? []), [guest.slice(dir.length + 1), file]];
127
+ }
128
+ const writable = Object.keys(mounts).filter((dir) => !(dir in downloads));
129
+ return {
130
+ description: 'WebAssembly tools, each in a worker, over an in-memory file system',
131
+ sdk: sdkPaths(options.sdkRoot),
132
+ moduleCachePath: `/tmp/${MODULE_CACHE}`,
133
+ moduleCacheFiles() {
134
+ const prefix = `${MODULE_CACHE}/`;
135
+ return directoryFiles(mounts['/tmp']!).filter(([file]) => file.startsWith(prefix)).map(([file, data]) => [file.slice(prefix.length), data]);
136
+ },
137
+ async readFile(file: string): Promise<Uint8Array> {
138
+ const dir = Object.keys(mounts).filter((mount) => file.startsWith(mount === '/' ? '/' : `${mount}/`)).sort((a, b) => b.length - a.length)[0];
139
+ const data = dir === undefined ? undefined : directoryFiles(mounts[dir]!).find(([path]) => `${dir === '/' ? '' : dir}/${path}` === file)?.[1];
140
+ if (!data) throw new Error(`${file}: no such file`);
141
+ return data;
142
+ },
143
+ async run(tool: ToolName, args: readonly string[]): Promise<RunResult> {
144
+ const module = options.tools[tool];
145
+ if (!module) throw new Error(`no WebAssembly module for ${tool}`);
146
+ const job: ToolJob = {
147
+ args: [tool, ...args],
148
+ env: { TMPDIR: '/tmp' },
149
+ module,
150
+ files: Object.fromEntries(Object.entries(mounts).map(([dir, contents]) => [dir, directoryFiles(contents)])),
151
+ downloads: Object.fromEntries(Object.entries(downloads).map(([dir, list]) => [dir, list.filter(([file]) => toolReads(tool, file))])),
152
+ plugins: tool === 'swift-frontend' ? plugins.map(([guest]) => guest) : [],
153
+ writable,
154
+ readOnce: tool === 'wasm-ld',
155
+ keep: namedTwice(args),
156
+ };
157
+ let reply = await inWorker<Extract<ToolReply, { type: 'done' }>>({ type: 'run', job });
158
+ if (reply.reopened) reply = await inWorker<Extract<ToolReply, { type: 'done' }>>({ type: 'run', job: { ...job, readOnce: false } });
159
+ for (const [dir, files] of Object.entries(reply.files)) mounts[dir] = memoryDirectory(files);
160
+ return reply.result;
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,67 @@
1
+ // A tool's worker (tool-host.ts): loads the tool and the files it reads from
2
+ // the cache, runs it once, sends back the files of the mounts it writes to.
3
+ // Or it puts downloads in the cache. The page's worker ends it then, and with
4
+ // it everything it loaded.
5
+
6
+ import { Directory, File } from '@bjorn3/browser_wasi_shim';
7
+ import { directoryFiles, memoryDirectory, runMemoryCommand } from '@swiftbrowser/wasm-compiler';
8
+ import { CONCURRENCY, compileModule, download, downloadBytes, openCache, pooled, prefetch } from './download';
9
+ import { ReadOnceFile } from './read-once';
10
+ import type { ToToolWorker, ToolJob, ToolReply } from './tool-host';
11
+
12
+ const post = (message: ToolReply, transfer: Transferable[] = []): void => (self as unknown as Worker).postMessage(message, transfer);
13
+
14
+ /**
15
+ * The tool's mounts and its plugins compiled, with the downloads in their
16
+ * places; nothing else keeps the downloads' bytes once this returns. With
17
+ * `job.readOnce` the downloads (but `job.keep`) are dropped as soon as the
18
+ * tool has read them (read-once.ts), and `onReopen` hears of one opened
19
+ * again after.
20
+ */
21
+ async function load(job: ToolJob, cache: Cache | null, onReopen: (path: string) => void): Promise<{ mounts: Record<string, Directory>; plugins: Record<string, WebAssembly.Module> }> {
22
+ const wanted = Object.entries(job.downloads).flatMap(([dir, list]) => list.map(([file, from]) => ({ dir, file, from })));
23
+ const downloaded = await pooled(wanted, CONCURRENCY, async ({ dir, file, from }) => ({ dir, file, data: await downloadBytes(cache, from) }));
24
+ const mounts = Object.fromEntries(Object.entries(job.files).map(([dir, list]) => [dir, memoryDirectory(list)]));
25
+ for (const { dir, file, data } of downloaded) {
26
+ const parts = file.split('/');
27
+ let parent = (mounts[dir] ??= memoryDirectory([]));
28
+ for (const part of parts.slice(0, -1)) {
29
+ const next = parent.contents.get(part) ?? new Directory(new Map());
30
+ parent.contents.set(part, next);
31
+ parent = next as Directory;
32
+ }
33
+ // The buffer (a download's bytes are all of it), so the File keeps it rather than a copy.
34
+ const once = job.readOnce && !job.keep.includes(parts.at(-1)!);
35
+ parent.contents.set(parts.at(-1)!, once ? new ReadOnceFile(data, () => onReopen(`${dir}/${file}`)) : new File(data.buffer));
36
+ }
37
+ const plugins = Object.fromEntries(await Promise.all(job.plugins.map(async (guest) => {
38
+ const { data } = downloaded.find(({ dir, file }) => `${dir}/${file}` === guest)!;
39
+ return [guest, await WebAssembly.compile(data)] as const;
40
+ })));
41
+ return { mounts, plugins };
42
+ }
43
+
44
+ async function run(job: ToolJob): Promise<void> {
45
+ const cache = await openCache();
46
+ let reopened: string | undefined;
47
+ const [module, { mounts, plugins }] = await Promise.all([download(cache, job.module).then(compileModule), load(job, cache, (path) => (reopened ??= path))]);
48
+ const result = await runMemoryCommand({ module, args: job.args, mounts, env: job.env, plugins });
49
+ const written = Object.fromEntries(job.writable.map((dir) => [dir, directoryFiles(mounts[dir]!)]));
50
+ const buffers = new Set(Object.values(written).flatMap((list) => list.map(([, data]) => data.buffer as ArrayBuffer)));
51
+ post({ type: 'done', result, files: written, ...(reopened ? { reopened } : {}) }, [...buffers]);
52
+ }
53
+
54
+ self.onmessage = async (event: MessageEvent<ToToolWorker>) => {
55
+ const message = event.data;
56
+ try {
57
+ if (message.type === 'run') return await run(message.job);
58
+ const cache = await openCache();
59
+ await pooled(message.files.map((file, index) => ({ file, index })), CONCURRENCY, async ({ file, index }) => {
60
+ await prefetch(cache, file);
61
+ post({ type: 'fetched', index });
62
+ });
63
+ post({ type: 'prefetched' });
64
+ } catch (error) {
65
+ post({ type: 'error', error: error instanceof Error ? (error.stack ?? error.message) : String(error) });
66
+ }
67
+ };
@@ -1,46 +1,70 @@
1
1
  // The playground's compiler, in a worker: the page sends an app's sources,
2
- // the worker answers with the linked module or the diagnostics. The tools
3
- // and files are loaded once and kept, and each compile starts from the
4
- // module cache the previous one left. The macro plugins arrive after the
5
- // tools: a compile that expands a macro before then waits for them and
6
- // runs again.
2
+ // the worker answers with the linked module or the diagnostics. The files
3
+ // are downloaded once, and each compile starts from the module cache the
4
+ // previous one left; each tool runs in a worker of its own (tool-host.ts).
5
+ // The macro plugins load only when a compile expands a macro: it fails for
6
+ // want of them, waits for them and runs again. Until then they are only
7
+ // downloaded into the cache, once a compile is done.
7
8
 
8
- import { type AppSources, missingPlugin, buildApp } from './build';
9
+ import { memoryDirectory } from '@swiftbrowser/wasm-compiler';
9
10
  import { type LoadedToolchain, loadToolchain } from './assets';
11
+ import { type AppSources, type Toolchain, buildApp, missingPlugin } from './build';
10
12
  import type { PlaygroundSource } from './files';
13
+ import { toolHost } from './tool-host';
11
14
 
12
15
  export type ToWorker = { type: 'compile'; id: number; source: PlaygroundSource; app: AppSources };
13
16
  export type FromWorker =
14
17
  | { type: 'progress'; text: string }
15
18
  | { type: 'warning'; text: string }
16
- | { type: 'result'; id: number; ok: boolean; output: string; wasm?: Uint8Array<ArrayBuffer>; steps: { tool: string; seconds: number }[] }
19
+ | { type: 'result'; id: number; ok: boolean; output: string; wasm?: Uint8Array<ArrayBuffer>; steps: { tool: string; seconds: number; memoryBytes?: number }[] }
17
20
  | { type: 'failure'; id: number; error: string };
18
21
 
19
22
  const post = (message: FromWorker, transfer: Transferable[] = []): void => (self as unknown as Worker).postMessage(message, transfer);
20
23
  let loaded: Promise<LoadedToolchain> | null = null;
21
24
  let moduleCache: [string, Uint8Array][] | null = null;
25
+ /** Whether compiles load the macro plugins: once one has wanted them. */
26
+ let withPlugins = false;
27
+
28
+ /** The toolchain the compiles run on, with or without the plugins. */
29
+ function toolchain(loaded: LoadedToolchain, plugins: boolean): Toolchain {
30
+ const names = plugins ? Object.keys(loaded.plugins) : [];
31
+ return {
32
+ host: ({ mounts, moduleCache }) =>
33
+ toolHost({
34
+ tools: loaded.tools,
35
+ mounts: { ...mounts, '/sdk': memoryDirectory(loaded.sdk.files) },
36
+ downloads: { '/sdk': loaded.sdk.downloads, '/kit': loaded.kit },
37
+ plugins: Object.fromEntries(names.map((name) => [`/plugins/${name}.wasm`, loaded.plugins[name]!])),
38
+ sdkRoot: '/sdk',
39
+ ...(moduleCache ? { moduleCache } : {}),
40
+ }),
41
+ plugins: names,
42
+ kitManifest: loaded.kitManifest,
43
+ icu: loaded.icu,
44
+ };
45
+ }
22
46
 
23
47
  self.onmessage = async (event: MessageEvent<ToWorker>) => {
24
48
  const { id, source, app } = event.data;
25
49
  try {
26
50
  loaded ??= loadToolchain(source, (text) => post({ type: 'progress', text })).then((toolchain) => {
27
- // Later compiles use the plugins as soon as they are in.
28
- toolchain.plugins.then((plugins) => (toolchain.toolchain.plugins = plugins), () => {});
29
51
  if (toolchain.warning) post({ type: 'warning', text: toolchain.warning });
30
52
  return toolchain;
31
53
  });
32
- const { toolchain, plugins, moduleCache: prebuilt } = await loaded;
33
- post({ type: 'progress', text: `compiling ${app.name}…` });
34
- let result = await buildApp(toolchain, app, { moduleCache: moduleCache ?? prebuilt });
54
+ const files = await loaded;
55
+ const onStep = (tool: string): void => post({ type: 'progress', text: `${tool === 'wasm-ld' ? 'linking' : 'compiling'} ${app.name}${withPlugins ? ' with the macro plugins' : ''}…` });
56
+ let result = await buildApp(toolchain(files, withPlugins), app, { moduleCache: moduleCache ?? files.moduleCache, onStep });
35
57
  moduleCache = result.moduleCache;
36
- if (!result.ok && Object.keys(toolchain.plugins).length === 0 && missingPlugin(result.output)) {
58
+ if (!result.ok && !withPlugins && missingPlugin(result.output)) {
37
59
  post({ type: 'progress', text: 'waiting for the macro plugins…' });
38
- toolchain.plugins = await plugins;
39
- post({ type: 'progress', text: `compiling ${app.name} with the macro plugins…` });
40
- result = await buildApp(toolchain, app, { moduleCache });
60
+ await files.prefetchPlugins();
61
+ withPlugins = true;
62
+ result = await buildApp(toolchain(files, withPlugins), app, { moduleCache, onStep });
41
63
  moduleCache = result.moduleCache;
42
64
  }
43
- const steps = result.steps.map(({ tool, seconds }) => ({ tool, seconds }));
65
+ // Behind the result: a macro later finds its plugin downloaded.
66
+ if (!withPlugins) void files.prefetchPlugins().catch(() => {});
67
+ const steps = result.steps.map(({ tool, seconds, memoryBytes }) => ({ tool, seconds, ...(memoryBytes !== undefined ? { memoryBytes } : {}) }));
44
68
  if (result.wasm) {
45
69
  const wasm = new Uint8Array(result.wasm);
46
70
  post({ type: 'result', id, ok: result.ok, output: result.output, wasm, steps }, [wasm.buffer]);
@@ -1,107 +0,0 @@
1
- /// <reference path="../vite-env.d.ts" />
2
- // The playground page: an app's sources in an editor, compiled and linked in
3
- // a worker by the WebAssembly Swift compiler (worker.ts), and run by the
4
- // renderer's own page in the frame, which takes the module as a blob: URL
5
- // (`?module=`, main.ts). Counter's sources come with the page, so after the
6
- // first visit's downloads nothing reaches a server.
7
-
8
- import type { FromWorker, ToWorker } from './worker';
9
-
10
- const sources = import.meta.glob('../../../Examples/Counter/Sources/**/*.swift', { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
11
- const APP = 'Counter';
12
-
13
- function $<T extends HTMLElement>(selector: string): T {
14
- const element = document.querySelector<T>(selector);
15
- if (!element) throw new Error(`missing ${selector}`);
16
- return element;
17
- }
18
- const filesNav = $<HTMLElement>('#files');
19
- const editor = $<HTMLTextAreaElement>('#source');
20
- const run = $<HTMLButtonElement>('#run');
21
- const status = $<HTMLElement>('#status');
22
- const diagnostics = $<HTMLPreElement>('#diagnostics');
23
- const frame = $<HTMLIFrameElement>('#app');
24
- const warning = $<HTMLElement>('#warning');
25
-
26
- // Path relative to Sources/ → text, as edited.
27
- const files: Record<string, string> = Object.fromEntries(
28
- Object.entries(sources).map(([file, text]) => [file.replace(/^.*\/Sources\//, ''), text]),
29
- );
30
- let current = Object.keys(files).sort()[0]!;
31
-
32
- function showFiles(): void {
33
- filesNav.replaceChildren(...Object.keys(files).sort().map((file) => {
34
- const button = document.createElement('button');
35
- button.type = 'button';
36
- button.textContent = file;
37
- button.setAttribute('aria-current', String(file === current));
38
- button.addEventListener('click', () => {
39
- files[current] = editor.value;
40
- current = file;
41
- editor.value = files[file]!;
42
- showFiles();
43
- });
44
- return button;
45
- }));
46
- }
47
- editor.value = files[current]!;
48
- editor.addEventListener('input', () => (files[current] = editor.value));
49
- showFiles();
50
-
51
- const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
52
- const source = {
53
- ...__SB_PLAYGROUND__,
54
- npm: new URL(__SB_PLAYGROUND__.npm, location.href).href,
55
- ...(__SB_PLAYGROUND__.packages
56
- ? { packages: Object.fromEntries(Object.entries(__SB_PLAYGROUND__.packages).map(([name, base]) => [name, new URL(base, location.href).href])) }
57
- : {}),
58
- };
59
- let nextId = 1;
60
- let moduleUrl: string | null = null;
61
- const started = new Map<number, number>();
62
-
63
- worker.onmessage = (event: MessageEvent<FromWorker>) => {
64
- const message = event.data;
65
- if (message.type === 'progress') {
66
- status.textContent = message.text;
67
- return;
68
- }
69
- if (message.type === 'warning') {
70
- warning.hidden = false;
71
- warning.textContent = message.text;
72
- return;
73
- }
74
- run.disabled = false;
75
- if (message.type === 'failure') {
76
- status.textContent = 'The compiler failed.';
77
- diagnostics.hidden = false;
78
- diagnostics.textContent = message.error;
79
- return;
80
- }
81
- const seconds = (performance.now() - (started.get(message.id) ?? performance.now())) / 1000;
82
- const steps = message.steps.map((step) => `${step.tool} ${step.seconds.toFixed(1)} s`).join(', ');
83
- diagnostics.hidden = message.ok || message.output === '';
84
- diagnostics.textContent = message.output;
85
- if (!message.ok || !message.wasm) {
86
- status.textContent = `Did not compile (${steps}).`;
87
- diagnostics.hidden = false;
88
- return;
89
- }
90
- status.textContent = `Compiled and linked in ${seconds.toFixed(1)} s (${steps}).`;
91
- if (moduleUrl) URL.revokeObjectURL(moduleUrl);
92
- moduleUrl = URL.createObjectURL(new Blob([message.wasm], { type: 'application/wasm' }));
93
- frame.src = `${import.meta.env.BASE_URL}app/?app=${APP}&module=${encodeURIComponent(moduleUrl)}`;
94
- };
95
-
96
- function compile(): void {
97
- run.disabled = true;
98
- diagnostics.hidden = true;
99
- status.textContent = 'Compiling…';
100
- const id = nextId++;
101
- started.set(id, performance.now());
102
- const message: ToWorker = { type: 'compile', id, source, app: { name: APP, files: { ...files } } };
103
- worker.postMessage(message);
104
- }
105
- run.addEventListener('click', compile);
106
- // The first run loads the compiler and the SDK too.
107
- compile();