@panyam/tsappkit 0.1.0 → 0.2.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/dist/wasmhost/index.d.mts +48 -0
- package/dist/wasmhost/index.d.ts +48 -0
- package/dist/wasmhost/index.js +152 -0
- package/dist/wasmhost/index.js.map +1 -0
- package/dist/wasmhost/index.mjs +145 -0
- package/dist/wasmhost/index.mjs.map +1 -0
- package/dist/wasmhost/worker.js +46 -0
- package/dist/wasmhost/worker.js.map +1 -0
- package/package.json +8 -1
- package/src/wasmhost/client.ts +144 -0
- package/src/wasmhost/drop.ts +74 -0
- package/src/wasmhost/index.ts +3 -0
- package/src/wasmhost/protocol.ts +24 -0
- package/src/wasmhost/worker.ts +81 -0
- package/src/page/IslandPage.test.ts +0 -83
- package/src/page/mount.test.ts +0 -73
- package/src/page/spec.test.ts +0 -65
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** A mount's files, keyed by slash-separated path within the mount. */
|
|
2
|
+
type Files = Record<string, Uint8Array>;
|
|
3
|
+
|
|
4
|
+
/** Where the worker's pieces are served, and the global name the Go side exports under. */
|
|
5
|
+
interface StartWorkerOptions {
|
|
6
|
+
/** The built worker script, @panyam/tsappkit/wasmhost/worker.js, as your bundler serves it. */
|
|
7
|
+
worker: string | URL;
|
|
8
|
+
/** The Go wasm build. */
|
|
9
|
+
wasm: string | URL;
|
|
10
|
+
/** Go's wasm_exec.js, from `$(go env GOROOT)/lib/wasm`, matching the Go that built the wasm. */
|
|
11
|
+
exec: string | URL;
|
|
12
|
+
/** The ns the Go program passed to wasmhost.Serve. */
|
|
13
|
+
ns: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Starts the worker and resolves with it once the Go program has installed its exports, or rejects
|
|
17
|
+
* with why it couldn't load. Relative URLs resolve against the page, not the worker script.
|
|
18
|
+
*/
|
|
19
|
+
declare function startWorker(opts: StartWorkerOptions): Promise<Worker>;
|
|
20
|
+
/**
|
|
21
|
+
* A fetch that answers in the worker, for `createConnectTransport({ baseUrl, fetch: workerFetch(worker) })`.
|
|
22
|
+
* Only the path and query reach the worker, so baseUrl can be anything absolute, such as
|
|
23
|
+
* location.origin. It rejects if the worker reports an error (no handler, a failed build, a handler
|
|
24
|
+
* panic) and resolves with any HTTP status the handler chose.
|
|
25
|
+
*/
|
|
26
|
+
declare function workerFetch(worker: Worker): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
27
|
+
/**
|
|
28
|
+
* Replaces the mount `name` with `files`. The buffers are transferred to the worker, not copied, so
|
|
29
|
+
* each Uint8Array is empty here afterwards; copy first anything the page still needs. Resolves once
|
|
30
|
+
* the worker has mounted them (and, for a rebuild-mode host, rebuilt its handler).
|
|
31
|
+
*/
|
|
32
|
+
declare function mountFiles(worker: Worker, name: string, files: Files): Promise<void>;
|
|
33
|
+
/** Removes the mount `name`. Removing a name that isn't mounted succeeds. */
|
|
34
|
+
declare function unmountFiles(worker: Worker, name: string): Promise<void>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads a drop into files keyed by path. A dropped folder keeps its own name as the first path
|
|
38
|
+
* element ("design/board.edn"), and dropped loose files sit at the top. Entries the browser can't
|
|
39
|
+
* read as files or folders are skipped.
|
|
40
|
+
*/
|
|
41
|
+
declare function filesFromDrop(dt: DataTransfer): Promise<Files>;
|
|
42
|
+
/**
|
|
43
|
+
* Reads an `<input type="file" webkitdirectory>` (or `multiple`) selection into files keyed by
|
|
44
|
+
* path. Folder picks use each file's webkitRelativePath, which starts with the folder's name.
|
|
45
|
+
*/
|
|
46
|
+
declare function filesFromFileList(list: FileList | File[]): Promise<Files>;
|
|
47
|
+
|
|
48
|
+
export { type Files, type StartWorkerOptions, filesFromDrop, filesFromFileList, mountFiles, startWorker, unmountFiles, workerFetch };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** A mount's files, keyed by slash-separated path within the mount. */
|
|
2
|
+
type Files = Record<string, Uint8Array>;
|
|
3
|
+
|
|
4
|
+
/** Where the worker's pieces are served, and the global name the Go side exports under. */
|
|
5
|
+
interface StartWorkerOptions {
|
|
6
|
+
/** The built worker script, @panyam/tsappkit/wasmhost/worker.js, as your bundler serves it. */
|
|
7
|
+
worker: string | URL;
|
|
8
|
+
/** The Go wasm build. */
|
|
9
|
+
wasm: string | URL;
|
|
10
|
+
/** Go's wasm_exec.js, from `$(go env GOROOT)/lib/wasm`, matching the Go that built the wasm. */
|
|
11
|
+
exec: string | URL;
|
|
12
|
+
/** The ns the Go program passed to wasmhost.Serve. */
|
|
13
|
+
ns: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Starts the worker and resolves with it once the Go program has installed its exports, or rejects
|
|
17
|
+
* with why it couldn't load. Relative URLs resolve against the page, not the worker script.
|
|
18
|
+
*/
|
|
19
|
+
declare function startWorker(opts: StartWorkerOptions): Promise<Worker>;
|
|
20
|
+
/**
|
|
21
|
+
* A fetch that answers in the worker, for `createConnectTransport({ baseUrl, fetch: workerFetch(worker) })`.
|
|
22
|
+
* Only the path and query reach the worker, so baseUrl can be anything absolute, such as
|
|
23
|
+
* location.origin. It rejects if the worker reports an error (no handler, a failed build, a handler
|
|
24
|
+
* panic) and resolves with any HTTP status the handler chose.
|
|
25
|
+
*/
|
|
26
|
+
declare function workerFetch(worker: Worker): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
27
|
+
/**
|
|
28
|
+
* Replaces the mount `name` with `files`. The buffers are transferred to the worker, not copied, so
|
|
29
|
+
* each Uint8Array is empty here afterwards; copy first anything the page still needs. Resolves once
|
|
30
|
+
* the worker has mounted them (and, for a rebuild-mode host, rebuilt its handler).
|
|
31
|
+
*/
|
|
32
|
+
declare function mountFiles(worker: Worker, name: string, files: Files): Promise<void>;
|
|
33
|
+
/** Removes the mount `name`. Removing a name that isn't mounted succeeds. */
|
|
34
|
+
declare function unmountFiles(worker: Worker, name: string): Promise<void>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads a drop into files keyed by path. A dropped folder keeps its own name as the first path
|
|
38
|
+
* element ("design/board.edn"), and dropped loose files sit at the top. Entries the browser can't
|
|
39
|
+
* read as files or folders are skipped.
|
|
40
|
+
*/
|
|
41
|
+
declare function filesFromDrop(dt: DataTransfer): Promise<Files>;
|
|
42
|
+
/**
|
|
43
|
+
* Reads an `<input type="file" webkitdirectory>` (or `multiple`) selection into files keyed by
|
|
44
|
+
* path. Folder picks use each file's webkitRelativePath, which starts with the folder's name.
|
|
45
|
+
*/
|
|
46
|
+
declare function filesFromFileList(list: FileList | File[]): Promise<Files>;
|
|
47
|
+
|
|
48
|
+
export { type Files, type StartWorkerOptions, filesFromDrop, filesFromFileList, mountFiles, startWorker, unmountFiles, workerFetch };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/wasmhost/client.ts
|
|
4
|
+
var Channel = class {
|
|
5
|
+
constructor(worker) {
|
|
6
|
+
this.worker = worker;
|
|
7
|
+
this.next = 1;
|
|
8
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
9
|
+
this.dead = null;
|
|
10
|
+
worker.addEventListener("message", (ev) => {
|
|
11
|
+
const m = ev.data;
|
|
12
|
+
if ("exited" in m) {
|
|
13
|
+
this.fail(new Error(`wasm host: ${m.exited}`));
|
|
14
|
+
} else if ("id" in m) {
|
|
15
|
+
this.pending.get(m.id)?.resolve(m);
|
|
16
|
+
this.pending.delete(m.id);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
worker.addEventListener("error", (ev) => this.fail(new Error(`wasm host worker: ${ev.message}`)));
|
|
20
|
+
}
|
|
21
|
+
call(req, transfer) {
|
|
22
|
+
if (this.dead) return Promise.reject(this.dead);
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const id = this.next++;
|
|
25
|
+
this.pending.set(id, { resolve, reject });
|
|
26
|
+
this.worker.postMessage({ ...req, id }, transfer);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
fail(err) {
|
|
30
|
+
this.dead = err;
|
|
31
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
32
|
+
this.pending.clear();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var channels = /* @__PURE__ */ new WeakMap();
|
|
36
|
+
function channel(worker) {
|
|
37
|
+
let c = channels.get(worker);
|
|
38
|
+
if (!c) {
|
|
39
|
+
c = new Channel(worker);
|
|
40
|
+
channels.set(worker, c);
|
|
41
|
+
}
|
|
42
|
+
return c;
|
|
43
|
+
}
|
|
44
|
+
function startWorker(opts) {
|
|
45
|
+
const abs = (u) => new URL(u, location.href).href;
|
|
46
|
+
const url = new URL(abs(opts.worker));
|
|
47
|
+
url.searchParams.set("wasm", abs(opts.wasm));
|
|
48
|
+
url.searchParams.set("exec", abs(opts.exec));
|
|
49
|
+
url.searchParams.set("ns", opts.ns);
|
|
50
|
+
const worker = new Worker(url);
|
|
51
|
+
channel(worker);
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const onMessage = (ev) => {
|
|
54
|
+
const m = ev.data;
|
|
55
|
+
if (!("ready" in m)) return;
|
|
56
|
+
worker.removeEventListener("message", onMessage);
|
|
57
|
+
if (m.ready) resolve(worker);
|
|
58
|
+
else reject(new Error(`wasm host failed to load: ${m.error}`));
|
|
59
|
+
};
|
|
60
|
+
worker.addEventListener("message", onMessage);
|
|
61
|
+
worker.addEventListener("error", (ev) => reject(new Error(`wasm host worker: ${ev.message}`)), {
|
|
62
|
+
once: true
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
var NULL_BODY = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
67
|
+
function workerFetch(worker) {
|
|
68
|
+
const c = channel(worker);
|
|
69
|
+
return async (input, init) => {
|
|
70
|
+
const req = new Request(input, init);
|
|
71
|
+
const buf = new Uint8Array(await req.arrayBuffer());
|
|
72
|
+
const body = buf.byteLength ? buf : null;
|
|
73
|
+
const headers = {};
|
|
74
|
+
req.headers.forEach((v, k) => headers[k] = v);
|
|
75
|
+
const u = new URL(req.url);
|
|
76
|
+
const reply = await c.call(
|
|
77
|
+
{ kind: "http", method: req.method, url: u.pathname + u.search, headers, body },
|
|
78
|
+
body ? [body.buffer] : []
|
|
79
|
+
);
|
|
80
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
81
|
+
const status = reply.status ?? 200;
|
|
82
|
+
const resBody = NULL_BODY.has(status) ? null : reply.body ?? null;
|
|
83
|
+
return new Response(resBody, { status, headers: reply.headers });
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function mountFiles(worker, name, files) {
|
|
87
|
+
const transfer = /* @__PURE__ */ new Set();
|
|
88
|
+
for (const b of Object.values(files)) {
|
|
89
|
+
if (Object.prototype.toString.call(b.buffer) === "[object ArrayBuffer]") transfer.add(b.buffer);
|
|
90
|
+
}
|
|
91
|
+
const reply = await channel(worker).call({ kind: "mount", name, files }, [...transfer]);
|
|
92
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
93
|
+
}
|
|
94
|
+
async function unmountFiles(worker, name) {
|
|
95
|
+
const reply = await channel(worker).call({ kind: "unmount", name }, []);
|
|
96
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/wasmhost/drop.ts
|
|
100
|
+
async function filesFromDrop(dt) {
|
|
101
|
+
const entries = [];
|
|
102
|
+
const loose = [];
|
|
103
|
+
for (const item of Array.from(dt.items)) {
|
|
104
|
+
if (item.kind !== "file") continue;
|
|
105
|
+
const entry = item.webkitGetAsEntry?.();
|
|
106
|
+
if (entry) entries.push(entry);
|
|
107
|
+
else {
|
|
108
|
+
const f = item.getAsFile();
|
|
109
|
+
if (f) loose.push(f);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const out = {};
|
|
113
|
+
await Promise.all([
|
|
114
|
+
...entries.map((e) => readEntry(e, out)),
|
|
115
|
+
...loose.map(async (f) => {
|
|
116
|
+
out[f.name] = new Uint8Array(await f.arrayBuffer());
|
|
117
|
+
})
|
|
118
|
+
]);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
async function filesFromFileList(list) {
|
|
122
|
+
const out = {};
|
|
123
|
+
await Promise.all(
|
|
124
|
+
Array.from(list).map(async (f) => {
|
|
125
|
+
out[f.webkitRelativePath || f.name] = new Uint8Array(await f.arrayBuffer());
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
async function readEntry(entry, out) {
|
|
131
|
+
if (entry.isFile) {
|
|
132
|
+
const f = await new Promise((ok, err) => entry.file(ok, err));
|
|
133
|
+
out[entry.fullPath.replace(/^\/+/, "")] = new Uint8Array(await f.arrayBuffer());
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!entry.isDirectory) return;
|
|
137
|
+
const reader = entry.createReader();
|
|
138
|
+
for (; ; ) {
|
|
139
|
+
const batch = await new Promise((ok, err) => reader.readEntries(ok, err));
|
|
140
|
+
if (batch.length === 0) return;
|
|
141
|
+
await Promise.all(batch.map((e) => readEntry(e, out)));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
exports.filesFromDrop = filesFromDrop;
|
|
146
|
+
exports.filesFromFileList = filesFromFileList;
|
|
147
|
+
exports.mountFiles = mountFiles;
|
|
148
|
+
exports.startWorker = startWorker;
|
|
149
|
+
exports.unmountFiles = unmountFiles;
|
|
150
|
+
exports.workerFetch = workerFetch;
|
|
151
|
+
//# sourceMappingURL=index.js.map
|
|
152
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/wasmhost/client.ts","../../src/wasmhost/drop.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// The page's side of a wasm host worker (goapplib issue 32). startWorker loads the app's Go wasm in\n// a Web Worker; workerFetch is a fetch to hand a Connect transport, so the app's generated clients\n// talk to the worker exactly as they would to a server; mountFiles pushes files in before the\n// requests that read them.\nimport type { Files, HostReply, HostRequest, HostStatus } from \"./protocol\";\n\nexport type { Files } from \"./protocol\";\n\n/** Where the worker's pieces are served, and the global name the Go side exports under. */\nexport interface StartWorkerOptions {\n /** The built worker script, @panyam/tsappkit/wasmhost/worker.js, as your bundler serves it. */\n worker: string | URL;\n /** The Go wasm build. */\n wasm: string | URL;\n /** Go's wasm_exec.js, from `$(go env GOROOT)/lib/wasm`, matching the Go that built the wasm. */\n exec: string | URL;\n /** The ns the Go program passed to wasmhost.Serve. */\n ns: string;\n}\n\ntype Unsent = HostRequest extends infer R ? (R extends HostRequest ? Omit<R, \"id\"> : never) : never;\n\nclass Channel {\n private next = 1;\n private pending = new Map<number, { resolve: (r: HostReply) => void; reject: (e: Error) => void }>();\n private dead: Error | null = null;\n\n constructor(private worker: Worker) {\n worker.addEventListener(\"message\", (ev: MessageEvent<HostReply | HostStatus>) => {\n const m = ev.data;\n if (\"exited\" in m) {\n this.fail(new Error(`wasm host: ${m.exited}`));\n } else if (\"id\" in m) {\n this.pending.get(m.id)?.resolve(m);\n this.pending.delete(m.id);\n }\n });\n worker.addEventListener(\"error\", (ev: ErrorEvent) => this.fail(new Error(`wasm host worker: ${ev.message}`)));\n }\n\n call(req: Unsent, transfer: Transferable[]): Promise<HostReply> {\n if (this.dead) return Promise.reject(this.dead);\n return new Promise((resolve, reject) => {\n const id = this.next++;\n this.pending.set(id, { resolve, reject });\n this.worker.postMessage({ ...req, id }, transfer);\n });\n }\n\n private fail(err: Error) {\n this.dead = err;\n for (const p of this.pending.values()) p.reject(err);\n this.pending.clear();\n }\n}\n\nconst channels = new WeakMap<Worker, Channel>();\n\nfunction channel(worker: Worker): Channel {\n let c = channels.get(worker);\n if (!c) {\n c = new Channel(worker);\n channels.set(worker, c);\n }\n return c;\n}\n\n/**\n * Starts the worker and resolves with it once the Go program has installed its exports, or rejects\n * with why it couldn't load. Relative URLs resolve against the page, not the worker script.\n */\nexport function startWorker(opts: StartWorkerOptions): Promise<Worker> {\n const abs = (u: string | URL) => new URL(u, location.href).href;\n const url = new URL(abs(opts.worker));\n url.searchParams.set(\"wasm\", abs(opts.wasm));\n url.searchParams.set(\"exec\", abs(opts.exec));\n url.searchParams.set(\"ns\", opts.ns);\n const worker = new Worker(url);\n channel(worker);\n return new Promise((resolve, reject) => {\n const onMessage = (ev: MessageEvent<HostStatus | HostReply>) => {\n const m = ev.data;\n if (!(\"ready\" in m)) return;\n worker.removeEventListener(\"message\", onMessage);\n if (m.ready) resolve(worker);\n else reject(new Error(`wasm host failed to load: ${m.error}`));\n };\n worker.addEventListener(\"message\", onMessage);\n worker.addEventListener(\"error\", (ev: ErrorEvent) => reject(new Error(`wasm host worker: ${ev.message}`)), {\n once: true,\n });\n });\n}\n\n// Statuses whose Response must have no body.\nconst NULL_BODY = new Set([101, 103, 204, 205, 304]);\n\n/**\n * A fetch that answers in the worker, for `createConnectTransport({ baseUrl, fetch: workerFetch(worker) })`.\n * Only the path and query reach the worker, so baseUrl can be anything absolute, such as\n * location.origin. It rejects if the worker reports an error (no handler, a failed build, a handler\n * panic) and resolves with any HTTP status the handler chose.\n */\nexport function workerFetch(worker: Worker): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {\n const c = channel(worker);\n return async (input, init) => {\n const req = new Request(input, init);\n const buf = new Uint8Array(await req.arrayBuffer());\n const body = buf.byteLength ? buf : null;\n const headers: Record<string, string> = {};\n req.headers.forEach((v, k) => (headers[k] = v));\n const u = new URL(req.url);\n const reply = await c.call(\n { kind: \"http\", method: req.method, url: u.pathname + u.search, headers, body },\n body ? [body.buffer] : [],\n );\n if (!reply.ok) throw new Error(reply.error);\n const status = reply.status ?? 200;\n const resBody = NULL_BODY.has(status) ? null : ((reply.body ?? null) as BodyInit | null);\n return new Response(resBody, { status, headers: reply.headers });\n };\n}\n\n/**\n * Replaces the mount `name` with `files`. The buffers are transferred to the worker, not copied, so\n * each Uint8Array is empty here afterwards; copy first anything the page still needs. Resolves once\n * the worker has mounted them (and, for a rebuild-mode host, rebuilt its handler).\n */\nexport async function mountFiles(worker: Worker, name: string, files: Files): Promise<void> {\n const transfer = new Set<ArrayBuffer>();\n for (const b of Object.values(files)) {\n // A tag check rather than instanceof, which fails for a buffer from another realm (an iframe),\n // and a SharedArrayBuffer can't be transferred.\n if (Object.prototype.toString.call(b.buffer) === \"[object ArrayBuffer]\") transfer.add(b.buffer as ArrayBuffer);\n }\n const reply = await channel(worker).call({ kind: \"mount\", name, files }, [...transfer]);\n if (!reply.ok) throw new Error(reply.error);\n}\n\n/** Removes the mount `name`. Removing a name that isn't mounted succeeds. */\nexport async function unmountFiles(worker: Worker, name: string): Promise<void> {\n const reply = await channel(worker).call({ kind: \"unmount\", name }, []);\n if (!reply.ok) throw new Error(reply.error);\n}\n","// Turning what a user drops or picks into the {path: bytes} map mountFiles takes. The bytes are\n// read here, before any request, because the Go side can't wait on File.arrayBuffer() mid-request.\nimport type { Files } from \"./protocol\";\n\n// The parts of the File and Directory Entries API used here. Chromium, Firefox and Safari all ship\n// it under the webkit prefix.\ninterface Entry {\n isFile: boolean;\n isDirectory: boolean;\n fullPath: string;\n}\ninterface FileEntry extends Entry {\n file(ok: (f: File) => void, err: (e: unknown) => void): void;\n}\ninterface DirectoryEntry extends Entry {\n createReader(): { readEntries(ok: (es: Entry[]) => void, err: (e: unknown) => void): void };\n}\n\n/**\n * Reads a drop into files keyed by path. A dropped folder keeps its own name as the first path\n * element (\"design/board.edn\"), and dropped loose files sit at the top. Entries the browser can't\n * read as files or folders are skipped.\n */\nexport async function filesFromDrop(dt: DataTransfer): Promise<Files> {\n const entries: Entry[] = [];\n const loose: File[] = [];\n for (const item of Array.from(dt.items)) {\n if (item.kind !== \"file\") continue;\n const entry = (item as DataTransferItem & { webkitGetAsEntry?: () => Entry | null }).webkitGetAsEntry?.();\n if (entry) entries.push(entry);\n else {\n const f = item.getAsFile();\n if (f) loose.push(f);\n }\n }\n const out: Files = {};\n await Promise.all([\n ...entries.map((e) => readEntry(e, out)),\n ...loose.map(async (f) => {\n out[f.name] = new Uint8Array(await f.arrayBuffer());\n }),\n ]);\n return out;\n}\n\n/**\n * Reads an `<input type=\"file\" webkitdirectory>` (or `multiple`) selection into files keyed by\n * path. Folder picks use each file's webkitRelativePath, which starts with the folder's name.\n */\nexport async function filesFromFileList(list: FileList | File[]): Promise<Files> {\n const out: Files = {};\n await Promise.all(\n Array.from(list).map(async (f) => {\n out[f.webkitRelativePath || f.name] = new Uint8Array(await f.arrayBuffer());\n }),\n );\n return out;\n}\n\nasync function readEntry(entry: Entry, out: Files): Promise<void> {\n if (entry.isFile) {\n const f = await new Promise<File>((ok, err) => (entry as FileEntry).file(ok, err));\n out[entry.fullPath.replace(/^\\/+/, \"\")] = new Uint8Array(await f.arrayBuffer());\n return;\n }\n if (!entry.isDirectory) return;\n const reader = (entry as DirectoryEntry).createReader();\n // readEntries returns a batch at a time (100 in Chromium) and an empty batch at the end.\n for (;;) {\n const batch = await new Promise<Entry[]>((ok, err) => reader.readEntries(ok, err));\n if (batch.length === 0) return;\n await Promise.all(batch.map((e) => readEntry(e, out)));\n }\n}\n"]}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/wasmhost/client.ts
|
|
2
|
+
var Channel = class {
|
|
3
|
+
constructor(worker) {
|
|
4
|
+
this.worker = worker;
|
|
5
|
+
this.next = 1;
|
|
6
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
7
|
+
this.dead = null;
|
|
8
|
+
worker.addEventListener("message", (ev) => {
|
|
9
|
+
const m = ev.data;
|
|
10
|
+
if ("exited" in m) {
|
|
11
|
+
this.fail(new Error(`wasm host: ${m.exited}`));
|
|
12
|
+
} else if ("id" in m) {
|
|
13
|
+
this.pending.get(m.id)?.resolve(m);
|
|
14
|
+
this.pending.delete(m.id);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
worker.addEventListener("error", (ev) => this.fail(new Error(`wasm host worker: ${ev.message}`)));
|
|
18
|
+
}
|
|
19
|
+
call(req, transfer) {
|
|
20
|
+
if (this.dead) return Promise.reject(this.dead);
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const id = this.next++;
|
|
23
|
+
this.pending.set(id, { resolve, reject });
|
|
24
|
+
this.worker.postMessage({ ...req, id }, transfer);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
fail(err) {
|
|
28
|
+
this.dead = err;
|
|
29
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
30
|
+
this.pending.clear();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var channels = /* @__PURE__ */ new WeakMap();
|
|
34
|
+
function channel(worker) {
|
|
35
|
+
let c = channels.get(worker);
|
|
36
|
+
if (!c) {
|
|
37
|
+
c = new Channel(worker);
|
|
38
|
+
channels.set(worker, c);
|
|
39
|
+
}
|
|
40
|
+
return c;
|
|
41
|
+
}
|
|
42
|
+
function startWorker(opts) {
|
|
43
|
+
const abs = (u) => new URL(u, location.href).href;
|
|
44
|
+
const url = new URL(abs(opts.worker));
|
|
45
|
+
url.searchParams.set("wasm", abs(opts.wasm));
|
|
46
|
+
url.searchParams.set("exec", abs(opts.exec));
|
|
47
|
+
url.searchParams.set("ns", opts.ns);
|
|
48
|
+
const worker = new Worker(url);
|
|
49
|
+
channel(worker);
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const onMessage = (ev) => {
|
|
52
|
+
const m = ev.data;
|
|
53
|
+
if (!("ready" in m)) return;
|
|
54
|
+
worker.removeEventListener("message", onMessage);
|
|
55
|
+
if (m.ready) resolve(worker);
|
|
56
|
+
else reject(new Error(`wasm host failed to load: ${m.error}`));
|
|
57
|
+
};
|
|
58
|
+
worker.addEventListener("message", onMessage);
|
|
59
|
+
worker.addEventListener("error", (ev) => reject(new Error(`wasm host worker: ${ev.message}`)), {
|
|
60
|
+
once: true
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
var NULL_BODY = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
65
|
+
function workerFetch(worker) {
|
|
66
|
+
const c = channel(worker);
|
|
67
|
+
return async (input, init) => {
|
|
68
|
+
const req = new Request(input, init);
|
|
69
|
+
const buf = new Uint8Array(await req.arrayBuffer());
|
|
70
|
+
const body = buf.byteLength ? buf : null;
|
|
71
|
+
const headers = {};
|
|
72
|
+
req.headers.forEach((v, k) => headers[k] = v);
|
|
73
|
+
const u = new URL(req.url);
|
|
74
|
+
const reply = await c.call(
|
|
75
|
+
{ kind: "http", method: req.method, url: u.pathname + u.search, headers, body },
|
|
76
|
+
body ? [body.buffer] : []
|
|
77
|
+
);
|
|
78
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
79
|
+
const status = reply.status ?? 200;
|
|
80
|
+
const resBody = NULL_BODY.has(status) ? null : reply.body ?? null;
|
|
81
|
+
return new Response(resBody, { status, headers: reply.headers });
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function mountFiles(worker, name, files) {
|
|
85
|
+
const transfer = /* @__PURE__ */ new Set();
|
|
86
|
+
for (const b of Object.values(files)) {
|
|
87
|
+
if (Object.prototype.toString.call(b.buffer) === "[object ArrayBuffer]") transfer.add(b.buffer);
|
|
88
|
+
}
|
|
89
|
+
const reply = await channel(worker).call({ kind: "mount", name, files }, [...transfer]);
|
|
90
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
91
|
+
}
|
|
92
|
+
async function unmountFiles(worker, name) {
|
|
93
|
+
const reply = await channel(worker).call({ kind: "unmount", name }, []);
|
|
94
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/wasmhost/drop.ts
|
|
98
|
+
async function filesFromDrop(dt) {
|
|
99
|
+
const entries = [];
|
|
100
|
+
const loose = [];
|
|
101
|
+
for (const item of Array.from(dt.items)) {
|
|
102
|
+
if (item.kind !== "file") continue;
|
|
103
|
+
const entry = item.webkitGetAsEntry?.();
|
|
104
|
+
if (entry) entries.push(entry);
|
|
105
|
+
else {
|
|
106
|
+
const f = item.getAsFile();
|
|
107
|
+
if (f) loose.push(f);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const out = {};
|
|
111
|
+
await Promise.all([
|
|
112
|
+
...entries.map((e) => readEntry(e, out)),
|
|
113
|
+
...loose.map(async (f) => {
|
|
114
|
+
out[f.name] = new Uint8Array(await f.arrayBuffer());
|
|
115
|
+
})
|
|
116
|
+
]);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
async function filesFromFileList(list) {
|
|
120
|
+
const out = {};
|
|
121
|
+
await Promise.all(
|
|
122
|
+
Array.from(list).map(async (f) => {
|
|
123
|
+
out[f.webkitRelativePath || f.name] = new Uint8Array(await f.arrayBuffer());
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
async function readEntry(entry, out) {
|
|
129
|
+
if (entry.isFile) {
|
|
130
|
+
const f = await new Promise((ok, err) => entry.file(ok, err));
|
|
131
|
+
out[entry.fullPath.replace(/^\/+/, "")] = new Uint8Array(await f.arrayBuffer());
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!entry.isDirectory) return;
|
|
135
|
+
const reader = entry.createReader();
|
|
136
|
+
for (; ; ) {
|
|
137
|
+
const batch = await new Promise((ok, err) => reader.readEntries(ok, err));
|
|
138
|
+
if (batch.length === 0) return;
|
|
139
|
+
await Promise.all(batch.map((e) => readEntry(e, out)));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { filesFromDrop, filesFromFileList, mountFiles, startWorker, unmountFiles, workerFetch };
|
|
144
|
+
//# sourceMappingURL=index.mjs.map
|
|
145
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/wasmhost/client.ts","../../src/wasmhost/drop.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["// The page's side of a wasm host worker (goapplib issue 32). startWorker loads the app's Go wasm in\n// a Web Worker; workerFetch is a fetch to hand a Connect transport, so the app's generated clients\n// talk to the worker exactly as they would to a server; mountFiles pushes files in before the\n// requests that read them.\nimport type { Files, HostReply, HostRequest, HostStatus } from \"./protocol\";\n\nexport type { Files } from \"./protocol\";\n\n/** Where the worker's pieces are served, and the global name the Go side exports under. */\nexport interface StartWorkerOptions {\n /** The built worker script, @panyam/tsappkit/wasmhost/worker.js, as your bundler serves it. */\n worker: string | URL;\n /** The Go wasm build. */\n wasm: string | URL;\n /** Go's wasm_exec.js, from `$(go env GOROOT)/lib/wasm`, matching the Go that built the wasm. */\n exec: string | URL;\n /** The ns the Go program passed to wasmhost.Serve. */\n ns: string;\n}\n\ntype Unsent = HostRequest extends infer R ? (R extends HostRequest ? Omit<R, \"id\"> : never) : never;\n\nclass Channel {\n private next = 1;\n private pending = new Map<number, { resolve: (r: HostReply) => void; reject: (e: Error) => void }>();\n private dead: Error | null = null;\n\n constructor(private worker: Worker) {\n worker.addEventListener(\"message\", (ev: MessageEvent<HostReply | HostStatus>) => {\n const m = ev.data;\n if (\"exited\" in m) {\n this.fail(new Error(`wasm host: ${m.exited}`));\n } else if (\"id\" in m) {\n this.pending.get(m.id)?.resolve(m);\n this.pending.delete(m.id);\n }\n });\n worker.addEventListener(\"error\", (ev: ErrorEvent) => this.fail(new Error(`wasm host worker: ${ev.message}`)));\n }\n\n call(req: Unsent, transfer: Transferable[]): Promise<HostReply> {\n if (this.dead) return Promise.reject(this.dead);\n return new Promise((resolve, reject) => {\n const id = this.next++;\n this.pending.set(id, { resolve, reject });\n this.worker.postMessage({ ...req, id }, transfer);\n });\n }\n\n private fail(err: Error) {\n this.dead = err;\n for (const p of this.pending.values()) p.reject(err);\n this.pending.clear();\n }\n}\n\nconst channels = new WeakMap<Worker, Channel>();\n\nfunction channel(worker: Worker): Channel {\n let c = channels.get(worker);\n if (!c) {\n c = new Channel(worker);\n channels.set(worker, c);\n }\n return c;\n}\n\n/**\n * Starts the worker and resolves with it once the Go program has installed its exports, or rejects\n * with why it couldn't load. Relative URLs resolve against the page, not the worker script.\n */\nexport function startWorker(opts: StartWorkerOptions): Promise<Worker> {\n const abs = (u: string | URL) => new URL(u, location.href).href;\n const url = new URL(abs(opts.worker));\n url.searchParams.set(\"wasm\", abs(opts.wasm));\n url.searchParams.set(\"exec\", abs(opts.exec));\n url.searchParams.set(\"ns\", opts.ns);\n const worker = new Worker(url);\n channel(worker);\n return new Promise((resolve, reject) => {\n const onMessage = (ev: MessageEvent<HostStatus | HostReply>) => {\n const m = ev.data;\n if (!(\"ready\" in m)) return;\n worker.removeEventListener(\"message\", onMessage);\n if (m.ready) resolve(worker);\n else reject(new Error(`wasm host failed to load: ${m.error}`));\n };\n worker.addEventListener(\"message\", onMessage);\n worker.addEventListener(\"error\", (ev: ErrorEvent) => reject(new Error(`wasm host worker: ${ev.message}`)), {\n once: true,\n });\n });\n}\n\n// Statuses whose Response must have no body.\nconst NULL_BODY = new Set([101, 103, 204, 205, 304]);\n\n/**\n * A fetch that answers in the worker, for `createConnectTransport({ baseUrl, fetch: workerFetch(worker) })`.\n * Only the path and query reach the worker, so baseUrl can be anything absolute, such as\n * location.origin. It rejects if the worker reports an error (no handler, a failed build, a handler\n * panic) and resolves with any HTTP status the handler chose.\n */\nexport function workerFetch(worker: Worker): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {\n const c = channel(worker);\n return async (input, init) => {\n const req = new Request(input, init);\n const buf = new Uint8Array(await req.arrayBuffer());\n const body = buf.byteLength ? buf : null;\n const headers: Record<string, string> = {};\n req.headers.forEach((v, k) => (headers[k] = v));\n const u = new URL(req.url);\n const reply = await c.call(\n { kind: \"http\", method: req.method, url: u.pathname + u.search, headers, body },\n body ? [body.buffer] : [],\n );\n if (!reply.ok) throw new Error(reply.error);\n const status = reply.status ?? 200;\n const resBody = NULL_BODY.has(status) ? null : ((reply.body ?? null) as BodyInit | null);\n return new Response(resBody, { status, headers: reply.headers });\n };\n}\n\n/**\n * Replaces the mount `name` with `files`. The buffers are transferred to the worker, not copied, so\n * each Uint8Array is empty here afterwards; copy first anything the page still needs. Resolves once\n * the worker has mounted them (and, for a rebuild-mode host, rebuilt its handler).\n */\nexport async function mountFiles(worker: Worker, name: string, files: Files): Promise<void> {\n const transfer = new Set<ArrayBuffer>();\n for (const b of Object.values(files)) {\n // A tag check rather than instanceof, which fails for a buffer from another realm (an iframe),\n // and a SharedArrayBuffer can't be transferred.\n if (Object.prototype.toString.call(b.buffer) === \"[object ArrayBuffer]\") transfer.add(b.buffer as ArrayBuffer);\n }\n const reply = await channel(worker).call({ kind: \"mount\", name, files }, [...transfer]);\n if (!reply.ok) throw new Error(reply.error);\n}\n\n/** Removes the mount `name`. Removing a name that isn't mounted succeeds. */\nexport async function unmountFiles(worker: Worker, name: string): Promise<void> {\n const reply = await channel(worker).call({ kind: \"unmount\", name }, []);\n if (!reply.ok) throw new Error(reply.error);\n}\n","// Turning what a user drops or picks into the {path: bytes} map mountFiles takes. The bytes are\n// read here, before any request, because the Go side can't wait on File.arrayBuffer() mid-request.\nimport type { Files } from \"./protocol\";\n\n// The parts of the File and Directory Entries API used here. Chromium, Firefox and Safari all ship\n// it under the webkit prefix.\ninterface Entry {\n isFile: boolean;\n isDirectory: boolean;\n fullPath: string;\n}\ninterface FileEntry extends Entry {\n file(ok: (f: File) => void, err: (e: unknown) => void): void;\n}\ninterface DirectoryEntry extends Entry {\n createReader(): { readEntries(ok: (es: Entry[]) => void, err: (e: unknown) => void): void };\n}\n\n/**\n * Reads a drop into files keyed by path. A dropped folder keeps its own name as the first path\n * element (\"design/board.edn\"), and dropped loose files sit at the top. Entries the browser can't\n * read as files or folders are skipped.\n */\nexport async function filesFromDrop(dt: DataTransfer): Promise<Files> {\n const entries: Entry[] = [];\n const loose: File[] = [];\n for (const item of Array.from(dt.items)) {\n if (item.kind !== \"file\") continue;\n const entry = (item as DataTransferItem & { webkitGetAsEntry?: () => Entry | null }).webkitGetAsEntry?.();\n if (entry) entries.push(entry);\n else {\n const f = item.getAsFile();\n if (f) loose.push(f);\n }\n }\n const out: Files = {};\n await Promise.all([\n ...entries.map((e) => readEntry(e, out)),\n ...loose.map(async (f) => {\n out[f.name] = new Uint8Array(await f.arrayBuffer());\n }),\n ]);\n return out;\n}\n\n/**\n * Reads an `<input type=\"file\" webkitdirectory>` (or `multiple`) selection into files keyed by\n * path. Folder picks use each file's webkitRelativePath, which starts with the folder's name.\n */\nexport async function filesFromFileList(list: FileList | File[]): Promise<Files> {\n const out: Files = {};\n await Promise.all(\n Array.from(list).map(async (f) => {\n out[f.webkitRelativePath || f.name] = new Uint8Array(await f.arrayBuffer());\n }),\n );\n return out;\n}\n\nasync function readEntry(entry: Entry, out: Files): Promise<void> {\n if (entry.isFile) {\n const f = await new Promise<File>((ok, err) => (entry as FileEntry).file(ok, err));\n out[entry.fullPath.replace(/^\\/+/, \"\")] = new Uint8Array(await f.arrayBuffer());\n return;\n }\n if (!entry.isDirectory) return;\n const reader = (entry as DirectoryEntry).createReader();\n // readEntries returns a batch at a time (100 in Chromium) and an empty batch at the end.\n for (;;) {\n const batch = await new Promise<Entry[]>((ok, err) => reader.readEntries(ok, err));\n if (batch.length === 0) return;\n await Promise.all(batch.map((e) => readEntry(e, out)));\n }\n}\n"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// src/wasmhost/worker.ts
|
|
4
|
+
var params = new URLSearchParams(self.location.search);
|
|
5
|
+
var wasmUrl = params.get("wasm") ?? "app.wasm";
|
|
6
|
+
var execUrl = params.get("exec") ?? "wasm_exec.js";
|
|
7
|
+
var ns = params.get("ns") ?? "wasmhost";
|
|
8
|
+
var post = (m, transfer = []) => self.postMessage(m, transfer);
|
|
9
|
+
async function boot() {
|
|
10
|
+
importScripts(execUrl);
|
|
11
|
+
const go = new self.Go();
|
|
12
|
+
const ready = new Promise((resolve) => {
|
|
13
|
+
self[ns] = { ready: resolve };
|
|
14
|
+
});
|
|
15
|
+
const res = await fetch(wasmUrl);
|
|
16
|
+
if (!res.ok) throw new Error(`fetch ${wasmUrl}: ${res.status}`);
|
|
17
|
+
const { instance } = res.headers.get("content-type")?.startsWith("application/wasm") ? await WebAssembly.instantiateStreaming(res, go.importObject) : await WebAssembly.instantiate(await res.arrayBuffer(), go.importObject);
|
|
18
|
+
void go.run(instance).then(() => post({ exited: "the Go program exited" }));
|
|
19
|
+
await ready;
|
|
20
|
+
return self[ns];
|
|
21
|
+
}
|
|
22
|
+
var booted = boot();
|
|
23
|
+
booted.then(
|
|
24
|
+
() => post({ ready: true }),
|
|
25
|
+
(err) => post({ ready: false, error: String(err) })
|
|
26
|
+
);
|
|
27
|
+
self.onmessage = async (ev) => {
|
|
28
|
+
const req = ev.data;
|
|
29
|
+
try {
|
|
30
|
+
const host = await booted;
|
|
31
|
+
if (req.kind === "http") {
|
|
32
|
+
const res = await host.http(req.method, req.url, req.headers, req.body);
|
|
33
|
+
post({ id: req.id, ok: true, status: res.status, headers: res.headers, body: res.body }, [res.body.buffer]);
|
|
34
|
+
} else if (req.kind === "mount") {
|
|
35
|
+
await host.mount(req.name, req.files);
|
|
36
|
+
post({ id: req.id, ok: true });
|
|
37
|
+
} else {
|
|
38
|
+
await host.unmount(req.name);
|
|
39
|
+
post({ id: req.id, ok: true });
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
post({ id: req.id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/wasmhost/worker.ts"],"sourcesContent":["// The wasm host worker. It loads a Go wasm build that calls goapplib's wasmhost.Serve (or\n// ServeRebuild) and forwards the page's messages to the functions that build exports on\n// globalThis[ns]. Running the Go program here, off the page's thread, keeps a request that takes\n// seconds from freezing the page.\n//\n// It is a classic worker, built as an IIFE, because Go's wasm_exec.js is a plain script defining\n// globalThis.Go, which importScripts can load and a module worker cannot. The asset URLs and the\n// namespace come in the worker's query string (?wasm=&exec=&ns=), which startWorker writes.\nimport type { Files, HostReply, HostRequest, HostStatus } from \"./protocol\";\n\ninterface Exports {\n ready?: () => void;\n http(\n method: string,\n url: string,\n headers: Record<string, string>,\n body: Uint8Array | null,\n ): Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>;\n mount(name: string, files: Files): Promise<void>;\n unmount(name: string): Promise<void>;\n}\n\n// The project compiles against the DOM lib, which describes a window, and the WebWorker lib can't\n// be loaded beside it, so the few worker globals used here are declared.\ndeclare function importScripts(...urls: string[]): void;\ndeclare const self: {\n location: Location;\n postMessage(message: unknown, transfer?: Transferable[]): void;\n onmessage: ((ev: MessageEvent<HostRequest>) => void) | null;\n Go: new () => { importObject: WebAssembly.Imports; run(i: WebAssembly.Instance): Promise<void> };\n} & Record<string, unknown>;\n\nconst params = new URLSearchParams(self.location.search);\nconst wasmUrl = params.get(\"wasm\") ?? \"app.wasm\";\nconst execUrl = params.get(\"exec\") ?? \"wasm_exec.js\";\nconst ns = params.get(\"ns\") ?? \"wasmhost\";\n\nconst post = (m: HostStatus | HostReply, transfer: Transferable[] = []) => self.postMessage(m, transfer);\n\nasync function boot(): Promise<Exports> {\n importScripts(execUrl);\n const go = new self.Go();\n const ready = new Promise<void>((resolve) => {\n self[ns] = { ready: resolve };\n });\n const res = await fetch(wasmUrl);\n if (!res.ok) throw new Error(`fetch ${wasmUrl}: ${res.status}`);\n // instantiateStreaming insists on the application/wasm content type, which not every static\n // server sends.\n const { instance } = res.headers.get(\"content-type\")?.startsWith(\"application/wasm\")\n ? await WebAssembly.instantiateStreaming(res, go.importObject)\n : await WebAssembly.instantiate(await res.arrayBuffer(), go.importObject);\n void go.run(instance).then(() => post({ exited: \"the Go program exited\" }));\n await ready;\n return self[ns] as Exports;\n}\n\nconst booted = boot();\nbooted.then(\n () => post({ ready: true }),\n (err: unknown) => post({ ready: false, error: String(err) }),\n);\n\nself.onmessage = async (ev) => {\n const req = ev.data;\n try {\n const host = await booted;\n if (req.kind === \"http\") {\n const res = await host.http(req.method, req.url, req.headers, req.body);\n post({ id: req.id, ok: true, status: res.status, headers: res.headers, body: res.body }, [res.body.buffer]);\n } else if (req.kind === \"mount\") {\n await host.mount(req.name, req.files);\n post({ id: req.id, ok: true });\n } else {\n await host.unmount(req.name);\n post({ id: req.id, ok: true });\n }\n } catch (err) {\n post({ id: req.id, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n};\n"],"mappings":";;;AAgCA,MAAM,SAAS,IAAI,gBAAgB,KAAK,SAAS,MAAM;AACvD,MAAM,UAAU,OAAO,IAAI,MAAM,KAAK;AACtC,MAAM,UAAU,OAAO,IAAI,MAAM,KAAK;AACtC,MAAM,KAAK,OAAO,IAAI,IAAI,KAAK;AAE/B,MAAM,OAAO,CAAC,GAA2B,WAA2B,CAAC,MAAM,KAAK,YAAY,GAAG,QAAQ;AAEvG,iBAAe,OAAyB;AACtC,kBAAc,OAAO;AACrB,UAAM,KAAK,IAAI,KAAK,GAAG;AACvB,UAAM,QAAQ,IAAI,QAAc,CAAC,YAAY;AAC3C,WAAK,EAAE,IAAI,EAAE,OAAO,QAAQ;AAAA,IAC9B,CAAC;AACD,UAAM,MAAM,MAAM,MAAM,OAAO;AAC/B,QAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,SAAS,OAAO,KAAK,IAAI,MAAM,EAAE;AAG9D,UAAM,EAAE,SAAS,IAAI,IAAI,QAAQ,IAAI,cAAc,GAAG,WAAW,kBAAkB,IAC/E,MAAM,YAAY,qBAAqB,KAAK,GAAG,YAAY,IAC3D,MAAM,YAAY,YAAY,MAAM,IAAI,YAAY,GAAG,GAAG,YAAY;AAC1E,SAAK,GAAG,IAAI,QAAQ,EAAE,KAAK,MAAM,KAAK,EAAE,QAAQ,wBAAwB,CAAC,CAAC;AAC1E,UAAM;AACN,WAAO,KAAK,EAAE;AAAA,EAChB;AAEA,MAAM,SAAS,KAAK;AACpB,SAAO;AAAA,IACL,MAAM,KAAK,EAAE,OAAO,KAAK,CAAC;AAAA,IAC1B,CAAC,QAAiB,KAAK,EAAE,OAAO,OAAO,OAAO,OAAO,GAAG,EAAE,CAAC;AAAA,EAC7D;AAEA,OAAK,YAAY,OAAO,OAAO;AAC7B,UAAM,MAAM,GAAG;AACf,QAAI;AACF,YAAM,OAAO,MAAM;AACnB,UAAI,IAAI,SAAS,QAAQ;AACvB,cAAM,MAAM,MAAM,KAAK,KAAK,IAAI,QAAQ,IAAI,KAAK,IAAI,SAAS,IAAI,IAAI;AACtE,aAAK,EAAE,IAAI,IAAI,IAAI,IAAI,MAAM,QAAQ,IAAI,QAAQ,SAAS,IAAI,SAAS,MAAM,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,MAAM,CAAC;AAAA,MAC5G,WAAW,IAAI,SAAS,SAAS;AAC/B,cAAM,KAAK,MAAM,IAAI,MAAM,IAAI,KAAK;AACpC,aAAK,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC;AAAA,MAC/B,OAAO;AACL,cAAM,KAAK,QAAQ,IAAI,IAAI;AAC3B,aAAK,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC;AAAA,MAC/B;AAAA,IACF,SAAS,KAAK;AACZ,WAAK,EAAE,IAAI,IAAI,IAAI,IAAI,OAAO,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EAAE,CAAC;AAAA,IACzF;AAAA,EACF;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panyam/tsappkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "TypeScript application toolkit with component lifecycle management, event system, UI utilities, and documentation site components",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -16,12 +16,19 @@
|
|
|
16
16
|
"import": "./dist/docs/index.mjs",
|
|
17
17
|
"require": "./dist/docs/index.js"
|
|
18
18
|
},
|
|
19
|
+
"./wasmhost": {
|
|
20
|
+
"types": "./dist/wasmhost/index.d.ts",
|
|
21
|
+
"import": "./dist/wasmhost/index.mjs",
|
|
22
|
+
"require": "./dist/wasmhost/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./wasmhost/worker.js": "./dist/wasmhost/worker.js",
|
|
19
25
|
"./styles/*": "./styles/*",
|
|
20
26
|
"./dist/*": "./dist/*"
|
|
21
27
|
},
|
|
22
28
|
"files": [
|
|
23
29
|
"dist",
|
|
24
30
|
"src",
|
|
31
|
+
"!src/**/*.test.ts",
|
|
25
32
|
"styles"
|
|
26
33
|
],
|
|
27
34
|
"scripts": {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The page's side of a wasm host worker (goapplib issue 32). startWorker loads the app's Go wasm in
|
|
2
|
+
// a Web Worker; workerFetch is a fetch to hand a Connect transport, so the app's generated clients
|
|
3
|
+
// talk to the worker exactly as they would to a server; mountFiles pushes files in before the
|
|
4
|
+
// requests that read them.
|
|
5
|
+
import type { Files, HostReply, HostRequest, HostStatus } from "./protocol";
|
|
6
|
+
|
|
7
|
+
export type { Files } from "./protocol";
|
|
8
|
+
|
|
9
|
+
/** Where the worker's pieces are served, and the global name the Go side exports under. */
|
|
10
|
+
export interface StartWorkerOptions {
|
|
11
|
+
/** The built worker script, @panyam/tsappkit/wasmhost/worker.js, as your bundler serves it. */
|
|
12
|
+
worker: string | URL;
|
|
13
|
+
/** The Go wasm build. */
|
|
14
|
+
wasm: string | URL;
|
|
15
|
+
/** Go's wasm_exec.js, from `$(go env GOROOT)/lib/wasm`, matching the Go that built the wasm. */
|
|
16
|
+
exec: string | URL;
|
|
17
|
+
/** The ns the Go program passed to wasmhost.Serve. */
|
|
18
|
+
ns: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Unsent = HostRequest extends infer R ? (R extends HostRequest ? Omit<R, "id"> : never) : never;
|
|
22
|
+
|
|
23
|
+
class Channel {
|
|
24
|
+
private next = 1;
|
|
25
|
+
private pending = new Map<number, { resolve: (r: HostReply) => void; reject: (e: Error) => void }>();
|
|
26
|
+
private dead: Error | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(private worker: Worker) {
|
|
29
|
+
worker.addEventListener("message", (ev: MessageEvent<HostReply | HostStatus>) => {
|
|
30
|
+
const m = ev.data;
|
|
31
|
+
if ("exited" in m) {
|
|
32
|
+
this.fail(new Error(`wasm host: ${m.exited}`));
|
|
33
|
+
} else if ("id" in m) {
|
|
34
|
+
this.pending.get(m.id)?.resolve(m);
|
|
35
|
+
this.pending.delete(m.id);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
worker.addEventListener("error", (ev: ErrorEvent) => this.fail(new Error(`wasm host worker: ${ev.message}`)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
call(req: Unsent, transfer: Transferable[]): Promise<HostReply> {
|
|
42
|
+
if (this.dead) return Promise.reject(this.dead);
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const id = this.next++;
|
|
45
|
+
this.pending.set(id, { resolve, reject });
|
|
46
|
+
this.worker.postMessage({ ...req, id }, transfer);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private fail(err: Error) {
|
|
51
|
+
this.dead = err;
|
|
52
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
53
|
+
this.pending.clear();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const channels = new WeakMap<Worker, Channel>();
|
|
58
|
+
|
|
59
|
+
function channel(worker: Worker): Channel {
|
|
60
|
+
let c = channels.get(worker);
|
|
61
|
+
if (!c) {
|
|
62
|
+
c = new Channel(worker);
|
|
63
|
+
channels.set(worker, c);
|
|
64
|
+
}
|
|
65
|
+
return c;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Starts the worker and resolves with it once the Go program has installed its exports, or rejects
|
|
70
|
+
* with why it couldn't load. Relative URLs resolve against the page, not the worker script.
|
|
71
|
+
*/
|
|
72
|
+
export function startWorker(opts: StartWorkerOptions): Promise<Worker> {
|
|
73
|
+
const abs = (u: string | URL) => new URL(u, location.href).href;
|
|
74
|
+
const url = new URL(abs(opts.worker));
|
|
75
|
+
url.searchParams.set("wasm", abs(opts.wasm));
|
|
76
|
+
url.searchParams.set("exec", abs(opts.exec));
|
|
77
|
+
url.searchParams.set("ns", opts.ns);
|
|
78
|
+
const worker = new Worker(url);
|
|
79
|
+
channel(worker);
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const onMessage = (ev: MessageEvent<HostStatus | HostReply>) => {
|
|
82
|
+
const m = ev.data;
|
|
83
|
+
if (!("ready" in m)) return;
|
|
84
|
+
worker.removeEventListener("message", onMessage);
|
|
85
|
+
if (m.ready) resolve(worker);
|
|
86
|
+
else reject(new Error(`wasm host failed to load: ${m.error}`));
|
|
87
|
+
};
|
|
88
|
+
worker.addEventListener("message", onMessage);
|
|
89
|
+
worker.addEventListener("error", (ev: ErrorEvent) => reject(new Error(`wasm host worker: ${ev.message}`)), {
|
|
90
|
+
once: true,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Statuses whose Response must have no body.
|
|
96
|
+
const NULL_BODY = new Set([101, 103, 204, 205, 304]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A fetch that answers in the worker, for `createConnectTransport({ baseUrl, fetch: workerFetch(worker) })`.
|
|
100
|
+
* Only the path and query reach the worker, so baseUrl can be anything absolute, such as
|
|
101
|
+
* location.origin. It rejects if the worker reports an error (no handler, a failed build, a handler
|
|
102
|
+
* panic) and resolves with any HTTP status the handler chose.
|
|
103
|
+
*/
|
|
104
|
+
export function workerFetch(worker: Worker): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
|
105
|
+
const c = channel(worker);
|
|
106
|
+
return async (input, init) => {
|
|
107
|
+
const req = new Request(input, init);
|
|
108
|
+
const buf = new Uint8Array(await req.arrayBuffer());
|
|
109
|
+
const body = buf.byteLength ? buf : null;
|
|
110
|
+
const headers: Record<string, string> = {};
|
|
111
|
+
req.headers.forEach((v, k) => (headers[k] = v));
|
|
112
|
+
const u = new URL(req.url);
|
|
113
|
+
const reply = await c.call(
|
|
114
|
+
{ kind: "http", method: req.method, url: u.pathname + u.search, headers, body },
|
|
115
|
+
body ? [body.buffer] : [],
|
|
116
|
+
);
|
|
117
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
118
|
+
const status = reply.status ?? 200;
|
|
119
|
+
const resBody = NULL_BODY.has(status) ? null : ((reply.body ?? null) as BodyInit | null);
|
|
120
|
+
return new Response(resBody, { status, headers: reply.headers });
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Replaces the mount `name` with `files`. The buffers are transferred to the worker, not copied, so
|
|
126
|
+
* each Uint8Array is empty here afterwards; copy first anything the page still needs. Resolves once
|
|
127
|
+
* the worker has mounted them (and, for a rebuild-mode host, rebuilt its handler).
|
|
128
|
+
*/
|
|
129
|
+
export async function mountFiles(worker: Worker, name: string, files: Files): Promise<void> {
|
|
130
|
+
const transfer = new Set<ArrayBuffer>();
|
|
131
|
+
for (const b of Object.values(files)) {
|
|
132
|
+
// A tag check rather than instanceof, which fails for a buffer from another realm (an iframe),
|
|
133
|
+
// and a SharedArrayBuffer can't be transferred.
|
|
134
|
+
if (Object.prototype.toString.call(b.buffer) === "[object ArrayBuffer]") transfer.add(b.buffer as ArrayBuffer);
|
|
135
|
+
}
|
|
136
|
+
const reply = await channel(worker).call({ kind: "mount", name, files }, [...transfer]);
|
|
137
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Removes the mount `name`. Removing a name that isn't mounted succeeds. */
|
|
141
|
+
export async function unmountFiles(worker: Worker, name: string): Promise<void> {
|
|
142
|
+
const reply = await channel(worker).call({ kind: "unmount", name }, []);
|
|
143
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
144
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Turning what a user drops or picks into the {path: bytes} map mountFiles takes. The bytes are
|
|
2
|
+
// read here, before any request, because the Go side can't wait on File.arrayBuffer() mid-request.
|
|
3
|
+
import type { Files } from "./protocol";
|
|
4
|
+
|
|
5
|
+
// The parts of the File and Directory Entries API used here. Chromium, Firefox and Safari all ship
|
|
6
|
+
// it under the webkit prefix.
|
|
7
|
+
interface Entry {
|
|
8
|
+
isFile: boolean;
|
|
9
|
+
isDirectory: boolean;
|
|
10
|
+
fullPath: string;
|
|
11
|
+
}
|
|
12
|
+
interface FileEntry extends Entry {
|
|
13
|
+
file(ok: (f: File) => void, err: (e: unknown) => void): void;
|
|
14
|
+
}
|
|
15
|
+
interface DirectoryEntry extends Entry {
|
|
16
|
+
createReader(): { readEntries(ok: (es: Entry[]) => void, err: (e: unknown) => void): void };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads a drop into files keyed by path. A dropped folder keeps its own name as the first path
|
|
21
|
+
* element ("design/board.edn"), and dropped loose files sit at the top. Entries the browser can't
|
|
22
|
+
* read as files or folders are skipped.
|
|
23
|
+
*/
|
|
24
|
+
export async function filesFromDrop(dt: DataTransfer): Promise<Files> {
|
|
25
|
+
const entries: Entry[] = [];
|
|
26
|
+
const loose: File[] = [];
|
|
27
|
+
for (const item of Array.from(dt.items)) {
|
|
28
|
+
if (item.kind !== "file") continue;
|
|
29
|
+
const entry = (item as DataTransferItem & { webkitGetAsEntry?: () => Entry | null }).webkitGetAsEntry?.();
|
|
30
|
+
if (entry) entries.push(entry);
|
|
31
|
+
else {
|
|
32
|
+
const f = item.getAsFile();
|
|
33
|
+
if (f) loose.push(f);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const out: Files = {};
|
|
37
|
+
await Promise.all([
|
|
38
|
+
...entries.map((e) => readEntry(e, out)),
|
|
39
|
+
...loose.map(async (f) => {
|
|
40
|
+
out[f.name] = new Uint8Array(await f.arrayBuffer());
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reads an `<input type="file" webkitdirectory>` (or `multiple`) selection into files keyed by
|
|
48
|
+
* path. Folder picks use each file's webkitRelativePath, which starts with the folder's name.
|
|
49
|
+
*/
|
|
50
|
+
export async function filesFromFileList(list: FileList | File[]): Promise<Files> {
|
|
51
|
+
const out: Files = {};
|
|
52
|
+
await Promise.all(
|
|
53
|
+
Array.from(list).map(async (f) => {
|
|
54
|
+
out[f.webkitRelativePath || f.name] = new Uint8Array(await f.arrayBuffer());
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readEntry(entry: Entry, out: Files): Promise<void> {
|
|
61
|
+
if (entry.isFile) {
|
|
62
|
+
const f = await new Promise<File>((ok, err) => (entry as FileEntry).file(ok, err));
|
|
63
|
+
out[entry.fullPath.replace(/^\/+/, "")] = new Uint8Array(await f.arrayBuffer());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!entry.isDirectory) return;
|
|
67
|
+
const reader = (entry as DirectoryEntry).createReader();
|
|
68
|
+
// readEntries returns a batch at a time (100 in Chromium) and an empty batch at the end.
|
|
69
|
+
for (;;) {
|
|
70
|
+
const batch = await new Promise<Entry[]>((ok, err) => reader.readEntries(ok, err));
|
|
71
|
+
if (batch.length === 0) return;
|
|
72
|
+
await Promise.all(batch.map((e) => readEntry(e, out)));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Messages between the page (client.ts) and the wasm host worker (worker.ts). One request message
|
|
2
|
+
// and one reply per call, matched by id. Byte buffers travel as transferables, not copies.
|
|
3
|
+
|
|
4
|
+
/** A mount's files, keyed by slash-separated path within the mount. */
|
|
5
|
+
export type Files = Record<string, Uint8Array>;
|
|
6
|
+
|
|
7
|
+
export type HostRequest =
|
|
8
|
+
| {
|
|
9
|
+
id: number;
|
|
10
|
+
kind: "http";
|
|
11
|
+
method: string;
|
|
12
|
+
url: string;
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
body: Uint8Array | null;
|
|
15
|
+
}
|
|
16
|
+
| { id: number; kind: "mount"; name: string; files: Files }
|
|
17
|
+
| { id: number; kind: "unmount"; name: string };
|
|
18
|
+
|
|
19
|
+
export type HostReply =
|
|
20
|
+
| { id: number; ok: true; status?: number; headers?: Record<string, string>; body?: Uint8Array }
|
|
21
|
+
| { id: number; ok: false; error: string };
|
|
22
|
+
|
|
23
|
+
/** Sent once when the wasm has loaded (or failed to), and again if the Go program exits. */
|
|
24
|
+
export type HostStatus = { ready: true } | { ready: false; error: string } | { exited: string };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The wasm host worker. It loads a Go wasm build that calls goapplib's wasmhost.Serve (or
|
|
2
|
+
// ServeRebuild) and forwards the page's messages to the functions that build exports on
|
|
3
|
+
// globalThis[ns]. Running the Go program here, off the page's thread, keeps a request that takes
|
|
4
|
+
// seconds from freezing the page.
|
|
5
|
+
//
|
|
6
|
+
// It is a classic worker, built as an IIFE, because Go's wasm_exec.js is a plain script defining
|
|
7
|
+
// globalThis.Go, which importScripts can load and a module worker cannot. The asset URLs and the
|
|
8
|
+
// namespace come in the worker's query string (?wasm=&exec=&ns=), which startWorker writes.
|
|
9
|
+
import type { Files, HostReply, HostRequest, HostStatus } from "./protocol";
|
|
10
|
+
|
|
11
|
+
interface Exports {
|
|
12
|
+
ready?: () => void;
|
|
13
|
+
http(
|
|
14
|
+
method: string,
|
|
15
|
+
url: string,
|
|
16
|
+
headers: Record<string, string>,
|
|
17
|
+
body: Uint8Array | null,
|
|
18
|
+
): Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>;
|
|
19
|
+
mount(name: string, files: Files): Promise<void>;
|
|
20
|
+
unmount(name: string): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The project compiles against the DOM lib, which describes a window, and the WebWorker lib can't
|
|
24
|
+
// be loaded beside it, so the few worker globals used here are declared.
|
|
25
|
+
declare function importScripts(...urls: string[]): void;
|
|
26
|
+
declare const self: {
|
|
27
|
+
location: Location;
|
|
28
|
+
postMessage(message: unknown, transfer?: Transferable[]): void;
|
|
29
|
+
onmessage: ((ev: MessageEvent<HostRequest>) => void) | null;
|
|
30
|
+
Go: new () => { importObject: WebAssembly.Imports; run(i: WebAssembly.Instance): Promise<void> };
|
|
31
|
+
} & Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
const params = new URLSearchParams(self.location.search);
|
|
34
|
+
const wasmUrl = params.get("wasm") ?? "app.wasm";
|
|
35
|
+
const execUrl = params.get("exec") ?? "wasm_exec.js";
|
|
36
|
+
const ns = params.get("ns") ?? "wasmhost";
|
|
37
|
+
|
|
38
|
+
const post = (m: HostStatus | HostReply, transfer: Transferable[] = []) => self.postMessage(m, transfer);
|
|
39
|
+
|
|
40
|
+
async function boot(): Promise<Exports> {
|
|
41
|
+
importScripts(execUrl);
|
|
42
|
+
const go = new self.Go();
|
|
43
|
+
const ready = new Promise<void>((resolve) => {
|
|
44
|
+
self[ns] = { ready: resolve };
|
|
45
|
+
});
|
|
46
|
+
const res = await fetch(wasmUrl);
|
|
47
|
+
if (!res.ok) throw new Error(`fetch ${wasmUrl}: ${res.status}`);
|
|
48
|
+
// instantiateStreaming insists on the application/wasm content type, which not every static
|
|
49
|
+
// server sends.
|
|
50
|
+
const { instance } = res.headers.get("content-type")?.startsWith("application/wasm")
|
|
51
|
+
? await WebAssembly.instantiateStreaming(res, go.importObject)
|
|
52
|
+
: await WebAssembly.instantiate(await res.arrayBuffer(), go.importObject);
|
|
53
|
+
void go.run(instance).then(() => post({ exited: "the Go program exited" }));
|
|
54
|
+
await ready;
|
|
55
|
+
return self[ns] as Exports;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const booted = boot();
|
|
59
|
+
booted.then(
|
|
60
|
+
() => post({ ready: true }),
|
|
61
|
+
(err: unknown) => post({ ready: false, error: String(err) }),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
self.onmessage = async (ev) => {
|
|
65
|
+
const req = ev.data;
|
|
66
|
+
try {
|
|
67
|
+
const host = await booted;
|
|
68
|
+
if (req.kind === "http") {
|
|
69
|
+
const res = await host.http(req.method, req.url, req.headers, req.body);
|
|
70
|
+
post({ id: req.id, ok: true, status: res.status, headers: res.headers, body: res.body }, [res.body.buffer]);
|
|
71
|
+
} else if (req.kind === "mount") {
|
|
72
|
+
await host.mount(req.name, req.files);
|
|
73
|
+
post({ id: req.id, ok: true });
|
|
74
|
+
} else {
|
|
75
|
+
await host.unmount(req.name);
|
|
76
|
+
post({ id: req.id, ok: true });
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
post({ id: req.id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import type { LCMComponent } from "../LCMComponent";
|
|
3
|
-
import { IslandPage } from "./IslandPage";
|
|
4
|
-
import type { PageSpec } from "./spec";
|
|
5
|
-
|
|
6
|
-
type Ctx = { built: number };
|
|
7
|
-
type Ext = { things: string[] };
|
|
8
|
-
|
|
9
|
-
const mounted: { name: string; el: HTMLElement; config: unknown; ctx: Ctx }[] = [];
|
|
10
|
-
const contexts: (PageSpec & Ext)[] = [];
|
|
11
|
-
|
|
12
|
-
const island = (name: string) => (el: HTMLElement, spec: { config: unknown }, ctx: Ctx) => {
|
|
13
|
-
mounted.push({ name, el, config: spec.config, ctx });
|
|
14
|
-
return { name } as unknown as LCMComponent;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
class TestPage extends IslandPage<Ctx, Ext> {
|
|
18
|
-
protected registry() {
|
|
19
|
-
return { player: island("player"), chat: island("chat") };
|
|
20
|
-
}
|
|
21
|
-
protected readExtension(raw: Record<string, unknown>): Ext {
|
|
22
|
-
return { things: Array.isArray(raw.things) ? raw.things.map(String) : [] };
|
|
23
|
-
}
|
|
24
|
-
protected makeContext(spec: PageSpec & Ext): Ctx {
|
|
25
|
-
contexts.push(spec);
|
|
26
|
-
return { built: contexts.length };
|
|
27
|
-
}
|
|
28
|
-
mount() {
|
|
29
|
-
return this.initializeSpecificComponents();
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function page(spec: string | null, slots: string[]) {
|
|
34
|
-
document.body.innerHTML =
|
|
35
|
-
(spec === null ? "" : `<script type="application/json" id="page-spec">${spec}</script>`) +
|
|
36
|
-
slots.map((s) => `<section data-slot="${s}"></section>`).join("");
|
|
37
|
-
return new TestPage("test");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe("IslandPage", () => {
|
|
41
|
-
let warn: ReturnType<typeof vi.spyOn>;
|
|
42
|
-
beforeEach(() => {
|
|
43
|
-
// jsdom has no matchMedia, and BasePage's ThemeManager asks it for the colour scheme.
|
|
44
|
-
window.matchMedia ??= ((query: string) => ({ matches: false, media: query, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
|
45
|
-
mounted.length = 0;
|
|
46
|
-
contexts.length = 0;
|
|
47
|
-
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
48
|
-
});
|
|
49
|
-
afterEach(() => warn.mockRestore());
|
|
50
|
-
|
|
51
|
-
it("mounts each island from the spec into its data-slot, sharing one context", () => {
|
|
52
|
-
const spec = JSON.stringify({
|
|
53
|
-
layout: "drawer",
|
|
54
|
-
islands: [
|
|
55
|
-
{ name: "player", slot: "main", config: { a: 1 } },
|
|
56
|
-
{ name: "nobody", slot: "side" },
|
|
57
|
-
{ name: "chat", slot: "drawer" },
|
|
58
|
-
],
|
|
59
|
-
things: ["x"],
|
|
60
|
-
});
|
|
61
|
-
const out = page(spec, ["main", "side", "drawer"]).mount();
|
|
62
|
-
expect(out).toHaveLength(2);
|
|
63
|
-
expect(mounted.map((m) => [m.name, m.el.dataset.slot, m.config])).toEqual([
|
|
64
|
-
["player", "main", { a: 1 }],
|
|
65
|
-
["chat", "drawer", {}],
|
|
66
|
-
]);
|
|
67
|
-
expect(mounted[0].ctx).toBe(mounted[1].ctx);
|
|
68
|
-
expect(contexts).toHaveLength(1);
|
|
69
|
-
expect(contexts[0].things).toEqual(["x"]);
|
|
70
|
-
expect(warn.mock.calls.flat().join("\n")).toMatch(/nobody/);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("mounts nothing, and builds no context, without a readable spec", () => {
|
|
74
|
-
for (const spec of [null, "not json"]) {
|
|
75
|
-
const p = page(spec, ["main"]);
|
|
76
|
-
warn.mockClear();
|
|
77
|
-
expect(p.mount()).toEqual([]);
|
|
78
|
-
expect(warn).toHaveBeenCalledOnce();
|
|
79
|
-
expect(String(warn.mock.calls[0][0])).toMatch(/page-spec/);
|
|
80
|
-
}
|
|
81
|
-
expect(contexts).toHaveLength(0);
|
|
82
|
-
});
|
|
83
|
-
});
|
package/src/page/mount.test.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { mountIslands, type Registry } from "./mount";
|
|
3
|
-
import type { PageSpec } from "./spec";
|
|
4
|
-
|
|
5
|
-
type El = { slot: string };
|
|
6
|
-
const ctx = { tag: "ctx" };
|
|
7
|
-
const bus = { tag: "bus" };
|
|
8
|
-
|
|
9
|
-
const spec: PageSpec = {
|
|
10
|
-
layout: "drawer",
|
|
11
|
-
islands: [
|
|
12
|
-
{ name: "player", slot: "main", presentation: "page", config: { a: 1 } },
|
|
13
|
-
{ name: "unknown", slot: "side", config: {} },
|
|
14
|
-
{ name: "chat", slot: "missing", config: {} },
|
|
15
|
-
{ name: "chat", slot: "drawer", presentation: "drawer", config: {} },
|
|
16
|
-
],
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
describe("mountIslands", () => {
|
|
20
|
-
it("mounts each island in its slot with its config and the page's context, in spec order", () => {
|
|
21
|
-
const calls: unknown[][] = [];
|
|
22
|
-
const registry: Registry<typeof ctx, El, string, typeof bus> = {
|
|
23
|
-
player: (el, island, c, b) => (calls.push([el, island, c, b]), "player-component"),
|
|
24
|
-
chat: (el, island) => (calls.push([el, island.presentation]), "chat-component"),
|
|
25
|
-
};
|
|
26
|
-
const logs: string[] = [];
|
|
27
|
-
const out = mountIslands(spec, registry, (slot) => (slot === "missing" ? null : { slot }), () => ctx, bus, (m) => logs.push(m));
|
|
28
|
-
expect(out).toEqual(["player-component", "chat-component"]);
|
|
29
|
-
expect(calls).toEqual([
|
|
30
|
-
[{ slot: "main" }, spec.islands[0], ctx, bus],
|
|
31
|
-
[{ slot: "drawer" }, "drawer"],
|
|
32
|
-
]);
|
|
33
|
-
// The unknown island and the missing slot are logged and skipped.
|
|
34
|
-
expect(logs).toHaveLength(2);
|
|
35
|
-
expect(logs.join("\n")).toMatch(/unknown/);
|
|
36
|
-
expect(logs.join("\n")).toMatch(/missing/);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("doesn't take an island name from the registry's prototype", () => {
|
|
40
|
-
const logs: string[] = [];
|
|
41
|
-
const out = mountIslands({ layout: "a", islands: [{ name: "toString", slot: "main", config: {} }] }, {}, (slot) => ({ slot }), () => ctx, bus, (m) => logs.push(m));
|
|
42
|
-
expect(out).toEqual([]);
|
|
43
|
-
expect(logs).toHaveLength(1);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("carries on when a factory throws", () => {
|
|
47
|
-
const logs: string[] = [];
|
|
48
|
-
const out = mountIslands(
|
|
49
|
-
spec,
|
|
50
|
-
{
|
|
51
|
-
player: () => {
|
|
52
|
-
throw new Error("boom");
|
|
53
|
-
},
|
|
54
|
-
chat: () => "ok",
|
|
55
|
-
},
|
|
56
|
-
(slot) => ({ slot }),
|
|
57
|
-
() => ctx,
|
|
58
|
-
bus,
|
|
59
|
-
(m) => logs.push(m),
|
|
60
|
-
);
|
|
61
|
-
expect(out).toEqual(["ok", "ok"]);
|
|
62
|
-
expect(logs.some((m) => m.includes("boom"))).toBe(true);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("builds the page's context once, and only when there's an island to mount", () => {
|
|
66
|
-
let built = 0;
|
|
67
|
-
const context = () => (built++, ctx);
|
|
68
|
-
mountIslands({ layout: "index", islands: [] }, {}, () => ({ slot: "" }), context, bus, () => {});
|
|
69
|
-
expect(built).toBe(0);
|
|
70
|
-
mountIslands(spec, { player: () => "a", chat: () => "b" }, (slot) => ({ slot }), context, bus, () => {});
|
|
71
|
-
expect(built).toBe(1);
|
|
72
|
-
});
|
|
73
|
-
});
|
package/src/page/spec.test.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { readSpec } from "./spec";
|
|
5
|
-
|
|
6
|
-
describe("readSpec", () => {
|
|
7
|
-
it("reads the spec goapplib's page.Spec writes (page/testdata/spec.json)", () => {
|
|
8
|
-
const text = readFileSync(join(__dirname, "../../../page/testdata/spec.json"), "utf8");
|
|
9
|
-
expect(readSpec(text)).toEqual({
|
|
10
|
-
layout: "drawer",
|
|
11
|
-
islands: [
|
|
12
|
-
{ name: "player", slot: "main", presentation: "page", config: { note: "</script>", urls: ["/a.json"] } },
|
|
13
|
-
{ name: "chat", slot: "side", config: {} },
|
|
14
|
-
],
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("is null when there's no spec or it isn't one", () => {
|
|
19
|
-
for (const bad of [null, undefined, "", "not json", "null", "[]", '{"islands":[]}', '{"layout":"drawer"}', '{"layout":3,"islands":[]}']) {
|
|
20
|
-
expect(readSpec(bad), String(bad)).toBeNull();
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("drops islands it couldn't mount and cleans the rest", () => {
|
|
25
|
-
const spec = readSpec(
|
|
26
|
-
JSON.stringify({
|
|
27
|
-
layout: "drawer",
|
|
28
|
-
islands: [
|
|
29
|
-
{ name: "player", slot: "main" },
|
|
30
|
-
{ slot: "orphan" },
|
|
31
|
-
{ name: "x", slot: 'main"] , body' },
|
|
32
|
-
{ name: "chat", slot: "drawer", presentation: 7, config: "nope" },
|
|
33
|
-
"junk",
|
|
34
|
-
],
|
|
35
|
-
}),
|
|
36
|
-
);
|
|
37
|
-
expect(spec).toEqual({
|
|
38
|
-
layout: "drawer",
|
|
39
|
-
islands: [
|
|
40
|
-
{ name: "player", slot: "main", config: {} },
|
|
41
|
-
{ name: "chat", slot: "drawer", config: {} },
|
|
42
|
-
],
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("leaves an app's own fields out unless it reads them", () => {
|
|
47
|
-
expect(readSpec('{"layout":"a","islands":[],"things":[1]}')).toEqual({ layout: "a", islands: [] });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("merges in what an extension reads, without letting it replace layout or islands", () => {
|
|
51
|
-
const text = JSON.stringify({ layout: "a", islands: [{ name: "p", slot: "main" }], things: [1, "x", 2] });
|
|
52
|
-
const spec = readSpec(text, (raw) => ({
|
|
53
|
-
things: Array.isArray(raw.things) ? raw.things.filter((t): t is number => typeof t === "number") : [],
|
|
54
|
-
layout: "overridden",
|
|
55
|
-
}));
|
|
56
|
-
expect(spec).toEqual({ layout: "a", islands: [{ name: "p", slot: "main", config: {} }], things: [1, 2] });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("is null when the extension throws", () => {
|
|
60
|
-
const spec = readSpec('{"layout":"a","islands":[]}', () => {
|
|
61
|
-
throw new Error("bad things");
|
|
62
|
-
});
|
|
63
|
-
expect(spec).toBeNull();
|
|
64
|
-
});
|
|
65
|
-
});
|