@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/protocol.ts
ADDED
|
@@ -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
|
+
})
|