@panyam/tsappkit 0.2.0 → 0.6.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/index.d.mts +113 -11
- package/dist/index.d.ts +113 -11
- package/dist/index.js +133 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +131 -24
- package/dist/index.mjs.map +1 -1
- package/dist/wasmhost/index.d.mts +15 -1
- package/dist/wasmhost/index.d.ts +15 -1
- package/dist/wasmhost/index.js +15 -2
- package/dist/wasmhost/index.js.map +1 -1
- package/dist/wasmhost/index.mjs +14 -3
- package/dist/wasmhost/index.mjs.map +1 -1
- package/dist/wasmhost/worker.js +10 -1
- package/dist/wasmhost/worker.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +4 -2
- package/src/page/IslandPage.ts +25 -0
- package/src/page/load.ts +86 -0
- package/src/page/mount.ts +105 -16
- package/src/page/spec.ts +7 -0
- package/src/wasmhost/client.ts +28 -2
- package/src/wasmhost/index.ts +1 -1
- package/src/wasmhost/protocol.ts +11 -1
- package/src/wasmhost/worker.ts +14 -1
package/src/page/mount.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseLoad } from "./load";
|
|
1
2
|
import type { IslandSpec, PageSpec } from "./spec";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -6,18 +7,65 @@ import type { IslandSpec, PageSpec } from "./spec";
|
|
|
6
7
|
*/
|
|
7
8
|
export type IslandFactory<Ctx, El, C, B> = (el: El, island: IslandSpec, ctx: Ctx, bus: B) => C;
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const LAZY: unique symbol = Symbol("lazy island");
|
|
11
|
+
|
|
12
|
+
/** A registry entry whose factory is loaded when the island mounts. Made by lazy. */
|
|
13
|
+
export interface LazyIsland<Ctx, El, C, B> {
|
|
14
|
+
readonly [LAZY]: () => Promise<IslandFactory<Ctx, El, C, B> | { default: IslandFactory<Ctx, El, C, B> }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A registry entry that loads its island's module only when the island
|
|
19
|
+
* mounts: `hero: lazy(() => import("./islands/hero"))`. With esbuild's
|
|
20
|
+
* --splitting each such module is its own chunk, so a page downloads only the
|
|
21
|
+
* islands its spec names, each when its `load` says. `load` resolves to the
|
|
22
|
+
* factory or to a module whose default export is the factory.
|
|
23
|
+
*
|
|
24
|
+
* A lazy island always mounts late, even an eager one, since its chunk
|
|
25
|
+
* arrives after the page has started; goapplib's page.Assets writes
|
|
26
|
+
* modulepreload links for the eager ones so that wait is short.
|
|
27
|
+
*/
|
|
28
|
+
export function lazy<Ctx, El, C, B>(
|
|
29
|
+
load: () => Promise<IslandFactory<Ctx, El, C, B> | { default: IslandFactory<Ctx, El, C, B> }>,
|
|
30
|
+
): LazyIsland<Ctx, El, C, B> {
|
|
31
|
+
return { [LAZY]: load };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The islands an entry can mount, by name: a factory, bundled with the entry,
|
|
36
|
+
* or a lazy entry, loaded as its own chunk when the island mounts.
|
|
37
|
+
*/
|
|
38
|
+
export type Registry<Ctx, El, C, B> = Record<string, IslandFactory<Ctx, El, C, B> | LazyIsland<Ctx, El, C, B>>;
|
|
39
|
+
|
|
40
|
+
/** How mountIslands handles islands that shouldn't mount at once. */
|
|
41
|
+
export interface MountOptions<El, C> {
|
|
42
|
+
/**
|
|
43
|
+
* Gets each island whose `load` isn't eager, with the slot it will mount
|
|
44
|
+
* in; call `mount` when it's time (IslandPage passes scheduleMount). An
|
|
45
|
+
* island with a `load` parseLoad doesn't know is logged and mounted at once
|
|
46
|
+
* instead, so it still shows up.
|
|
47
|
+
*/
|
|
48
|
+
defer?: (island: IslandSpec, el: El, mount: () => void) => void;
|
|
49
|
+
/** Gets what the factory built for each island mounted later: through `defer`, or from a lazy entry. */
|
|
50
|
+
onLateMount?: (component: C, island: IslandSpec) => void;
|
|
51
|
+
}
|
|
11
52
|
|
|
12
53
|
/**
|
|
13
54
|
* Mounts every island in `spec` into the element `findSlot` gives for its
|
|
14
|
-
* slot, and returns what the factories built, in spec order.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
55
|
+
* slot, and returns what the factories built at once, in spec order. With
|
|
56
|
+
* `options.defer`, an island whose `load` isn't eager is handed to it instead
|
|
57
|
+
* and reported through `options.onLateMount` when it mounts; without it,
|
|
58
|
+
* every island mounts now whatever its `load` says. A lazy entry is loaded
|
|
59
|
+
* when its island would mount and is reported through `onLateMount` too, so
|
|
60
|
+
* it's never in the returned list.
|
|
61
|
+
*
|
|
62
|
+
* `context` builds the page's shared services; it's called once, before the
|
|
63
|
+
* first island mounts (eager or late), and not at all on a page with nothing
|
|
64
|
+
* to mount, so a page without islands doesn't start what they'd share. An
|
|
65
|
+
* island the registry doesn't know, a slot that isn't on the page, or a
|
|
66
|
+
* factory that throws (now or later), or a lazy entry that fails to load, is
|
|
67
|
+
* reported through `log` and skipped,
|
|
68
|
+
* so one bad entry doesn't take the rest of the page down with it.
|
|
21
69
|
*
|
|
22
70
|
* Plain types throughout (no DOM), so it runs under node in tests and on a
|
|
23
71
|
* bare page without BasePage.
|
|
@@ -29,12 +77,13 @@ export function mountIslands<Ctx, El, C, B>(
|
|
|
29
77
|
context: () => Ctx,
|
|
30
78
|
bus: B,
|
|
31
79
|
log: (message: string) => void,
|
|
80
|
+
options: MountOptions<El, C> = {},
|
|
32
81
|
): C[] {
|
|
33
82
|
const out: C[] = [];
|
|
34
83
|
let ctx: Ctx | undefined;
|
|
35
84
|
for (const island of spec.islands) {
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
85
|
+
const entry = Object.prototype.hasOwnProperty.call(registry, island.name) ? registry[island.name] : undefined;
|
|
86
|
+
if (!entry) {
|
|
38
87
|
log(`page spec: no island called "${island.name}" in this page's registry`);
|
|
39
88
|
continue;
|
|
40
89
|
}
|
|
@@ -43,12 +92,52 @@ export function mountIslands<Ctx, El, C, B>(
|
|
|
43
92
|
log(`page spec: island "${island.name}" wants slot "${island.slot}", which isn't on the page`);
|
|
44
93
|
continue;
|
|
45
94
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
95
|
+
const build = (factory: IslandFactory<Ctx, El, C, B>): C | undefined => {
|
|
96
|
+
try {
|
|
97
|
+
ctx ??= context();
|
|
98
|
+
return factory(el, island, ctx, bus);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log(`page spec: island "${island.name}" failed to mount: ${message(err)}`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const mountLate = () => {
|
|
105
|
+
const late = (factory: IslandFactory<Ctx, El, C, B>) => {
|
|
106
|
+
const c = build(factory);
|
|
107
|
+
if (c !== undefined) options.onLateMount?.(c, island);
|
|
108
|
+
};
|
|
109
|
+
if (typeof entry === "function") {
|
|
110
|
+
late(entry);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// The executor turns a loader that throws into a rejection, logged like any other.
|
|
114
|
+
new Promise<Awaited<ReturnType<(typeof entry)[typeof LAZY]>>>((resolve) => resolve(entry[LAZY]())).then(
|
|
115
|
+
(m) => {
|
|
116
|
+
const factory = typeof m === "function" ? m : m?.default;
|
|
117
|
+
if (typeof factory === "function") late(factory);
|
|
118
|
+
else log(`page spec: island "${island.name}" loaded, but its module has no factory (a default export or the function itself)`);
|
|
119
|
+
},
|
|
120
|
+
(err) => log(`page spec: island "${island.name}" failed to load: ${message(err)}`),
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
const strategy = parseLoad(island.load);
|
|
124
|
+
if (strategy === null) {
|
|
125
|
+
log(`page spec: island "${island.name}" has load "${island.load}", which isn't eager, idle, visible or media:<query>; mounting it now`);
|
|
126
|
+
}
|
|
127
|
+
if (options.defer && strategy !== null && strategy.kind !== "eager") {
|
|
128
|
+
options.defer(island, el, mountLate);
|
|
129
|
+
continue;
|
|
51
130
|
}
|
|
131
|
+
if (typeof entry !== "function") {
|
|
132
|
+
mountLate();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const c = build(entry);
|
|
136
|
+
if (c !== undefined) out.push(c);
|
|
52
137
|
}
|
|
53
138
|
return out;
|
|
54
139
|
}
|
|
140
|
+
|
|
141
|
+
function message(err: unknown): string {
|
|
142
|
+
return err instanceof Error ? err.message : String(err);
|
|
143
|
+
}
|
package/src/page/spec.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface IslandSpec {
|
|
|
15
15
|
presentation?: string;
|
|
16
16
|
/** Handed to the factory as is. Always an object. */
|
|
17
17
|
config: Record<string, unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* When it mounts: `eager` (also when absent), `idle`, `visible` or
|
|
20
|
+
* `media:<query>` (see parseLoad). IslandPage waits for it; mountIslands
|
|
21
|
+
* does when given a `defer`.
|
|
22
|
+
*/
|
|
23
|
+
load?: string;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export interface PageSpec {
|
|
@@ -61,6 +67,7 @@ export function readSpec<Ext extends object>(text: string | null | undefined, ex
|
|
|
61
67
|
slot: is.slot,
|
|
62
68
|
...(typeof is.presentation === "string" && { presentation: is.presentation }),
|
|
63
69
|
config: isObject(is.config) ? is.config : {},
|
|
70
|
+
...(typeof is.load === "string" && { load: is.load }),
|
|
64
71
|
});
|
|
65
72
|
}
|
|
66
73
|
const spec: PageSpec = { layout: raw.layout, islands };
|
package/src/wasmhost/client.ts
CHANGED
|
@@ -126,15 +126,41 @@ export function workerFetch(worker: Worker): (input: RequestInfo | URL, init?: R
|
|
|
126
126
|
* each Uint8Array is empty here afterwards; copy first anything the page still needs. Resolves once
|
|
127
127
|
* the worker has mounted them (and, for a rebuild-mode host, rebuilt its handler).
|
|
128
128
|
*/
|
|
129
|
-
export
|
|
129
|
+
export function mountFiles(worker: Worker, name: string, files: Files): Promise<void> {
|
|
130
|
+
return sendFiles(worker, "mount", name, files);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Adds `files` to the mount `name`, keeping the files it already holds; a file at a path the mount
|
|
135
|
+
* already has is replaced, and a missing mount is created. This suits a page that brings files in a
|
|
136
|
+
* piece at a time, since it only sends what's new. The worker checks the whole batch first, so a
|
|
137
|
+
* rejection (a path that is already a directory, say) leaves the mount as it was. Buffers are
|
|
138
|
+
* transferred as with mountFiles.
|
|
139
|
+
*/
|
|
140
|
+
export function addFiles(worker: Worker, name: string, files: Files): Promise<void> {
|
|
141
|
+
return sendFiles(worker, "add", name, files);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function sendFiles(worker: Worker, kind: "mount" | "add", name: string, files: Files): Promise<void> {
|
|
130
145
|
const transfer = new Set<ArrayBuffer>();
|
|
131
146
|
for (const b of Object.values(files)) {
|
|
132
147
|
// A tag check rather than instanceof, which fails for a buffer from another realm (an iframe),
|
|
133
148
|
// and a SharedArrayBuffer can't be transferred.
|
|
134
149
|
if (Object.prototype.toString.call(b.buffer) === "[object ArrayBuffer]") transfer.add(b.buffer as ArrayBuffer);
|
|
135
150
|
}
|
|
136
|
-
const reply = await channel(worker).call({ kind
|
|
151
|
+
const reply = await channel(worker).call({ kind, name, files }, [...transfer]);
|
|
152
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The bytes of linear memory the worker's wasm holds. Wasm memory grows and never shrinks, so this
|
|
157
|
+
* is the peak the engine has needed so far, which is what an app weighs before giving a browser a
|
|
158
|
+
* bigger job.
|
|
159
|
+
*/
|
|
160
|
+
export async function workerMemory(worker: Worker): Promise<number> {
|
|
161
|
+
const reply = await channel(worker).call({ kind: "stats" }, []);
|
|
137
162
|
if (!reply.ok) throw new Error(reply.error);
|
|
163
|
+
return reply.memoryBytes ?? 0;
|
|
138
164
|
}
|
|
139
165
|
|
|
140
166
|
/** Removes the mount `name`. Removing a name that isn't mounted succeeds. */
|
package/src/wasmhost/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { startWorker, workerFetch, mountFiles, unmountFiles } from "./client";
|
|
1
|
+
export { startWorker, workerFetch, mountFiles, addFiles, unmountFiles, workerMemory } from "./client";
|
|
2
2
|
export type { StartWorkerOptions, Files } from "./client";
|
|
3
3
|
export { filesFromDrop, filesFromFileList } from "./drop";
|
package/src/wasmhost/protocol.ts
CHANGED
|
@@ -14,10 +14,20 @@ export type HostRequest =
|
|
|
14
14
|
body: Uint8Array | null;
|
|
15
15
|
}
|
|
16
16
|
| { id: number; kind: "mount"; name: string; files: Files }
|
|
17
|
+
| { id: number; kind: "add"; name: string; files: Files }
|
|
18
|
+
| { id: number; kind: "stats" }
|
|
17
19
|
| { id: number; kind: "unmount"; name: string };
|
|
18
20
|
|
|
19
21
|
export type HostReply =
|
|
20
|
-
| {
|
|
22
|
+
| {
|
|
23
|
+
id: number;
|
|
24
|
+
ok: true;
|
|
25
|
+
status?: number;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
body?: Uint8Array;
|
|
28
|
+
/** For stats: the wasm linear memory's size, which only grows. */
|
|
29
|
+
memoryBytes?: number;
|
|
30
|
+
}
|
|
21
31
|
| { id: number; ok: false; error: string };
|
|
22
32
|
|
|
23
33
|
/** Sent once when the wasm has loaded (or failed to), and again if the Go program exits. */
|
package/src/wasmhost/worker.ts
CHANGED
|
@@ -17,6 +17,7 @@ interface Exports {
|
|
|
17
17
|
body: Uint8Array | null,
|
|
18
18
|
): Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>;
|
|
19
19
|
mount(name: string, files: Files): Promise<void>;
|
|
20
|
+
add(name: string, files: Files): Promise<void>;
|
|
20
21
|
unmount(name: string): Promise<void>;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -35,6 +36,9 @@ const wasmUrl = params.get("wasm") ?? "app.wasm";
|
|
|
35
36
|
const execUrl = params.get("exec") ?? "wasm_exec.js";
|
|
36
37
|
const ns = params.get("ns") ?? "wasmhost";
|
|
37
38
|
|
|
39
|
+
// The wasm's linear memory, exported by Go as `mem`. It only grows, so its size is the peak so far.
|
|
40
|
+
let memory: WebAssembly.Memory | undefined;
|
|
41
|
+
|
|
38
42
|
const post = (m: HostStatus | HostReply, transfer: Transferable[] = []) => self.postMessage(m, transfer);
|
|
39
43
|
|
|
40
44
|
async function boot(): Promise<Exports> {
|
|
@@ -50,6 +54,7 @@ async function boot(): Promise<Exports> {
|
|
|
50
54
|
const { instance } = res.headers.get("content-type")?.startsWith("application/wasm")
|
|
51
55
|
? await WebAssembly.instantiateStreaming(res, go.importObject)
|
|
52
56
|
: await WebAssembly.instantiate(await res.arrayBuffer(), go.importObject);
|
|
57
|
+
memory = instance.exports.mem as WebAssembly.Memory | undefined;
|
|
53
58
|
void go.run(instance).then(() => post({ exited: "the Go program exited" }));
|
|
54
59
|
await ready;
|
|
55
60
|
return self[ns] as Exports;
|
|
@@ -71,9 +76,17 @@ self.onmessage = async (ev) => {
|
|
|
71
76
|
} else if (req.kind === "mount") {
|
|
72
77
|
await host.mount(req.name, req.files);
|
|
73
78
|
post({ id: req.id, ok: true });
|
|
74
|
-
} else {
|
|
79
|
+
} else if (req.kind === "add") {
|
|
80
|
+
await host.add(req.name, req.files);
|
|
81
|
+
post({ id: req.id, ok: true });
|
|
82
|
+
} else if (req.kind === "stats") {
|
|
83
|
+
post({ id: req.id, ok: true, memoryBytes: memory?.buffer.byteLength ?? 0 });
|
|
84
|
+
} else if (req.kind === "unmount") {
|
|
75
85
|
await host.unmount(req.name);
|
|
76
86
|
post({ id: req.id, ok: true });
|
|
87
|
+
} else {
|
|
88
|
+
// A page newer than this worker can send a kind it doesn't know; say so rather than guess.
|
|
89
|
+
throw new Error(`unknown request kind ${JSON.stringify((req as { kind: unknown }).kind)}`);
|
|
77
90
|
}
|
|
78
91
|
} catch (err) {
|
|
79
92
|
post({ id: req.id, ok: false, error: err instanceof Error ? err.message : String(err) });
|