@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.
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +12 -5
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/plugins/playground.d.ts +52 -16
- package/node/dist/plugins/playground.d.ts.map +1 -1
- package/node/dist/plugins/playground.js +186 -22
- package/node/dist/plugins/playground.js.map +1 -1
- package/node/dist/src/playground/files.d.ts +73 -9
- package/node/dist/src/playground/files.d.ts.map +1 -1
- package/node/dist/src/playground/files.js +29 -6
- package/node/dist/src/playground/files.js.map +1 -1
- package/node/dist/src/playground/sections.d.ts +5 -0
- package/node/dist/src/playground/sections.d.ts.map +1 -0
- package/node/dist/src/playground/sections.js +88 -0
- package/node/dist/src/playground/sections.js.map +1 -0
- package/package.json +2 -1
- package/plugins/playground.ts +183 -27
- package/src/playground/assets.ts +100 -80
- package/src/playground/build.ts +30 -19
- package/src/playground/download.ts +125 -0
- package/src/playground/files.ts +89 -14
- package/src/playground/freeze.ts +105 -0
- package/src/playground/main.ts +42 -4
- package/src/playground/playground.css +1 -0
- package/src/playground/read-once.ts +75 -0
- package/src/playground/sections.ts +77 -0
- package/src/playground/tool-host.ts +163 -0
- package/src/playground/tool-worker.ts +67 -0
- package/src/playground/worker.ts +47 -21
- package/src/vite-env.d.ts +2 -4
package/src/playground/build.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// An app compiled and linked in the browser: what the CLI does with
|
|
2
|
-
// SWIFTBROWSER_COMPILER=wasm (packages/cli/src/wasmCompiler.ts),
|
|
3
|
-
// in-memory
|
|
2
|
+
// SWIFTBROWSER_COMPILER=wasm (packages/cli/src/wasmCompiler.ts), over an
|
|
3
|
+
// in-memory file system instead of the real one: the toolchain makes the
|
|
4
|
+
// host (the playground's runs each tool in a worker, tool-host.ts). The
|
|
5
|
+
// app's sources are staged
|
|
4
6
|
// for the shim with the CLI's own code (its browser entry) and compiled with
|
|
5
7
|
// the CLI's flags against the kit, so a module built here is the module the
|
|
6
8
|
// CLI builds. The playground's worker runs it, and so does the Node script
|
|
@@ -20,26 +22,35 @@ import {
|
|
|
20
22
|
optionalRequirementNames,
|
|
21
23
|
transformSource,
|
|
22
24
|
} from '@swiftbrowser/cli/browser';
|
|
23
|
-
import { type CompileStep, compile, dataObject, memoryDirectory
|
|
25
|
+
import { type CompileStep, type MemoryHost, compile, dataObject, memoryDirectory } from '@swiftbrowser/wasm-compiler';
|
|
24
26
|
|
|
25
27
|
/** Where the kit's modules and archives are, as the compiler sees them. */
|
|
26
28
|
const KIT_ROOT = '/kit/wasm32-unknown-wasip1';
|
|
27
29
|
|
|
28
30
|
/** What every compile shares, loaded once. */
|
|
29
31
|
export interface Toolchain {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The host the tools run on, over the build's own directories (/app,
|
|
34
|
+
* /work) and the toolchain's: the Swift SDK's `wasm32-unknown-wasip1`
|
|
35
|
+
* directory at /sdk, the kit's (its `wasm32-unknown-wasip1/Modules` and
|
|
36
|
+
* `lib`) at /kit, and each of `plugins` at /plugins/<name>.wasm.
|
|
37
|
+
*/
|
|
38
|
+
host(options: HostOptions): MemoryHost;
|
|
39
|
+
/** The macro plugins a compile loads, by the module whose macros they expand. */
|
|
40
|
+
plugins: string[];
|
|
37
41
|
/** kit.json's target and product graph. */
|
|
38
42
|
kitManifest: { targets: Record<string, string[]>; products: Record<string, string[]> };
|
|
39
43
|
/** Foundation's ICU data, trimmed, and the symbol it is read through. */
|
|
40
44
|
icu: { symbol: string; data: Uint8Array };
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
export interface HostOptions {
|
|
48
|
+
mounts: Record<string, Directory>;
|
|
49
|
+
moduleCache?: Iterable<readonly [string, Uint8Array]>;
|
|
50
|
+
/** Every path the tools open, as a guest path. */
|
|
51
|
+
onOpen?: (path: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
export interface AppSources {
|
|
44
55
|
/** The module name (a Swift identifier). */
|
|
45
56
|
name: string;
|
|
@@ -62,6 +73,8 @@ export interface BuildOptions {
|
|
|
62
73
|
moduleCache?: Iterable<readonly [string, Uint8Array]>;
|
|
63
74
|
/** Every path the tools open, as a guest path. */
|
|
64
75
|
onOpen?: (path: string) => void;
|
|
76
|
+
/** Each tool as it starts. */
|
|
77
|
+
onStep?: (tool: string) => void;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
/** The app's sources as the CLI stages them (stage.ts): adjusted for the shim, with the generated Foundation aliases. */
|
|
@@ -105,18 +118,11 @@ export async function buildApp(toolchain: Toolchain, app: AppSources, options: B
|
|
|
105
118
|
const staged = stageSources(app);
|
|
106
119
|
const encoder = new TextEncoder();
|
|
107
120
|
const sources = Object.keys(staged).sort().map((file) => `/app/${file}`);
|
|
108
|
-
const
|
|
109
|
-
const host = memoryHost({
|
|
110
|
-
tools: toolchain.tools,
|
|
121
|
+
const host = toolchain.host({
|
|
111
122
|
mounts: {
|
|
112
|
-
'/sdk': toolchain.sdk,
|
|
113
|
-
'/kit': toolchain.kit,
|
|
114
123
|
'/app': memoryDirectory(Object.entries(staged).map(([file, text]) => [file, encoder.encode(text)])),
|
|
115
124
|
'/work': memoryDirectory([['icudt.o', dataObject(toolchain.icu.symbol, toolchain.icu.data)]]),
|
|
116
|
-
'/plugins': memoryDirectory(plugins.map(([name, plugin]) => [`${name}.wasm`, plugin.bytes])),
|
|
117
125
|
},
|
|
118
|
-
sdkRoot: '/sdk',
|
|
119
|
-
plugins: Object.fromEntries(plugins.map(([name, plugin]) => [`/plugins/${name}.wasm`, plugin.module])),
|
|
120
126
|
...(options.moduleCache ? { moduleCache: options.moduleCache } : {}),
|
|
121
127
|
...(options.onOpen ? { onOpen: options.onOpen } : {}),
|
|
122
128
|
});
|
|
@@ -125,16 +131,21 @@ export async function buildApp(toolchain: Toolchain, app: AppSources, options: B
|
|
|
125
131
|
const result = await compile(host, {
|
|
126
132
|
moduleName: app.name,
|
|
127
133
|
sources,
|
|
134
|
+
...(options.onStep ? { onStep: ({ tool }: { tool: string }) => options.onStep!(tool) } : {}),
|
|
128
135
|
objectDir: '/work',
|
|
129
136
|
output: `/work/${app.name}.wasm`,
|
|
130
137
|
wholeModule: true,
|
|
131
138
|
frontendFlags: [
|
|
132
139
|
...appFrontendFlags({ name: app.name, swiftLanguageMode: '5', defines: [] }, sources, `${KIT_ROOT}/Modules`),
|
|
133
|
-
...plugins.flatMap((
|
|
140
|
+
...toolchain.plugins.flatMap((name) => ['-load-plugin-executable', `/plugins/${name}.wasm#${name}`]),
|
|
134
141
|
],
|
|
135
142
|
objects: ['/work/icudt.o'],
|
|
136
143
|
linkerFlags: [
|
|
137
144
|
'--gc-sections',
|
|
145
|
+
// No DWARF, as the CLI's release builds have none (its wasm-opt pass
|
|
146
|
+
// drops it): the frame runs the app, nothing debugs it, and the linker
|
|
147
|
+
// needs less memory without copying it out (the names stay).
|
|
148
|
+
'--strip-debug',
|
|
138
149
|
...appLinkerFlags(),
|
|
139
150
|
...archives.flatMap((archive) => ['--whole-archive', archive, '--no-whole-archive']),
|
|
140
151
|
],
|
|
@@ -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
|
+
}
|
package/src/playground/files.ts
CHANGED
|
@@ -1,18 +1,93 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// What the playground downloads, all from npm packages at one version (the
|
|
2
|
+
// release's lockstep version), through a CDN that serves npm packages file by
|
|
3
|
+
// file (jsDelivr): the compiler, the linker and the toolchain's macro plugins
|
|
4
|
+
// from @swiftbrowser/wasm-tools, the Swift SDK's files from the
|
|
5
|
+
// @swiftbrowser/wasm-sdk packages, the kit's from @swiftbrowser/kit, and what
|
|
6
|
+
// only the playground needs from @swiftbrowser/playground: its manifest
|
|
7
|
+
// (which files of those packages a compile reads), the trimmed ICU data and
|
|
8
|
+
// the module cache. playground/build-assets.ts makes that package.
|
|
6
9
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
export const PLAYGROUND_PACKAGE = '@swiftbrowser/playground';
|
|
11
|
+
export const PLAYGROUND_MANIFEST = 'playground.json';
|
|
12
|
+
/** The trimmed ICU data, in @swiftbrowser/playground. */
|
|
13
|
+
export const PLAYGROUND_ICU = 'icu.dat';
|
|
14
|
+
/** The module cache, packed (pack.ts), in @swiftbrowser/playground. */
|
|
15
|
+
export const PLAYGROUND_MODULE_CACHE = 'module-cache.pack';
|
|
16
|
+
/** Where the npm packages are fetched from by default. */
|
|
17
|
+
export const JSDELIVR = 'https://cdn.jsdelivr.net/npm/';
|
|
18
|
+
|
|
19
|
+
/** A file of one of the packages. */
|
|
20
|
+
export interface PackageFile {
|
|
21
|
+
package: string;
|
|
22
|
+
/** The path in the package; `.br` files are brotli-compressed. */
|
|
23
|
+
file: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @swiftbrowser/playground's playground.json. */
|
|
27
|
+
export interface PlaygroundManifest {
|
|
28
|
+
/** The symbol Foundation's ICU reads its data through (`icudtNN_dat`). */
|
|
29
|
+
icuSymbol: string;
|
|
30
|
+
/** The apps whose compiles recorded the files. */
|
|
31
|
+
apps: string[];
|
|
32
|
+
frontend: PackageFile;
|
|
33
|
+
linker: PackageFile;
|
|
14
34
|
/** The macro plugins by the module whose macros they expand. */
|
|
15
|
-
plugins: Record<string,
|
|
35
|
+
plugins: Record<string, PackageFile>;
|
|
36
|
+
/**
|
|
37
|
+
* The Swift SDK's files a compile reads, by package, each relative to the
|
|
38
|
+
* SDK's `wasm32-unknown-wasip1` directory (the package's own
|
|
39
|
+
* `wasm32-unknown-wasip1/` holds it there).
|
|
40
|
+
*/
|
|
41
|
+
sdk: Record<string, string[]>;
|
|
42
|
+
/** The kit's files a compile reads, relative to @swiftbrowser/kit (`wasm32-unknown-wasip1/...`). */
|
|
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>;
|
|
50
|
+
/**
|
|
51
|
+
* The SwiftBrowser sources the kit was built from (@swiftbrowser/swift's
|
|
52
|
+
* `sourcesHash`), when build-assets.ts could tell: the page compares it
|
|
53
|
+
* with the sources its own renderer was built from.
|
|
54
|
+
*/
|
|
55
|
+
shimSources?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Files, as `<package>/<path>`, that the site serving them stores
|
|
58
|
+
* brotli-compressed (`<path>.brotli`, SITE_BROTLI): an SDK or kit file,
|
|
59
|
+
* the ICU data or the module cache (plugins/playground.ts). A tool or
|
|
60
|
+
* plugin is named by its compressed file instead.
|
|
61
|
+
*/
|
|
62
|
+
brotli?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The suffix of a file a site stores brotli-compressed. Not `.br`: a static
|
|
67
|
+
* server may send a `.br` file as `Content-Encoding: br`, which the browser
|
|
68
|
+
* unpacks before the page could.
|
|
69
|
+
*/
|
|
70
|
+
export const SITE_BROTLI = '.brotli';
|
|
71
|
+
|
|
72
|
+
/** Whether a file is brotli-compressed by its name: an npm package's `.br`, or a site's. */
|
|
73
|
+
export const isBrotli = (file: string): boolean => file.endsWith('.br') || file.endsWith(SITE_BROTLI);
|
|
74
|
+
|
|
75
|
+
/** Where the packages come from. */
|
|
76
|
+
export interface PlaygroundSource {
|
|
77
|
+
/** A CDN's npm base URL, which serves every package at `version`... */
|
|
78
|
+
npm: string;
|
|
79
|
+
version: string;
|
|
80
|
+
/**
|
|
81
|
+
* ...but those named here, each served from its own base URL: the site's
|
|
82
|
+
* copy, made from the same commit as its renderer (plugins/playground.ts).
|
|
83
|
+
*/
|
|
84
|
+
packages?: Record<string, string>;
|
|
85
|
+
/** The SwiftBrowser sources the site's renderer was built from (`sourcesHash`). */
|
|
86
|
+
shimSources?: string;
|
|
16
87
|
}
|
|
17
88
|
|
|
18
|
-
|
|
89
|
+
/** The URL of a package's file. */
|
|
90
|
+
export function packageUrl(source: PlaygroundSource, name: string, file: string): string {
|
|
91
|
+
const base = source.packages?.[name];
|
|
92
|
+
return base ? new URL(file, base).href : new URL(`${name}@${source.version}/${file}`, source.npm).href;
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|
package/src/playground/main.ts
CHANGED
|
@@ -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>;
|
|
@@ -21,6 +22,7 @@ const run = $<HTMLButtonElement>('#run');
|
|
|
21
22
|
const status = $<HTMLElement>('#status');
|
|
22
23
|
const diagnostics = $<HTMLPreElement>('#diagnostics');
|
|
23
24
|
const frame = $<HTMLIFrameElement>('#app');
|
|
25
|
+
const warning = $<HTMLElement>('#warning');
|
|
24
26
|
|
|
25
27
|
// Path relative to Sources/ → text, as edited.
|
|
26
28
|
const files: Record<string, string> = Object.fromEntries(
|
|
@@ -48,9 +50,20 @@ editor.addEventListener('input', () => (files[current] = editor.value));
|
|
|
48
50
|
showFiles();
|
|
49
51
|
|
|
50
52
|
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
|
|
51
|
-
const
|
|
53
|
+
const source = {
|
|
54
|
+
...__SB_PLAYGROUND__,
|
|
55
|
+
npm: new URL(__SB_PLAYGROUND__.npm, location.href).href,
|
|
56
|
+
...(__SB_PLAYGROUND__.packages
|
|
57
|
+
? { packages: Object.fromEntries(Object.entries(__SB_PLAYGROUND__.packages).map(([name, base]) => [name, new URL(base, location.href).href])) }
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
52
60
|
let nextId = 1;
|
|
53
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
|
+
});
|
|
54
67
|
const started = new Map<number, number>();
|
|
55
68
|
|
|
56
69
|
worker.onmessage = (event: MessageEvent<FromWorker>) => {
|
|
@@ -59,6 +72,11 @@ worker.onmessage = (event: MessageEvent<FromWorker>) => {
|
|
|
59
72
|
status.textContent = message.text;
|
|
60
73
|
return;
|
|
61
74
|
}
|
|
75
|
+
if (message.type === 'warning') {
|
|
76
|
+
warning.hidden = false;
|
|
77
|
+
warning.textContent = message.text;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
62
80
|
run.disabled = false;
|
|
63
81
|
if (message.type === 'failure') {
|
|
64
82
|
status.textContent = 'The compiler failed.';
|
|
@@ -67,7 +85,10 @@ worker.onmessage = (event: MessageEvent<FromWorker>) => {
|
|
|
67
85
|
return;
|
|
68
86
|
}
|
|
69
87
|
const seconds = (performance.now() - (started.get(message.id) ?? performance.now())) / 1000;
|
|
70
|
-
|
|
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('; ');
|
|
71
92
|
diagnostics.hidden = message.ok || message.output === '';
|
|
72
93
|
diagnostics.textContent = message.output;
|
|
73
94
|
if (!message.ok || !message.wasm) {
|
|
@@ -76,18 +97,35 @@ worker.onmessage = (event: MessageEvent<FromWorker>) => {
|
|
|
76
97
|
return;
|
|
77
98
|
}
|
|
78
99
|
status.textContent = `Compiled and linked in ${seconds.toFixed(1)} s (${steps}).`;
|
|
79
|
-
if (
|
|
100
|
+
if (frozenUrl) URL.revokeObjectURL(frozenUrl);
|
|
101
|
+
frozenUrl = null;
|
|
80
102
|
moduleUrl = URL.createObjectURL(new Blob([message.wasm], { type: 'application/wasm' }));
|
|
81
103
|
frame.src = `${import.meta.env.BASE_URL}app/?app=${APP}&module=${encodeURIComponent(moduleUrl)}`;
|
|
82
104
|
};
|
|
83
105
|
|
|
84
106
|
function compile(): void {
|
|
85
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
|
+
}
|
|
86
124
|
diagnostics.hidden = true;
|
|
87
125
|
status.textContent = 'Compiling…';
|
|
88
126
|
const id = nextId++;
|
|
89
127
|
started.set(id, performance.now());
|
|
90
|
-
const message: ToWorker = { type: 'compile', id,
|
|
128
|
+
const message: ToWorker = { type: 'compile', id, source, app: { name: APP, files: { ...files } } };
|
|
91
129
|
worker.postMessage(message);
|
|
92
130
|
}
|
|
93
131
|
run.addEventListener('click', compile);
|
|
@@ -27,4 +27,5 @@ main { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 16px; pa
|
|
|
27
27
|
#run:disabled { opacity: 0.5; cursor: default; }
|
|
28
28
|
#status { color: var(--muted); font-size: 13px; }
|
|
29
29
|
#diagnostics { margin: 0; padding: 12px; border: 1px solid var(--error); border-radius: 8px; background: var(--panel); color: var(--error); font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow: auto; max-height: 320px; }
|
|
30
|
+
#warning { margin: 0; padding: 8px 12px; border: 1px solid var(--error); border-radius: 8px; background: var(--panel); font-size: 13px; }
|
|
30
31
|
.preview iframe { width: 100%; height: 860px; border: none; border-radius: 12px; background: var(--panel); }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Inputs a tool reads whole once, dropped as soon as it has. The linker
|
|
2
|
+
// reads each archive into its own memory and keeps it there, so after that
|
|
3
|
+
// the worker's copy is a second one, a hundred megabytes for an app's
|
|
4
|
+
// archives, at the point where a phone's browser has least to spare.
|
|
5
|
+
|
|
6
|
+
import { File, OpenFile, wasi } from '@bjorn3/browser_wasi_shim';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A read-only file whose bytes go once every open of it is closed and one
|
|
10
|
+
* read all of them. Its size stays. Opening it after that fails
|
|
11
|
+
* (`onReopen` is told), since its bytes are gone: the tool has to run again
|
|
12
|
+
* with them kept.
|
|
13
|
+
*/
|
|
14
|
+
export class ReadOnceFile extends File {
|
|
15
|
+
private readonly length: bigint;
|
|
16
|
+
private open = 0;
|
|
17
|
+
private wholeRead = false;
|
|
18
|
+
released = false;
|
|
19
|
+
|
|
20
|
+
constructor(data: Uint8Array, private readonly onReopen: () => void) {
|
|
21
|
+
// The buffer, not the view, so the File keeps the bytes rather than a copy.
|
|
22
|
+
super(data.byteOffset === 0 && data.byteLength === data.buffer.byteLength ? data.buffer : data, { readonly: true });
|
|
23
|
+
this.length = BigInt(data.byteLength);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override get size(): bigint {
|
|
27
|
+
return this.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override path_open(oflags: number, fs_rights_base: bigint, fd_flags: number): { ret: number; fd_obj: OpenFile } {
|
|
31
|
+
if (this.released) {
|
|
32
|
+
this.onReopen();
|
|
33
|
+
return { ret: wasi.ERRNO_IO, fd_obj: null as unknown as OpenFile };
|
|
34
|
+
}
|
|
35
|
+
const opened = super.path_open(oflags, fs_rights_base, fd_flags);
|
|
36
|
+
if (opened.ret !== wasi.ERRNO_SUCCESS) return opened;
|
|
37
|
+
this.open += 1;
|
|
38
|
+
return { ret: opened.ret, fd_obj: new ReadOnceOpenFile(this) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How an open of it ended: whether it read every byte. */
|
|
42
|
+
closed(readAll: boolean): void {
|
|
43
|
+
this.open -= 1;
|
|
44
|
+
this.wholeRead ||= readAll;
|
|
45
|
+
if (this.open === 0 && this.wholeRead) {
|
|
46
|
+
this.data = new Uint8Array(0);
|
|
47
|
+
this.released = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class ReadOnceOpenFile extends OpenFile {
|
|
53
|
+
private bytesRead = 0;
|
|
54
|
+
|
|
55
|
+
constructor(private readonly once: ReadOnceFile) {
|
|
56
|
+
super(once);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override fd_read(size: number): { ret: number; data: Uint8Array } {
|
|
60
|
+
const result = super.fd_read(size);
|
|
61
|
+
this.bytesRead += result.data.byteLength;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override fd_pread(size: number, offset: bigint): { ret: number; data: Uint8Array } {
|
|
66
|
+
const result = super.fd_pread(size, offset);
|
|
67
|
+
this.bytesRead += result.data.byteLength;
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
override fd_close(): number {
|
|
72
|
+
this.once.closed(BigInt(this.bytesRead) >= this.once.size);
|
|
73
|
+
return super.fd_close();
|
|
74
|
+
}
|
|
75
|
+
}
|