@hoardodile/sdk-web 0.0.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/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @hoardodile/sdk-web — the pure-browser iframe runtime for hoardodile
3
+ * content plugins. This is the only entry a framework-agnostic plugin
4
+ * needs: the wire protocol (versioned via {@link PROTOCOL_VERSION}),
5
+ * host bridge, stores, and theme/font/visibility helpers.
6
+ *
7
+ * The shared message/danmaku/anchor shapes re-exported at the top come
8
+ * from `@hoardodile/sdk-types` — that package is the contract, this one
9
+ * is the runtime. Plugin authors should import from
10
+ * `@hoardodile/sdk-react` when using React, and only drop to this
11
+ * package for framework-agnostic code. The browser-side host runtime
12
+ * (`@hoardodile/host-web`) consumes this protocol, never redefines it.
13
+ */
14
+
15
+ export type {
16
+ AnchorData,
17
+ Danmaku,
18
+ DanmakuListFilter,
19
+ DanmakuMode,
20
+ FileStats,
21
+ Message,
22
+ ResAnchor,
23
+ } from "@hoardodile/sdk-types"
24
+ export type { ImageVariantSpec } from "@hoardodile/sdk-types/image-variant"
25
+ export { ensureHostBridge, isRecord } from "./bridge.ts"
26
+ export { booleanCodec, jsonCodec, numberCodec } from "./codecs.ts"
27
+ export {
28
+ createWebPluginAPI,
29
+ type DeepPartial,
30
+ type StubbedPluginAPI,
31
+ } from "./fixtures.ts"
32
+ export {
33
+ applyFonts,
34
+ applyTheme,
35
+ getPluginContext,
36
+ getVisibilitySnapshot,
37
+ mountPlugin,
38
+ subscribeToVisibility,
39
+ } from "./lifecycle.ts"
40
+ export type {
41
+ Host,
42
+ HostMessage,
43
+ HostPush,
44
+ HostPushes,
45
+ HostResponse,
46
+ InvalidateTarget,
47
+ PluginContextPainted,
48
+ PluginFonts,
49
+ PluginIframeContext,
50
+ PluginMessage,
51
+ PluginRequest,
52
+ PluginRequests,
53
+ PluginResolvedTheme,
54
+ PluginSubscribe,
55
+ PluginThemePalette,
56
+ ReadFileRange,
57
+ RequestInput,
58
+ RequestOutput,
59
+ } from "./protocol.ts"
60
+ export {
61
+ hostPushKeys,
62
+ invalidatePushKeys,
63
+ PROTOCOL_VERSION,
64
+ pluginMethods,
65
+ pluginThemePalettes,
66
+ } from "./protocol.ts"
67
+ export {
68
+ createIframeHostAPI,
69
+ extractFontsPayload,
70
+ extractPrefPayload,
71
+ extractThemePayload,
72
+ } from "./runtime.ts"
73
+ export {
74
+ broadcastPrefChange,
75
+ getPluginCacheStore,
76
+ getPluginPrefStore,
77
+ seedPluginStores,
78
+ setPluginCache,
79
+ setPluginPref,
80
+ snapshotCacheEntries,
81
+ subscribeToPrefChanges,
82
+ } from "./stores.ts"
83
+ export type {
84
+ Codec,
85
+ FileUrlVariant,
86
+ MutationState,
87
+ PluginResource,
88
+ QueryState,
89
+ ReactivePluginAPI,
90
+ Theme,
91
+ WebPluginAPI,
92
+ } from "./types.ts"
@@ -0,0 +1,200 @@
1
+ import type { PluginContextPainted, PluginIframeContext } from "./protocol.ts"
2
+ import { hostPushKeys, PROTOCOL_VERSION } from "./protocol.ts"
3
+
4
+ let pluginContext: PluginIframeContext | undefined
5
+
6
+ /** The last context pushed by the host, if any. */
7
+ export function getPluginContext(): PluginIframeContext | undefined {
8
+ return pluginContext
9
+ }
10
+
11
+ function setPluginContext(ctx: PluginIframeContext): void {
12
+ pluginContext = ctx
13
+ }
14
+
15
+ // ── Visibility (framework-agnostic store) ────────────────────────────────
16
+
17
+ let currentVisibility = true
18
+ const visibilityListeners = new Set<(visible: boolean) => void>()
19
+
20
+ /**
21
+ * Subscribe to iframe visibility changes (tab hidden, iframe released,
22
+ * overlays opened by the host). The callback receives the current
23
+ * visibility; returns an unsubscribe function. Backs `useVisibility`
24
+ * in `@hoardodile/sdk-react`.
25
+ */
26
+ export function subscribeToVisibility(
27
+ cb: (visible: boolean) => void,
28
+ ): () => void {
29
+ visibilityListeners.add(cb)
30
+ return () => {
31
+ visibilityListeners.delete(cb)
32
+ }
33
+ }
34
+
35
+ /** Current visibility snapshot without subscribing. */
36
+ export function getVisibilitySnapshot(): boolean {
37
+ return currentVisibility
38
+ }
39
+
40
+ function publishVisibilityChange(visible: boolean): void {
41
+ if (currentVisibility === visible) return
42
+ currentVisibility = visible
43
+ for (const cb of visibilityListeners) {
44
+ cb(visible)
45
+ }
46
+ }
47
+
48
+ // ── Mount lifecycle ──────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Invokes `cb` once the current DOM state has painted: the first rAF
52
+ * frame paints the pending tree, the second guarantees the compositor
53
+ * surface update landed. If rAF is throttled (document not rendering)
54
+ * `cb` never fires — callers need their own fallback.
55
+ */
56
+ function afterNextPaintedFrame(cb: () => void): void {
57
+ requestAnimationFrame(() => {
58
+ requestAnimationFrame(cb)
59
+ })
60
+ }
61
+
62
+ /**
63
+ * Sets up listeners for host→plugin communication via `postMessage` and
64
+ * `CustomEvent` fallback. The host pushes context and visibility updates;
65
+ * this function invokes `mount(ctx)` whenever a new context arrives.
66
+ *
67
+ * Lifecycle contract: the host may replace the context at any time — a
68
+ * pooled iframe document is reused across resources without a reload — so
69
+ * `mount` runs once per context, not once per page load. Clean up
70
+ * per-resource state yourself, or rely on `createPluginRoot` from
71
+ * `@hoardodile/sdk-react`, which remounts the tree by `resId` by
72
+ * default. When the host rebinds the iframe to a new resource, a late
73
+ * unmount cache flush stamped with the old resId is stale-dropped by the
74
+ * host (the plugin's debounced write still lands via the new binding).
75
+ */
76
+ export function mountPlugin(mount: (ctx: PluginIframeContext) => void): void {
77
+ function applyContext(ctx: PluginIframeContext) {
78
+ setPluginContext(ctx)
79
+ // A fresh context means the host is showing this iframe again.
80
+ // Publish rather than assign so plugins that keep their tree
81
+ // mounted across contexts (remountOnResourceChange: false) wake
82
+ // up after a release pushed visible:false; the publish helper
83
+ // already dedupes no-change transitions.
84
+ publishVisibilityChange(true)
85
+ mount(ctx)
86
+ // Acknowledge only once the new tree has painted into the iframe's
87
+ // compositor surface: the host keeps a freshly claimed iframe
88
+ // transparent (but painted) until this ack arrives, and a bare DOM
89
+ // commit would still replay the previous resource's last frame for
90
+ // a frame or two while the new raster is in flight. If rAF is
91
+ // throttled the ack never fires and the host's fallback takes over.
92
+ afterNextPaintedFrame(() => {
93
+ window.parent.postMessage(
94
+ {
95
+ type: "contextPainted",
96
+ resId: ctx.resId,
97
+ proto: PROTOCOL_VERSION,
98
+ } satisfies PluginContextPainted,
99
+ "*",
100
+ )
101
+ })
102
+ }
103
+
104
+ window.addEventListener("message", (event: MessageEvent) => {
105
+ // Only trust context/visibility pushes from the host parent window.
106
+ if (event.source !== window.parent) return
107
+ const msg = event.data
108
+ if (!isRecord(msg) || msg.type !== "push") return
109
+ if (msg.key === hostPushKeys.context) {
110
+ applyContext(msg.data as PluginIframeContext)
111
+ } else if (msg.key === hostPushKeys.visibility) {
112
+ publishVisibilityChange((msg.data as { visible: boolean }).visible)
113
+ }
114
+ })
115
+
116
+ window.addEventListener("context-ready", (e: Event) => {
117
+ applyContext((e as CustomEvent<PluginIframeContext>).detail)
118
+ })
119
+ window.addEventListener("visibility-changed", (e: Event) => {
120
+ publishVisibilityChange(
121
+ (e as CustomEvent<{ visible: boolean }>).detail.visible,
122
+ )
123
+ })
124
+
125
+ const w = window as unknown as Record<string, unknown>
126
+ if (w.__pluginContext !== undefined) {
127
+ applyContext(w.__pluginContext as PluginIframeContext)
128
+ }
129
+ if (w.__pluginVisibility !== undefined) {
130
+ publishVisibilityChange(
131
+ (w.__pluginVisibility as { visible: boolean }).visible,
132
+ )
133
+ }
134
+ }
135
+
136
+ // ── Theme & font application ─────────────────────────────────────────────
137
+
138
+ /** Applies theme classes to `document.documentElement` so CSS variables update. */
139
+ export function applyTheme(
140
+ resolvedTheme: string,
141
+ palette: string,
142
+ iconStyle: string,
143
+ ): void {
144
+ const root = document.documentElement
145
+ root.classList.remove("light", "dark")
146
+ root.classList.add(resolvedTheme)
147
+ // Strip every palette class generically — no per-palette list to keep in
148
+ // sync, and stale classes from older versions get cleaned up too.
149
+ for (const cls of [...root.classList]) {
150
+ if (cls.startsWith("theme-")) root.classList.remove(cls)
151
+ }
152
+ if (palette !== "mono") {
153
+ root.classList.add(`theme-${palette}`)
154
+ }
155
+ // `data-icon-style` drives the grayscale rule in `@hoardodile/ui/theme.css`;
156
+ // `linear` only matters to the host's own icon registry.
157
+ root.dataset.iconStyle = iconStyle
158
+ }
159
+
160
+ // The plugin bundle's theme.css only defines a `--font-sans` fallback;
161
+ // nothing in it consumes `--font-app`, so the SDK installs this rule once
162
+ // to make the document actually pick up the inherited font.
163
+ const FONT_STYLE_ID = "plugin-host-font"
164
+
165
+ /**
166
+ * Applies the host app font to the iframe document: injects each preset
167
+ * stylesheet once (idempotent per path — they are absolute `/fonts/...`
168
+ * URLs statically served by the host, which the sandboxed iframe can
169
+ * load), then points `--font-app` at the family stack. An empty family
170
+ * means the plugin opted out of font inheritance: the variable is
171
+ * removed and the document falls back to the plugin's own `--font-sans`.
172
+ */
173
+ export function applyFonts(family: string, cssPaths: readonly string[]): void {
174
+ for (const path of cssPaths) {
175
+ const selector = `link[rel="stylesheet"][href="${path}"]`
176
+ if (document.head.querySelector(selector) !== null) continue
177
+ const link = document.createElement("link")
178
+ link.rel = "stylesheet"
179
+ link.href = path
180
+ document.head.appendChild(link)
181
+ }
182
+
183
+ const root = document.documentElement
184
+ if (family === "") {
185
+ root.style.removeProperty("--font-app")
186
+ } else {
187
+ root.style.setProperty("--font-app", family)
188
+ }
189
+
190
+ if (document.getElementById(FONT_STYLE_ID) === null) {
191
+ const style = document.createElement("style")
192
+ style.id = FONT_STYLE_ID
193
+ style.textContent = "html{font-family:var(--font-app,var(--font-sans))}"
194
+ document.head.appendChild(style)
195
+ }
196
+ }
197
+
198
+ function isRecord(value: unknown): value is Record<string, unknown> {
199
+ return value !== null && typeof value === "object" && !Array.isArray(value)
200
+ }