@swiftbrowser/web 0.8.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.
@@ -0,0 +1,88 @@
1
+ // A WebAssembly module without its custom sections, as it streams in. A
2
+ // tool's or plugin's run reads none of them, but a browser keeps them beside
3
+ // the compiled code: the function names are a quarter of the compiler and of
4
+ // the linker, and a macro plugin carries its DWARF.
5
+ /** Passes a module through, dropping each custom section (id 0). */
6
+ export function withoutCustomSections() {
7
+ // The preamble's bytes still to pass, then a section header's bytes so far
8
+ // (its id and LEB128 size), then the section's bytes to pass or to drop.
9
+ let preamble = 8;
10
+ let header = [];
11
+ let pass = 0;
12
+ let drop = 0;
13
+ return new TransformStream({
14
+ transform(chunk, controller) {
15
+ let at = 0;
16
+ while (at < chunk.length) {
17
+ if (preamble > 0 || pass > 0) {
18
+ const count = Math.min(preamble > 0 ? preamble : pass, chunk.length - at);
19
+ controller.enqueue(chunk.subarray(at, at + count));
20
+ if (preamble > 0)
21
+ preamble -= count;
22
+ else
23
+ pass -= count;
24
+ at += count;
25
+ }
26
+ else if (drop > 0) {
27
+ const count = Math.min(drop, chunk.length - at);
28
+ drop -= count;
29
+ at += count;
30
+ }
31
+ else {
32
+ const byte = chunk[at++];
33
+ header.push(byte);
34
+ if (header.length === 1 || byte & 0x80) {
35
+ if (header.length > 6)
36
+ throw new Error('a WebAssembly section size is longer than five bytes');
37
+ continue;
38
+ }
39
+ let size = 0;
40
+ for (let i = header.length - 1; i >= 1; i--)
41
+ size = size * 128 + (header[i] & 0x7f);
42
+ if (header[0] === 0)
43
+ drop = size;
44
+ else {
45
+ controller.enqueue(new Uint8Array(header));
46
+ pass = size;
47
+ }
48
+ header = [];
49
+ }
50
+ }
51
+ },
52
+ flush() {
53
+ if (preamble > 0 || pass > 0 || drop > 0 || header.length > 0)
54
+ throw new Error('a WebAssembly module ended early');
55
+ },
56
+ });
57
+ }
58
+ /** `module` without its custom sections, at once: what `withoutCustomSections` streams. */
59
+ export function stripCustomSections(module) {
60
+ const kept = [module.subarray(0, 8)];
61
+ let at = 8;
62
+ while (at < module.length) {
63
+ const start = at;
64
+ const id = module[at++];
65
+ let size = 0;
66
+ for (let shift = 0;; shift += 7) {
67
+ if (at >= module.length)
68
+ throw new Error('a WebAssembly module ended early');
69
+ const byte = module[at++];
70
+ size += (byte & 0x7f) * 2 ** shift;
71
+ if (!(byte & 0x80))
72
+ break;
73
+ }
74
+ if (at + size > module.length)
75
+ throw new Error('a WebAssembly module ended early');
76
+ if (id !== 0)
77
+ kept.push(module.subarray(start, at + size));
78
+ at += size;
79
+ }
80
+ const out = new Uint8Array(kept.reduce((total, part) => total + part.length, 0));
81
+ let offset = 0;
82
+ for (const part of kept) {
83
+ out.set(part, offset);
84
+ offset += part.length;
85
+ }
86
+ return out;
87
+ }
88
+ //# sourceMappingURL=sections.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sections.js","sourceRoot":"","sources":["../../../../src/playground/sections.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -56,6 +56,7 @@
56
56
  "@playwright/test": "^1.56.0",
57
57
  "@types/node": "^26.6.4",
58
58
  "@types/qrcode": "^1.5.6",
59
+ "brotli-dec-wasm": "^2.3.2",
59
60
  "typescript": "^5.9.2",
60
61
  "vitest": "^5.0.3"
61
62
  }
@@ -3,33 +3,55 @@
3
3
  * by the WebAssembly compiler (docs/in-browser-compiler.md, Milestone 3).
4
4
  * Only this repository's site has it: its code imports
5
5
  * @swiftbrowser/wasm-compiler and the CLI's browser entry from their sources
6
- * (the aliases below). playground/build-assets.ts writes the assets and
7
- * their manifest to a directory; the build reads the manifest, and the page
8
- * fetches the files from /playground-assets/, where the dev server serves
9
- * that directory, or, deployed, from where they were uploaded
10
- * (playground/publish-assets.mjs).
6
+ * (the aliases below). The page fetches the npm packages it reads
7
+ * (src/playground/files.ts) from a CDN that serves them file by file, at one
8
+ * version, except those the site serves itself (`localPackages`): the kit
9
+ * and @swiftbrowser/playground made from the same commit as the site's
10
+ * renderer, so the two cannot drift, and any package the CDN does not have
11
+ * at that version, the compiler's included. The dev server serves this repository's packages at
12
+ * /npm/, laid out as the CDN serves them, and a built site carries the
13
+ * files the page reads of its local packages there.
11
14
  */
12
15
 
13
- import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
16
+ import { createHash } from 'node:crypto';
17
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
14
18
  import path from 'node:path';
19
+ import { brotliCompressSync, brotliDecompressSync, constants } from 'node:zlib';
15
20
  import type { Plugin } from 'vite';
16
- import { PLAYGROUND_MANIFEST, type PlaygroundFiles } from '../src/playground/files.js';
21
+ import {
22
+ PLAYGROUND_ICU,
23
+ PLAYGROUND_MANIFEST,
24
+ PLAYGROUND_MODULE_CACHE,
25
+ PLAYGROUND_PACKAGE,
26
+ SITE_BROTLI,
27
+ isBrotli,
28
+ type PackageFile,
29
+ type PlaygroundManifest,
30
+ type PlaygroundSource,
31
+ } from '../src/playground/files.js';
32
+ import { stripCustomSections } from '../src/playground/sections.js';
17
33
 
18
- export interface PlaygroundOptions {
19
- /** The directory build-assets wrote: its manifest, and the files the dev server serves at /playground-assets/. */
20
- assetsDir: string;
21
- /** Where the page fetches the assets from; default /playground-assets/. */
22
- assetsUrl?: string;
23
- }
34
+ export type PlaygroundOptions = PlaygroundSource;
24
35
 
25
- /** The downloads the assets' manifest names. */
26
- export function playgroundFiles(options: PlaygroundOptions): PlaygroundFiles {
27
- const manifest = path.join(options.assetsDir, PLAYGROUND_MANIFEST);
28
- if (!existsSync(manifest)) throw new Error(`${manifest} is missing: build the playground's assets (playground/build-assets.ts)`);
29
- return JSON.parse(readFileSync(manifest, 'utf8')) as PlaygroundFiles;
30
- }
36
+ /** Where the dev server serves the workspaces' packages, and a built site its local ones. */
37
+ export const PLAYGROUND_NPM_PATH = '/npm/';
38
+ /** The kit, which must come from the same commit as the renderer. */
39
+ export const KIT_PACKAGE = '@swiftbrowser/kit';
40
+ /**
41
+ * Files a site stores brotli-compressed past this size: Cloudflare Pages
42
+ * serves files of up to 25 MiB.
43
+ */
44
+ export const SITE_FILE_LIMIT = 24 * 1024 * 1024;
45
+ /** Bumped whenever writeLocalPackages writes other files for the same packages. */
46
+ const SITE_LAYOUT = 3;
31
47
 
32
- export const PLAYGROUND_ASSETS_PATH = '/playground-assets/';
48
+ /**
49
+ * Files a site stores brotli-compressed from this size, when compressing
50
+ * saves a tenth: a static host may not compress what it serves in transit
51
+ * (Cloudflare Pages leaves application/octet-stream as it is), and the
52
+ * archives and modules compress to about a fifth.
53
+ */
54
+ export const SITE_COMPRESS_FROM = 64 * 1024;
33
55
 
34
56
  /** The source aliases of the playground's imports, from the repository root. */
35
57
  export function playgroundAliases(repoRoot: string): Record<string, string> {
@@ -39,18 +61,152 @@ export function playgroundAliases(repoRoot: string): Record<string, string> {
39
61
  };
40
62
  }
41
63
 
42
- export function playgroundAssets(options: PlaygroundOptions): Plugin {
64
+ /** The workspaces' package directories by package name. */
65
+ export function workspacePackages(repoRoot: string): Map<string, string> {
66
+ const dirs = new Map<string, string>();
67
+ const packagesDir = path.join(repoRoot, 'packages');
68
+ for (const name of readdirSync(packagesDir)) {
69
+ const manifest = path.join(packagesDir, name, 'package.json');
70
+ if (existsSync(manifest)) dirs.set((JSON.parse(readFileSync(manifest, 'utf8')) as { name: string }).name, path.join(packagesDir, name));
71
+ }
72
+ return dirs;
73
+ }
74
+
75
+ /** `@scope/name@version/file` → [name, file], whatever the version. */
76
+ export function packageRequest(pathname: string): [string, string] | null {
77
+ const match = /^(@[^/]+\/[^/@]+)@[^/]+\/(.+)$/.exec(pathname);
78
+ return match ? [match[1]!, match[2]!] : null;
79
+ }
80
+
81
+ /** Every file of `manifest` by package, as the package holds it. */
82
+ export function manifestFiles(manifest: PlaygroundManifest): Map<string, string[]> {
83
+ const files = new Map<string, string[]>();
84
+ const add = ({ package: name, file }: PackageFile): void => void (files.get(name) ?? files.set(name, []).get(name)!).push(file);
85
+ for (const file of [manifest.frontend, manifest.linker, ...Object.values(manifest.plugins)]) add(file);
86
+ for (const [name, list] of Object.entries(manifest.sdk)) for (const file of list) add({ package: name, file: `wasm32-unknown-wasip1/${file}` });
87
+ for (const file of [...manifest.kit, 'kit.json']) add({ package: KIT_PACKAGE, file });
88
+ for (const file of [PLAYGROUND_MANIFEST, PLAYGROUND_ICU, PLAYGROUND_MODULE_CACHE]) add({ package: PLAYGROUND_PACKAGE, file });
89
+ return files;
90
+ }
91
+
92
+ /**
93
+ * The packages the site serves itself: @swiftbrowser/playground and the kit
94
+ * always, and `others`, each at /npm/<name>@site-<hash>/, the hash naming
95
+ * what the workspaces hold so a browser's cache keeps no stale file.
96
+ */
97
+ export function localPackages(repoRoot: string, others: readonly string[] = []): Record<string, string> {
98
+ const dirs = workspacePackages(repoRoot);
99
+ const own = dirs.get(PLAYGROUND_PACKAGE)!;
100
+ const manifestFile = path.join(own, PLAYGROUND_MANIFEST);
101
+ if (!existsSync(manifestFile)) throw new Error(`${manifestFile} is missing: run \`npm run playground:assets --workspace @swiftbrowser/web\``);
102
+ const hash = createHash('sha256');
103
+ for (const file of [PLAYGROUND_MANIFEST, PLAYGROUND_ICU, PLAYGROUND_MODULE_CACHE]) hash.update(readFileSync(path.join(own, file)));
104
+ hash.update(readFileSync(path.join(dirs.get(KIT_PACKAGE)!, 'kit.json')));
105
+ const names = [...new Set([PLAYGROUND_PACKAGE, KIT_PACKAGE, ...others])].sort();
106
+ for (const name of names) hash.update(`${name}\n`);
107
+ // And how writeLocalPackages lays them out: another layout is other files.
108
+ hash.update(`layout ${SITE_LAYOUT} ${SITE_FILE_LIMIT} ${SITE_COMPRESS_FROM}\n`);
109
+ const version = `site-${hash.digest('hex').slice(0, 12)}`;
110
+ return Object.fromEntries(names.map((name) => [name, `${PLAYGROUND_NPM_PATH}${name}@${version}/`]));
111
+ }
112
+
113
+ /** `data` brotli-compressed at `quality`, with the window the tools are published with. */
114
+ const brotli = (data: Uint8Array, quality: number): Uint8Array =>
115
+ brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: quality, [constants.BROTLI_PARAM_LGWIN]: 24, [constants.BROTLI_PARAM_SIZE_HINT]: data.length } });
116
+
117
+ /**
118
+ * Writes the files the page reads of each local package under `outDir`, at
119
+ * the paths `packages` names: brotli-compressed as `<file>.brotli` from
120
+ * SITE_COMPRESS_FROM (and always over the size limit), and playground.json
121
+ * naming those (`brotli`). A tool or plugin goes without its custom
122
+ * sections, which the page drops anyway (sections.ts), as
123
+ * `<module>.brotli`: so the compiler fits the size limit, compressed harder
124
+ * when it must, and the site can serve it when npm has not got this one.
125
+ */
126
+ export function writeLocalPackages(repoRoot: string, packages: Record<string, string>, outDir: string, limit = SITE_FILE_LIMIT, compressFrom = SITE_COMPRESS_FROM): { files: number; bytes: number } {
127
+ const dirs = workspacePackages(repoRoot);
128
+ const manifest = JSON.parse(readFileSync(path.join(dirs.get(PLAYGROUND_PACKAGE)!, PLAYGROUND_MANIFEST), 'utf8')) as PlaygroundManifest;
129
+ const key = (file: PackageFile): string => `${file.package}/${file.file}`;
130
+ const modules = new Set([manifest.frontend, manifest.linker, ...Object.values(manifest.plugins)].map(key));
131
+ /** Files stored under another name: brotli-compressed by the site. */
132
+ const renamed = new Map<string, string>();
133
+ const rename = (file: PackageFile): PackageFile => ({ ...file, file: renamed.get(key(file)) ?? file.file });
134
+ const written = { files: 0, bytes: 0 };
135
+ for (const [name, files] of manifestFiles(manifest)) {
136
+ const base = packages[name];
137
+ if (!base) continue;
138
+ const dir = dirs.get(name);
139
+ if (!dir) throw new Error(`the site serves ${name}, which is not a package of this repository`);
140
+ for (const file of files) {
141
+ if (name === PLAYGROUND_PACKAGE && file === PLAYGROUND_MANIFEST) continue;
142
+ let data: Uint8Array = readFileSync(path.join(dir, file));
143
+ let target = file;
144
+ if (modules.has(`${name}/${file}`)) {
145
+ const module = stripCustomSections(isBrotli(file) ? brotliDecompressSync(data) : data);
146
+ data = brotli(module, 5);
147
+ if (data.length > limit) data = brotli(module, 11);
148
+ if (data.length > limit) throw new Error(`${name}/${file} is over ${limit} bytes even compressed, without its custom sections`);
149
+ target = `${file.replace(/\.br$/, '')}${SITE_BROTLI}`;
150
+ } else if (isBrotli(file)) {
151
+ if (data.length > limit) throw new Error(`${name}/${file} is over ${limit} bytes compressed: the site cannot serve it`);
152
+ } else if (data.length >= compressFrom || data.length > limit) {
153
+ const compressed = brotli(data, 5);
154
+ if (compressed.length > limit) throw new Error(`${name}/${file} is over ${limit} bytes even compressed`);
155
+ if (data.length > limit || compressed.length < data.length * 0.9) {
156
+ data = compressed;
157
+ target = `${file}${SITE_BROTLI}`;
158
+ }
159
+ }
160
+ if (target !== file) renamed.set(`${name}/${file}`, target);
161
+ const out = path.join(outDir, base, target);
162
+ mkdirSync(path.dirname(out), { recursive: true });
163
+ writeFileSync(out, data);
164
+ written.files += 1;
165
+ written.bytes += data.length;
166
+ }
167
+ }
168
+ if (packages[PLAYGROUND_PACKAGE]) {
169
+ // A tool or plugin is named by its compressed file; the rest by the list.
170
+ const listed = [...renamed.keys()].filter((entry) => !modules.has(entry)).sort();
171
+ const site: PlaygroundManifest = {
172
+ ...manifest,
173
+ frontend: rename(manifest.frontend),
174
+ linker: rename(manifest.linker),
175
+ plugins: Object.fromEntries(Object.entries(manifest.plugins).map(([macro, file]) => [macro, rename(file)])),
176
+ ...(listed.length > 0 ? { brotli: listed } : {}),
177
+ };
178
+ const out = path.join(outDir, packages[PLAYGROUND_PACKAGE], PLAYGROUND_MANIFEST);
179
+ mkdirSync(path.dirname(out), { recursive: true });
180
+ writeFileSync(out, `${JSON.stringify(site)}\n`);
181
+ written.files += 1;
182
+ }
183
+ return written;
184
+ }
185
+
186
+ export function playgroundPackages(repoRoot: string, options: PlaygroundOptions): Plugin {
187
+ let outDir = '';
43
188
  return {
44
- name: 'swiftbrowser-playground-assets',
189
+ name: 'swiftbrowser-playground-packages',
190
+ configResolved(config) {
191
+ outDir = path.resolve(config.root, config.build.outDir);
192
+ },
45
193
  configureServer(server) {
46
- const dir = path.resolve(options.assetsDir);
47
- server.middlewares.use(PLAYGROUND_ASSETS_PATH, (req, res, next) => {
48
- const file = path.join(dir, decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname));
49
- if (!file.startsWith(dir) || !existsSync(file) || statSync(file).isDirectory()) return next();
50
- res.setHeader('Content-Type', file.endsWith('.gz') ? 'application/gzip' : 'application/octet-stream');
194
+ const packages = workspacePackages(repoRoot);
195
+ server.middlewares.use(PLAYGROUND_NPM_PATH, (req, res, next) => {
196
+ const request = packageRequest(decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname).replace(/^\//, ''));
197
+ const dir = request && packages.get(request[0]);
198
+ if (!request || !dir) return next();
199
+ const file = path.join(dir, request[1]);
200
+ if (!existsSync(file) || statSync(file).isDirectory() || !realpathSync(file).startsWith(realpathSync(dir))) return next();
201
+ res.setHeader('Content-Type', file.endsWith('.wasm') ? 'application/wasm' : file.endsWith('.json') ? 'application/json' : 'application/octet-stream');
51
202
  res.setHeader('Content-Length', statSync(file).size);
52
203
  createReadStream(file).pipe(res);
53
204
  });
54
205
  },
206
+ writeBundle() {
207
+ if (!options.packages) return;
208
+ const { files, bytes } = writeLocalPackages(repoRoot, options.packages, outDir);
209
+ this.info(`the playground's local packages: ${files} files, ${(bytes / 1e6).toFixed(1)} MB`);
210
+ },
55
211
  };
56
212
  }
@@ -1,96 +1,116 @@
1
- // The playground's downloads (playground/build-assets.ts makes them): the
2
- // compiler, the linker, the macro plugins and the pack of SDK and kit files,
3
- // fetched from one base URL (a public bucket, or the dev server's copy) and
4
- // kept in the Cache API, so a compile needs no server request after the
5
- // first visit. Each file is gzipped, unpacked here as it arrives (no server
6
- // or CDN has to set Content-Encoding), and named by its contents, so a new
7
- // build is a new download.
1
+ // The playground's downloads (files.ts): its manifest from
2
+ // @swiftbrowser/playground, then the tools, the SDK's and the kit's files it
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
+ // 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).
8
7
 
9
- import { memoryDirectory } from '@swiftbrowser/wasm-compiler';
10
8
  import type { Toolchain } from './build';
11
- import type { PlaygroundFiles } from './files';
9
+ import { type Download, cacheKey, downloadBytes, openCache } from './download';
10
+ import {
11
+ PLAYGROUND_ICU,
12
+ PLAYGROUND_MANIFEST,
13
+ PLAYGROUND_MODULE_CACHE,
14
+ PLAYGROUND_PACKAGE,
15
+ SITE_BROTLI,
16
+ type PackageFile,
17
+ type PlaygroundManifest,
18
+ type PlaygroundSource,
19
+ packageUrl,
20
+ } from './files';
12
21
  import { readPack } from './pack';
22
+ import { prefetchInWorker } from './tool-host';
13
23
 
14
- /** What the pack's index records besides its files. */
15
- export interface PlaygroundMeta {
16
- /** The symbol Foundation's ICU reads its data through (`icudtNN_dat`). */
17
- icuSymbol: string;
18
- /** The apps whose compiles the pack was recorded from. */
19
- apps: string[];
20
- }
21
-
22
- const CACHE = 'swiftbrowser-playground';
23
-
24
- /** A response for `url`, from the cache when it has one. */
25
- async function cached(url: string): Promise<Response> {
26
- const cache = typeof caches === 'undefined' ? null : await caches.open(CACHE).catch(() => null);
27
- const hit = await cache?.match(url);
28
- if (hit) return hit;
29
- const response = await fetch(url);
30
- if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
31
- await cache?.put(url, response.clone()).catch(() => {});
32
- return response;
33
- }
34
-
35
- /** The file at `url`, unpacked when it is gzipped. */
36
- async function body(url: string): Promise<Response> {
37
- const response = await cached(url);
38
- if (!url.endsWith('.gz') || !response.body) return response;
39
- const type = url.endsWith('.wasm.gz') ? 'application/wasm' : 'application/octet-stream';
40
- return new Response(response.body.pipeThrough(new DecompressionStream('gzip')), { headers: { 'content-type': type } });
41
- }
42
-
43
- async function compileModule(url: string): Promise<WebAssembly.Module> {
44
- const response = await body(url);
45
- if (typeof WebAssembly.compileStreaming === 'function' && (response.headers.get('content-type') ?? '').includes('application/wasm')) {
46
- return WebAssembly.compileStreaming(response);
47
- }
48
- return WebAssembly.compile(await response.arrayBuffer());
49
- }
50
-
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
+ */
51
29
  export interface LoadedToolchain {
52
- /** The tools and files, without macro plugins until `plugins` settles. */
53
- toolchain: Toolchain;
54
- /**
55
- * The macro plugins, downloading behind the first compile: most apps
56
- * expand no macro, and the plugins are a fifth of the download.
57
- */
58
- 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'];
59
39
  /** The module cache built with the playground's flags. */
60
40
  moduleCache: [string, Uint8Array][];
61
- meta: PlaygroundMeta;
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>;
46
+ manifest: PlaygroundManifest;
47
+ /** Why the kit may not suit this page's renderer, when it may not. */
48
+ warning?: string;
49
+ }
50
+
51
+ /**
52
+ * Whether the kit the manifest names was built from the sources the page's
53
+ * renderer was: the two speak one protocol, which either may change.
54
+ */
55
+ export function kitWarning(manifest: Pick<PlaygroundManifest, 'shimSources'>, source: Pick<PlaygroundSource, 'shimSources'>): string | undefined {
56
+ if (!manifest.shimSources || !source.shimSources || manifest.shimSources === source.shimSources) return undefined;
57
+ return `The kit was built from other SwiftBrowser sources (${manifest.shimSources}) than this page's renderer (${source.shimSources}): an app may not run as it should.`;
62
58
  }
63
59
 
64
- export async function loadToolchain(base: string, files: PlaygroundFiles, onProgress: (text: string) => void = () => {}): Promise<LoadedToolchain> {
65
- const url = (file: string): string => new URL(file, base).href;
60
+ export async function loadToolchain(source: PlaygroundSource, onProgress: (text: string) => void = () => {}): Promise<LoadedToolchain> {
61
+ const cache = await openCache();
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));
66
64
  const started = performance.now();
67
65
  const lap = (text: string): void => onProgress(`${text} (${((performance.now() - started) / 1000).toFixed(1)} s)`);
68
- const [frontend, linker, packBytes] = await Promise.all([
69
- compileModule(url(files.frontend)).then((module) => (lap('compiler ready'), module)),
70
- compileModule(url(files.linker)).then((module) => (lap('linker ready'), module)),
71
- body(url(files.pack)).then((response) => response.arrayBuffer()).then((buffer) => (lap('SDK ready'), new Uint8Array(buffer))),
66
+ const own = (file: string): PackageFile => ({ package: PLAYGROUND_PACKAGE, file });
67
+ const manifest = JSON.parse(new TextDecoder().decode(await bytes(own(PLAYGROUND_MANIFEST)))) as PlaygroundManifest;
68
+
69
+ const kitPackage = '@swiftbrowser/kit';
70
+ // What a site stores brotli-compressed it serves under another name.
71
+ const brotli = new Set(manifest.brotli ?? []);
72
+ const served = (name: string, file: string): PackageFile => ({ package: name, file: brotli.has(`${name}/${file}`) ? `${file}${SITE_BROTLI}` : 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)];
79
+ let done = 0;
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');
83
+ if (++done % 50 === 0 || done === total) onProgress(`SDK and kit: ${done} of ${total} files`);
84
+ };
85
+ const [, kitJson, icu, moduleCacheBytes] = await Promise.all([
86
+ prefetchInWorker(downloads, fetched).then(() => lap('SDK ready')),
87
+ bytes(served(kitPackage, 'kit.json')),
88
+ bytes(served(PLAYGROUND_PACKAGE, PLAYGROUND_ICU)),
89
+ bytes(served(PLAYGROUND_PACKAGE, PLAYGROUND_MODULE_CACHE)),
72
90
  ]);
73
- const pack = readPack<PlaygroundMeta>(packBytes);
74
- const plugins = Promise.all(Object.entries(files.plugins).map(async ([name, file]) => {
75
- const bytes = new Uint8Array(await (await body(url(file))).arrayBuffer());
76
- return [name, { module: await WebAssembly.compile(bytes), bytes }] as const;
77
- })).then(Object.fromEntries<Toolchain['plugins'][string]>);
78
- // A failed download surfaces when a compile needs the plugins.
79
- plugins.catch(() => {});
80
- const kitJson = pack.file('kit.json');
81
- const icu = pack.file('icu.dat');
82
- if (!kitJson || !icu) throw new Error('playground.pack has no kit.json or icu.dat');
91
+ const encoder = new TextEncoder();
92
+ const inline = Object.entries(manifest.inline ?? {}).map(([path, text]): [string, Uint8Array] => [path, encoder.encode(text)]);
93
+
94
+ // Every file is named by its version: drop what an older one left.
95
+ void (async () => {
96
+ const wanted = new Set([
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))),
99
+ ]);
100
+ for (const request of (await cache?.keys()) ?? []) if (!wanted.has(request.url)) await cache!.delete(request);
101
+ })().catch(() => {});
102
+
103
+ const warning = kitWarning(manifest, source);
83
104
  return {
84
- toolchain: {
85
- tools: { 'swift-frontend': frontend, 'wasm-ld': linker },
86
- plugins: {},
87
- sdk: memoryDirectory(pack.files('sdk')),
88
- kit: memoryDirectory(pack.files('kit')),
89
- kitManifest: JSON.parse(new TextDecoder().decode(kitJson)) as Toolchain['kitManifest'],
90
- icu: { symbol: pack.meta.icuSymbol, data: icu },
91
- },
105
+ tools,
92
106
  plugins,
93
- moduleCache: pack.files('module-cache'),
94
- meta: pack.meta,
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 },
111
+ moduleCache: readPack(moduleCacheBytes).files('module-cache'),
112
+ prefetchPlugins: () => prefetchInWorker(Object.values(plugins)),
113
+ manifest,
114
+ ...(warning ? { warning } : {}),
95
115
  };
96
116
  }