@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.
@@ -0,0 +1,448 @@
1
+ import type {
2
+ FileStats,
3
+ PluginAssetDeleteResult,
4
+ PluginDownloadRequest,
5
+ PluginDownloadResult,
6
+ ReadFileRange,
7
+ SearchMeta,
8
+ SerializedFileList,
9
+ } from "@hoardodile/sdk-types"
10
+
11
+ /**
12
+ * Wire protocol version shared between the plugin SDK and the browser
13
+ * host. Bumped only on incompatible protocol changes. Plugins stamp every
14
+ * outbound message with it; the host warns loudly when a plugin was built
15
+ * against a different version.
16
+ */
17
+ export const PROTOCOL_VERSION = 1 as const
18
+
19
+ /** Shared read-range contract; defined once in `@hoardodile/sdk-types`. */
20
+ export type { ReadFileRange } from "@hoardodile/sdk-types"
21
+
22
+ export type PluginResolvedTheme = "light" | "dark"
23
+
24
+ /**
25
+ * Canonical list of theme palette ids — the single source of truth shared
26
+ * by the host app and every plugin. `mono` is the default and has no CSS
27
+ * class (it lives in `:root` / `.dark`); every other id maps to a
28
+ * `.theme-<id>` block in `@hoardodile/ui/theme.css` and a
29
+ * `theme.palette.<id>` i18n label.
30
+ */
31
+ export const pluginThemePalettes = [
32
+ "mono",
33
+ "sage",
34
+ "parchment",
35
+ "azure",
36
+ "hoardodile",
37
+ ] as const
38
+
39
+ export type PluginThemePalette = (typeof pluginThemePalettes)[number]
40
+
41
+ /** Icon rendering style as chosen in host Settings → Icons. */
42
+ export type PluginIconStyle = "duotone" | "grayscale" | "linear"
43
+
44
+ /** Host app font as observed by the plugin. */
45
+ export type PluginFonts = {
46
+ /** CSS `font-family` stack; empty when the plugin opted out of inheritance. */
47
+ readonly family: string
48
+ /** Absolute paths (`/fonts/...`) of the preset stylesheets backing the stack. */
49
+ readonly cssPaths: readonly string[]
50
+ }
51
+
52
+ /**
53
+ * Context injected into the iframe as `window.__context__` and pushed via
54
+ * the `context` host push. Not a one-shot: the host may push a replacement
55
+ * context at any time (a pooled iframe is rebound across resources without
56
+ * a reload), and every push re-invokes the `mountPlugin` mount callback.
57
+ */
58
+ export type PluginIframeContext = {
59
+ readonly pluginId: string
60
+ readonly resId: string
61
+ readonly resName: string
62
+ readonly sourceMeta: unknown
63
+ readonly searchMeta: SearchMeta | undefined
64
+ readonly fileStats: FileStats | undefined
65
+ readonly contentPluginId: string
66
+ /** Current UI language code. The iframe uses this to select its own locale bundle. */
67
+ readonly language: string
68
+ /** Current resolved theme (light or dark). */
69
+ readonly resolvedTheme: PluginResolvedTheme
70
+ /** Current theme palette. */
71
+ readonly palette: PluginThemePalette
72
+ /** Current icon rendering style (Settings → Icons). */
73
+ readonly iconStyle: PluginIconStyle
74
+ /**
75
+ * Host app font to apply inside the iframe: a CSS `font-family` stack
76
+ * plus the preset stylesheets that back it. An empty family means the
77
+ * plugin opted out (`ui.inheritFont: false`) and keeps its own fonts.
78
+ */
79
+ readonly fonts: PluginFonts
80
+ /** Initial plugin-scoped prefs (unprefixed keys) loaded from server. */
81
+ readonly initialPrefs: Record<string, string>
82
+ /** Initial plugin+resId cache entries (unprefixed keys) loaded from server. */
83
+ readonly initialCache: Record<string, string>
84
+ /**
85
+ * Short-lived token that lets the sandboxed iframe fetch resource files
86
+ * without a session cookie (null-origin iframe cannot send SameSite cookies).
87
+ */
88
+ readonly fileToken: string
89
+ /**
90
+ * Short-lived token for the plugin's own asset vault URLs
91
+ * (`/api/plugin-assets/<pluginId>/<token>/<path>`), issued only when
92
+ * the plugin's manifest declares the `download` permission. Empty
93
+ * string otherwise — `resolveAssetUrl` throws on the empty token
94
+ * (never builds a malformed `/…//path` URL); check the permission
95
+ * before relying on vault URLs.
96
+ */
97
+ readonly assetToken: string
98
+ }
99
+
100
+ /** Wire request from plugin (iframe) to host. */
101
+ export type PluginRequest = {
102
+ readonly type: "request"
103
+ readonly id: number
104
+ readonly method: string
105
+ readonly params?: unknown
106
+ /** Wire protocol version the plugin was built against (see {@link PROTOCOL_VERSION}). */
107
+ readonly proto?: number
108
+ /**
109
+ * SDK-internal scope stamp: the resource the request was issued for,
110
+ * captured by the runtime when the plugin called the API. The host
111
+ * drops the request as stale when the stamp no longer matches the
112
+ * iframe's binding (e.g. an unmount flush racing a rebind), so late
113
+ * requests never leak into the wrong resource. Plugin code never
114
+ * sets this.
115
+ */
116
+ readonly resId?: string
117
+ }
118
+
119
+ /** Wire response from host to plugin for a prior request. */
120
+ export type HostResponse = {
121
+ readonly type: "response"
122
+ readonly id: number
123
+ readonly ok: boolean
124
+ readonly data?: unknown
125
+ readonly error?: string
126
+ /**
127
+ * Optional machine-readable plugin error code (e.g. the asset
128
+ * `DENIED` / `UNAVAILABLE` / `POLICY` vocabulary) so the bridge can
129
+ * reject with an Error carrying the code — plugin code branches on
130
+ * `err.name` across the postMessage boundary.
131
+ */
132
+ readonly errorCode?: string
133
+ /**
134
+ * Legacy alias of {@link errorCode} (same value) kept for host
135
+ * builds predating the unified field. The bridge reads
136
+ * `errorCode ?? errorName`.
137
+ */
138
+ readonly errorName?: string
139
+ }
140
+
141
+ /** Wire push event from host to plugin. */
142
+ export type HostPush = {
143
+ readonly type: "push"
144
+ readonly key: string
145
+ readonly data?: unknown
146
+ }
147
+
148
+ /** Wire subscription request from plugin to host. */
149
+ export type PluginSubscribe = {
150
+ readonly type: "subscribe"
151
+ readonly key: string
152
+ /** Wire protocol version the plugin was built against (see {@link PROTOCOL_VERSION}). */
153
+ readonly proto?: number
154
+ }
155
+
156
+ /**
157
+ * Wire acknowledgement from plugin to host: the pushed context has been
158
+ * applied *and painted* — the mount callback returned (for React plugins
159
+ * the new tree is already committed via flushSync) and a frame with the
160
+ * new content has reached the compositor. The host keeps a freshly
161
+ * claimed pooled iframe transparent until this arrives, so the previous
162
+ * resource's content never shows under a new claim.
163
+ */
164
+ export type PluginContextPainted = {
165
+ readonly type: "contextPainted"
166
+ readonly resId: string
167
+ /** Wire protocol version the plugin was built against (see {@link PROTOCOL_VERSION}). */
168
+ readonly proto?: number
169
+ }
170
+
171
+ /** Union of all messages a plugin can send to the host. */
172
+ export type PluginMessage =
173
+ | PluginRequest
174
+ | PluginSubscribe
175
+ | PluginContextPainted
176
+
177
+ /** Union of all messages the host can send to a plugin. */
178
+ export type HostMessage = HostResponse | HostPush
179
+
180
+ /** Type-safe request protocol table. Each entry declares input and output. */
181
+ export type PluginRequests = {
182
+ logInfo: {
183
+ readonly input: {
184
+ readonly message: string
185
+ readonly data?: Record<string, unknown>
186
+ }
187
+ readonly output: undefined
188
+ }
189
+ logWarn: {
190
+ readonly input: {
191
+ readonly message: string
192
+ readonly data?: Record<string, unknown>
193
+ }
194
+ readonly output: undefined
195
+ }
196
+ logError: {
197
+ readonly input: {
198
+ readonly message: string
199
+ readonly data?: Record<string, unknown>
200
+ }
201
+ readonly output: undefined
202
+ }
203
+ listFiles: {
204
+ readonly input: undefined
205
+ readonly output: SerializedFileList
206
+ }
207
+ readFile: {
208
+ readonly input: {
209
+ readonly path: string
210
+ /** Byte range (see {@link ReadFileRange}); omitted = whole file. */
211
+ readonly range?: ReadFileRange
212
+ }
213
+ readonly output: ArrayBuffer
214
+ // Timeout: see {@link pluginRequestTimeouts.readFile} (large files
215
+ // stream through the host process before the bytes arrive).
216
+ }
217
+ listMessages: {
218
+ readonly input: undefined
219
+ readonly output: readonly import("@hoardodile/sdk-types").Message[]
220
+ }
221
+ createMessage: {
222
+ readonly input: {
223
+ readonly body: string
224
+ /** Wire anchor envelope; plugins pass raw data, the SDK wraps it. */
225
+ readonly anchor?: import("@hoardodile/sdk-types").AnchorData
226
+ }
227
+ readonly output: import("@hoardodile/sdk-types").Message
228
+ }
229
+ listDanmaku: {
230
+ readonly input: {
231
+ readonly filter?: import("@hoardodile/sdk-types").DanmakuListFilter
232
+ }
233
+ readonly output: readonly import("@hoardodile/sdk-types").Danmaku[]
234
+ }
235
+ createDanmaku: {
236
+ readonly input: {
237
+ readonly text: string
238
+ /** Wire anchor envelope (see {@link PluginRequests.createMessage}). */
239
+ readonly anchor: import("@hoardodile/sdk-types").AnchorData
240
+ readonly mode?: import("@hoardodile/sdk-types").DanmakuMode
241
+ }
242
+ readonly output: import("@hoardodile/sdk-types").Danmaku
243
+ }
244
+ setPref: {
245
+ /** Persist a plugin-wide preference; host broadcasts `prefsChanged`. */
246
+ readonly input: { readonly key: string; readonly value: string }
247
+ readonly output: undefined
248
+ }
249
+ setCache: {
250
+ /**
251
+ * Persist a per-resource cache entry; host broadcasts
252
+ * `cacheChanged`.
253
+ */
254
+ readonly input: {
255
+ readonly key: string
256
+ readonly value: string
257
+ }
258
+ readonly output: undefined
259
+ }
260
+ invalidate: {
261
+ /** Request the host to invalidate cached data for a target. */
262
+ readonly input: { readonly target: InvalidateTarget }
263
+ readonly output: undefined
264
+ }
265
+ /**
266
+ * User-consented download into the plugin's own asset vault (see
267
+ * `@hoardodile/sdk-types/plugin-asset`). The host asks the user with
268
+ * the shared consent dialog; cached destinations resolve without any
269
+ * dialog. Rejections carry a machine-readable `err.name`
270
+ * (`DENIED` / `UNAVAILABLE` / `POLICY`).
271
+ *
272
+ * Timeout: {@link pluginRequestTimeouts.download} — the client-side
273
+ * ceiling for the whole flow (consent dialog + transfer), declared in
274
+ * the protocol meta rather than ad hoc at the call site.
275
+ */
276
+ download: {
277
+ readonly input: PluginDownloadRequest
278
+ readonly output: PluginDownloadResult
279
+ }
280
+ /**
281
+ * Remove a vault file (idempotent); the plugin decides its own vault
282
+ * lifecycle — no user consent, nothing leaves the host.
283
+ */
284
+ deleteAsset: {
285
+ readonly input: { readonly path: string }
286
+ readonly output: PluginAssetDeleteResult
287
+ }
288
+ }
289
+
290
+ /** Type-safe push protocol table. */
291
+ export type HostPushes = {
292
+ context: PluginIframeContext
293
+ visibility: { readonly visible: boolean }
294
+ themeChanged: {
295
+ readonly resolvedTheme: string
296
+ readonly palette: string
297
+ /** Active icon rendering style — host applies it as `data-icon-style`. */
298
+ readonly iconStyle: PluginIconStyle
299
+ }
300
+ fontsChanged: PluginFonts
301
+ /**
302
+ * The wire payload is a bare language-code string: it predates the
303
+ * typed protocol table and must stay stable for already-installed
304
+ * plugin builds (see `pushLanguageChanged` in apps/web — do not wrap
305
+ * it in an object).
306
+ */
307
+ languageChanged: string
308
+ prefsChanged: { readonly key: string; readonly value?: string }
309
+ /**
310
+ * A plugin+resource cache entry changed. With data, carries the single
311
+ * changed entry; without data (undefined), all entries were cleared and
312
+ * the plugin should drop its whole cache store.
313
+ */
314
+ cacheChanged:
315
+ | { readonly resId: string; readonly key: string; readonly value?: string }
316
+ | undefined
317
+ /**
318
+ * Host-initiated request to jump to an anchor (e.g. the user clicked a
319
+ * comment anchor in the host UI). Carries the plugin-defined anchor data
320
+ * only — the resource is always the iframe's own.
321
+ */
322
+ anchorJump: import("@hoardodile/sdk-types").AnchorData
323
+ "res:invalidate": undefined
324
+ "resources:invalidate": undefined
325
+ "messages:invalidate": undefined
326
+ "danmaku:invalidate": undefined
327
+ }
328
+
329
+ /** Extract the input type for a request key. */
330
+ export type RequestInput<K extends keyof PluginRequests> =
331
+ PluginRequests[K]["input"]
332
+
333
+ /** Extract the output type for a request key. */
334
+ export type RequestOutput<K extends keyof PluginRequests> =
335
+ PluginRequests[K]["output"]
336
+
337
+ /** Targets that can be invalidated from the plugin runtime. */
338
+ export type InvalidateTarget = "resource" | "resources" | "messages" | "danmaku"
339
+
340
+ // ── Runtime wire constants ─────────────────────────────────────────────────
341
+ //
342
+ // The type tables above are the protocol's single source of truth; these
343
+ // constants are their runtime mirror. The two are interlocked at compile
344
+ // time in both directions: a constant value that is not a table key fails
345
+ // `satisfies`, and a table key missing from the constant fails the
346
+ // `AssertTrue` checks below. Add a key to both or neither.
347
+
348
+ /** Wire keys for host→plugin pushes, mirroring {@link HostPushes}. */
349
+ export const hostPushKeys = {
350
+ context: "context",
351
+ visibility: "visibility",
352
+ themeChanged: "themeChanged",
353
+ fontsChanged: "fontsChanged",
354
+ languageChanged: "languageChanged",
355
+ prefsChanged: "prefsChanged",
356
+ cacheChanged: "cacheChanged",
357
+ anchorJump: "anchorJump",
358
+ resInvalidate: "res:invalidate",
359
+ resourcesInvalidate: "resources:invalidate",
360
+ messagesInvalidate: "messages:invalidate",
361
+ danmakuInvalidate: "danmaku:invalidate",
362
+ } as const satisfies Record<string, keyof HostPushes>
363
+
364
+ /** Wire method names for plugin→host requests, mirroring {@link PluginRequests}. */
365
+ export const pluginMethods = {
366
+ // Files
367
+ readFile: "readFile",
368
+ listFiles: "listFiles",
369
+
370
+ // Messages
371
+ listMessages: "listMessages",
372
+ createMessage: "createMessage",
373
+
374
+ // Danmaku
375
+ listDanmaku: "listDanmaku",
376
+ createDanmaku: "createDanmaku",
377
+
378
+ // Preferences / cache
379
+ setPref: "setPref",
380
+ setCache: "setCache",
381
+
382
+ // Cache invalidation
383
+ invalidate: "invalidate",
384
+
385
+ // Plugin asset vault
386
+ download: "download",
387
+ deleteAsset: "deleteAsset",
388
+
389
+ // Logging — must match the PluginRequests keys exactly,
390
+ // otherwise plugin log calls are silently swallowed.
391
+ logInfo: "logInfo",
392
+ logWarn: "logWarn",
393
+ logError: "logError",
394
+ } as const satisfies Record<string, keyof PluginRequests>
395
+
396
+ /** Push key broadcast after each {@link InvalidateTarget} is invalidated. */
397
+ export const invalidatePushKeys = {
398
+ resource: hostPushKeys.resInvalidate,
399
+ resources: hostPushKeys.resourcesInvalidate,
400
+ messages: hostPushKeys.messagesInvalidate,
401
+ danmaku: hostPushKeys.danmakuInvalidate,
402
+ } as const satisfies Record<InvalidateTarget, keyof HostPushes>
403
+
404
+ /** Per-method bridge timeouts (ms), mirroring the entries above. */
405
+ export const pluginRequestTimeouts = {
406
+ readFile: 120_000,
407
+ download: 300_000,
408
+ } as const satisfies Partial<Record<keyof PluginRequests, number>>
409
+
410
+ /** Resolves to `T` only when `T` is `true`; otherwise a compile error. */
411
+ type AssertTrue<T extends true> = T
412
+
413
+ // Reverse coverage: every table key must appear as a constant value.
414
+ // Exported only so noUnusedLocals keeps these checks alive — never import.
415
+ export type _HostPushesCoveredByKeys = AssertTrue<
416
+ keyof HostPushes extends (typeof hostPushKeys)[keyof typeof hostPushKeys]
417
+ ? true
418
+ : false
419
+ >
420
+ export type _PluginRequestsCoveredByMethods = AssertTrue<
421
+ keyof PluginRequests extends (typeof pluginMethods)[keyof typeof pluginMethods]
422
+ ? true
423
+ : false
424
+ >
425
+
426
+ /**
427
+ * Type-safe host bridge. The runtime still serialises messages as plain
428
+ * postMessage objects; this contract gives compile-time guarantees to callers.
429
+ *
430
+ * Per-method timeouts are declared on the protocol table entries
431
+ * (`timeoutMs`) — the bridge reads them, callers never pass one.
432
+ */
433
+ export type Host = {
434
+ request<K extends keyof PluginRequests>(
435
+ method: K,
436
+ ...args: RequestInput<K> extends void ? [] : [RequestInput<K>]
437
+ ): Promise<RequestOutput<K>>
438
+ subscribe<K extends keyof HostPushes>(
439
+ key: K,
440
+ handler: (data: HostPushes[K]) => void,
441
+ ): () => void
442
+ /**
443
+ * Internal — returns a Host whose requests are stamped with the given
444
+ * resource scope (see {@link PluginRequest.resId}). Used by the runtime
445
+ * to bind one API instance to the resource it was created for.
446
+ */
447
+ withScope: (resId: string) => Host
448
+ }
@@ -0,0 +1,63 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, test } from "vitest"
3
+ import { buildFileUrl, buildFrameUrl, resolveFilesBaseUrl } from "./urls.ts"
4
+
5
+ const RES_ID = "res_1"
6
+ const TOKEN = "tok-abc"
7
+
8
+ describe("resolveFilesBaseUrl", () => {
9
+ test("tokenized files root with trailing slash", () => {
10
+ expect(resolveFilesBaseUrl(RES_ID, TOKEN)).toBe(
11
+ "/api/resources/res_1/files/tok-abc/",
12
+ )
13
+ })
14
+ })
15
+
16
+ describe("buildFileUrl", () => {
17
+ test("no variant addresses the original bytes", () => {
18
+ expect(buildFileUrl(RES_ID, "a.png", TOKEN)).toBe(
19
+ "/api/resources/res_1/files/tok-abc/a.png",
20
+ )
21
+ })
22
+
23
+ test("original alias emits no query", () => {
24
+ expect(buildFileUrl(RES_ID, "a.png", TOKEN, "original")).toBe(
25
+ "/api/resources/res_1/files/tok-abc/a.png",
26
+ )
27
+ })
28
+
29
+ test("preview alias emits the compatibility query", () => {
30
+ expect(buildFileUrl(RES_ID, "a.png", TOKEN, "preview")).toBe(
31
+ "/api/resources/res_1/files/tok-abc/a.png?size=preview",
32
+ )
33
+ })
34
+
35
+ test("a custom spec emits size=preview plus the variant parameters", () => {
36
+ expect(
37
+ buildFileUrl(RES_ID, "a.png", TOKEN, {
38
+ format: "webp",
39
+ fit: "exact",
40
+ quality: 80,
41
+ }),
42
+ ).toBe(
43
+ "/api/resources/res_1/files/tok-abc/a.png?size=preview&fmt=webp&fit=exact&q=80",
44
+ )
45
+ })
46
+
47
+ test("filenames and tokens are encoded (resIds are server-generated, not encoded)", () => {
48
+ expect(buildFileUrl(RES_ID, "dir/page.png", "t/ok", "preview")).toBe(
49
+ "/api/resources/res_1/files/t%2Fok/dir%2Fpage.png?size=preview",
50
+ )
51
+ })
52
+ })
53
+
54
+ describe("buildFrameUrl", () => {
55
+ test("time is clamped and rounded into the path", () => {
56
+ expect(buildFrameUrl(RES_ID, "clip.mp4", -5, TOKEN)).toBe(
57
+ "/api/resources/res_1/frame/tok-abc/clip.mp4/0",
58
+ )
59
+ expect(buildFrameUrl(RES_ID, "clip.mp4", 1250.6, TOKEN)).toBe(
60
+ "/api/resources/res_1/frame/tok-abc/clip.mp4/1251",
61
+ )
62
+ })
63
+ })