@swiftbrowser/web 0.9.0 → 0.9.1

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.
@@ -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
- // Each is kept in the Cache API under its URL, which a version makes
6
- // immutable, so a compile needs no request after the first visit. The tools
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
- const CACHE = 'swiftbrowser-playground';
29
- /** Requests in flight at once for the SDK's and kit's files. */
30
- const CONCURRENCY = 24;
31
-
32
- type Brotli = typeof import('brotli-dec-wasm/web');
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
- /** The tools and files, without macro plugins until `plugins` settles. */
112
- toolchain: Toolchain;
113
- /**
114
- * The macro plugins, downloading behind the first compile: most apps
115
- * expand no macro, and the plugins are a fifth of the download.
116
- */
117
- plugins: Promise<Toolchain['plugins']>;
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 url = ({ package: name, file }: PackageFile): string => packageUrl(source, name, file);
137
- const bytes = async (file: PackageFile): Promise<Uint8Array<ArrayBuffer>> => new Uint8Array(await (await download(cache, url(file))).arrayBuffer());
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 sdkFiles = Object.entries(manifest.sdk).flatMap(([name, files]) => files.map((file) => ({ path: file, from: served(name, `wasm32-unknown-wasip1/${file}`) })));
148
- const kitFiles = [...manifest.kit, 'kit.json'].map((file) => ({ path: file, from: served(kitPackage, file) }));
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 = sdkFiles.length + kitFiles.length;
151
- const files = pooled([...sdkFiles, ...kitFiles], CONCURRENCY, async ({ path, from }) => {
152
- const data = await bytes(from);
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
- return [path, data] as [string, Uint8Array];
155
- });
156
- const [frontend, linker, all, icu, moduleCacheBytes] = await Promise.all([
157
- download(cache, url(manifest.frontend)).then(compileModule).then((module) => (lap('compiler ready'), module)),
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 sdk = all.slice(0, sdkFiles.length);
164
- const kit = all.slice(sdkFiles.length);
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 plugins.then(async () => {
95
+ void (async () => {
175
96
  const wanted = new Set([
176
- ...[manifest.frontend, manifest.linker, ...Object.values(manifest.plugins), ...sdkFiles.map((f) => f.from), ...kitFiles.map((f) => f.from)].map(url),
177
- url(own(PLAYGROUND_MANIFEST)),
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
- toolchain: {
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
- ...(kitWarning(manifest, source) ? { warning: kitWarning(manifest, source)! } : {}),
114
+ ...(warning ? { warning } : {}),
196
115
  };
197
116
  }
@@ -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), on the
3
- // in-memory host instead of the file system. The app's sources are staged
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, memoryHost } from '@swiftbrowser/wasm-compiler';
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
- tools: { 'swift-frontend': WebAssembly.Module; 'wasm-ld': WebAssembly.Module };
31
- /** Macro plugins by the module whose macros they expand, with their bytes (the compiler checks the file). */
32
- plugins: Record<string, { module: WebAssembly.Module; bytes: Uint8Array }>;
33
- /** The Swift SDK's `wasm32-unknown-wasip1` directory. */
34
- sdk: Directory;
35
- /** The kit's directory (its `wasm32-unknown-wasip1/Modules` and `lib`). */
36
- kit: Directory;
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 plugins = Object.entries(toolchain.plugins);
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(([name]) => ['-load-plugin-executable', `/plugins/${name}.wasm#${name}`]),
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
  ],
@@ -0,0 +1,125 @@
1
+ // The playground's downloads, each kept in the Cache API under its URL,
2
+ // which a version makes immutable, so a compile needs no request after the
3
+ // first visit. The tools come brotli-compressed, as does a file too big for
4
+ // the site, and no browser's DecompressionStream reads brotli: a small
5
+ // WebAssembly decoder unpacks them as they arrive, and the cache keeps them
6
+ // unpacked. Other files the server compresses in transit.
7
+
8
+ import { isBrotli } from './files';
9
+ import { withoutCustomSections } from './sections';
10
+
11
+ const CACHE = 'swiftbrowser-playground';
12
+ /** Requests in flight at once for the SDK's and kit's files. */
13
+ export const CONCURRENCY = 24;
14
+
15
+ type Brotli = typeof import('brotli-dec-wasm/web');
16
+ let brotli: Promise<Brotli> | null = null;
17
+
18
+ /** The brotli decoder, loaded on first use. */
19
+ function brotliDecoder(): Promise<Brotli> {
20
+ brotli ??= Promise.all([import('brotli-dec-wasm/web'), import('brotli-dec-wasm/web/bg.wasm?url')]).then(async ([decoder, wasm]) => {
21
+ await decoder.default({ module_or_path: wasm.default });
22
+ return decoder;
23
+ });
24
+ return brotli;
25
+ }
26
+
27
+ /** A stream that unpacks brotli as its input arrives. */
28
+ function brotliStream(decoder: Brotli): TransformStream<Uint8Array, Uint8Array> {
29
+ const stream = new decoder.BrotliDecStream();
30
+ let code: number = decoder.BrotliStreamResultCode.NeedsMoreInput;
31
+ return new TransformStream({
32
+ transform(chunk, controller) {
33
+ let input = chunk;
34
+ do {
35
+ const result = stream.dec(input, 1 << 22);
36
+ if (result.buf.length > 0) controller.enqueue(result.buf);
37
+ input = input.subarray(result.input_offset);
38
+ code = result.code;
39
+ } while (code === decoder.BrotliStreamResultCode.NeedsMoreOutput);
40
+ },
41
+ flush() {
42
+ stream.free();
43
+ if (code !== decoder.BrotliStreamResultCode.ResultSuccess) throw new Error('a brotli file ended early');
44
+ },
45
+ });
46
+ }
47
+
48
+ /**
49
+ * A file the playground downloads. A tool's or plugin's module is kept
50
+ * without its custom sections (sections.ts), under a key of its own.
51
+ */
52
+ export interface Download {
53
+ url: string;
54
+ module?: boolean;
55
+ }
56
+
57
+ /** Where the cache keeps a download: its URL, which a version makes immutable. */
58
+ export const cacheKey = (file: Download): string => (file.module ? `${file.url}?custom-sections=none` : file.url);
59
+
60
+ export const openCache = async (): Promise<Cache | null> => (typeof caches === 'undefined' ? null : caches.open(CACHE).catch(() => null));
61
+
62
+ /** A download from the network, unpacked as it arrives when it is brotli (`isBrotli`). */
63
+ async function fetchFile(file: Download): Promise<Response> {
64
+ const response = await fetch(file.url);
65
+ if (!response.ok || !response.body) throw new Error(`${file.url}: HTTP ${response.status}`);
66
+ if (!isBrotli(file.url) && !file.module) return response;
67
+ let body: ReadableStream<Uint8Array> = response.body;
68
+ if (isBrotli(file.url)) body = body.pipeThrough(brotliStream(await brotliDecoder()));
69
+ if (file.module) body = body.pipeThrough(withoutCustomSections());
70
+ const type = file.module || /\.wasm\.(br|brotli)$/.test(file.url) ? 'application/wasm' : 'application/octet-stream';
71
+ return new Response(body, { headers: { 'content-type': type } });
72
+ }
73
+
74
+ /**
75
+ * A download, from the cache, which takes it from the network first when it
76
+ * has not got it. The cache writes it out as it arrives and the page reads
77
+ * it back, so a file is never held twice: a phone's browser runs out of
78
+ * memory long before a desktop's (a tee would buffer a whole compiler for
79
+ * the slower reader). Of the headers only the type is kept
80
+ * (compileStreaming wants it).
81
+ */
82
+ export async function download(cache: Cache | null, file: Download): Promise<Response> {
83
+ const hit = await cache?.match(cacheKey(file));
84
+ if (hit) return hit;
85
+ const response = await fetchFile(file);
86
+ if (!cache) return response;
87
+ const headers = { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' };
88
+ const stored = await cache.put(cacheKey(file), new Response(response.body, { headers })).then(() => cache.match(cacheKey(file)), () => undefined);
89
+ // A cache that is full or refuses: the network again, straight to the page.
90
+ return stored ?? fetchFile(file);
91
+ }
92
+
93
+ export const downloadBytes = async (cache: Cache | null, file: Download): Promise<Uint8Array<ArrayBuffer>> => new Uint8Array(await (await download(cache, file)).arrayBuffer());
94
+
95
+ /**
96
+ * Puts a download in the cache, without keeping it, unless the cache has it.
97
+ * A cache that refuses it leaves it to the network, where a tool's worker
98
+ * downloads it from (tool-host.ts).
99
+ */
100
+ export async function prefetch(cache: Cache | null, file: Download): Promise<void> {
101
+ if (!cache || (await cache.match(cacheKey(file)))) return;
102
+ const response = await fetchFile(file);
103
+ const headers = { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' };
104
+ await cache.put(cacheKey(file), new Response(response.body, { headers })).catch(() => {});
105
+ }
106
+
107
+ export async function compileModule(response: Response): Promise<WebAssembly.Module> {
108
+ if (typeof WebAssembly.compileStreaming === 'function' && (response.headers.get('content-type') ?? '').includes('application/wasm')) {
109
+ return WebAssembly.compileStreaming(response);
110
+ }
111
+ return WebAssembly.compile(await response.arrayBuffer());
112
+ }
113
+
114
+ /** `work` over `items`, at most `limit` at once, in order. */
115
+ export async function pooled<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
116
+ const results: R[] = new Array(items.length);
117
+ let next = 0;
118
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
119
+ while (next < items.length) {
120
+ const index = next++;
121
+ results[index] = await work(items[index]!);
122
+ }
123
+ }));
124
+ return results;
125
+ }
@@ -41,6 +41,12 @@ export interface PlaygroundManifest {
41
41
  sdk: Record<string, string[]>;
42
42
  /** The kit's files a compile reads, relative to @swiftbrowser/kit (`wasm32-unknown-wasip1/...`). */
43
43
  kit: string[];
44
+ /**
45
+ * SDK files (paths as in `sdk`) the manifest carries itself, as text: a
46
+ * CDN refuses some names (jsDelivr a `.lnk`, as the SDK names its
47
+ * static-executable flags).
48
+ */
49
+ inline?: Record<string, string>;
44
50
  /**
45
51
  * The SwiftBrowser sources the kit was built from (@swiftbrowser/swift's
46
52
  * `sourcesHash`), when build-assets.ts could tell: the page compares it
@@ -0,0 +1,105 @@
1
+ // The app as it looks, without the app: what the frame shows while a
2
+ // compile runs. The running app (its page, its module and its memory) would
3
+ // stay beside the compiler's and the linker's, which a phone's browser has
4
+ // not got room for; a copy of its document, scripts gone, takes next to
5
+ // nothing. What a copy of the markup would lose is carried over: the rules
6
+ // of the page's own sheets (a script may have inserted them), what the user
7
+ // typed, canvases and images whose blob: URLs go with the app, and how far
8
+ // each part scrolled.
9
+
10
+ /** The attribute a frozen copy's scrolled elements carry their offsets in, `left,top`. */
11
+ const SCROLL = 'data-sb-frozen-scroll';
12
+
13
+ /** A document of `doc` as it is now, with no scripts, as HTML; null when there is no app in it. */
14
+ export function frozenCopy(doc: Document): string | null {
15
+ if (!doc.body || doc.location.href === 'about:blank') return null;
16
+ const copy = doc.documentElement.cloneNode(true) as HTMLElement;
17
+ const pairs = (selector: string): [Element, Element][] => {
18
+ const live = [...doc.documentElement.querySelectorAll(selector)];
19
+ const copied = [...copy.querySelectorAll(selector)];
20
+ return live.map((element, index) => [element, copied[index]!]);
21
+ };
22
+
23
+ for (const [live, copied] of pairs('input, textarea, select')) {
24
+ if (live instanceof HTMLInputElement) {
25
+ copied.setAttribute('value', live.value);
26
+ if (live.checked) copied.setAttribute('checked', '');
27
+ else copied.removeAttribute('checked');
28
+ } else if (live instanceof HTMLTextAreaElement) {
29
+ copied.textContent = live.value;
30
+ } else if (live instanceof HTMLSelectElement) {
31
+ [...(copied as HTMLSelectElement).options].forEach((option, index) => option.toggleAttribute('selected', index === live.selectedIndex));
32
+ }
33
+ }
34
+ // A canvas's pixels and a blob: image are not in the markup.
35
+ const picture = (source: CanvasImageSource & { width: number; height: number }, width: number, height: number): string | null => {
36
+ try {
37
+ const canvas = doc.createElement('canvas');
38
+ canvas.width = width;
39
+ canvas.height = height;
40
+ canvas.getContext('2d')?.drawImage(source, 0, 0);
41
+ return canvas.toDataURL();
42
+ } catch {
43
+ return null;
44
+ }
45
+ };
46
+ for (const [live, copied] of pairs('canvas')) {
47
+ const url = picture(live as HTMLCanvasElement, (live as HTMLCanvasElement).width, (live as HTMLCanvasElement).height);
48
+ if (!url) continue;
49
+ const image = doc.createElement('img');
50
+ for (const { name, value } of [...copied.attributes]) image.setAttribute(name, value);
51
+ image.src = url;
52
+ copied.replaceWith(image);
53
+ }
54
+ for (const [live, copied] of pairs('img')) {
55
+ const image = live as HTMLImageElement;
56
+ if (!image.currentSrc.startsWith('blob:') || !image.complete || image.naturalWidth === 0) continue;
57
+ const url = picture(image, image.naturalWidth, image.naturalHeight);
58
+ if (url) {
59
+ copied.setAttribute('src', url);
60
+ copied.removeAttribute('srcset');
61
+ }
62
+ }
63
+ for (const [live, copied] of pairs('*')) {
64
+ if (live.scrollTop || live.scrollLeft) copied.setAttribute(SCROLL, `${live.scrollLeft},${live.scrollTop}`);
65
+ }
66
+
67
+ const root = doc.scrollingElement;
68
+ if (root && (root.scrollTop || root.scrollLeft)) copy.setAttribute(SCROLL, `${root.scrollLeft},${root.scrollTop}`);
69
+
70
+ // The rules of the sheets in the page, as they are now (a script may have
71
+ // inserted some), inline. A linked sheet stays a link: its relative URLs
72
+ // are its own, and the browser has it cached.
73
+ const rules: string[] = [];
74
+ const styles = [...doc.querySelectorAll('style')];
75
+ const copiedStyles = [...copy.querySelectorAll('style')];
76
+ for (const sheet of [...doc.styleSheets, ...doc.adoptedStyleSheets]) {
77
+ if (sheet.href) continue;
78
+ try {
79
+ rules.push([...sheet.cssRules].map((rule) => rule.cssText).join('\n'));
80
+ const index = styles.indexOf(sheet.ownerNode as HTMLStyleElement);
81
+ if (index >= 0) copiedStyles[index]!.remove();
82
+ } catch {
83
+ // A sheet whose rules cannot be read keeps its element.
84
+ }
85
+ }
86
+ for (const element of copy.querySelectorAll('script, link[rel="modulepreload"], link[rel="preload"]')) element.remove();
87
+ const head = copy.querySelector('head') ?? copy.insertBefore(doc.createElement('head'), copy.firstChild);
88
+ const style = doc.createElement('style');
89
+ style.textContent = rules.join('\n');
90
+ head.prepend(style);
91
+ // Its URLs as the app's page had them.
92
+ const base = doc.createElement('base');
93
+ base.href = doc.baseURI;
94
+ head.prepend(base);
95
+ copy.querySelector('body')?.setAttribute('inert', '');
96
+ return `<!doctype html>\n${copy.outerHTML}`;
97
+ }
98
+
99
+ /** Scrolls a frozen copy's elements back to where the app had them, once it has loaded. */
100
+ export function restoreScroll(doc: Document): void {
101
+ for (const element of doc.querySelectorAll(`[${SCROLL}]`)) {
102
+ const [left, top] = element.getAttribute(SCROLL)!.split(',').map(Number);
103
+ element.scrollTo(left ?? 0, top ?? 0);
104
+ }
105
+ }
@@ -5,6 +5,7 @@
5
5
  // (`?module=`, main.ts). Counter's sources come with the page, so after the
6
6
  // first visit's downloads nothing reaches a server.
7
7
 
8
+ import { frozenCopy, restoreScroll } from './freeze';
8
9
  import type { FromWorker, ToWorker } from './worker';
9
10
 
10
11
  const sources = import.meta.glob('../../../Examples/Counter/Sources/**/*.swift', { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
@@ -58,6 +59,11 @@ const source = {
58
59
  };
59
60
  let nextId = 1;
60
61
  let moduleUrl: string | null = null;
62
+ /** The still copy of the last app the frame shows while a compile runs. */
63
+ let frozenUrl: string | null = null;
64
+ frame.addEventListener('load', () => {
65
+ if (frozenUrl && frame.contentDocument && frame.contentWindow?.location.href === frozenUrl) restoreScroll(frame.contentDocument);
66
+ });
61
67
  const started = new Map<number, number>();
62
68
 
63
69
  worker.onmessage = (event: MessageEvent<FromWorker>) => {
@@ -79,7 +85,10 @@ worker.onmessage = (event: MessageEvent<FromWorker>) => {
79
85
  return;
80
86
  }
81
87
  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(', ');
88
+ // Each tool's memory too: what a phone's browser runs out of first.
89
+ const steps = message.steps
90
+ .map((step) => `${step.tool} ${step.seconds.toFixed(1)} s${step.memoryBytes ? `, ${Math.round(step.memoryBytes / 1e6)} MB` : ''}`)
91
+ .join('; ');
83
92
  diagnostics.hidden = message.ok || message.output === '';
84
93
  diagnostics.textContent = message.output;
85
94
  if (!message.ok || !message.wasm) {
@@ -88,13 +97,30 @@ worker.onmessage = (event: MessageEvent<FromWorker>) => {
88
97
  return;
89
98
  }
90
99
  status.textContent = `Compiled and linked in ${seconds.toFixed(1)} s (${steps}).`;
91
- if (moduleUrl) URL.revokeObjectURL(moduleUrl);
100
+ if (frozenUrl) URL.revokeObjectURL(frozenUrl);
101
+ frozenUrl = null;
92
102
  moduleUrl = URL.createObjectURL(new Blob([message.wasm], { type: 'application/wasm' }));
93
103
  frame.src = `${import.meta.env.BASE_URL}app/?app=${APP}&module=${encodeURIComponent(moduleUrl)}`;
94
104
  };
95
105
 
96
106
  function compile(): void {
97
107
  run.disabled = true;
108
+ // The last build's app goes while the compiler runs: its page, its module
109
+ // and its memory would stay beside the linker's, which a phone has not got.
110
+ // A still copy of it stays in its place (freeze.ts).
111
+ if (moduleUrl) {
112
+ let copy: string | null = null;
113
+ try {
114
+ copy = frame.contentDocument ? frozenCopy(frame.contentDocument) : null;
115
+ } catch {
116
+ // No copy: the frame goes blank.
117
+ }
118
+ if (frozenUrl) URL.revokeObjectURL(frozenUrl);
119
+ frozenUrl = copy ? URL.createObjectURL(new Blob([copy], { type: 'text/html' })) : null;
120
+ frame.src = frozenUrl ?? 'about:blank';
121
+ URL.revokeObjectURL(moduleUrl);
122
+ moduleUrl = null;
123
+ }
98
124
  diagnostics.hidden = true;
99
125
  status.textContent = 'Compiling…';
100
126
  const id = nextId++;