@hoardodile/host-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,370 @@
1
+ import type {
2
+ DanmakuListFilter,
3
+ PluginAssetDeleteResult,
4
+ PluginDownloadRequest,
5
+ PluginDownloadResult,
6
+ } from "@hoardodile/sdk-types"
7
+ import {
8
+ type HostPush,
9
+ type HostResponse,
10
+ hostPushKeys,
11
+ invalidatePushKeys,
12
+ type PluginIframeContext,
13
+ pluginMethods,
14
+ } from "@hoardodile/sdk-web"
15
+ import { requestSchemas } from "../host-core/request-schemas.ts"
16
+ import type { HostBinding } from "../host-core/router.ts"
17
+ import {
18
+ createHostRouter,
19
+ defineHandler,
20
+ type HostHandlerEntry,
21
+ } from "../host-core/router.ts"
22
+ import type { MockFileBackend } from "./file-backends.ts"
23
+ import {
24
+ createMockDanmakuStore,
25
+ createMockMessageStore,
26
+ type MockDanmakuStore,
27
+ type MockMessageStore,
28
+ } from "./stores.ts"
29
+
30
+ /**
31
+ * The offline host side of the plugin postMessage bridge. Implements the
32
+ * same routing, validation and scoping as the production host (via
33
+ * host-core) with in-memory data — so plugin iframes run with no server
34
+ * at all. Shared by automated component tests (jsdom: register the test
35
+ * window) and the manual workbench page (register the real iframe
36
+ * window).
37
+ */
38
+
39
+ export type MockHostLogger = {
40
+ readonly info: (message: string, data?: unknown) => void
41
+ readonly warn: (message: string, data?: unknown) => void
42
+ readonly error: (message: string, data?: unknown) => void
43
+ }
44
+
45
+ const defaultLogger: MockHostLogger = {
46
+ info(message, data) {
47
+ console.log(`[mock-host] ${message}`, data ?? "")
48
+ },
49
+ warn(message, data) {
50
+ console.warn(`[mock-host] ${message}`, data ?? "")
51
+ },
52
+ error(message, data) {
53
+ console.error(`[mock-host] ${message}`, data ?? "")
54
+ },
55
+ }
56
+
57
+ export type MockHostOptions = {
58
+ /**
59
+ * Window that receives plugin postMessage traffic: the page window in
60
+ * a workbench, the test window in jsdom. The host listens on it and
61
+ * posts responses to it.
62
+ */
63
+ readonly targetWindow: Window
64
+ readonly files: MockFileBackend
65
+ readonly messages?: MockMessageStore
66
+ readonly danmaku?: MockDanmakuStore
67
+ /** Initial plugin-scoped prefs. */
68
+ readonly prefs?: Readonly<Record<string, string>>
69
+ /** Initial plugin+resId cache entries. */
70
+ readonly cache?: Readonly<Record<string, string>>
71
+ readonly logger?: MockHostLogger
72
+ /** Called after a plugin writes a pref. */
73
+ readonly onPrefChanged?: (key: string, value: string) => void
74
+ /** Called after a plugin writes a cache entry. */
75
+ readonly onCacheChanged?: (resId: string, key: string, value: string) => void
76
+ /**
77
+ * Plugin asset vault implementation (workbench only). Absent → the
78
+ * asset methods answer `UNAVAILABLE` (tests, jsdom hosts). The real
79
+ * app routes these through its server pipeline via tRPC — the mock
80
+ * never talks to it.
81
+ */
82
+ readonly assetVault?: PluginAssetVaultMock
83
+ }
84
+
85
+ /**
86
+ * The workbench-side plugin asset vault: the mock hands `download` and
87
+ * `deleteAsset` to the host app's implementation (consent dialog + dev
88
+ * server fetch + local vault), mirroring the server pipeline's
89
+ * request/result vocabulary.
90
+ */
91
+ export type PluginAssetVaultMock = {
92
+ readonly download: (
93
+ request: PluginDownloadRequest,
94
+ ) => Promise<PluginDownloadResult>
95
+ readonly deleteAsset: (path: string) => Promise<PluginAssetDeleteResult>
96
+ }
97
+
98
+ export type MockHost = {
99
+ /**
100
+ * Bind a message source to a plugin/resource. The real host binds
101
+ * iframe contentWindows; jsdom tests register the test window itself
102
+ * (plugin code posts to `window.parent`, which is itself).
103
+ */
104
+ readonly register: (source: unknown, binding: HostBinding) => void
105
+ readonly unregister: (source: unknown) => void
106
+ /** Push a host event to one source. */
107
+ readonly push: (source: unknown, key: string, data?: unknown) => void
108
+ /** Push the plugin context (the iframe mounts on this). */
109
+ readonly pushContext: (source: unknown, ctx: PluginIframeContext) => void
110
+ readonly setVisibility: (source: unknown, visible: boolean) => void
111
+ readonly messages: MockMessageStore
112
+ readonly danmaku: MockDanmakuStore
113
+ readonly prefs: ReadonlyMap<string, string>
114
+ readonly cache: ReadonlyMap<string, string>
115
+ readonly dispose: () => void
116
+ }
117
+
118
+ export function createMockHost(opts: MockHostOptions): MockHost {
119
+ const targetWindow = opts.targetWindow
120
+ const logger = opts.logger ?? defaultLogger
121
+ const messages = opts.messages ?? createMockMessageStore()
122
+ const danmaku = opts.danmaku ?? createMockDanmakuStore()
123
+ const prefs = new Map(Object.entries(opts.prefs ?? {}))
124
+ const cache = new Map(Object.entries(opts.cache ?? {}))
125
+ const bindings = new Map<unknown, HostBinding>()
126
+ const subscriptions = new Map<unknown, Set<string>>()
127
+
128
+ function postToSource(source: unknown, msg: HostPush | HostResponse): void {
129
+ ;(source as Window).postMessage(msg, "*")
130
+ }
131
+
132
+ function pushToSource(source: unknown, key: string, data?: unknown): void {
133
+ postToSource(source, { type: "push", key, data })
134
+ }
135
+
136
+ const handlers: readonly HostHandlerEntry[] = [
137
+ defineHandler(
138
+ pluginMethods.readFile,
139
+ requestSchemas[pluginMethods.readFile],
140
+ async (ctx, params) => {
141
+ return opts.files.readFile(ctx.resId, params.path, params.range)
142
+ },
143
+ ),
144
+
145
+ defineHandler(pluginMethods.listFiles, async (ctx) => {
146
+ // Production answers with the rows the plugin's own listFiles
147
+ // hook produced; a backend that can obtain them serves those.
148
+ const entries = await opts.files.listFileEntries?.(ctx.resId)
149
+ if (entries !== undefined) return entries
150
+ // No hook (or no way to reach it): the server falls back to
151
+ // bare, naturally sorted filenames — mirror that exactly, or
152
+ // plugins typed against the fallback shape break here only.
153
+ const names = [...(await opts.files.listFiles(ctx.resId))]
154
+ names.sort((a, b) =>
155
+ a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
156
+ )
157
+ return names
158
+ }),
159
+
160
+ defineHandler(
161
+ pluginMethods.logInfo,
162
+ requestSchemas[pluginMethods.logInfo],
163
+ (_ctx, params) => {
164
+ logger.info(logMessage(params), logData(params))
165
+ },
166
+ ),
167
+ defineHandler(
168
+ pluginMethods.logWarn,
169
+ requestSchemas[pluginMethods.logWarn],
170
+ (_ctx, params) => {
171
+ logger.warn(logMessage(params), logData(params))
172
+ },
173
+ ),
174
+ defineHandler(
175
+ pluginMethods.logError,
176
+ requestSchemas[pluginMethods.logError],
177
+ (_ctx, params) => {
178
+ logger.error(logMessage(params), logData(params))
179
+ },
180
+ ),
181
+
182
+ defineHandler(pluginMethods.listMessages, async (ctx) => {
183
+ return messages.list(ctx.resId)
184
+ }),
185
+
186
+ defineHandler(
187
+ pluginMethods.createMessage,
188
+ requestSchemas[pluginMethods.createMessage],
189
+ async (ctx, params) => {
190
+ const anchor =
191
+ params.anchor === undefined
192
+ ? undefined
193
+ : { ...params.anchor, resId: ctx.resId }
194
+ return messages.create(ctx.resId, { body: params.body, anchor })
195
+ },
196
+ ),
197
+
198
+ defineHandler(
199
+ pluginMethods.listDanmaku,
200
+ requestSchemas[pluginMethods.listDanmaku],
201
+ async (ctx, params) => {
202
+ const rows = danmaku.list(ctx.resId)
203
+ const filter = params.filter
204
+ if (filter === undefined) return rows
205
+ return rows.filter((d) => matchesDanmakuFilter(d.anchor.data, filter))
206
+ },
207
+ ),
208
+
209
+ defineHandler(
210
+ pluginMethods.createDanmaku,
211
+ requestSchemas[pluginMethods.createDanmaku],
212
+ async (ctx, params) => {
213
+ return danmaku.create(ctx.resId, {
214
+ text: params.text,
215
+ anchor: { ...params.anchor, resId: ctx.resId },
216
+ mode: params.mode,
217
+ })
218
+ },
219
+ ),
220
+
221
+ defineHandler(
222
+ pluginMethods.setPref,
223
+ requestSchemas[pluginMethods.setPref],
224
+ async (_ctx, params) => {
225
+ prefs.set(params.key, params.value)
226
+ opts.onPrefChanged?.(params.key, params.value)
227
+ },
228
+ ),
229
+
230
+ defineHandler(
231
+ pluginMethods.setCache,
232
+ requestSchemas[pluginMethods.setCache],
233
+ async (ctx, params) => {
234
+ // A write from a never-bound iframe has nowhere to land —
235
+ // drop it silently, same as the production host.
236
+ if (ctx.resId === "") return
237
+ cache.set(`${ctx.resId}:${params.key}`, params.value)
238
+ opts.onCacheChanged?.(ctx.resId, params.key, params.value)
239
+ },
240
+ ),
241
+
242
+ defineHandler(
243
+ pluginMethods.invalidate,
244
+ requestSchemas[pluginMethods.invalidate],
245
+ async (ctx, params) => {
246
+ // Notify the caller so its query hooks refetch — the mock's
247
+ // stores are the source of truth, so the refetch returns the
248
+ // updated data.
249
+ pushToSource(ctx.source, invalidatePushKeys[params.target])
250
+ },
251
+ ),
252
+
253
+ // Without a wired asset vault the offline host answers
254
+ // UNAVAILABLE — the exact vocabulary a plugin sees from a generic
255
+ // mock. The workbench passes `assetVault` to run the real flow
256
+ // (consent dialog + dev-server download into a local vault).
257
+ defineHandler(
258
+ pluginMethods.download,
259
+ requestSchemas[pluginMethods.download],
260
+ async (_ctx, params) => {
261
+ const vault = opts.assetVault
262
+ if (vault === undefined) throw unavailableAssetError("download")
263
+ return vault.download(params)
264
+ },
265
+ ),
266
+ defineHandler(
267
+ pluginMethods.deleteAsset,
268
+ requestSchemas[pluginMethods.deleteAsset],
269
+ async (_ctx, params) => {
270
+ const vault = opts.assetVault
271
+ if (vault === undefined) throw unavailableAssetError("deleteAsset")
272
+ return vault.deleteAsset(params.path)
273
+ },
274
+ ),
275
+ ]
276
+
277
+ const router = createHostRouter(handlers, {
278
+ resolveSource(event) {
279
+ // jsdom delivers same-window messages with source nulled out;
280
+ // real iframes always carry their contentWindow.
281
+ const source = event.source === null ? targetWindow : event.source
282
+ const record = bindings.get(source)
283
+ if (record === undefined) return undefined
284
+ return { source, record }
285
+ },
286
+ respond(source, response) {
287
+ postToSource(source, response)
288
+ },
289
+ subscribe(source, key) {
290
+ let keys = subscriptions.get(source)
291
+ if (keys === undefined) {
292
+ keys = new Set()
293
+ subscriptions.set(source, keys)
294
+ }
295
+ keys.add(key)
296
+ },
297
+ })
298
+
299
+ function onMessage(event: MessageEvent): void {
300
+ router(event)
301
+ }
302
+ targetWindow.addEventListener("message", onMessage)
303
+
304
+ return {
305
+ register(source, binding) {
306
+ bindings.set(source, binding)
307
+ },
308
+ unregister(source) {
309
+ bindings.delete(source)
310
+ subscriptions.delete(source)
311
+ },
312
+ push(source, key, data) {
313
+ pushToSource(source, key, data)
314
+ },
315
+ pushContext(source, ctx) {
316
+ pushToSource(source, hostPushKeys.context, ctx)
317
+ },
318
+ setVisibility(source, visible) {
319
+ pushToSource(source, hostPushKeys.visibility, { visible })
320
+ },
321
+ messages,
322
+ danmaku,
323
+ prefs,
324
+ cache,
325
+ dispose() {
326
+ targetWindow.removeEventListener("message", onMessage)
327
+ bindings.clear()
328
+ subscriptions.clear()
329
+ },
330
+ }
331
+ }
332
+
333
+ function logMessage(params: unknown): string {
334
+ const p = params as { message?: unknown } | undefined
335
+ return typeof p?.message === "string" ? p.message : String(params)
336
+ }
337
+
338
+ function logData(params: unknown): unknown {
339
+ const p = params as { data?: unknown } | undefined
340
+ return p?.data
341
+ }
342
+
343
+ function isRecord(value: unknown): value is Record<string, unknown> {
344
+ return value !== null && typeof value === "object" && !Array.isArray(value)
345
+ }
346
+
347
+ /**
348
+ * Matches a danmaku against the plugin-declared filter: every declared
349
+ * field must equal the value stored under the same key in the anchor's
350
+ * `data` — identical semantics to the production host handler.
351
+ */
352
+ function matchesDanmakuFilter(
353
+ data: unknown,
354
+ filter: DanmakuListFilter,
355
+ ): boolean {
356
+ if (!isRecord(data)) return false
357
+ for (const [key, value] of Object.entries(filter)) {
358
+ if (value !== undefined && data[key] !== value) return false
359
+ }
360
+ return true
361
+ }
362
+
363
+ /** Machine-readable asset-error rejection (the shared vocabulary). */
364
+ function unavailableAssetError(method: string): Error {
365
+ const err = new Error(
366
+ `${method}() is unavailable in the offline host — plugin asset downloads need the app server runtime`,
367
+ )
368
+ err.name = "UNAVAILABLE"
369
+ return err
370
+ }
@@ -0,0 +1,89 @@
1
+ import type { Danmaku, Message } from "@hoardodile/sdk-types"
2
+
3
+ /**
4
+ * In-memory stores backing the offline mock host's message and danmaku
5
+ * handlers. Rows are shaped like the server's responses so plugin UI
6
+ * tests exercise the real consumption paths.
7
+ */
8
+
9
+ let nextId = 1
10
+
11
+ function generateId(prefix: string): string {
12
+ return `${prefix}-${nextId++}`
13
+ }
14
+
15
+ export type MockMessageStore = {
16
+ readonly list: (resId: string) => readonly Message[]
17
+ readonly create: (
18
+ resId: string,
19
+ input: { body: string; anchor?: unknown },
20
+ ) => Message
21
+ }
22
+
23
+ /**
24
+ * `seed` pre-fills the store with rows the plugin should already see —
25
+ * the workbench passes the resource's real comments so the iframe opens
26
+ * with the same content the app would show.
27
+ */
28
+ export function createMockMessageStore(
29
+ seed: readonly Message[] = [],
30
+ ): MockMessageStore {
31
+ const rows: Message[] = [...seed]
32
+ return {
33
+ list(resId) {
34
+ return rows.filter((m) => m.resIds.includes(resId))
35
+ },
36
+ create(resId, input) {
37
+ const message: Message = {
38
+ id: generateId("msg"),
39
+ body: input.body,
40
+ createdAt: Date.now(),
41
+ charIds: [],
42
+ resIds: [resId],
43
+ likeCount: 0,
44
+ dislikeCount: 0,
45
+ replyCount: 0,
46
+ anchor: input.anchor as Message["anchor"],
47
+ }
48
+ rows.push(message)
49
+ return message
50
+ },
51
+ }
52
+ }
53
+
54
+ export type MockDanmakuStore = {
55
+ readonly list: (resId: string) => readonly Danmaku[]
56
+ readonly create: (
57
+ resId: string,
58
+ input: { text: string; anchor: unknown; mode?: string },
59
+ ) => Danmaku
60
+ }
61
+
62
+ /** See {@link createMockMessageStore} for `seed`. */
63
+ export function createMockDanmakuStore(
64
+ seed: readonly Danmaku[] = [],
65
+ ): MockDanmakuStore {
66
+ const rows: Danmaku[] = [...seed]
67
+ return {
68
+ list(resId) {
69
+ return rows.filter((d) => d.anchor.resId === resId)
70
+ },
71
+ create(resId, input) {
72
+ const danmaku: Danmaku = {
73
+ id: generateId("dm"),
74
+ anchor: { resId, data: (input.anchor as { data?: unknown }).data },
75
+ text: input.text,
76
+ color: "#fff",
77
+ mode:
78
+ input.mode === "scroll" ||
79
+ input.mode === "top" ||
80
+ input.mode === "bottom"
81
+ ? input.mode
82
+ : "scroll",
83
+ createdAt: Date.now(),
84
+ }
85
+ rows.push(danmaku)
86
+ return danmaku
87
+ },
88
+ }
89
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Node-side file backends for the offline mock host: real directories,
3
+ * read through the host's own containers so mock reads behave exactly
4
+ * like production reads. Component tests point the mock at a storage
5
+ * root or a fixture directory with no server involved.
6
+ */
7
+ import {
8
+ createDirectoryContainer,
9
+ createPluginResourceAPI,
10
+ } from "@hoardodile/host"
11
+ import type { MockFileBackend } from "../mock/file-backends.ts"
12
+
13
+ /**
14
+ * A mock file backend over a raw directory — a resource folder under a
15
+ * real storage root (bare files, the production shape) or a plain
16
+ * fixture directory. Read-only by construction — immutable versioned
17
+ * partitions make this bypass safe.
18
+ */
19
+ export function createDirectoryFileBackend(dir: string): MockFileBackend {
20
+ const api = createPluginResourceAPI({ view: createDirectoryContainer(dir) })
21
+ return {
22
+ async listFiles() {
23
+ return api.listFileNames()
24
+ },
25
+ async readFile(_resId, path, range) {
26
+ const bytes = await api.readFile(path, range)
27
+ return transferOwned(bytes)
28
+ },
29
+ async statFile(_resId, path) {
30
+ return api.statFile(path)
31
+ },
32
+ }
33
+ }
34
+
35
+ function transferOwned(bytes: Uint8Array): ArrayBuffer {
36
+ return bytes.slice().buffer
37
+ }