@hoardodile/sdk-react 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/query.ts ADDED
@@ -0,0 +1,407 @@
1
+ import type {
2
+ AnchorData,
3
+ Danmaku,
4
+ DanmakuListFilter,
5
+ DanmakuMode,
6
+ Message,
7
+ PluginSchema,
8
+ } from "@hoardodile/sdk-types"
9
+ import type {
10
+ Codec,
11
+ Host,
12
+ MutationState,
13
+ PluginFonts,
14
+ QueryState,
15
+ ReactivePluginAPI,
16
+ Theme,
17
+ } from "@hoardodile/sdk-web"
18
+ import {
19
+ extractFontsPayload,
20
+ extractPrefPayload,
21
+ extractThemePayload,
22
+ getPluginPrefStore,
23
+ invalidatePushKeys,
24
+ setPluginPref,
25
+ subscribeToPrefChanges,
26
+ } from "@hoardodile/sdk-web"
27
+ import { useEffect, useMemo, useState } from "react"
28
+
29
+ // ── Query state helpers ──────────────────────────────────────────────────
30
+
31
+ function buildQuerySuccessState<T>(data: T): QueryState<T> {
32
+ return { data, isLoading: false, isError: false, error: null }
33
+ }
34
+
35
+ function buildQueryErrorState(err: unknown): QueryState<never> {
36
+ return {
37
+ data: undefined,
38
+ isLoading: false,
39
+ isError: true,
40
+ error: err instanceof Error ? err : new Error(String(err)),
41
+ }
42
+ }
43
+
44
+ function buildQueryLoadingState(): QueryState<never> {
45
+ return { data: undefined, isLoading: true, isError: false, error: null }
46
+ }
47
+
48
+ // ── Base query hook ──────────────────────────────────────────────────────
49
+
50
+ type PluginRequestKey = keyof import("@hoardodile/sdk-web").PluginRequests
51
+ type HostPushKey = keyof import("@hoardodile/sdk-web").HostPushes
52
+
53
+ type UseHostQueryOptions<K extends PluginRequestKey> = {
54
+ readonly method: K
55
+ readonly params: import("@hoardodile/sdk-web").RequestInput<K>
56
+ readonly invalidateKey: HostPushKey
57
+ readonly extraDeps?: readonly unknown[]
58
+ }
59
+
60
+ function useHostQuery<K extends PluginRequestKey, T>(
61
+ host: Host,
62
+ options: UseHostQueryOptions<K>,
63
+ ): QueryState<T> {
64
+ const { method, params, invalidateKey, extraDeps = [] } = options
65
+ const [state, setState] = useState<QueryState<T>>(buildQueryLoadingState)
66
+
67
+ useEffect(() => {
68
+ let cancelled = false
69
+ setState(buildQueryLoadingState())
70
+
71
+ function fetchData() {
72
+ const args = params === undefined ? [] : [params]
73
+ host
74
+ .request(method, ...(args as never))
75
+ .then((result) => {
76
+ if (!cancelled) {
77
+ setState(buildQuerySuccessState(result as T))
78
+ }
79
+ })
80
+ .catch((err: unknown) => {
81
+ if (!cancelled) {
82
+ setState(buildQueryErrorState(err))
83
+ }
84
+ })
85
+ }
86
+
87
+ fetchData()
88
+ const unsub = host.subscribe(invalidateKey, fetchData as never)
89
+ return function cleanup() {
90
+ cancelled = true
91
+ unsub()
92
+ }
93
+ }, [host, method, invalidateKey, ...extraDeps])
94
+
95
+ return state
96
+ }
97
+
98
+ // ── File queries ─────────────────────────────────────────────────────────
99
+
100
+ function useFileList(host: Host, contextDeps: readonly unknown[]) {
101
+ return useHostQuery<"listFiles", readonly string[]>(host, {
102
+ method: "listFiles",
103
+ params: undefined,
104
+ invalidateKey: invalidatePushKeys.resource,
105
+ extraDeps: contextDeps,
106
+ })
107
+ }
108
+ // ── Message queries ──────────────────────────────────────────────────────
109
+
110
+ function useMessageList(host: Host, contextDeps: readonly unknown[]) {
111
+ return useHostQuery<"listMessages", readonly Message[]>(host, {
112
+ method: "listMessages",
113
+ params: undefined,
114
+ invalidateKey: invalidatePushKeys.messages,
115
+ extraDeps: contextDeps,
116
+ })
117
+ }
118
+
119
+ // ── Danmaku queries ───────────────────────────────────────────────────────
120
+
121
+ function useDanmakuList(
122
+ host: Host,
123
+ contextDeps: readonly unknown[],
124
+ filter?: DanmakuListFilter,
125
+ ) {
126
+ return useHostQuery<"listDanmaku", readonly Danmaku[]>(host, {
127
+ method: "listDanmaku",
128
+ params: { filter },
129
+ invalidateKey: invalidatePushKeys.danmaku,
130
+ // The filter object is a fresh literal on every render; a stable
131
+ // serialization keeps the effect from refetching in a loop while
132
+ // still refetching when any filter value actually changes.
133
+ extraDeps: [
134
+ ...contextDeps,
135
+ filter === undefined ? undefined : JSON.stringify(filter),
136
+ ],
137
+ })
138
+ }
139
+
140
+ // ── Mutations ────────────────────────────────────────────────────────────
141
+
142
+ function useHostMutation<
143
+ K extends PluginRequestKey,
144
+ TArgs extends import("@hoardodile/sdk-web").RequestInput<K>,
145
+ TResult extends import("@hoardodile/sdk-web").RequestOutput<K>,
146
+ >(host: Host, method: K): MutationState<TArgs, TResult> {
147
+ const [isPending, setIsPending] = useState(false)
148
+
149
+ async function mutate(args: TArgs): Promise<TResult> {
150
+ setIsPending(true)
151
+ try {
152
+ const requestArgs = args === undefined ? [] : [args]
153
+ return (await host.request(
154
+ method,
155
+ ...(requestArgs as never),
156
+ )) as unknown as TResult
157
+ } finally {
158
+ setIsPending(false)
159
+ }
160
+ }
161
+
162
+ return { mutate, isPending }
163
+ }
164
+
165
+ function useCreateMessage(
166
+ host: Host,
167
+ ): MutationState<
168
+ { readonly body: string; readonly anchor?: unknown },
169
+ Message
170
+ > {
171
+ const base = useHostMutation<
172
+ "createMessage",
173
+ { readonly body: string; readonly anchor?: AnchorData },
174
+ Message
175
+ >(host, "createMessage")
176
+ // The hook input is the raw plugin location data; the wire anchor is
177
+ // the `{ data }` envelope (see sdk-web runtime).
178
+ return {
179
+ isPending: base.isPending,
180
+ async mutate(input) {
181
+ return base.mutate({
182
+ body: input.body,
183
+ anchor: input.anchor === undefined ? undefined : { data: input.anchor },
184
+ })
185
+ },
186
+ }
187
+ }
188
+
189
+ function useCreateDanmaku(host: Host): MutationState<
190
+ {
191
+ readonly text: string
192
+ readonly anchor: unknown
193
+ readonly mode?: DanmakuMode
194
+ },
195
+ Danmaku
196
+ > {
197
+ const base = useHostMutation<
198
+ "createDanmaku",
199
+ {
200
+ readonly text: string
201
+ readonly anchor: AnchorData
202
+ readonly mode?: DanmakuMode
203
+ },
204
+ Danmaku
205
+ >(host, "createDanmaku")
206
+ return {
207
+ isPending: base.isPending,
208
+ async mutate(input) {
209
+ return base.mutate({
210
+ text: input.text,
211
+ anchor: { data: input.anchor },
212
+ mode: input.mode,
213
+ })
214
+ },
215
+ }
216
+ }
217
+
218
+ // ── Preferences hook ─────────────────────────────────────────────────────
219
+
220
+ /** Serialize a typed value to its stored string form (codec or String()). */
221
+ function encodePrefValue<T>(codec: Codec<T> | undefined, value: T): string {
222
+ return codec !== undefined ? codec.encode(value) : String(value)
223
+ }
224
+
225
+ /** Parse a stored string back to its typed form, falling back on malformed input. */
226
+ function decodePrefValue<T>(
227
+ codec: Codec<T> | undefined,
228
+ raw: string,
229
+ fallback: T,
230
+ ): T {
231
+ if (codec === undefined) return raw as unknown as T
232
+ const decoded = codec.decode(raw)
233
+ return decoded !== undefined ? decoded : fallback
234
+ }
235
+
236
+ function usePref<T>(
237
+ host: Host,
238
+ key: string,
239
+ defaultValue: T,
240
+ codec?: Codec<T>,
241
+ ): readonly [T, (value: T) => void] {
242
+ const store = getPluginPrefStore()
243
+ const encodedDefault = useMemo(
244
+ function computeEncodedDefault() {
245
+ return encodePrefValue(codec, defaultValue)
246
+ },
247
+ [codec, defaultValue],
248
+ )
249
+
250
+ const [raw, setRawState] = useState(function getInitial() {
251
+ return store.get(key) ?? encodedDefault
252
+ })
253
+
254
+ useEffect(
255
+ function subscribeToStoreChanges() {
256
+ return subscribeToPrefChanges(key, function onChange() {
257
+ setRawState(getPluginPrefStore().get(key) ?? encodedDefault)
258
+ })
259
+ },
260
+ [key, encodedDefault],
261
+ )
262
+
263
+ useEffect(
264
+ function subscribeToHostPush() {
265
+ return host.subscribe("prefsChanged", function handlePrefPush(data) {
266
+ const payload = extractPrefPayload(data)
267
+ if (payload === undefined || payload.key !== key) return
268
+ if (payload.value !== undefined) {
269
+ setPluginPref(key, payload.value)
270
+ setRawState(payload.value)
271
+ } else {
272
+ setRawState(encodedDefault)
273
+ }
274
+ })
275
+ },
276
+ [host, key, encodedDefault],
277
+ )
278
+
279
+ const value = useMemo(
280
+ function decodeValue() {
281
+ return decodePrefValue(codec, raw, defaultValue)
282
+ },
283
+ [raw, codec, defaultValue],
284
+ )
285
+
286
+ function setValue(next: T): void {
287
+ const encoded = encodePrefValue(codec, next)
288
+ setPluginPref(key, encoded)
289
+ setRawState(encoded)
290
+ host.request("setPref", { key, value: encoded }).catch(() => {})
291
+ }
292
+
293
+ return [value, setValue] as const
294
+ }
295
+
296
+ // ── Host push hooks ──────────────────────────────────────────────────────
297
+
298
+ /**
299
+ * Subscribe to a host push and merge the extracted patch into state. The
300
+ * extractor returns `undefined` (or an empty patch) when the push carries
301
+ * no applicable change, so spurious pushes never re-render.
302
+ */
303
+ function useHostPush<T>(
304
+ host: Host,
305
+ key: HostPushKey,
306
+ extract: (data: unknown) => Partial<T> | undefined,
307
+ initial: T,
308
+ ): T {
309
+ const [value, setValue] = useState(initial)
310
+
311
+ useEffect(() => {
312
+ const unsub = host.subscribe(key, function handlePush(data) {
313
+ const patch = extract(data)
314
+ if (patch !== undefined) {
315
+ setValue((prev) => ({ ...prev, ...patch }))
316
+ }
317
+ })
318
+ return function cleanup() {
319
+ unsub()
320
+ }
321
+ }, [host, key, extract])
322
+
323
+ return value
324
+ }
325
+
326
+ // ── Theme hook ───────────────────────────────────────────────────────────
327
+
328
+ function extractThemePatch(data: unknown): Partial<Theme> | undefined {
329
+ const { resolvedTheme, palette, iconStyle } = extractThemePayload(data)
330
+ if (
331
+ resolvedTheme === undefined &&
332
+ palette === undefined &&
333
+ iconStyle === undefined
334
+ ) {
335
+ return undefined
336
+ }
337
+ return {
338
+ ...(resolvedTheme !== undefined ? { resolvedTheme } : {}),
339
+ ...(palette !== undefined ? { palette } : {}),
340
+ ...(iconStyle !== undefined ? { iconStyle } : {}),
341
+ }
342
+ }
343
+
344
+ function useTheme(
345
+ host: Host,
346
+ initialResolvedTheme: string,
347
+ initialPalette: string,
348
+ initialIconStyle: string,
349
+ ): Theme {
350
+ return useHostPush(host, "themeChanged", extractThemePatch, {
351
+ resolvedTheme: initialResolvedTheme,
352
+ palette: initialPalette,
353
+ iconStyle: initialIconStyle,
354
+ })
355
+ }
356
+
357
+ // ── Font hook ────────────────────────────────────────────────────────────
358
+
359
+ function useFont(host: Host, initialFonts: PluginFonts): PluginFonts {
360
+ return useHostPush(host, "fontsChanged", extractFontsPayload, initialFonts)
361
+ }
362
+
363
+ // ── Public factory ───────────────────────────────────────────────────────
364
+
365
+ /**
366
+ * Builds the reactive half of the plugin API (`useFileList`,
367
+ * `useMessageList`, `useCreateMessage`, `useDanmakuList`,
368
+ * `useCreateDanmaku`, `usePref`, `useTheme`, `useFont`) on top of the
369
+ * imperative `WebPluginAPI` and the host bridge. Queries refetch
370
+ * automatically on the matching host invalidation push and when the
371
+ * iframe is rebound to another resource.
372
+ *
373
+ * Consumed by {@link createPluginRoot}; call it directly only when
374
+ * composing your own runtime. The returned hooks are bound to the
375
+ * `host` passed in — the one from `ensureHostBridge()`.
376
+ */
377
+ export function createPluginQueryAPI<
378
+ TSchema extends PluginSchema = PluginSchema,
379
+ >(
380
+ host: Host,
381
+ ctx: {
382
+ readonly resolvedTheme: string
383
+ readonly palette: string
384
+ readonly iconStyle: string
385
+ readonly fonts: PluginFonts
386
+ readonly resId: string
387
+ },
388
+ ): ReactivePluginAPI<TSchema> {
389
+ // Refetch when the iframe is rebound to another resource without a
390
+ // remount (createPluginRoot's `remountOnResourceChange: false`). With
391
+ // the default remount this dep is constant for the mount's lifetime.
392
+ const contextDeps = [ctx.resId]
393
+ return {
394
+ useFileList: () =>
395
+ useFileList(host, contextDeps) as QueryState<readonly TSchema["file"][]>,
396
+ useMessageList: () => useMessageList(host, contextDeps),
397
+ useCreateMessage: () => useCreateMessage(host),
398
+ useDanmakuList: (filter?: DanmakuListFilter) =>
399
+ useDanmakuList(host, contextDeps, filter),
400
+ useCreateDanmaku: () => useCreateDanmaku(host),
401
+ usePref: <T>(key: string, defaultValue: T, codec?: Codec<T>) =>
402
+ usePref(host, key, defaultValue, codec),
403
+ useTheme: () =>
404
+ useTheme(host, ctx.resolvedTheme, ctx.palette, ctx.iconStyle),
405
+ useFont: () => useFont(host, ctx.fonts),
406
+ }
407
+ }
package/src/root.tsx ADDED
@@ -0,0 +1,144 @@
1
+ import type { PluginSchema } from "@hoardodile/sdk-types"
2
+ import {
3
+ applyFonts,
4
+ applyTheme,
5
+ createIframeHostAPI,
6
+ ensureHostBridge,
7
+ getVisibilitySnapshot,
8
+ mountPlugin,
9
+ subscribeToVisibility,
10
+ } from "@hoardodile/sdk-web"
11
+ import type { ComponentType, Provider, ReactNode } from "react"
12
+ import { createElement, useEffect, useSyncExternalStore } from "react"
13
+ import { flushSync } from "react-dom"
14
+ import { createRoot } from "react-dom/client"
15
+ import { usePluginAPI } from "./context.tsx"
16
+ import type { FullPluginAPI } from "./define-api.ts"
17
+ import { createPluginQueryAPI } from "./query.ts"
18
+
19
+ export type PluginRootConfig<TSchema extends PluginSchema = PluginSchema> = {
20
+ /** Root component rendered inside the plugin iframe. */
21
+ readonly render: ComponentType
22
+ /**
23
+ * Typed provider returned by {@link definePluginAPI}. This is the single
24
+ * source of truth for the plugin schema type.
25
+ */
26
+ readonly provider: Provider<FullPluginAPI<TSchema> | null>
27
+ /**
28
+ * When `true` (default), the whole plugin tree remounts whenever the
29
+ * iframe is rebound to another resource — the safe choice: all
30
+ * per-resource state resets automatically.
31
+ *
32
+ * Set to `false` for fine-grained updates (e.g. cheap same-plugin
33
+ * navigation): the mounted tree stays alive and only re-renders with
34
+ * the new `api`. Queries refetch automatically, but every piece of
35
+ * per-resource state becomes the plugin's own responsibility — key
36
+ * subtrees and memos by `api.resource.id` and reset any hydration
37
+ * flags yourself.
38
+ */
39
+ readonly remountOnResourceChange?: boolean
40
+ }
41
+
42
+ function ThemeSync({ children }: { readonly children: ReactNode }) {
43
+ const api = usePluginAPI()
44
+ const { resolvedTheme, palette, iconStyle } = api.useTheme()
45
+
46
+ useEffect(
47
+ function applyOnChange() {
48
+ applyTheme(resolvedTheme, palette, iconStyle)
49
+ },
50
+ [resolvedTheme, palette, iconStyle],
51
+ )
52
+
53
+ return children
54
+ }
55
+
56
+ function FontSync({ children }: { readonly children: ReactNode }) {
57
+ const api = usePluginAPI()
58
+ const { family, cssPaths } = api.useFont()
59
+
60
+ useEffect(
61
+ function applyOnChange() {
62
+ applyFonts(family, cssPaths)
63
+ },
64
+ [family, cssPaths],
65
+ )
66
+
67
+ return children
68
+ }
69
+
70
+ /**
71
+ * Subscribe to the iframe visibility state from the host: `false` while
72
+ * the iframe is parked offscreen in the preview window. Do NOT gate
73
+ * rendering on it — parked slots are meant to pre-paint so a flip is a
74
+ * style swap, and an empty tree defeats that. Use visibility only to
75
+ * pause active behavior: media playback, autoplay, timers.
76
+ */
77
+ export function useVisibility(): boolean {
78
+ return useSyncExternalStore(subscribeToVisibility, getVisibilitySnapshot)
79
+ }
80
+
81
+ /**
82
+ * One-call plugin bootstrap. Handles `mountPlugin`, `createRoot` caching,
83
+ * iframe host API, typed `PluginAPIProvider`, reactive theme application, and
84
+ * visibility subscription.
85
+ *
86
+ * The supplied component receives no props; it should call `usePluginAPI()`
87
+ * and `useVisibility()` internally as needed. By default the root remounts
88
+ * when the resource changes (see
89
+ * {@link PluginRootConfig.remountOnResourceChange}); even then, use
90
+ * `api.resource.id` as a key inside your component if you need finer
91
+ * control.
92
+ */
93
+ export function createPluginRoot<TSchema extends PluginSchema = PluginSchema>(
94
+ config: PluginRootConfig<TSchema>,
95
+ ): void {
96
+ let root: ReturnType<typeof createRoot> | undefined
97
+
98
+ mountPlugin(function onContext(ctx) {
99
+ flushSync(() => {
100
+ if (root === undefined) {
101
+ const el = document.getElementById("root")
102
+ if (el === null) {
103
+ console.error("[plugin] #root element not found — cannot mount")
104
+ return
105
+ }
106
+ root = createRoot(el)
107
+ }
108
+ const host = ensureHostBridge()
109
+ const baseApi = createIframeHostAPI<TSchema>(ctx)
110
+ const api: FullPluginAPI<TSchema> = {
111
+ ...baseApi,
112
+ ...createPluginQueryAPI(host, {
113
+ resolvedTheme: ctx.resolvedTheme,
114
+ palette: ctx.palette,
115
+ iconStyle: ctx.iconStyle,
116
+ fonts: ctx.fonts,
117
+ resId: ctx.resId,
118
+ }),
119
+ }
120
+ applyTheme(ctx.resolvedTheme, ctx.palette, ctx.iconStyle)
121
+ applyFonts(ctx.fonts.family, ctx.fonts.cssPaths)
122
+ root.render(
123
+ createElement(
124
+ config.provider,
125
+ { value: api },
126
+ createElement(
127
+ ThemeSync,
128
+ null,
129
+ createElement(
130
+ FontSync,
131
+ null,
132
+ createElement(
133
+ config.render,
134
+ config.remountOnResourceChange === false
135
+ ? {}
136
+ : { key: ctx.resId },
137
+ ),
138
+ ),
139
+ ),
140
+ ),
141
+ )
142
+ })
143
+ })
144
+ }
@@ -0,0 +1,61 @@
1
+ import { useEffect, useRef } from "react"
2
+ import { usePluginAPI } from "./context.tsx"
3
+
4
+ const DEFAULT_DEBOUNCE_MS = 500
5
+
6
+ /**
7
+ * Persist a value to the per-resource plugin cache: debounced writes while
8
+ * the value changes, plus a flush on `pagehide` / `beforeunload` / unmount
9
+ * so no pending update is lost.
10
+ *
11
+ * Use this for reader positions, resume timestamps, and similar
12
+ * continuously-changing state. Pass `undefined` as the value (or
13
+ * `disabled: true`) to skip persistence while the real value is loading.
14
+ */
15
+ export function useCacheWriter<T>(options: {
16
+ readonly key: string
17
+ readonly value: T | undefined
18
+ readonly encode: (value: T) => string
19
+ readonly disabled?: boolean
20
+ readonly debounceMs?: number
21
+ }): void {
22
+ const api = usePluginAPI()
23
+ const {
24
+ key,
25
+ value,
26
+ encode,
27
+ disabled = false,
28
+ debounceMs = DEFAULT_DEBOUNCE_MS,
29
+ } = options
30
+ const latestRef = useRef(value)
31
+ latestRef.current = value
32
+ const encodeRef = useRef(encode)
33
+ encodeRef.current = encode
34
+
35
+ useEffect(() => {
36
+ if (disabled || value === undefined) return
37
+ const timer = setTimeout(() => {
38
+ const snap = latestRef.current
39
+ if (snap === undefined) return
40
+ api.setCache(key, encodeRef.current(snap))
41
+ }, debounceMs)
42
+ return () => clearTimeout(timer)
43
+ }, [api, key, value, disabled, debounceMs])
44
+
45
+ useEffect(() => {
46
+ if (disabled) return
47
+ function flush() {
48
+ const snap = latestRef.current
49
+ if (snap === undefined) return
50
+ api.setCache(key, encodeRef.current(snap))
51
+ }
52
+ window.addEventListener("pagehide", flush)
53
+ window.addEventListener("beforeunload", flush)
54
+ return () => {
55
+ window.removeEventListener("pagehide", flush)
56
+ window.removeEventListener("beforeunload", flush)
57
+ flush()
58
+ }
59
+ // api is structurally stable; flush reads the latest value via ref.
60
+ }, [api, key, disabled])
61
+ }