@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.
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wooloo <ayan0312000@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @hoardodile/host-web
2
+
3
+ The browser-side plugin host runtime: the shared host-core protocol
4
+ router (used by the hoardodile web app in production and by the offline
5
+ mock) plus the mock host for component tests and the workbench. The wire
6
+ protocol itself stays in `@hoardodile/sdk-web` — this package consumes
7
+ it, never redefines it.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @hoardodile/host-web
13
+ ```
14
+
15
+ ## What's in it
16
+
17
+ - **`createHostRouter`** / **`defineHandler`** — the host-core protocol
18
+ router: message demux, method routing, per-method param validation,
19
+ stale-request scoping, response envelope. The real app and the mock
20
+ assemble on this module so routing and validation never drift
21
+ - **`createMockHost`** — an in-memory host for jsdom component tests and
22
+ the workbench; register a window and drive the iframe bridge with no
23
+ server
24
+ - **`createInMemoryFileBackend`** — file backend for the mock
25
+
26
+ ## Subpaths
27
+
28
+ | Entry | Contents |
29
+ | ----- | -------- |
30
+ | `@hoardodile/host-web` | Browser-safe router + mock (no node imports) |
31
+ | `@hoardodile/host-web/node` | Node file backends (directory-backed mock storage) |
@@ -0,0 +1,27 @@
1
+ import { ReadFileRange } from '@hoardodile/sdk-types';
2
+
3
+ /**
4
+ * File backends for the offline mock host: the plugin's `listFiles` and
5
+ * `readFile` requests resolve against this interface, so a mock can be
6
+ * pointed at an in-memory map, a real directory (Node), or a read-only
7
+ * HTTP mount (workbench) without changing the host.
8
+ */
9
+ type MockFileBackend = {
10
+ readonly listFiles: (resId: string) => Promise<readonly string[]>;
11
+ readonly readFile: (resId: string, path: string, range?: ReadFileRange) => Promise<ArrayBuffer>;
12
+ readonly statFile: (resId: string, path: string) => Promise<{
13
+ readonly sizeBytes: number;
14
+ } | undefined>;
15
+ /**
16
+ * The rows the plugin's own `listFiles` hook produced, when the
17
+ * backend can obtain them (the workbench reads them from a sandboxed
18
+ * hook snapshot). In production the host answers `listFiles` with
19
+ * exactly these plugin-shaped entries; returning `undefined` falls
20
+ * back to generic `{filename, ext, sizeBytes}` rows.
21
+ */
22
+ readonly listFileEntries?: (resId: string) => Promise<readonly unknown[] | undefined>;
23
+ };
24
+ /** In-memory file map backend for unit tests. `resId` is ignored. */
25
+ declare function createInMemoryFileBackend(files?: Readonly<Record<string, string | Uint8Array>>): MockFileBackend;
26
+
27
+ export { type MockFileBackend as M, createInMemoryFileBackend as c };
@@ -0,0 +1,293 @@
1
+ import { z } from 'zod';
2
+ import { HostResponse, PluginIframeContext } from '@hoardodile/sdk-web';
3
+ import { M as MockFileBackend } from './file-backends-Dyy2BxUM.js';
4
+ export { c as createInMemoryFileBackend } from './file-backends-Dyy2BxUM.js';
5
+ import { Danmaku, Message, PluginDownloadRequest, PluginDownloadResult, PluginAssetDeleteResult } from '@hoardodile/sdk-types';
6
+ export { ReadFileRange } from '@hoardodile/sdk-types';
7
+
8
+ /**
9
+ * The consent-dialog queue for plugin asset downloads — the shared
10
+ * implementation both host apps mount:
11
+ *
12
+ * - the **app** feeds it from SSE (`pluginDownloadRequested` /
13
+ * `pluginDownloadResolved`) and drives decisions through the server
14
+ * (`pluginAsset.decide`); the resolved broadcast closes entries here;
15
+ * - the **workbench** has no server: its asset engine enqueues via
16
+ * {@link request} and the dialog answers via {@link decide} — the same
17
+ * dialog component, one queue per page, FIFO, one dialog at a time.
18
+ *
19
+ * State-only by design: decisions never mutate the queue directly
20
+ * except through `close`/`decide`, which the two hosts wire to their
21
+ * own decision path.
22
+ */
23
+ /**
24
+ * A queued consent question: the ticket shape. Structurally identical to
25
+ * the server's `pluginDownloadRequested` SSE event minus its `type`
26
+ * discriminator (and to `pluginAsset.listPending` rows) — declared here
27
+ * instead of importing `@hoardodile/schemas` so the host-core package
28
+ * stays dependency-lean.
29
+ */
30
+ type DownloadConsentEntry = {
31
+ readonly ticketId: string;
32
+ readonly pluginId: string;
33
+ readonly pluginName: string;
34
+ readonly url: string;
35
+ readonly dest: string;
36
+ readonly sizeBytes?: number;
37
+ readonly reason?: string;
38
+ };
39
+ type State = {
40
+ readonly queue: readonly DownloadConsentEntry[];
41
+ };
42
+ declare function subscribeDownloadConsent(listener: () => void): () => void;
43
+ declare function getDownloadConsentSnapshot(): State;
44
+ /** A new ticket arrived (SSE) or was requested locally: append if absent. */
45
+ declare function enqueueDownloadConsent(entry: DownloadConsentEntry): void;
46
+ /** A ticket resolved (decide/timeout/dispose): drop it in every consumer. */
47
+ declare function closeDownloadConsent(ticketId: string): void;
48
+ /**
49
+ * Replace the queue from a pending list (app: `listPending` after an SSE
50
+ * reconnect; workbench: same API on restart).
51
+ */
52
+ declare function rehydrateDownloadConsent(entries: readonly DownloadConsentEntry[]): void;
53
+ /**
54
+ * Enqueue a ticket and wait for its decision (workbench engine path).
55
+ * The dialog's answer arrives through {@link decideDownloadConsent}.
56
+ */
57
+ declare function requestDownloadConsent(entry: DownloadConsentEntry): Promise<boolean>;
58
+ /**
59
+ * Answer a ticket from the dialog. Closes the entry and resolves any
60
+ * awaiting engine call. Idempotent; unknown tickets are a no-op.
61
+ */
62
+ declare function decideDownloadConsent(ticketId: string, approved: boolean): void;
63
+ /** Test-only: reset the singleton between tests. */
64
+ declare function resetDownloadConsent(): void;
65
+
66
+ /**
67
+ * Wire-level param schemas for every request method, shared by the real
68
+ * host (apps/web) and the offline mock host. Both hosts validate
69
+ * identical shapes, so a plugin that passes validation in the mock is
70
+ * guaranteed to pass in production.
71
+ */
72
+ /**
73
+ * Wire shape of a message/danmaku anchor: the plugin location payload
74
+ * only. The resource id is host state — the host stamps its own binding
75
+ * into the anchor before it reaches the server.
76
+ */
77
+ declare const anchorData: z.ZodObject<{
78
+ data: z.ZodOptional<z.ZodUnknown>;
79
+ }, z.core.$strict>;
80
+ declare const requestSchemas: {
81
+ readFile: z.ZodObject<{
82
+ path: z.ZodString;
83
+ range: z.ZodOptional<z.ZodObject<{
84
+ start: z.ZodOptional<z.ZodNumber>;
85
+ end: z.ZodOptional<z.ZodNumber>;
86
+ }, z.core.$strip>>;
87
+ }, z.core.$strip>;
88
+ listFiles: undefined;
89
+ listMessages: undefined;
90
+ createMessage: z.ZodObject<{
91
+ body: z.ZodString;
92
+ anchor: z.ZodOptional<z.ZodObject<{
93
+ data: z.ZodOptional<z.ZodUnknown>;
94
+ }, z.core.$strict>>;
95
+ }, z.core.$strip>;
96
+ listDanmaku: z.ZodObject<{
97
+ filter: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
98
+ }, z.core.$strip>;
99
+ createDanmaku: z.ZodObject<{
100
+ text: z.ZodString;
101
+ anchor: z.ZodObject<{
102
+ data: z.ZodOptional<z.ZodUnknown>;
103
+ }, z.core.$strict>;
104
+ mode: z.ZodOptional<z.ZodString>;
105
+ }, z.core.$strip>;
106
+ setPref: z.ZodObject<{
107
+ key: z.ZodString;
108
+ value: z.ZodString;
109
+ }, z.core.$strip>;
110
+ setCache: z.ZodObject<{
111
+ key: z.ZodString;
112
+ value: z.ZodString;
113
+ }, z.core.$strip>;
114
+ invalidate: z.ZodObject<{
115
+ target: z.ZodEnum<{
116
+ resource: "resource";
117
+ resources: "resources";
118
+ messages: "messages";
119
+ danmaku: "danmaku";
120
+ }>;
121
+ }, z.core.$strip>;
122
+ download: z.ZodObject<{
123
+ url: z.ZodString;
124
+ dest: z.ZodString;
125
+ sha256: z.ZodOptional<z.ZodString>;
126
+ reason: z.ZodOptional<z.ZodString>;
127
+ }, z.core.$strip>;
128
+ deleteAsset: z.ZodObject<{
129
+ path: z.ZodString;
130
+ }, z.core.$strip>;
131
+ logInfo: z.ZodObject<{
132
+ message: z.ZodString;
133
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
134
+ }, z.core.$strip>;
135
+ logWarn: z.ZodObject<{
136
+ message: z.ZodString;
137
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
138
+ }, z.core.$strip>;
139
+ logError: z.ZodObject<{
140
+ message: z.ZodString;
141
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
142
+ }, z.core.$strip>;
143
+ };
144
+
145
+ /**
146
+ * The shared host-side protocol core: message demux, method routing,
147
+ * per-method param validation, stale-request scoping and the response
148
+ * envelope. Both the real host (apps/web) and the offline mock host
149
+ * assemble on this module, so routing and validation never drift.
150
+ */
151
+ /** What a registered iframe (or mock window) is bound to. */
152
+ type HostBinding = {
153
+ readonly pluginId: string;
154
+ readonly resId: string;
155
+ };
156
+ type HostRouterDeps = {
157
+ /**
158
+ * Authenticate an inbound message event: narrow it to a trusted source
159
+ * + binding, or return `undefined` to drop the message. The real host
160
+ * validates origin/source against its iframe registry; the mock
161
+ * validates against its own registered windows.
162
+ */
163
+ readonly resolveSource: (event: MessageEvent) => {
164
+ readonly source: unknown;
165
+ readonly record: HostBinding;
166
+ } | undefined;
167
+ /** Send a response back to the source (the layer's postMessage exit). */
168
+ readonly respond: (source: unknown, response: HostResponse) => void;
169
+ /** Record a subscription key for the source. */
170
+ readonly subscribe: (source: unknown, key: string) => void;
171
+ };
172
+ type HostHandlerContext = {
173
+ readonly source: unknown;
174
+ /**
175
+ * The resource this request is scoped to: the source's current
176
+ * binding ("" only for never-bound sources). Requests stamped by the
177
+ * SDK with a different resource are dropped as stale before they
178
+ * reach a handler.
179
+ */
180
+ readonly resId: string;
181
+ readonly pluginId: string;
182
+ };
183
+ type HostHandlerEntry = {
184
+ readonly method: string;
185
+ /** Param schema; validated in the router before the handler runs. */
186
+ readonly schema: z.ZodTypeAny | undefined;
187
+ readonly handler: (ctx: HostHandlerContext, params: unknown) => Promise<unknown>;
188
+ };
189
+ declare function defineHandler<TReturn>(method: string, handler: (ctx: HostHandlerContext) => Promise<TReturn> | TReturn): HostHandlerEntry;
190
+ declare function defineHandler<TSchema extends z.ZodTypeAny, TReturn>(method: string, schema: TSchema, handler: (ctx: HostHandlerContext, params: z.infer<TSchema>) => Promise<TReturn> | TReturn): HostHandlerEntry;
191
+ /**
192
+ * Create the per-window message handler: authenticates the event,
193
+ * routes requests by method, validates params, drops stale scopes and
194
+ * wraps every outcome in the response envelope.
195
+ */
196
+ declare function createHostRouter(handlers: readonly HostHandlerEntry[], deps: HostRouterDeps): (event: MessageEvent) => void;
197
+
198
+ type MockMessageStore = {
199
+ readonly list: (resId: string) => readonly Message[];
200
+ readonly create: (resId: string, input: {
201
+ body: string;
202
+ anchor?: unknown;
203
+ }) => Message;
204
+ };
205
+ /**
206
+ * `seed` pre-fills the store with rows the plugin should already see —
207
+ * the workbench passes the resource's real comments so the iframe opens
208
+ * with the same content the app would show.
209
+ */
210
+ declare function createMockMessageStore(seed?: readonly Message[]): MockMessageStore;
211
+ type MockDanmakuStore = {
212
+ readonly list: (resId: string) => readonly Danmaku[];
213
+ readonly create: (resId: string, input: {
214
+ text: string;
215
+ anchor: unknown;
216
+ mode?: string;
217
+ }) => Danmaku;
218
+ };
219
+ /** See {@link createMockMessageStore} for `seed`. */
220
+ declare function createMockDanmakuStore(seed?: readonly Danmaku[]): MockDanmakuStore;
221
+
222
+ /**
223
+ * The offline host side of the plugin postMessage bridge. Implements the
224
+ * same routing, validation and scoping as the production host (via
225
+ * host-core) with in-memory data — so plugin iframes run with no server
226
+ * at all. Shared by automated component tests (jsdom: register the test
227
+ * window) and the manual workbench page (register the real iframe
228
+ * window).
229
+ */
230
+ type MockHostLogger = {
231
+ readonly info: (message: string, data?: unknown) => void;
232
+ readonly warn: (message: string, data?: unknown) => void;
233
+ readonly error: (message: string, data?: unknown) => void;
234
+ };
235
+ type MockHostOptions = {
236
+ /**
237
+ * Window that receives plugin postMessage traffic: the page window in
238
+ * a workbench, the test window in jsdom. The host listens on it and
239
+ * posts responses to it.
240
+ */
241
+ readonly targetWindow: Window;
242
+ readonly files: MockFileBackend;
243
+ readonly messages?: MockMessageStore;
244
+ readonly danmaku?: MockDanmakuStore;
245
+ /** Initial plugin-scoped prefs. */
246
+ readonly prefs?: Readonly<Record<string, string>>;
247
+ /** Initial plugin+resId cache entries. */
248
+ readonly cache?: Readonly<Record<string, string>>;
249
+ readonly logger?: MockHostLogger;
250
+ /** Called after a plugin writes a pref. */
251
+ readonly onPrefChanged?: (key: string, value: string) => void;
252
+ /** Called after a plugin writes a cache entry. */
253
+ readonly onCacheChanged?: (resId: string, key: string, value: string) => void;
254
+ /**
255
+ * Plugin asset vault implementation (workbench only). Absent → the
256
+ * asset methods answer `UNAVAILABLE` (tests, jsdom hosts). The real
257
+ * app routes these through its server pipeline via tRPC — the mock
258
+ * never talks to it.
259
+ */
260
+ readonly assetVault?: PluginAssetVaultMock;
261
+ };
262
+ /**
263
+ * The workbench-side plugin asset vault: the mock hands `download` and
264
+ * `deleteAsset` to the host app's implementation (consent dialog + dev
265
+ * server fetch + local vault), mirroring the server pipeline's
266
+ * request/result vocabulary.
267
+ */
268
+ type PluginAssetVaultMock = {
269
+ readonly download: (request: PluginDownloadRequest) => Promise<PluginDownloadResult>;
270
+ readonly deleteAsset: (path: string) => Promise<PluginAssetDeleteResult>;
271
+ };
272
+ type MockHost = {
273
+ /**
274
+ * Bind a message source to a plugin/resource. The real host binds
275
+ * iframe contentWindows; jsdom tests register the test window itself
276
+ * (plugin code posts to `window.parent`, which is itself).
277
+ */
278
+ readonly register: (source: unknown, binding: HostBinding) => void;
279
+ readonly unregister: (source: unknown) => void;
280
+ /** Push a host event to one source. */
281
+ readonly push: (source: unknown, key: string, data?: unknown) => void;
282
+ /** Push the plugin context (the iframe mounts on this). */
283
+ readonly pushContext: (source: unknown, ctx: PluginIframeContext) => void;
284
+ readonly setVisibility: (source: unknown, visible: boolean) => void;
285
+ readonly messages: MockMessageStore;
286
+ readonly danmaku: MockDanmakuStore;
287
+ readonly prefs: ReadonlyMap<string, string>;
288
+ readonly cache: ReadonlyMap<string, string>;
289
+ readonly dispose: () => void;
290
+ };
291
+ declare function createMockHost(opts: MockHostOptions): MockHost;
292
+
293
+ export { type DownloadConsentEntry, type HostBinding, type HostHandlerContext, type HostHandlerEntry, type HostRouterDeps, type MockDanmakuStore, MockFileBackend, type MockHost, type MockHostLogger, type MockHostOptions, type MockMessageStore, type PluginAssetVaultMock, anchorData, closeDownloadConsent, createHostRouter, createMockDanmakuStore, createMockHost, createMockMessageStore, decideDownloadConsent, defineHandler, enqueueDownloadConsent, getDownloadConsentSnapshot, rehydrateDownloadConsent, requestDownloadConsent, requestSchemas, resetDownloadConsent, subscribeDownloadConsent };