@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/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
|
+
}
|