@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,234 @@
1
+ import {
2
+ type HostResponse,
3
+ type PluginMessage,
4
+ PROTOCOL_VERSION,
5
+ } from "@hoardodile/sdk-web"
6
+ import type { z } from "zod"
7
+
8
+ /**
9
+ * The shared host-side protocol core: message demux, method routing,
10
+ * per-method param validation, stale-request scoping and the response
11
+ * envelope. Both the real host (apps/web) and the offline mock host
12
+ * assemble on this module, so routing and validation never drift.
13
+ */
14
+
15
+ /** What a registered iframe (or mock window) is bound to. */
16
+ export type HostBinding = {
17
+ readonly pluginId: string
18
+ readonly resId: string
19
+ }
20
+
21
+ export type HostRouterDeps = {
22
+ /**
23
+ * Authenticate an inbound message event: narrow it to a trusted source
24
+ * + binding, or return `undefined` to drop the message. The real host
25
+ * validates origin/source against its iframe registry; the mock
26
+ * validates against its own registered windows.
27
+ */
28
+ readonly resolveSource: (
29
+ event: MessageEvent,
30
+ ) => { readonly source: unknown; readonly record: HostBinding } | undefined
31
+ /** Send a response back to the source (the layer's postMessage exit). */
32
+ readonly respond: (source: unknown, response: HostResponse) => void
33
+ /** Record a subscription key for the source. */
34
+ readonly subscribe: (source: unknown, key: string) => void
35
+ }
36
+
37
+ export type HostHandlerContext = {
38
+ readonly source: unknown
39
+ /**
40
+ * The resource this request is scoped to: the source's current
41
+ * binding ("" only for never-bound sources). Requests stamped by the
42
+ * SDK with a different resource are dropped as stale before they
43
+ * reach a handler.
44
+ */
45
+ readonly resId: string
46
+ readonly pluginId: string
47
+ }
48
+
49
+ export type HostHandlerEntry = {
50
+ readonly method: string
51
+ /** Param schema; validated in the router before the handler runs. */
52
+ readonly schema: z.ZodTypeAny | undefined
53
+ readonly handler: (
54
+ ctx: HostHandlerContext,
55
+ params: unknown,
56
+ ) => Promise<unknown>
57
+ }
58
+
59
+ export function defineHandler<TReturn>(
60
+ method: string,
61
+ handler: (ctx: HostHandlerContext) => Promise<TReturn> | TReturn,
62
+ ): HostHandlerEntry
63
+
64
+ export function defineHandler<TSchema extends z.ZodTypeAny, TReturn>(
65
+ method: string,
66
+ schema: TSchema,
67
+ handler: (
68
+ ctx: HostHandlerContext,
69
+ params: z.infer<TSchema>,
70
+ ) => Promise<TReturn> | TReturn,
71
+ ): HostHandlerEntry
72
+
73
+ export function defineHandler(
74
+ method: string,
75
+ schemaOrHandler:
76
+ | z.ZodTypeAny
77
+ | ((ctx: HostHandlerContext) => Promise<unknown> | unknown),
78
+ maybeHandler?: (
79
+ ctx: HostHandlerContext,
80
+ params: unknown,
81
+ ) => Promise<unknown> | unknown,
82
+ ): HostHandlerEntry {
83
+ if (typeof schemaOrHandler === "function") {
84
+ return {
85
+ method,
86
+ schema: undefined,
87
+ handler: async (ctx) => schemaOrHandler(ctx),
88
+ }
89
+ }
90
+ if (maybeHandler === undefined) {
91
+ throw new Error(`defineHandler("${method}") called without a handler`)
92
+ }
93
+ return {
94
+ method,
95
+ schema: schemaOrHandler,
96
+ handler: async (ctx, params) => maybeHandler(ctx, params),
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Create the per-window message handler: authenticates the event,
102
+ * routes requests by method, validates params, drops stale scopes and
103
+ * wraps every outcome in the response envelope.
104
+ */
105
+ export function createHostRouter(
106
+ handlers: readonly HostHandlerEntry[],
107
+ deps: HostRouterDeps,
108
+ ): (event: MessageEvent) => void {
109
+ const registry = new Map<string, HostHandlerEntry["handler"]>()
110
+ const schemas = new Map<string, z.ZodTypeAny>()
111
+ for (const entry of handlers) {
112
+ if (registry.has(entry.method)) {
113
+ throw new Error(`Duplicate handler method: ${entry.method}`)
114
+ }
115
+ registry.set(entry.method, entry.handler)
116
+ if (entry.schema !== undefined) schemas.set(entry.method, entry.schema)
117
+ }
118
+
119
+ // Warn once per source so a mismatched plugin build does not spam the
120
+ // console on every message, while still surfacing the problem loudly.
121
+ const warnedSources = new WeakSet<object>()
122
+
123
+ return function handleMessage(event: MessageEvent) {
124
+ const resolved = deps.resolveSource(event)
125
+ if (resolved === undefined) return
126
+ const { source, record } = resolved
127
+
128
+ const msg = event.data as PluginMessage
129
+ if (msg == null || typeof msg !== "object" || msg.type === undefined) {
130
+ return
131
+ }
132
+
133
+ // Protocol handshake: every SDK-build plugin stamps its messages
134
+ // with the protocol version it was built against. A mismatch (or a
135
+ // missing stamp — an old plugin build) means the wire contract may
136
+ // have drifted; the message still routes so old plugins keep
137
+ // working, but the developer gets a loud warning.
138
+ const proto = (msg as { proto?: unknown }).proto
139
+ if (proto !== PROTOCOL_VERSION && !warnedSources.has(source as object)) {
140
+ warnedSources.add(source as object)
141
+ if (proto === undefined) {
142
+ console.warn(
143
+ `[host-web] plugin iframe (${record.pluginId}) did not stamp its protocol version — it was built against an older SDK. Assuming PROTOCOL_VERSION ${PROTOCOL_VERSION}; rebuild the plugin to silence this warning.`,
144
+ )
145
+ } else {
146
+ console.warn(
147
+ `[host-web] plugin iframe (${record.pluginId}) speaks protocol version ${String(proto)} but this host speaks ${PROTOCOL_VERSION} — the plugin was built against a different SDK version and may misbehave.`,
148
+ )
149
+ }
150
+ }
151
+
152
+ if (msg.type === "subscribe") {
153
+ deps.subscribe(source, msg.key)
154
+ return
155
+ }
156
+
157
+ if (msg.type !== "request") return
158
+
159
+ function respond(response: HostResponse): void {
160
+ deps.respond(source, response)
161
+ }
162
+
163
+ // The SDK stamps each request with the resource it was issued for
164
+ // (PluginRequest.resId). A stamp that no longer matches the
165
+ // binding marks the request as stale — the tree that issued it is
166
+ // gone (e.g. an unmount flush racing a rebind) — so it is dropped
167
+ // silently instead of leaking into the wrong resource. Unstamped
168
+ // requests (older plugin builds) use the current binding, which
169
+ // outlives release, so late flushes after a close still land.
170
+ if (
171
+ typeof msg.resId === "string" &&
172
+ msg.resId !== "" &&
173
+ msg.resId !== record.resId
174
+ ) {
175
+ respond({ type: "response", id: msg.id, ok: true })
176
+ return
177
+ }
178
+
179
+ const ctx: HostHandlerContext = {
180
+ source,
181
+ resId: record.resId,
182
+ pluginId: record.pluginId,
183
+ }
184
+
185
+ const handler = registry.get(msg.method)
186
+ if (handler === undefined) {
187
+ respond({
188
+ type: "response",
189
+ id: msg.id,
190
+ ok: false,
191
+ error: `Unknown method: ${msg.method}`,
192
+ })
193
+ return
194
+ }
195
+
196
+ let params: unknown
197
+ const schema = schemas.get(msg.method)
198
+ if (schema !== undefined) {
199
+ const parsed = schema.safeParse(msg.params)
200
+ if (!parsed.success) {
201
+ respond({
202
+ type: "response",
203
+ id: msg.id,
204
+ ok: false,
205
+ error: `Invalid params for ${msg.method}: ${parsed.error.message}`,
206
+ })
207
+ return
208
+ }
209
+ params = parsed.data
210
+ } else {
211
+ params = msg.params
212
+ }
213
+
214
+ handler(ctx, params)
215
+ .then((data) => {
216
+ respond({ type: "response", id: msg.id, ok: true, data })
217
+ })
218
+ .catch((err) => {
219
+ const errorName =
220
+ err instanceof Error && err.name !== "Error" ? err.name : undefined
221
+ respond({
222
+ type: "response",
223
+ id: msg.id,
224
+ ok: false,
225
+ error: err instanceof Error ? err.message : String(err),
226
+ // Machine-readable plugin error code (`DENIED`/…)
227
+ // travels as `errorCode`; `errorName` is the legacy
228
+ // alias (same value) so older SDK builds keep working.
229
+ errorCode: errorName,
230
+ errorName,
231
+ })
232
+ })
233
+ }
234
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @hoardodile/host-web — the browser-side plugin host runtime:
3
+ * the shared host-core protocol router (used by apps/web in production
4
+ * and by the offline mock) plus the mock host for component tests and
5
+ * the workbench. The wire protocol itself stays in
6
+ * @hoardodile/sdk-web — this package consumes it, never redefines
7
+ * it. Node file backends live in `@hoardodile/host-web/node`.
8
+ */
9
+
10
+ export {
11
+ closeDownloadConsent,
12
+ type DownloadConsentEntry,
13
+ decideDownloadConsent,
14
+ enqueueDownloadConsent,
15
+ getDownloadConsentSnapshot,
16
+ rehydrateDownloadConsent,
17
+ requestDownloadConsent,
18
+ resetDownloadConsent,
19
+ subscribeDownloadConsent,
20
+ } from "./consent/consent-store.ts"
21
+ export { anchorData, requestSchemas } from "./host-core/request-schemas.ts"
22
+ export {
23
+ createHostRouter,
24
+ defineHandler,
25
+ type HostBinding,
26
+ type HostHandlerContext,
27
+ type HostHandlerEntry,
28
+ type HostRouterDeps,
29
+ } from "./host-core/router.ts"
30
+ export {
31
+ createInMemoryFileBackend,
32
+ type MockFileBackend,
33
+ type ReadFileRange,
34
+ } from "./mock/file-backends.ts"
35
+ export {
36
+ createMockHost,
37
+ type MockHost,
38
+ type MockHostLogger,
39
+ type MockHostOptions,
40
+ type PluginAssetVaultMock,
41
+ } from "./mock/host.ts"
42
+ export {
43
+ createMockDanmakuStore,
44
+ createMockMessageStore,
45
+ type MockDanmakuStore,
46
+ type MockMessageStore,
47
+ } from "./mock/stores.ts"
@@ -0,0 +1,69 @@
1
+ import type { ReadFileRange } from "@hoardodile/sdk-types"
2
+
3
+ /** Shared read-range contract; defined once in `@hoardodile/sdk-types`. */
4
+ export type { ReadFileRange } from "@hoardodile/sdk-types"
5
+
6
+ /**
7
+ * File backends for the offline mock host: the plugin's `listFiles` and
8
+ * `readFile` requests resolve against this interface, so a mock can be
9
+ * pointed at an in-memory map, a real directory (Node), or a read-only
10
+ * HTTP mount (workbench) without changing the host.
11
+ */
12
+ export type MockFileBackend = {
13
+ readonly listFiles: (resId: string) => Promise<readonly string[]>
14
+ readonly readFile: (
15
+ resId: string,
16
+ path: string,
17
+ range?: ReadFileRange,
18
+ ) => Promise<ArrayBuffer>
19
+ readonly statFile: (
20
+ resId: string,
21
+ path: string,
22
+ ) => Promise<{ readonly sizeBytes: number } | undefined>
23
+ /**
24
+ * The rows the plugin's own `listFiles` hook produced, when the
25
+ * backend can obtain them (the workbench reads them from a sandboxed
26
+ * hook snapshot). In production the host answers `listFiles` with
27
+ * exactly these plugin-shaped entries; returning `undefined` falls
28
+ * back to generic `{filename, ext, sizeBytes}` rows.
29
+ */
30
+ readonly listFileEntries?: (
31
+ resId: string,
32
+ ) => Promise<readonly unknown[] | undefined>
33
+ }
34
+
35
+ /** In-memory file map backend for unit tests. `resId` is ignored. */
36
+ export function createInMemoryFileBackend(
37
+ files: Readonly<Record<string, string | Uint8Array>> = {},
38
+ ): MockFileBackend {
39
+ return {
40
+ async listFiles() {
41
+ return Object.keys(files)
42
+ },
43
+ async readFile(_resId, path, range) {
44
+ const content = files[path]
45
+ if (content === undefined) {
46
+ throw new Error(`mock file backend has no entry ${path}`)
47
+ }
48
+ const bytes =
49
+ typeof content === "string"
50
+ ? new TextEncoder().encode(content)
51
+ : content
52
+ // Mirrors host semantics: the range is clamped to the content
53
+ // size; a start at or past the end resolves to an empty result.
54
+ if (range === undefined) return bytes.slice().buffer
55
+ const start = Math.max(0, range.start ?? 0)
56
+ const end = Math.min(range.end ?? bytes.length, bytes.length)
57
+ return bytes.slice(start, end).buffer
58
+ },
59
+ async statFile(_resId, path) {
60
+ const content = files[path]
61
+ if (content === undefined) return undefined
62
+ const bytes =
63
+ typeof content === "string"
64
+ ? new TextEncoder().encode(content)
65
+ : content
66
+ return { sizeBytes: bytes.byteLength }
67
+ },
68
+ }
69
+ }
@@ -0,0 +1,188 @@
1
+ import type { PluginIframeContext } from "@hoardodile/sdk-web"
2
+ import { createIframeHostAPI, ensureHostBridge } from "@hoardodile/sdk-web"
3
+ import { afterEach, describe, expect, test, vi } from "vitest"
4
+ import { createInMemoryFileBackend } from "./file-backends.ts"
5
+ import { createMockHost } from "./host.ts"
6
+
7
+ /**
8
+ * Full bridge round-trip in jsdom: the plugin-side runtime posts to
9
+ * `window.parent` (itself here), the mock host listens on the same
10
+ * window, and responses come back through the same postMessage channel —
11
+ * the exact wire a real sandboxed iframe uses.
12
+ */
13
+
14
+ function buildContext(
15
+ overrides: Partial<PluginIframeContext> = {},
16
+ ): PluginIframeContext {
17
+ return {
18
+ pluginId: "test-plugin",
19
+ resId: "r-1",
20
+ resName: "Test Resource",
21
+ sourceMeta: undefined,
22
+ searchMeta: undefined,
23
+ fileStats: undefined,
24
+ contentPluginId: "test-plugin",
25
+ language: "en",
26
+ resolvedTheme: "dark",
27
+ palette: "parchment",
28
+ iconStyle: "duotone",
29
+ fonts: { family: "", cssPaths: [] },
30
+ initialPrefs: {},
31
+ initialCache: {},
32
+ fileToken: "",
33
+ assetToken: "",
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ afterEach(() => {
39
+ // Ensure no global bridge listener leaks between tests.
40
+ window.parent.postMessage = window.parent.postMessage
41
+ })
42
+
43
+ describe("createMockHost bridge", () => {
44
+ test("listFiles and readFile round-trip through postMessage", async () => {
45
+ const host = createMockHost({
46
+ targetWindow: window,
47
+ files: createInMemoryFileBackend({ "a.txt": "hello", "b.bin": "beta" }),
48
+ })
49
+ host.register(window, { pluginId: "test-plugin", resId: "r-1" })
50
+ try {
51
+ ensureHostBridge()
52
+ const api = createIframeHostAPI(buildContext())
53
+
54
+ // No listFileEntries: the server's own fallback is bare,
55
+ // naturally sorted filenames.
56
+ expect(await api.listFiles()).toEqual(["a.txt", "b.bin"])
57
+ const data = await api.readFile("a.txt")
58
+ expect(new TextDecoder().decode(data)).toBe("hello")
59
+ } finally {
60
+ host.dispose()
61
+ }
62
+ })
63
+
64
+ test("the listFiles fallback sorts naturally, not lexicographically", async () => {
65
+ const host = createMockHost({
66
+ targetWindow: window,
67
+ files: createInMemoryFileBackend({
68
+ "10.png": "a",
69
+ "2.png": "b",
70
+ "1.png": "c",
71
+ }),
72
+ })
73
+ host.register(window, { pluginId: "test-plugin", resId: "r-1" })
74
+ try {
75
+ ensureHostBridge()
76
+ const api = createIframeHostAPI(buildContext())
77
+
78
+ expect(await api.listFiles()).toEqual(["1.png", "2.png", "10.png"])
79
+ } finally {
80
+ host.dispose()
81
+ }
82
+ })
83
+
84
+ test("listFileEntries serves the plugin's own file rows", async () => {
85
+ const pluginRows = [
86
+ { filename: "01.jpg", type: "image", width: 800, height: 1200 },
87
+ { filename: "clip.mp4", type: "video", durationMs: 4200 },
88
+ ]
89
+ const host = createMockHost({
90
+ targetWindow: window,
91
+ files: {
92
+ ...createInMemoryFileBackend({ "01.jpg": "a", "clip.mp4": "b" }),
93
+ listFileEntries: async () => pluginRows,
94
+ },
95
+ })
96
+ host.register(window, { pluginId: "test-plugin", resId: "r-1" })
97
+ try {
98
+ ensureHostBridge()
99
+ const api = createIframeHostAPI(buildContext())
100
+
101
+ expect(await api.listFiles()).toEqual(pluginRows)
102
+ } finally {
103
+ host.dispose()
104
+ }
105
+ })
106
+
107
+ test("listFileEntries returning undefined falls back to bare filenames", async () => {
108
+ const host = createMockHost({
109
+ targetWindow: window,
110
+ files: {
111
+ ...createInMemoryFileBackend({ "a.txt": "hello" }),
112
+ listFileEntries: async () => undefined,
113
+ },
114
+ })
115
+ host.register(window, { pluginId: "test-plugin", resId: "r-1" })
116
+ try {
117
+ ensureHostBridge()
118
+ const api = createIframeHostAPI(buildContext())
119
+
120
+ expect(await api.listFiles()).toEqual(["a.txt"])
121
+ } finally {
122
+ host.dispose()
123
+ }
124
+ })
125
+
126
+ test("messages round-trip into the in-memory store", async () => {
127
+ const host = createMockHost({
128
+ targetWindow: window,
129
+ files: createInMemoryFileBackend(),
130
+ })
131
+ host.register(window, { pluginId: "test-plugin", resId: "r-1" })
132
+ try {
133
+ ensureHostBridge()
134
+ const api = createIframeHostAPI(buildContext())
135
+
136
+ const created = await api.createMessage({
137
+ body: "hello world",
138
+ anchor: { data: { page: 2 } },
139
+ })
140
+ expect(created.body).toBe("hello world")
141
+ expect((created.anchor as { resId: string }).resId).toBe("r-1")
142
+
143
+ const list = await api.listMessages()
144
+ expect(list).toHaveLength(1)
145
+ expect(list[0]?.body).toBe("hello world")
146
+ } finally {
147
+ host.dispose()
148
+ }
149
+ })
150
+
151
+ test("prefs and cache writes land in the host maps", async () => {
152
+ const host = createMockHost({
153
+ targetWindow: window,
154
+ files: createInMemoryFileBackend(),
155
+ })
156
+ host.register(window, { pluginId: "test-plugin", resId: "r-1" })
157
+ try {
158
+ ensureHostBridge()
159
+ const api = createIframeHostAPI(buildContext())
160
+
161
+ // setPref/setCache are fire-and-forget on the wire — poll for
162
+ // the request to land in the host maps.
163
+ api.setPref("theme", "dark")
164
+ await vi.waitFor(() => expect(host.prefs.get("theme")).toBe("dark"))
165
+ api.setCache("scroll", "42")
166
+ await vi.waitFor(() => expect(host.cache.get("r-1:scroll")).toBe("42"))
167
+ } finally {
168
+ host.dispose()
169
+ }
170
+ })
171
+
172
+ test("requests from unregistered sources are dropped", async () => {
173
+ const host = createMockHost({
174
+ targetWindow: window,
175
+ files: createInMemoryFileBackend({ "a.txt": "hello" }),
176
+ })
177
+ try {
178
+ ensureHostBridge()
179
+ const api = createIframeHostAPI(buildContext())
180
+
181
+ // No registration: the request never resolves. The bridge's
182
+ // request timeout (10s) bounds the wait.
183
+ await expect(api.listFiles()).rejects.toThrow(/timed out/)
184
+ } finally {
185
+ host.dispose()
186
+ }
187
+ }, 15_000)
188
+ })