@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/LICENSE +18 -0
- package/README.md +47 -0
- package/dist/index.d.ts +727 -0
- package/dist/index.js +683 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/bridge.ts +178 -0
- package/src/codecs.ts +54 -0
- package/src/fixtures.ts +154 -0
- package/src/index.ts +92 -0
- package/src/lifecycle.ts +200 -0
- package/src/protocol.ts +448 -0
- package/src/runtime.test.ts +63 -0
- package/src/runtime.ts +279 -0
- package/src/stores.ts +103 -0
- package/src/types.ts +252 -0
- package/src/urls.ts +66 -0
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"
|
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|