@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/runtime.ts ADDED
@@ -0,0 +1,279 @@
1
+ import type {
2
+ AnchorData,
3
+ Danmaku,
4
+ DanmakuListFilter,
5
+ DanmakuMode,
6
+ Message,
7
+ PluginAssetDeleteResult,
8
+ PluginDownloadRequest,
9
+ PluginDownloadResult,
10
+ PluginSchema,
11
+ } from "@hoardodile/sdk-types"
12
+ import { ensureHostBridge } from "./bridge.ts"
13
+ import type {
14
+ InvalidateTarget,
15
+ PluginFonts,
16
+ PluginIframeContext,
17
+ ReadFileRange,
18
+ } from "./protocol.ts"
19
+ import {
20
+ broadcastPrefChange,
21
+ getPluginCacheStore,
22
+ getPluginPrefStore,
23
+ seedPluginStores,
24
+ setPluginCache,
25
+ setPluginPref,
26
+ snapshotCacheEntries,
27
+ } from "./stores.ts"
28
+ import type { FileUrlVariant, WebPluginAPI } from "./types.ts"
29
+ import {
30
+ buildAssetUrl,
31
+ buildFileUrl,
32
+ buildFrameUrl,
33
+ resolveFilesBaseUrl,
34
+ } from "./urls.ts"
35
+
36
+ // ── Pure helpers ─────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Narrow an unknown value to a plain record. Handy for decoding
40
+ * plugin-defined payloads (e.g. anchor data) without assertion casts.
41
+ */
42
+ export function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return value !== null && typeof value === "object" && !Array.isArray(value)
44
+ }
45
+
46
+ /**
47
+ * Extract `{ resolvedTheme, palette, iconStyle }` from an unknown host
48
+ * payload. Malformed input yields `undefined` fields rather than throwing —
49
+ * theme pushes are best-effort.
50
+ */
51
+ export function extractThemePayload(data: unknown): {
52
+ resolvedTheme: string | undefined
53
+ palette: string | undefined
54
+ iconStyle: string | undefined
55
+ } {
56
+ if (!isRecord(data)) {
57
+ return {
58
+ resolvedTheme: undefined,
59
+ palette: undefined,
60
+ iconStyle: undefined,
61
+ }
62
+ }
63
+ return {
64
+ resolvedTheme:
65
+ typeof data.resolvedTheme === "string" ? data.resolvedTheme : undefined,
66
+ palette: typeof data.palette === "string" ? data.palette : undefined,
67
+ iconStyle: typeof data.iconStyle === "string" ? data.iconStyle : undefined,
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Extract the host app font payload (`family` + stylesheet paths) from
73
+ * an unknown host push; `undefined` when the shape does not match.
74
+ */
75
+ export function extractFontsPayload(data: unknown): PluginFonts | undefined {
76
+ if (!isRecord(data) || typeof data.family !== "string") return undefined
77
+ const cssPaths = Array.isArray(data.cssPaths)
78
+ ? data.cssPaths.filter((p): p is string => typeof p === "string")
79
+ : []
80
+ return { family: data.family, cssPaths }
81
+ }
82
+
83
+ /**
84
+ * Extract a `{ key, value }` pref update from an unknown host push;
85
+ * `undefined` when the shape does not match. `value` may be `undefined`
86
+ * for a removal.
87
+ */
88
+ export function extractPrefPayload(
89
+ data: unknown,
90
+ ): { readonly key: string; readonly value: string | undefined } | undefined {
91
+ if (!isRecord(data)) return undefined
92
+ const key = data.key
93
+ if (typeof key !== "string") return undefined
94
+ return {
95
+ key,
96
+ value: typeof data.value === "string" ? data.value : undefined,
97
+ }
98
+ }
99
+
100
+ // ── Public API ───────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Builds the full {@link WebPluginAPI} for a plugin running inside a sandboxed
104
+ * iframe. Communicates with the host via postMessage.
105
+ */
106
+ export function createIframeHostAPI<
107
+ TSchema extends PluginSchema = PluginSchema,
108
+ >(ctx: PluginIframeContext): WebPluginAPI<TSchema> {
109
+ const host = ensureHostBridge().withScope(ctx.resId)
110
+ seedPluginStores(ctx)
111
+
112
+ function logInfo(message: string, data?: Record<string, unknown>): void {
113
+ host.request("logInfo", { message, data }).catch(() => {})
114
+ }
115
+ function logWarn(message: string, data?: Record<string, unknown>): void {
116
+ host.request("logWarn", { message, data }).catch(() => {})
117
+ }
118
+ function logError(message: string, data?: Record<string, unknown>): void {
119
+ host.request("logError", { message, data }).catch(() => {})
120
+ }
121
+
122
+ function listFiles(): Promise<readonly TSchema["file"][]> {
123
+ return host.request("listFiles") as Promise<readonly TSchema["file"][]>
124
+ }
125
+
126
+ function readFile(path: string, range?: ReadFileRange): Promise<ArrayBuffer> {
127
+ return host.request("readFile", { path, range })
128
+ }
129
+
130
+ function resolveFileUrl(filename: string, variant?: FileUrlVariant): string {
131
+ return buildFileUrl(ctx.resId, filename, ctx.fileToken, variant)
132
+ }
133
+
134
+ function resolveExtractedUrl(path: string): string {
135
+ return `/api/resources/${ctx.resId}/extracted/${encodeURIComponent(
136
+ ctx.fileToken,
137
+ )}/${encodeURIComponent(path)}`
138
+ }
139
+
140
+ function extractProgressUrl(): string {
141
+ // The trailing slash carries the token segment the auth preHandler
142
+ // strips (see apps/server/src/infra/http/plugin.ts).
143
+ return `/api/resources/${ctx.resId}/extract-progress/${encodeURIComponent(
144
+ ctx.fileToken,
145
+ )}/`
146
+ }
147
+
148
+ function resolveBaseUrl(): string {
149
+ return resolveFilesBaseUrl(ctx.resId, ctx.fileToken)
150
+ }
151
+
152
+ function resolveFrameUrl(filename: string, timeMs: number): string {
153
+ return buildFrameUrl(ctx.resId, filename, timeMs, ctx.fileToken)
154
+ }
155
+
156
+ function listMessages(): Promise<readonly Message[]> {
157
+ return host.request("listMessages")
158
+ }
159
+
160
+ function createMessage(input: {
161
+ readonly body: string
162
+ readonly anchor?: unknown
163
+ }): Promise<Message> {
164
+ // The plugin deals in raw location data; the wire anchor is the
165
+ // `{ data }` envelope (the resource id is host state).
166
+ return host.request("createMessage", {
167
+ body: input.body,
168
+ anchor: input.anchor === undefined ? undefined : { data: input.anchor },
169
+ })
170
+ }
171
+
172
+ function listDanmaku(
173
+ filter?: DanmakuListFilter,
174
+ ): Promise<readonly Danmaku[]> {
175
+ return host.request("listDanmaku", { filter })
176
+ }
177
+
178
+ function createDanmaku(input: {
179
+ readonly text: string
180
+ readonly anchor: unknown
181
+ readonly mode?: DanmakuMode
182
+ }): Promise<Danmaku> {
183
+ return host.request("createDanmaku", {
184
+ text: input.text,
185
+ anchor: { data: input.anchor },
186
+ mode: input.mode,
187
+ })
188
+ }
189
+
190
+ function getPref(key: string): string | undefined {
191
+ return getPluginPrefStore().get(key) ?? undefined
192
+ }
193
+
194
+ function setPref(key: string, value: string): void {
195
+ setPluginPref(key, value)
196
+ broadcastPrefChange(key)
197
+ host.request("setPref", { key, value }).catch(() => {})
198
+ }
199
+
200
+ function getCache(key: string): string | undefined {
201
+ return getPluginCacheStore().get(key) ?? undefined
202
+ }
203
+
204
+ function setCache(key: string, value: string): void {
205
+ setPluginCache(key, value)
206
+ host.request("setCache", { key, value }).catch(() => {})
207
+ }
208
+
209
+ function listCache(): readonly {
210
+ readonly key: string
211
+ readonly value: string
212
+ }[] {
213
+ return snapshotCacheEntries()
214
+ }
215
+
216
+ function invalidate(target: InvalidateTarget): Promise<void> {
217
+ return host.request("invalidate", { target })
218
+ }
219
+
220
+ function download(
221
+ request: PluginDownloadRequest,
222
+ ): Promise<PluginDownloadResult> {
223
+ // The protocol table declares the 5-minute ceiling (consent dialog
224
+ // + transfer) — the bridge reads it; no per-call override here.
225
+ return host.request("download", request)
226
+ }
227
+
228
+ function resolveAssetUrl(path: string): string {
229
+ if (ctx.assetToken.length === 0) {
230
+ throw new Error(
231
+ 'resolveAssetUrl() — the plugin has no asset token: declare "download": true in the manifest and reload the preview',
232
+ )
233
+ }
234
+ return buildAssetUrl(ctx.pluginId, path, ctx.assetToken)
235
+ }
236
+
237
+ function deleteAsset(path: string): Promise<PluginAssetDeleteResult> {
238
+ return host.request("deleteAsset", { path })
239
+ }
240
+
241
+ function onAnchorJump(cb: (anchor: AnchorData) => void): () => void {
242
+ return host.subscribe("anchorJump", cb)
243
+ }
244
+
245
+ return {
246
+ logInfo,
247
+ logWarn,
248
+ logError,
249
+ resource: {
250
+ id: ctx.resId,
251
+ name: ctx.resName,
252
+ sourceMeta: ctx.sourceMeta as TSchema["sourceMeta"],
253
+ searchMeta: ctx.searchMeta as TSchema["searchMeta"],
254
+ fileStats: ctx.fileStats,
255
+ contentPluginId: ctx.contentPluginId,
256
+ },
257
+ listFiles,
258
+ readFile,
259
+ resolveFileUrl,
260
+ resolveExtractedUrl,
261
+ extractProgressUrl,
262
+ resolveBaseUrl,
263
+ resolveFrameUrl,
264
+ download,
265
+ resolveAssetUrl,
266
+ deleteAsset,
267
+ listMessages,
268
+ createMessage,
269
+ listDanmaku,
270
+ createDanmaku,
271
+ getPref,
272
+ setPref,
273
+ getCache,
274
+ setCache,
275
+ listCache,
276
+ invalidate,
277
+ onAnchorJump,
278
+ } satisfies WebPluginAPI<TSchema>
279
+ }
package/src/stores.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { PluginIframeContext } from "./protocol.ts"
2
+
3
+ /**
4
+ * In-memory module-level stores backing the plugin's pref/cache APIs
5
+ * (`api.getPref`/`setPref`, `api.getCache`/`setCache`). Seeded from the
6
+ * iframe context on mount; prefs mirror the host's plugin-wide settings,
7
+ * cache is per-resource state that the host persists (debounced) and
8
+ * restores on the next visit.
9
+ */
10
+ const pluginPrefStore = new Map<string, string>()
11
+ const pluginCacheStore = new Map<string, string>()
12
+
13
+ /**
14
+ * Reset both stores and seed them from the iframe context. Called once
15
+ * by the runtime on mount; must run before any pref/cache access.
16
+ */
17
+ export function seedPluginStores(ctx: PluginIframeContext): void {
18
+ pluginPrefStore.clear()
19
+ for (const [k, v] of Object.entries(ctx.initialPrefs)) {
20
+ pluginPrefStore.set(k, v)
21
+ }
22
+ pluginCacheStore.clear()
23
+ for (const [k, v] of Object.entries(ctx.initialCache)) {
24
+ pluginCacheStore.set(k, v)
25
+ }
26
+ }
27
+
28
+ /** Read-only view of the plugin's in-memory pref store. */
29
+ export function getPluginPrefStore(): ReadonlyMap<string, string> {
30
+ return pluginPrefStore
31
+ }
32
+
33
+ /** Write a pref locally; mirror it to the host immediately. */
34
+ export function setPluginPref(key: string, value: string): void {
35
+ pluginPrefStore.set(key, value)
36
+ }
37
+
38
+ /** Read-only view of the plugin's in-memory cache store. */
39
+ export function getPluginCacheStore(): ReadonlyMap<string, string> {
40
+ return pluginCacheStore
41
+ }
42
+
43
+ /**
44
+ * Write a cache entry locally and mirror it to the host for persistence.
45
+ * Continuously-changing state (scroll positions, resume timestamps)
46
+ * should go through the debounced `useCacheWriter` in
47
+ * `@hoardodile/sdk-react` instead of calling this directly on every
48
+ * change.
49
+ */
50
+ export function setPluginCache(key: string, value: string): void {
51
+ pluginCacheStore.set(key, value)
52
+ }
53
+
54
+ /**
55
+ * Snapshot of all cache entries, as returned by `api.listCache`. Values
56
+ * are the raw serialized strings stored via {@link setPluginCache}.
57
+ */
58
+ export function snapshotCacheEntries(): {
59
+ readonly key: string
60
+ readonly value: string
61
+ }[] {
62
+ const result: { readonly key: string; readonly value: string }[] = []
63
+ for (const [key, value] of pluginCacheStore) {
64
+ result.push({ key, value })
65
+ }
66
+ return result
67
+ }
68
+
69
+ // ── Pref change pub/sub ──────────────────────────────────────────────────
70
+
71
+ const prefChangeListeners = new Map<string, Set<() => void>>()
72
+
73
+ /**
74
+ * Subscribe to pref changes for `key` (both local writes and host-pushed
75
+ * updates). Returns an unsubscribe function. Backs the reactive
76
+ * `usePref` hook in `@hoardodile/sdk-react`.
77
+ */
78
+ export function subscribeToPrefChanges(
79
+ key: string,
80
+ cb: () => void,
81
+ ): () => void {
82
+ let listeners = prefChangeListeners.get(key)
83
+ if (listeners === undefined) {
84
+ listeners = new Set()
85
+ prefChangeListeners.set(key, listeners)
86
+ }
87
+ listeners.add(cb)
88
+ return function unsubscribe() {
89
+ listeners!.delete(cb)
90
+ if (listeners!.size === 0) {
91
+ prefChangeListeners.delete(key)
92
+ }
93
+ }
94
+ }
95
+
96
+ /** Notify all subscribers of `key` about a value change. */
97
+ export function broadcastPrefChange(key: string): void {
98
+ const listeners = prefChangeListeners.get(key)
99
+ if (listeners === undefined) return
100
+ for (const cb of listeners) {
101
+ cb()
102
+ }
103
+ }
package/src/types.ts ADDED
@@ -0,0 +1,252 @@
1
+ import type {
2
+ AnchorData,
3
+ Danmaku,
4
+ DanmakuListFilter,
5
+ DanmakuMode,
6
+ FileStats,
7
+ Message,
8
+ PluginAssetDeleteResult,
9
+ PluginDownloadRequest,
10
+ PluginDownloadResult,
11
+ PluginSchema,
12
+ } from "@hoardodile/sdk-types"
13
+ import type { ImageVariantSpec } from "@hoardodile/sdk-types/image-variant"
14
+ import type {
15
+ InvalidateTarget,
16
+ PluginFonts,
17
+ ReadFileRange,
18
+ } from "./protocol.ts"
19
+
20
+ /**
21
+ * What {@link WebPluginAPI.resolveFileUrl} may address: the original
22
+ * bytes (`"original"`, or omit the argument), the default preview
23
+ * variant (`"preview"`), or a custom derived image via
24
+ * {@link ImageVariantSpec}.
25
+ */
26
+ export type FileUrlVariant = "original" | "preview" | ImageVariantSpec
27
+
28
+ /**
29
+ * The current resource as seen by the plugin iframe: id/name plus the
30
+ * schema-typed metadata (`sourceMeta`, `searchMeta`, `fileStats`) that
31
+ * the host derived at import time. Injected via the iframe context; the
32
+ * reactive hooks derive from it, so render code reads the live value
33
+ * from `usePluginAPI().resource`.
34
+ */
35
+ export type PluginResource<TSchema extends PluginSchema = PluginSchema> = {
36
+ readonly id: string
37
+ readonly name: string
38
+ readonly sourceMeta: TSchema["sourceMeta"]
39
+ readonly searchMeta: TSchema["searchMeta"]
40
+ readonly fileStats: FileStats | undefined
41
+ readonly contentPluginId: string
42
+ }
43
+
44
+ /** Encode/decode pair for typed preference values. */
45
+ export type Codec<T> = {
46
+ readonly encode: (value: T) => string
47
+ readonly decode: (raw: string) => T | undefined
48
+ }
49
+
50
+ /** Reactive query state returned by hooks. */
51
+ export type QueryState<T> = {
52
+ readonly data: T | undefined
53
+ readonly isLoading: boolean
54
+ readonly isError: boolean
55
+ readonly error: Error | null
56
+ }
57
+
58
+ /** Reactive mutation state returned by hooks. */
59
+ export type MutationState<TInput, TOutput> = {
60
+ readonly mutate: (input: TInput) => Promise<TOutput>
61
+ readonly isPending: boolean
62
+ }
63
+
64
+ /** Current theme as observed by the plugin. */
65
+ export type Theme = {
66
+ readonly resolvedTheme: string
67
+ readonly palette: string
68
+ /** Icon rendering style (`duotone` | `grayscale` | `linear`). */
69
+ readonly iconStyle: string
70
+ }
71
+
72
+ /**
73
+ * Imperative, framework-agnostic API surface injected into plugin render
74
+ * modules. Reactive hooks (see {@link ReactivePluginAPI}) are provided by
75
+ * framework adapters — `@hoardodile/sdk-react` composes both into the
76
+ * full API seen by React plugin components.
77
+ */
78
+ export type WebPluginAPI<TSchema extends PluginSchema = PluginSchema> = {
79
+ /** Logging */
80
+ readonly logInfo: (message: string, data?: Record<string, unknown>) => void
81
+ readonly logWarn: (message: string, data?: Record<string, unknown>) => void
82
+ readonly logError: (message: string, data?: Record<string, unknown>) => void
83
+
84
+ /** Resource context. */
85
+ readonly resource: PluginResource<TSchema>
86
+
87
+ /** Files. */
88
+ readonly listFiles: () => Promise<readonly TSchema["file"][]>
89
+ /**
90
+ * Read a file relative to the resource root. Without `range` the whole
91
+ * file is returned; large files should be read in bounded chunks via
92
+ * the byte range (mirrors the server-side `ResourceAPI.readFile`).
93
+ */
94
+ readonly readFile: (
95
+ path: string,
96
+ range?: ReadFileRange,
97
+ ) => Promise<ArrayBuffer>
98
+ /**
99
+ * Resolve a server-rendered URL for a file inside the resource.
100
+ * Without `variant` (or with `"original"`) the URL addresses the
101
+ * original bytes; `"preview"` selects the default preview variant
102
+ * (AVIF, fit inside the standard area cap); pass an
103
+ * {@link ImageVariantSpec} to request a custom derived image —
104
+ * e.g. `{ format: "webp", fit: "exact" }` transcodes to WebP at the
105
+ * source's exact pixel dimensions (no resize), and
106
+ * `{ maxArea: 2_000_000 }` caps a downscale. Variant renders are
107
+ * cached by the host; pick the `file.preview` flag to gate an
108
+ * original/preview toggle.
109
+ */
110
+ readonly resolveFileUrl: (
111
+ filename: string,
112
+ variant?: FileUrlVariant,
113
+ ) => string
114
+ /**
115
+ * Resolve the URL of a file materialized by the plugin's
116
+ * `extractArchive` hook: an inner entry of an archive (zip/tar)
117
+ * served from the host's extraction cache. `path` is the entry's
118
+ * relative path inside the archive, exactly as returned by
119
+ * `extractArchive` / the `listFiles` hook. Tokenized like
120
+ * `resolveFileUrl`.
121
+ */
122
+ readonly resolveExtractedUrl: (path: string) => string
123
+ /**
124
+ * Resolve the URL of the host's in-flight extraction progress for
125
+ * this resource (see `extractArchive`). Returns
126
+ * `{ done, total }` while materializing, `null` otherwise. Tokenized
127
+ * like `resolveFileUrl`; polls are cheap (no-store JSON).
128
+ */
129
+ readonly extractProgressUrl: () => string
130
+ /**
131
+ * Root URL of the current resource's files directory, trailing-slash
132
+ * included. For vendor SDKs that internally join relative paths and need
133
+ * a base.
134
+ */
135
+ readonly resolveBaseUrl: () => string
136
+ /**
137
+ * Resolve a server-rendered frame thumbnail URL for a video file at the
138
+ * given timestamp (in milliseconds, measured from the start of the
139
+ * file). The server decodes the requested frame on demand; callers
140
+ * should debounce frequent invocations (e.g. while scrubbing) to avoid
141
+ * a flood of decode requests.
142
+ */
143
+ readonly resolveFrameUrl: (filename: string, timeMs: number) => string
144
+
145
+ /** Plugin asset vault. */
146
+ /**
147
+ * User-consented download into the plugin's own asset vault: when the
148
+ * destination already exists the host answers `cached: true` (no
149
+ * dialog, no network); otherwise the host asks the user (shared
150
+ * consent dialog, URL shown verbatim) and downloads on approval.
151
+ * Rejections carry a machine-readable `err.name`
152
+ * (`DENIED` / `UNAVAILABLE` / `POLICY`).
153
+ */
154
+ readonly download: (
155
+ request: PluginDownloadRequest,
156
+ ) => Promise<PluginDownloadResult>
157
+ /**
158
+ * Resolve the tokenized URL of a file in the plugin's own vault
159
+ * (`/api/plugin-assets/<pluginId>/<token>/<path>`). Use it to load a
160
+ * downloaded runtime or asset from inside the sandboxed iframe, e.g.
161
+ * `<script src={api.resolveAssetUrl("runtime/live2d.min.js")} />`
162
+ * (served with an exact JS MIME + `nosniff`, so classic scripts,
163
+ * module imports and `fetch` all work).
164
+ */
165
+ readonly resolveAssetUrl: (path: string) => string
166
+ /**
167
+ * Remove a vault file (idempotent: an absent file answers
168
+ * `{ existed: false }`). The plugin decides the vault's lifecycle —
169
+ * no user consent, nothing leaves the host.
170
+ */
171
+ readonly deleteAsset: (path: string) => Promise<PluginAssetDeleteResult>
172
+
173
+ /** Messages. */
174
+ readonly listMessages: () => Promise<readonly Message[]>
175
+ readonly createMessage: (input: {
176
+ readonly body: string
177
+ /** Raw plugin location data (see {@link PluginSchema.anchor}). */
178
+ readonly anchor?: TSchema["anchor"]
179
+ }) => Promise<Message>
180
+
181
+ /** Danmaku. */
182
+ readonly listDanmaku: (
183
+ filter?: DanmakuListFilter,
184
+ ) => Promise<readonly Danmaku[]>
185
+ readonly createDanmaku: (input: {
186
+ readonly text: string
187
+ /** Raw plugin location data (see {@link PluginSchema.anchor}). */
188
+ readonly anchor: TSchema["anchor"]
189
+ readonly mode?: DanmakuMode
190
+ }) => Promise<Danmaku>
191
+
192
+ /** Preferences. */
193
+ readonly getPref: (key: string) => string | undefined
194
+ readonly setPref: (key: string, value: string) => void
195
+
196
+ /** Cache. */
197
+ readonly getCache: (key: string) => string | undefined
198
+ readonly setCache: (key: string, value: string) => void
199
+ readonly listCache: () => readonly {
200
+ readonly key: string
201
+ readonly value: string
202
+ }[]
203
+
204
+ /** Invalidation. */
205
+ readonly invalidate: (target: InvalidateTarget) => Promise<void>
206
+
207
+ /**
208
+ * Subscribe to host-initiated anchor jumps (e.g. the user clicked a
209
+ * comment anchor in the host UI). The callback receives the raw wire
210
+ * envelope ({@link AnchorData}) — decode `anchor.data` yourself, or
211
+ * use the typed `useAnchorJump` from `@hoardodile/sdk-react`, which
212
+ * decodes at the SDK boundary. Always targets the iframe's own
213
+ * resource. Returns an unsubscribe function.
214
+ */
215
+ readonly onAnchorJump: (cb: (anchor: AnchorData) => void) => () => void
216
+ }
217
+
218
+ /**
219
+ * Reactive (hook-based) API surface, implemented by framework adapters.
220
+ * Plain `@hoardodile/sdk-web` consumers get the imperative
221
+ * {@link WebPluginAPI} only; `@hoardodile/sdk-react` provides these via
222
+ * `createPluginQueryAPI`.
223
+ */
224
+ export type ReactivePluginAPI<TSchema extends PluginSchema = PluginSchema> = {
225
+ readonly useFileList: () => QueryState<readonly TSchema["file"][]>
226
+ readonly useMessageList: () => QueryState<readonly Message[]>
227
+ readonly useCreateMessage: () => MutationState<
228
+ {
229
+ readonly body: string
230
+ readonly anchor?: TSchema["anchor"]
231
+ },
232
+ Message
233
+ >
234
+ readonly useDanmakuList: (
235
+ filter?: DanmakuListFilter,
236
+ ) => QueryState<readonly Danmaku[]>
237
+ readonly useCreateDanmaku: () => MutationState<
238
+ {
239
+ readonly text: string
240
+ readonly anchor: TSchema["anchor"]
241
+ readonly mode?: DanmakuMode
242
+ },
243
+ Danmaku
244
+ >
245
+ readonly usePref: <T>(
246
+ key: string,
247
+ defaultValue: T,
248
+ codec?: Codec<T>,
249
+ ) => readonly [T, (value: T) => void]
250
+ readonly useTheme: () => Theme
251
+ readonly useFont: () => PluginFonts
252
+ }
package/src/urls.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { imageVariantQuery } from "@hoardodile/sdk-types/image-variant"
2
+ import type { FileUrlVariant } from "./types.ts"
3
+
4
+ // ── File URL resolution ──────────────────────────────────────────────────
5
+ // Pure string builders: the iframe's null origin cannot send cookies, so
6
+ // the host embeds a short-lived token in the path (see
7
+ // apps/server/src/infra/http/plugin.ts). Kept side-effect free so the
8
+ // wire shape is unit-testable without the host bridge.
9
+
10
+ export function resolveFilesBaseUrl(resId: string, token: string): string {
11
+ return `/api/resources/${resId}/files/${encodeURIComponent(token)}/`
12
+ }
13
+
14
+ /**
15
+ * Build the URL for a resource file. Without `variant` (or with
16
+ * `"original"`) the original bytes are addressed; `"preview"` selects
17
+ * the default preview variant; an {@link ImageVariantSpec} requests a
18
+ * custom derived image. Derived URLs always carry `size=preview` (the
19
+ * compatibility alias) alongside the explicit variant parameters, so a
20
+ * server that predates the generic contract degrades to its default
21
+ * preview instead of silently serving the original.
22
+ */
23
+ export function buildFileUrl(
24
+ resId: string,
25
+ filename: string,
26
+ token: string,
27
+ variant?: FileUrlVariant,
28
+ ): string {
29
+ const url = `/api/resources/${resId}/files/${encodeURIComponent(token)}/${encodeURIComponent(filename)}`
30
+ if (variant === "preview") {
31
+ return `${url}?size=preview`
32
+ }
33
+ if (variant !== undefined && variant !== "original") {
34
+ return `${url}?${imageVariantQuery(variant)}`
35
+ }
36
+ return url
37
+ }
38
+
39
+ /** Build the URL for a video frame thumbnail at `timeMs`. */
40
+ export function buildFrameUrl(
41
+ resId: string,
42
+ filename: string,
43
+ timeMs: number,
44
+ token: string,
45
+ ): string {
46
+ const time = String(Math.max(0, Math.round(timeMs)))
47
+ return `/api/resources/${resId}/frame/${encodeURIComponent(token)}/${encodeURIComponent(filename)}/${time}`
48
+ }
49
+
50
+ /**
51
+ * Build the URL of a file in the plugin's own asset vault. The host
52
+ * serves it via the tokenized `/api/plugin-assets/:id/:token/:path`
53
+ * route (see `apps/server/src/infra/http/plugin-assets.ts`): `token` is
54
+ * the plugin-scoped asset token from the iframe context — vault files
55
+ * are host data, so they are served fresh (`no-cache`), never through
56
+ * the service worker.
57
+ */
58
+ export function buildAssetUrl(
59
+ pluginId: string,
60
+ path: string,
61
+ token: string,
62
+ ): string {
63
+ return `/api/plugin-assets/${encodeURIComponent(pluginId)}/${encodeURIComponent(
64
+ token,
65
+ )}/${encodeURIComponent(path)}`
66
+ }