@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/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
- /** The islands an entry can mount, by name. An entry bundles only what its registry names. */
10
- export type Registry<Ctx, El, C, B> = Record<string, IslandFactory<Ctx, El, C, B>>;
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. `context` builds
15
- * the page's shared services; it's called once, before the first island
16
- * mounts, and not at all on a page with nothing to mount, so a page without
17
- * islands doesn't start what they'd share. An island the registry doesn't
18
- * know, a slot that isn't on the page, or a factory that throws is reported
19
- * through `log` and skipped, so one bad entry doesn't take the rest of the
20
- * page down with it.
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 factory = Object.prototype.hasOwnProperty.call(registry, island.name) ? registry[island.name] : undefined;
37
- if (!factory) {
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
- try {
47
- ctx ??= context();
48
- out.push(factory(el, island, ctx, bus));
49
- } catch (err) {
50
- log(`page spec: island "${island.name}" failed to mount: ${err instanceof Error ? err.message : String(err)}`);
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 };
@@ -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 async function mountFiles(worker: Worker, name: string, files: Files): Promise<void> {
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: "mount", name, files }, [...transfer]);
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. */
@@ -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";
@@ -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
- | { id: number; ok: true; status?: number; headers?: Record<string, string>; body?: Uint8Array }
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. */
@@ -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) });