@swiftbrowser/web 0.1.0
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/README.md +600 -0
- package/app/index.html +125 -0
- package/index.html +56 -0
- package/node/dist/node/apps.d.ts +58 -0
- package/node/dist/node/apps.d.ts.map +1 -0
- package/node/dist/node/apps.js +60 -0
- package/node/dist/node/apps.js.map +1 -0
- package/node/dist/node/config.d.ts +42 -0
- package/node/dist/node/config.d.ts.map +1 -0
- package/node/dist/node/config.js +99 -0
- package/node/dist/node/config.js.map +1 -0
- package/node/dist/node/examples.d.ts +39 -0
- package/node/dist/node/examples.d.ts.map +1 -0
- package/node/dist/node/examples.js +116 -0
- package/node/dist/node/examples.js.map +1 -0
- package/node/dist/node/index.d.ts +47 -0
- package/node/dist/node/index.d.ts.map +1 -0
- package/node/dist/node/index.js +52 -0
- package/node/dist/node/index.js.map +1 -0
- package/node/dist/plugins/app-manifests.d.ts +61 -0
- package/node/dist/plugins/app-manifests.d.ts.map +1 -0
- package/node/dist/plugins/app-manifests.js +168 -0
- package/node/dist/plugins/app-manifests.js.map +1 -0
- package/node/dist/plugins/asset-catalogs.d.ts +119 -0
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
- package/node/dist/plugins/asset-catalogs.js +488 -0
- package/node/dist/plugins/asset-catalogs.js.map +1 -0
- package/node/dist/plugins/bundle-resources.d.ts +49 -0
- package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
- package/node/dist/plugins/bundle-resources.js +172 -0
- package/node/dist/plugins/bundle-resources.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +33 -0
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
- package/node/dist/plugins/swift-wasm.js +241 -0
- package/node/dist/plugins/swift-wasm.js.map +1 -0
- package/node/dist/src/assets.d.ts +60 -0
- package/node/dist/src/assets.d.ts.map +1 -0
- package/node/dist/src/assets.js +54 -0
- package/node/dist/src/assets.js.map +1 -0
- package/node/dist/src/devEvents.d.ts +31 -0
- package/node/dist/src/devEvents.d.ts.map +1 -0
- package/node/dist/src/devEvents.js +9 -0
- package/node/dist/src/devEvents.js.map +1 -0
- package/package.json +56 -0
- package/plugins/app-manifests.ts +192 -0
- package/plugins/asset-catalogs.ts +558 -0
- package/plugins/bundle-resources.ts +195 -0
- package/plugins/swift-wasm.ts +259 -0
- package/public/_headers +23 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon.svg +14 -0
- package/public/manifest.webmanifest +14 -0
- package/src/animation.ts +521 -0
- package/src/assets.ts +86 -0
- package/src/chartDom.ts +481 -0
- package/src/devEvents.ts +35 -0
- package/src/deviceMode.ts +80 -0
- package/src/ghost.ts +25 -0
- package/src/interaction.ts +1113 -0
- package/src/landing.css +261 -0
- package/src/landing.ts +141 -0
- package/src/layout/chart.ts +477 -0
- package/src/layout/controls.ts +116 -0
- package/src/layout/engine.ts +3581 -0
- package/src/layout/index.ts +91 -0
- package/src/layout/measure.ts +86 -0
- package/src/layout/text.ts +124 -0
- package/src/layout/types.ts +153 -0
- package/src/layout/typography.ts +188 -0
- package/src/layoutDom.ts +549 -0
- package/src/layoutEvents.ts +110 -0
- package/src/locale.ts +64 -0
- package/src/main.ts +554 -0
- package/src/mock.ts +752 -0
- package/src/nodeQueries.ts +60 -0
- package/src/protocol.ts +1421 -0
- package/src/renderer.ts +2882 -0
- package/src/styles.css +2834 -0
- package/src/styles.ts +373 -0
- package/src/swipe.ts +110 -0
- package/src/symbols.ts +595 -0
- package/src/typography.ts +76 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wasm.ts +400 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's resource files (the Xcode target's Copy Bundle Resources phase,
|
|
3
|
+
* or `resources` in swiftbrowser.json) as one manifest at `/bundle/<App>.json`
|
|
4
|
+
* (`{ files: [{ path, size }] }`) plus the files at `/bundle/<App>/<path>`,
|
|
5
|
+
* dev server and `vite build` alike. The page fetches them before loading the
|
|
6
|
+
* module and mounts them at `/bundle` in the WASI filesystem, where the shim's
|
|
7
|
+
* `Bundle.main` finds them (docs/ops-protocol.md, "Phase 11").
|
|
8
|
+
*
|
|
9
|
+
* A resource entry that is a file is published under its base name, like
|
|
10
|
+
* Xcode copies it; a directory keeps its name and structure (a folder
|
|
11
|
+
* reference). Asset catalogs are not resources here: plugins/asset-catalogs.ts
|
|
12
|
+
* turns them into the catalog manifest.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import type { Plugin } from 'vite';
|
|
18
|
+
import type { AppSpec } from '../node/apps.js';
|
|
19
|
+
|
|
20
|
+
export const BUNDLE_DIR = 'bundle';
|
|
21
|
+
|
|
22
|
+
export interface BundleFile {
|
|
23
|
+
/** Path inside the bundle, `/`-separated (`missions.json`, `Data/notes.txt`). */
|
|
24
|
+
path: string;
|
|
25
|
+
size: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BundleManifest {
|
|
29
|
+
files: BundleFile[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The fields of an {@link AppSpec} this plugin reads. */
|
|
33
|
+
export type BundleSpec = Pick<AppSpec, 'name' | 'resources'>;
|
|
34
|
+
|
|
35
|
+
export interface BuiltBundle {
|
|
36
|
+
manifest: BundleManifest;
|
|
37
|
+
/** Published path (`bundle/App/missions.json`) → source file on disk. */
|
|
38
|
+
files: Map<string, string>;
|
|
39
|
+
warnings: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PUBLISHED = /^[A-Za-z0-9_.\-]+$/;
|
|
43
|
+
|
|
44
|
+
/** URL path of an app's bundle manifest. */
|
|
45
|
+
export function bundleManifestPath(app: string): string {
|
|
46
|
+
return `/${BUNDLE_DIR}/${app}.json`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** URL path of a file inside an app's bundle. */
|
|
50
|
+
export function bundleFilePath(app: string, file: string): string {
|
|
51
|
+
return `/${BUNDLE_DIR}/${app}/${file.split('/').map(encodeURIComponent).join('/')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walk(dir: string, prefix: string, into: (relative: string, file: string) => void): void {
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
56
|
+
if (entry.name.startsWith('.')) continue;
|
|
57
|
+
const full = path.join(dir, entry.name);
|
|
58
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
59
|
+
if (entry.isDirectory()) walk(full, relative, into);
|
|
60
|
+
else if (entry.isFile()) into(relative, full);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lists the resources of a spec as published paths and sources. */
|
|
65
|
+
export function buildBundle(app: string, resources: readonly string[] | undefined): BuiltBundle {
|
|
66
|
+
const files = new Map<string, string>();
|
|
67
|
+
const manifest: BundleManifest = { files: [] };
|
|
68
|
+
const warnings: string[] = [];
|
|
69
|
+
const add = (relative: string, source: string): void => {
|
|
70
|
+
if (!relative.split('/').every((part) => PUBLISHED.test(part))) {
|
|
71
|
+
warnings.push(`${relative}: resource names may use letters, digits, ".", "_" and "-" only; skipped`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (manifest.files.some((f) => f.path === relative)) {
|
|
75
|
+
warnings.push(`${relative}: two resources publish under the same name; the first one wins`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
manifest.files.push({ path: relative, size: statSync(source).size });
|
|
79
|
+
files.set(`${BUNDLE_DIR}/${app}/${relative}`, source);
|
|
80
|
+
};
|
|
81
|
+
for (const resource of resources ?? []) {
|
|
82
|
+
if (!existsSync(resource)) {
|
|
83
|
+
warnings.push(`${resource}: not found`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (statSync(resource).isDirectory()) walk(resource, path.basename(resource), add);
|
|
87
|
+
else add(path.basename(resource), resource);
|
|
88
|
+
}
|
|
89
|
+
return { manifest, files, warnings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function bundleManifestJson(manifest: BundleManifest): string {
|
|
93
|
+
return JSON.stringify(manifest, null, 2) + '\n';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The app named by a manifest request URL (`/bundle/<App>.json`), or null. */
|
|
97
|
+
export function bundleRequestApp(url: string): string | null {
|
|
98
|
+
const match = new RegExp(`^/${BUNDLE_DIR}/([A-Za-z0-9_-]+)\\.json(?:\\?|$)`).exec(url);
|
|
99
|
+
return match?.[1] ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The app and published path of a resource request (`/bundle/<App>/<path>`), or null. */
|
|
103
|
+
export function bundleRequestFile(url: string): { app: string; fileName: string } | null {
|
|
104
|
+
const match = new RegExp(`^/${BUNDLE_DIR}/([A-Za-z0-9_-]+)/([^?]+)(?:\\?|$)`).exec(url);
|
|
105
|
+
if (!match?.[1] || !match[2]) return null;
|
|
106
|
+
let relative: string;
|
|
107
|
+
try {
|
|
108
|
+
relative = match[2].split('/').map(decodeURIComponent).join('/');
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (!relative.split('/').every((part) => PUBLISHED.test(part) && part !== '.' && part !== '..')) return null;
|
|
113
|
+
return { app: match[1], fileName: `${BUNDLE_DIR}/${match[1]}/${relative}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
117
|
+
'.json': 'application/json',
|
|
118
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
119
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
120
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
121
|
+
'.xml': 'application/xml',
|
|
122
|
+
'.plist': 'application/xml',
|
|
123
|
+
'.strings': 'text/plain; charset=utf-8',
|
|
124
|
+
'.png': 'image/png',
|
|
125
|
+
'.jpg': 'image/jpeg',
|
|
126
|
+
'.jpeg': 'image/jpeg',
|
|
127
|
+
'.gif': 'image/gif',
|
|
128
|
+
'.svg': 'image/svg+xml',
|
|
129
|
+
'.pdf': 'application/pdf',
|
|
130
|
+
'.mp3': 'audio/mpeg',
|
|
131
|
+
'.m4a': 'audio/mp4',
|
|
132
|
+
'.wav': 'audio/wav',
|
|
133
|
+
'.mp4': 'video/mp4',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function bundleContentType(file: string): string {
|
|
137
|
+
return CONTENT_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function bundleResources(specs: readonly BundleSpec[]): Plugin {
|
|
141
|
+
const byName = new Map(specs.map((spec) => [spec.name, spec]));
|
|
142
|
+
return {
|
|
143
|
+
name: 'swiftbrowser:bundle-resources',
|
|
144
|
+
configureServer(server) {
|
|
145
|
+
const warned = new Set<string>();
|
|
146
|
+
const build = (spec: BundleSpec): BuiltBundle => {
|
|
147
|
+
const built = buildBundle(spec.name, spec.resources);
|
|
148
|
+
for (const warning of built.warnings) {
|
|
149
|
+
if (warned.has(warning)) continue;
|
|
150
|
+
warned.add(warning);
|
|
151
|
+
server.config.logger.warn(`[bundle-resources] ${spec.name}: ${warning}`, { timestamp: true });
|
|
152
|
+
}
|
|
153
|
+
return built;
|
|
154
|
+
};
|
|
155
|
+
server.middlewares.use((req, res, next) => {
|
|
156
|
+
const url = req.url ?? '';
|
|
157
|
+
const app = bundleRequestApp(url);
|
|
158
|
+
if (app !== null) {
|
|
159
|
+
const spec = byName.get(app);
|
|
160
|
+
if (!spec) {
|
|
161
|
+
res.statusCode = 404;
|
|
162
|
+
res.end();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
res.setHeader('Content-Type', 'application/json');
|
|
166
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
167
|
+
res.end(bundleManifestJson(build(spec).manifest));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const file = bundleRequestFile(url);
|
|
171
|
+
if (file === null) return next();
|
|
172
|
+
const spec = byName.get(file.app);
|
|
173
|
+
const source = spec ? build(spec).files.get(file.fileName) : undefined;
|
|
174
|
+
if (!source) {
|
|
175
|
+
res.statusCode = 404;
|
|
176
|
+
res.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
res.setHeader('Content-Type', bundleContentType(source));
|
|
180
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
181
|
+
res.end(readFileSync(source));
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
generateBundle() {
|
|
185
|
+
for (const spec of specs) {
|
|
186
|
+
const built = buildBundle(spec.name, spec.resources);
|
|
187
|
+
for (const warning of built.warnings) this.warn(`${spec.name}: ${warning}`);
|
|
188
|
+
this.emitFile({ type: 'asset', fileName: `${BUNDLE_DIR}/${spec.name}.json`, source: bundleManifestJson(built.manifest) });
|
|
189
|
+
for (const [fileName, source] of built.files) {
|
|
190
|
+
this.emitFile({ type: 'asset', fileName, source: readFileSync(source) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite plugin for the registered apps' Wasm modules.
|
|
3
|
+
*
|
|
4
|
+
* Dev server:
|
|
5
|
+
* - Serves `/<name>.wasm` from each spec's `wasm` file (`application/wasm`,
|
|
6
|
+
* never cached). When the file is missing, or its sources changed since the
|
|
7
|
+
* server started, and the spec has a `build`, the request builds it first.
|
|
8
|
+
* - Watches every spec's `watch` directories through the dev server's own
|
|
9
|
+
* chokidar watcher, debounces changes (300ms) and runs `spec.build()` for
|
|
10
|
+
* every app whose sources changed among those the page has requested since
|
|
11
|
+
* the server started (`/<name>.wasm` requests register an app). Builds run
|
|
12
|
+
* one at a time.
|
|
13
|
+
* - Sends `sb:build-start` / `sb:build-end` websocket events so the page can
|
|
14
|
+
* show a "Rebuilding…" indicator, then a `full-reload` on success or an
|
|
15
|
+
* `sb:build-error` with the compiler output on failure. The page stashes the
|
|
16
|
+
* app's `@State` snapshot before the reload and feeds it back through the
|
|
17
|
+
* `SB_STATE` WASI environment variable (see src/main.ts).
|
|
18
|
+
*
|
|
19
|
+
* `vite build`: copies each spec's `wasm` into the output directory as
|
|
20
|
+
* `<name>.wasm` (building it first when it is missing and the spec can), so
|
|
21
|
+
* nothing has to ride along in `public/`. Files that are there anyway are
|
|
22
|
+
* still copied by Vite as before.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
|
28
|
+
import type { AppSpec } from '../node/apps.js';
|
|
29
|
+
import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from '../src/devEvents.js';
|
|
30
|
+
|
|
31
|
+
export interface SwiftWasmOptions {
|
|
32
|
+
/** Debounce window for bursts of file changes, in ms. */
|
|
33
|
+
debounceMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The app named by a module request URL (`/<name>.wasm`), or null. */
|
|
37
|
+
export function wasmRequestApp(url: string): string | null {
|
|
38
|
+
const match = /^\/([A-Za-z0-9_-]+)\.wasm(?:\?|$)/.exec(url);
|
|
39
|
+
return match?.[1] ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BatchResult {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
/** The failed app and its compiler output, when `ok` is false. */
|
|
45
|
+
failure?: { app: string; output: string };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The message of a rejected `build()`: the compiler output. */
|
|
49
|
+
function failureOutput(error: unknown): string {
|
|
50
|
+
if (error instanceof Error) return error.message;
|
|
51
|
+
return String(error);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function swiftWasm(specs: readonly AppSpec[], options: SwiftWasmOptions = {}): Plugin {
|
|
55
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
56
|
+
const byName = new Map(specs.map((spec) => [spec.name, spec]));
|
|
57
|
+
|
|
58
|
+
let config: ResolvedConfig | undefined;
|
|
59
|
+
let server: ViteDevServer | undefined;
|
|
60
|
+
|
|
61
|
+
const log = (message: string): void => {
|
|
62
|
+
config?.logger.info(`[swift-wasm] ${message}`, { timestamp: true });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
// Dev server: build queue
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** Apps requested since the server started; their sources trigger rebuilds. */
|
|
70
|
+
const tracked = new Set<string>();
|
|
71
|
+
/** Tracked apps whose sources changed since their last build. */
|
|
72
|
+
const dirty = new Set<string>();
|
|
73
|
+
/** Untracked apps whose sources changed: rebuilt before the first request serves them. */
|
|
74
|
+
const stale = new Set<string>();
|
|
75
|
+
/** The build in flight or queued for an app, so a request can wait for it instead of starting another. */
|
|
76
|
+
const queued = new Map<string, Promise<BatchResult>>();
|
|
77
|
+
/** Builds run one at a time (SwiftPM shares one `.build` directory). */
|
|
78
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
79
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
|
|
81
|
+
const runBatch = async (targets: string[]): Promise<BatchResult> => {
|
|
82
|
+
const started = Date.now();
|
|
83
|
+
log(`building ${targets.join(', ')}…`);
|
|
84
|
+
server?.ws.send('sb:build-start', { apps: targets } satisfies BuildStartPayload);
|
|
85
|
+
const result: BatchResult = { ok: true };
|
|
86
|
+
for (const app of targets) {
|
|
87
|
+
const spec = byName.get(app);
|
|
88
|
+
if (!spec?.build) continue;
|
|
89
|
+
try {
|
|
90
|
+
await spec.build();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const output = failureOutput(error);
|
|
93
|
+
result.ok = false;
|
|
94
|
+
result.failure = { app, output };
|
|
95
|
+
log(`build failed for ${app}`);
|
|
96
|
+
for (const line of output.split('\n')) if (line.trim()) log(` ${line}`);
|
|
97
|
+
server?.ws.send('sb:build-error', { app, output } satisfies BuildErrorPayload);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const durationMs = Date.now() - started;
|
|
102
|
+
server?.ws.send('sb:build-end', { apps: targets, ok: result.ok, durationMs } satisfies BuildEndPayload);
|
|
103
|
+
if (result.ok) {
|
|
104
|
+
log(`built ${targets.join(', ')} in ${(durationMs / 1000).toFixed(1)}s; reloading`);
|
|
105
|
+
server?.ws.send({ type: 'full-reload', path: '*' });
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const enqueue = (targets: string[]): Promise<BatchResult> => {
|
|
111
|
+
const batch = queue.then(() => runBatch(targets));
|
|
112
|
+
queue = batch.catch(() => undefined);
|
|
113
|
+
for (const app of targets) {
|
|
114
|
+
queued.set(app, batch);
|
|
115
|
+
void batch.finally(() => {
|
|
116
|
+
if (queued.get(app) === batch) queued.delete(app);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return batch;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Waits for the build of `app` if one is queued, else queues one. */
|
|
123
|
+
const ensureBuilt = (app: string): Promise<BatchResult> => queued.get(app) ?? enqueue([app]);
|
|
124
|
+
|
|
125
|
+
const flushDirty = (): void => {
|
|
126
|
+
const targets = Array.from(dirty).filter((app) => !queued.has(app));
|
|
127
|
+
dirty.clear();
|
|
128
|
+
if (targets.length === 0) return;
|
|
129
|
+
void enqueue(targets);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const schedule = (): void => {
|
|
133
|
+
if (timer) clearTimeout(timer);
|
|
134
|
+
timer = setTimeout(() => {
|
|
135
|
+
timer = undefined;
|
|
136
|
+
flushDirty();
|
|
137
|
+
}, debounceMs);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
// Dev server: serving `/<name>.wasm`
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const serveWasm = async (
|
|
145
|
+
spec: AppSpec,
|
|
146
|
+
res: import('node:http').ServerResponse,
|
|
147
|
+
method: string | undefined,
|
|
148
|
+
): Promise<void> => {
|
|
149
|
+
if (spec.build && (!existsSync(spec.wasm) || stale.has(spec.name))) {
|
|
150
|
+
if (!existsSync(spec.wasm)) log(`${spec.name}: ${path.basename(spec.wasm)} is missing; building it first`);
|
|
151
|
+
stale.delete(spec.name);
|
|
152
|
+
const result = await ensureBuilt(spec.name);
|
|
153
|
+
if (!result.ok && !existsSync(spec.wasm)) {
|
|
154
|
+
res.statusCode = 500;
|
|
155
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
156
|
+
res.end(`Build failed for ${spec.name}:\n${result.failure?.output ?? ''}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!existsSync(spec.wasm)) {
|
|
161
|
+
res.statusCode = 404;
|
|
162
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
163
|
+
res.end(`${spec.name}: ${spec.wasm} does not exist`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
res.statusCode = 200;
|
|
167
|
+
res.setHeader('Content-Type', 'application/wasm');
|
|
168
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
169
|
+
res.setHeader('Content-Length', String(statSync(spec.wasm).size));
|
|
170
|
+
if (method === 'HEAD') {
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
createReadStream(spec.wasm).pipe(res);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// -------------------------------------------------------------------------
|
|
178
|
+
// `vite build`: copy the modules into the bundle
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
const copyModules = async (outDir: string): Promise<void> => {
|
|
182
|
+
for (const spec of specs) {
|
|
183
|
+
if (!existsSync(spec.wasm) && spec.build) {
|
|
184
|
+
log(`${spec.name}: ${spec.wasm} is missing; building it`);
|
|
185
|
+
await spec.build();
|
|
186
|
+
}
|
|
187
|
+
if (!existsSync(spec.wasm)) {
|
|
188
|
+
if (spec.build) throw new Error(`[swift-wasm] ${spec.name}: ${spec.wasm} still does not exist after building it`);
|
|
189
|
+
config?.logger.warn(`[swift-wasm] ${spec.name}: ${spec.wasm} does not exist; the site has no module for it`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
mkdirSync(outDir, { recursive: true });
|
|
193
|
+
const dest = path.join(outDir, `${spec.name}.wasm`);
|
|
194
|
+
if (path.resolve(spec.wasm) !== path.resolve(dest)) copyFileSync(spec.wasm, dest);
|
|
195
|
+
}
|
|
196
|
+
// Modules of apps outside the registry (the examples' `public/*.wasm`,
|
|
197
|
+
// copied with the public dir) are not part of this site.
|
|
198
|
+
const known = new Set(specs.map((spec) => `${spec.name}.wasm`));
|
|
199
|
+
for (const entry of readdirSync(outDir)) {
|
|
200
|
+
if (entry.endsWith('.wasm') && !known.has(entry)) rmSync(path.join(outDir, entry), { force: true });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
name: 'swiftbrowser:swift-wasm',
|
|
206
|
+
configResolved(resolved) {
|
|
207
|
+
config = resolved;
|
|
208
|
+
},
|
|
209
|
+
configureServer(devServer) {
|
|
210
|
+
server = devServer;
|
|
211
|
+
const watched = specs.flatMap((spec) =>
|
|
212
|
+
(spec.watch ?? []).map((dir) => ({ app: spec.name, root: path.resolve(dir) })),
|
|
213
|
+
);
|
|
214
|
+
const appsOf = (file: string): string[] =>
|
|
215
|
+
file.endsWith('.swift')
|
|
216
|
+
? watched.filter(({ root }) => file.startsWith(root + path.sep)).map(({ app }) => app)
|
|
217
|
+
: [];
|
|
218
|
+
|
|
219
|
+
devServer.middlewares.use((req, res, next) => {
|
|
220
|
+
const name = wasmRequestApp(req.url ?? '');
|
|
221
|
+
const spec = name === null ? undefined : byName.get(name);
|
|
222
|
+
// Unknown names fall through to Vite (a stray file in `public/`, or a 404).
|
|
223
|
+
if (!spec) return next();
|
|
224
|
+
if (!tracked.has(spec.name)) {
|
|
225
|
+
tracked.add(spec.name);
|
|
226
|
+
log(`tracking ${spec.name} for rebuilds`);
|
|
227
|
+
}
|
|
228
|
+
serveWasm(spec, res, req.method).catch((error: unknown) => {
|
|
229
|
+
config?.logger.error(`[swift-wasm] ${spec.name}: ${failureOutput(error)}`);
|
|
230
|
+
if (!res.headersSent) res.statusCode = 500;
|
|
231
|
+
res.end();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const roots = Array.from(new Set(watched.map(({ root }) => root))).filter((root) => existsSync(root));
|
|
236
|
+
if (roots.length > 0) devServer.watcher.add(roots);
|
|
237
|
+
devServer.watcher.on('all', (event, file) => {
|
|
238
|
+
if (event === 'addDir' || event === 'unlinkDir') return;
|
|
239
|
+
const apps = appsOf(file);
|
|
240
|
+
if (apps.length === 0) return;
|
|
241
|
+
log(`${event} ${file}`);
|
|
242
|
+
for (const app of new Set(apps)) {
|
|
243
|
+
if (!byName.get(app)?.build) continue;
|
|
244
|
+
if (tracked.has(app)) dirty.add(app);
|
|
245
|
+
else stale.add(app);
|
|
246
|
+
}
|
|
247
|
+
if (dirty.size > 0) schedule();
|
|
248
|
+
});
|
|
249
|
+
if (roots.length > 0) {
|
|
250
|
+
log(`watching ${roots.join(', ')} for .swift changes (apps: ${specs.map((spec) => spec.name).join(', ')})`);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
async writeBundle(outputOptions) {
|
|
254
|
+
if (!config) return;
|
|
255
|
+
const outDir = outputOptions.dir ?? path.resolve(config.root, config.build.outDir);
|
|
256
|
+
await copyModules(outDir);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
package/public/_headers
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Cloudflare Pages headers (https://developers.cloudflare.com/pages/configuration/headers/)
|
|
2
|
+
|
|
3
|
+
# The Wasm modules are not content-hashed: always revalidate them.
|
|
4
|
+
/*.wasm
|
|
5
|
+
Cache-Control: no-cache
|
|
6
|
+
Content-Type: application/wasm
|
|
7
|
+
|
|
8
|
+
# Vite's hashed bundles can be cached forever.
|
|
9
|
+
/assets/*
|
|
10
|
+
Cache-Control: public, max-age=31536000, immutable
|
|
11
|
+
|
|
12
|
+
# Web app manifests (one per example, see plugins/app-manifests.ts).
|
|
13
|
+
/manifest.webmanifest
|
|
14
|
+
Content-Type: application/manifest+json
|
|
15
|
+
Cache-Control: no-cache
|
|
16
|
+
/manifests/*
|
|
17
|
+
Content-Type: application/manifest+json
|
|
18
|
+
Cache-Control: no-cache
|
|
19
|
+
|
|
20
|
+
# Asset catalog manifests and files (one directory per example, see
|
|
21
|
+
# plugins/asset-catalogs.ts). Not content-hashed: always revalidate.
|
|
22
|
+
/catalog/*
|
|
23
|
+
Cache-Control: no-cache
|
|
Binary file
|
package/public/icon.svg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" width="180" height="180">
|
|
2
|
+
<!-- SwiftBrowser: a phone outline on the iOS accent blue. The same picture is
|
|
3
|
+
drawn as apple-touch-icon.png (iOS masks the corners itself). -->
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
6
|
+
<stop offset="0" stop-color="#2f9bff"/>
|
|
7
|
+
<stop offset="1" stop-color="#0060df"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<rect width="180" height="180" fill="url(#bg)"/>
|
|
11
|
+
<rect x="55" y="30" width="70" height="120" rx="14" ry="14" fill="none" stroke="#ffffff" stroke-width="8"/>
|
|
12
|
+
<rect x="76" y="42" width="28" height="6" rx="3" ry="3" fill="#ffffff"/>
|
|
13
|
+
<circle cx="90" cy="92" r="16" fill="#ffffff"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "SwiftBrowser",
|
|
3
|
+
"short_name": "SwiftBrowser",
|
|
4
|
+
"description": "SwiftUI example apps compiled to WebAssembly, rendered in the browser.",
|
|
5
|
+
"start_url": "/app/",
|
|
6
|
+
"scope": "/",
|
|
7
|
+
"display": "standalone",
|
|
8
|
+
"orientation": "portrait",
|
|
9
|
+
"background_color": "#000000",
|
|
10
|
+
"icons": [
|
|
11
|
+
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" },
|
|
12
|
+
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
|
13
|
+
]
|
|
14
|
+
}
|