@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 +18 -0
- package/README.md +31 -0
- package/dist/file-backends-Dyy2BxUM.d.ts +27 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +532 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.js +25 -0
- package/dist/node.js.map +1 -0
- package/package.json +60 -0
- package/src/consent/consent-store.test.ts +105 -0
- package/src/consent/consent-store.ts +129 -0
- package/src/host-core/request-schemas.ts +85 -0
- package/src/host-core/router.test.ts +231 -0
- package/src/host-core/router.ts +234 -0
- package/src/index.ts +47 -0
- package/src/mock/file-backends.ts +69 -0
- package/src/mock/host.test.ts +188 -0
- package/src/mock/host.ts +370 -0
- package/src/mock/stores.ts +89 -0
- package/src/mock-node/index.ts +37 -0
package/dist/node.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createPluginResourceAPI, createDirectoryContainer } from '@hoardodile/host';
|
|
2
|
+
|
|
3
|
+
// src/mock-node/index.ts
|
|
4
|
+
function createDirectoryFileBackend(dir) {
|
|
5
|
+
const api = createPluginResourceAPI({ view: createDirectoryContainer(dir) });
|
|
6
|
+
return {
|
|
7
|
+
async listFiles() {
|
|
8
|
+
return api.listFileNames();
|
|
9
|
+
},
|
|
10
|
+
async readFile(_resId, path, range) {
|
|
11
|
+
const bytes = await api.readFile(path, range);
|
|
12
|
+
return transferOwned(bytes);
|
|
13
|
+
},
|
|
14
|
+
async statFile(_resId, path) {
|
|
15
|
+
return api.statFile(path);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function transferOwned(bytes) {
|
|
20
|
+
return bytes.slice().buffer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { createDirectoryFileBackend };
|
|
24
|
+
//# sourceMappingURL=node.js.map
|
|
25
|
+
//# sourceMappingURL=node.js.map
|
package/dist/node.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/mock-node/index.ts"],"names":[],"mappings":";;;AAkBO,SAAS,2BAA2B,GAAA,EAA8B;AACxE,EAAA,MAAM,MAAM,uBAAA,CAAwB,EAAE,MAAM,wBAAA,CAAyB,GAAG,GAAG,CAAA;AAC3E,EAAA,OAAO;AAAA,IACN,MAAM,SAAA,GAAY;AACjB,MAAA,OAAO,IAAI,aAAA,EAAc;AAAA,IAC1B,CAAA;AAAA,IACA,MAAM,QAAA,CAAS,MAAA,EAAQ,IAAA,EAAM,KAAA,EAAO;AACnC,MAAA,MAAM,KAAA,GAAQ,MAAM,GAAA,CAAI,QAAA,CAAS,MAAM,KAAK,CAAA;AAC5C,MAAA,OAAO,cAAc,KAAK,CAAA;AAAA,IAC3B,CAAA;AAAA,IACA,MAAM,QAAA,CAAS,MAAA,EAAQ,IAAA,EAAM;AAC5B,MAAA,OAAO,GAAA,CAAI,SAAS,IAAI,CAAA;AAAA,IACzB;AAAA,GACD;AACD;AAEA,SAAS,cAAc,KAAA,EAAgC;AACtD,EAAA,OAAO,KAAA,CAAM,OAAM,CAAE,MAAA;AACtB","file":"node.js","sourcesContent":["/**\n * Node-side file backends for the offline mock host: real directories,\n * read through the host's own containers so mock reads behave exactly\n * like production reads. Component tests point the mock at a storage\n * root or a fixture directory with no server involved.\n */\nimport {\n\tcreateDirectoryContainer,\n\tcreatePluginResourceAPI,\n} from \"@hoardodile/host\"\nimport type { MockFileBackend } from \"../mock/file-backends.ts\"\n\n/**\n * A mock file backend over a raw directory — a resource folder under a\n * real storage root (bare files, the production shape) or a plain\n * fixture directory. Read-only by construction — immutable versioned\n * partitions make this bypass safe.\n */\nexport function createDirectoryFileBackend(dir: string): MockFileBackend {\n\tconst api = createPluginResourceAPI({ view: createDirectoryContainer(dir) })\n\treturn {\n\t\tasync listFiles() {\n\t\t\treturn api.listFileNames()\n\t\t},\n\t\tasync readFile(_resId, path, range) {\n\t\t\tconst bytes = await api.readFile(path, range)\n\t\t\treturn transferOwned(bytes)\n\t\t},\n\t\tasync statFile(_resId, path) {\n\t\t\treturn api.statFile(path)\n\t\t},\n\t}\n}\n\nfunction transferOwned(bytes: Uint8Array): ArrayBuffer {\n\treturn bytes.slice().buffer\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hoardodile/host-web",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Browser-side hoardodile host runtime: host-core protocol router and the offline mock host.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"hoardodile",
|
|
8
|
+
"plugin",
|
|
9
|
+
"host",
|
|
10
|
+
"browser",
|
|
11
|
+
"mock"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/hoardodile/hoardodile.git",
|
|
16
|
+
"directory": "plugins/host-web"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"development": "./src/index.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./node": {
|
|
26
|
+
"types": "./dist/node.d.ts",
|
|
27
|
+
"development": "./src/node.ts",
|
|
28
|
+
"default": "./dist/node.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"zod": "^4.4.3",
|
|
44
|
+
"@hoardodile/host": "0.0.0",
|
|
45
|
+
"@hoardodile/sdk-types": "0.0.0",
|
|
46
|
+
"@hoardodile/sdk-web": "0.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^26.2.0",
|
|
50
|
+
"jsdom": "^30.0.1",
|
|
51
|
+
"tsup": "^8.5.1",
|
|
52
|
+
"typescript": "5.9.3",
|
|
53
|
+
"vitest": "^4.1.11"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup",
|
|
57
|
+
"lint": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
closeDownloadConsent,
|
|
4
|
+
type DownloadConsentEntry,
|
|
5
|
+
decideDownloadConsent,
|
|
6
|
+
enqueueDownloadConsent,
|
|
7
|
+
getDownloadConsentSnapshot,
|
|
8
|
+
rehydrateDownloadConsent,
|
|
9
|
+
requestDownloadConsent,
|
|
10
|
+
resetDownloadConsent,
|
|
11
|
+
subscribeDownloadConsent,
|
|
12
|
+
} from "./consent-store"
|
|
13
|
+
|
|
14
|
+
function ticket(ticketId: string, dest = "runtime.mjs"): DownloadConsentEntry {
|
|
15
|
+
return {
|
|
16
|
+
ticketId,
|
|
17
|
+
pluginId: "p-1",
|
|
18
|
+
pluginName: "Test",
|
|
19
|
+
url: "https://example.com/runtime.mjs",
|
|
20
|
+
dest,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("download consent store", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
resetDownloadConsent()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("queues tickets FIFO and dedupes by id", () => {
|
|
30
|
+
enqueueDownloadConsent(ticket("a1"))
|
|
31
|
+
enqueueDownloadConsent(ticket("a2"))
|
|
32
|
+
enqueueDownloadConsent(ticket("a1"))
|
|
33
|
+
const { queue } = getDownloadConsentSnapshot()
|
|
34
|
+
expect(queue.map((t) => t.ticketId)).toEqual(["a1", "a2"])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("closes a ticket by id and notifies subscribers", () => {
|
|
38
|
+
let notified = 0
|
|
39
|
+
const unsubscribe = subscribeDownloadConsent(() => {
|
|
40
|
+
notified += 1
|
|
41
|
+
})
|
|
42
|
+
try {
|
|
43
|
+
enqueueDownloadConsent(ticket("b1"))
|
|
44
|
+
enqueueDownloadConsent(ticket("b2"))
|
|
45
|
+
closeDownloadConsent("b1")
|
|
46
|
+
expect(getDownloadConsentSnapshot().queue.map((t) => t.ticketId)).toEqual(
|
|
47
|
+
["b2"],
|
|
48
|
+
)
|
|
49
|
+
expect(notified).toBe(3)
|
|
50
|
+
} finally {
|
|
51
|
+
unsubscribe()
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("rehydrates the whole queue (SSE reconnect / engine restart)", () => {
|
|
56
|
+
enqueueDownloadConsent(ticket("c1"))
|
|
57
|
+
rehydrateDownloadConsent([ticket("c3", "b.mjs"), ticket("c4", "c.mjs")])
|
|
58
|
+
expect(getDownloadConsentSnapshot().queue.map((t) => t.ticketId)).toEqual([
|
|
59
|
+
"c3",
|
|
60
|
+
"c4",
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("unsubscribe stops notifications", () => {
|
|
65
|
+
let notified = 0
|
|
66
|
+
const unsubscribe = subscribeDownloadConsent(() => {
|
|
67
|
+
notified += 1
|
|
68
|
+
})
|
|
69
|
+
unsubscribe()
|
|
70
|
+
enqueueDownloadConsent(ticket("d1"))
|
|
71
|
+
expect(notified).toBe(0)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("request enqueues and waits; decide resolves and closes (engine path)", async () => {
|
|
75
|
+
const consent = requestDownloadConsent(ticket("e1"))
|
|
76
|
+
expect(getDownloadConsentSnapshot().queue).toHaveLength(1)
|
|
77
|
+
decideDownloadConsent("e1", true)
|
|
78
|
+
await expect(consent).resolves.toBe(true)
|
|
79
|
+
expect(getDownloadConsentSnapshot().queue).toHaveLength(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("denied decision resolves false and closes; unknown ids are no-ops", async () => {
|
|
83
|
+
const consent = requestDownloadConsent(ticket("f1"))
|
|
84
|
+
decideDownloadConsent("unknown", true)
|
|
85
|
+
decideDownloadConsent("f1", false)
|
|
86
|
+
await expect(consent).resolves.toBe(false)
|
|
87
|
+
expect(getDownloadConsentSnapshot().queue).toHaveLength(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("a duplicate in-flight request resolves false without double queueing", async () => {
|
|
91
|
+
const first = requestDownloadConsent(ticket("g1"))
|
|
92
|
+
const second = requestDownloadConsent(ticket("g1"))
|
|
93
|
+
await expect(second).resolves.toBe(false)
|
|
94
|
+
expect(getDownloadConsentSnapshot().queue).toHaveLength(1)
|
|
95
|
+
decideDownloadConsent("g1", true)
|
|
96
|
+
await expect(first).resolves.toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("reset resolves every waiter and empties the queue", async () => {
|
|
100
|
+
const consent = requestDownloadConsent(ticket("h1"))
|
|
101
|
+
resetDownloadConsent()
|
|
102
|
+
await expect(consent).resolves.toBe(false)
|
|
103
|
+
expect(getDownloadConsentSnapshot().queue).toHaveLength(0)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent-dialog queue for plugin asset downloads — the shared
|
|
3
|
+
* implementation both host apps mount:
|
|
4
|
+
*
|
|
5
|
+
* - the **app** feeds it from SSE (`pluginDownloadRequested` /
|
|
6
|
+
* `pluginDownloadResolved`) and drives decisions through the server
|
|
7
|
+
* (`pluginAsset.decide`); the resolved broadcast closes entries here;
|
|
8
|
+
* - the **workbench** has no server: its asset engine enqueues via
|
|
9
|
+
* {@link request} and the dialog answers via {@link decide} — the same
|
|
10
|
+
* dialog component, one queue per page, FIFO, one dialog at a time.
|
|
11
|
+
*
|
|
12
|
+
* State-only by design: decisions never mutate the queue directly
|
|
13
|
+
* except through `close`/`decide`, which the two hosts wire to their
|
|
14
|
+
* own decision path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A queued consent question: the ticket shape. Structurally identical to
|
|
19
|
+
* the server's `pluginDownloadRequested` SSE event minus its `type`
|
|
20
|
+
* discriminator (and to `pluginAsset.listPending` rows) — declared here
|
|
21
|
+
* instead of importing `@hoardodile/schemas` so the host-core package
|
|
22
|
+
* stays dependency-lean.
|
|
23
|
+
*/
|
|
24
|
+
export type DownloadConsentEntry = {
|
|
25
|
+
readonly ticketId: string
|
|
26
|
+
readonly pluginId: string
|
|
27
|
+
readonly pluginName: string
|
|
28
|
+
readonly url: string
|
|
29
|
+
readonly dest: string
|
|
30
|
+
readonly sizeBytes?: number
|
|
31
|
+
readonly reason?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type State = {
|
|
35
|
+
readonly queue: readonly DownloadConsentEntry[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let state: State = { queue: [] }
|
|
39
|
+
const listeners = new Set<() => void>()
|
|
40
|
+
|
|
41
|
+
/** Engine side: tickets awaiting a local decision (workbench path). */
|
|
42
|
+
const pending = new Map<
|
|
43
|
+
string,
|
|
44
|
+
{ readonly resolve: (approved: boolean) => void }
|
|
45
|
+
>()
|
|
46
|
+
|
|
47
|
+
function setState(next: State): void {
|
|
48
|
+
state = next
|
|
49
|
+
for (const listener of listeners) {
|
|
50
|
+
listener()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function subscribeDownloadConsent(listener: () => void): () => void {
|
|
55
|
+
listeners.add(listener)
|
|
56
|
+
return () => {
|
|
57
|
+
listeners.delete(listener)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getDownloadConsentSnapshot(): State {
|
|
62
|
+
return state
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A new ticket arrived (SSE) or was requested locally: append if absent. */
|
|
66
|
+
export function enqueueDownloadConsent(entry: DownloadConsentEntry): void {
|
|
67
|
+
for (const existing of state.queue) {
|
|
68
|
+
if (existing.ticketId === entry.ticketId) return
|
|
69
|
+
}
|
|
70
|
+
setState({ queue: [...state.queue, entry] })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A ticket resolved (decide/timeout/dispose): drop it in every consumer. */
|
|
74
|
+
export function closeDownloadConsent(ticketId: string): void {
|
|
75
|
+
const queue = state.queue.filter((entry) => entry.ticketId !== ticketId)
|
|
76
|
+
if (queue.length === state.queue.length) return
|
|
77
|
+
setState({ queue })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Replace the queue from a pending list (app: `listPending` after an SSE
|
|
82
|
+
* reconnect; workbench: same API on restart).
|
|
83
|
+
*/
|
|
84
|
+
export function rehydrateDownloadConsent(
|
|
85
|
+
entries: readonly DownloadConsentEntry[],
|
|
86
|
+
): void {
|
|
87
|
+
const byTicket = new Map<string, DownloadConsentEntry>()
|
|
88
|
+
for (const entry of entries) byTicket.set(entry.ticketId, entry)
|
|
89
|
+
setState({ queue: [...byTicket.values()] })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enqueue a ticket and wait for its decision (workbench engine path).
|
|
94
|
+
* The dialog's answer arrives through {@link decideDownloadConsent}.
|
|
95
|
+
*/
|
|
96
|
+
export function requestDownloadConsent(
|
|
97
|
+
entry: DownloadConsentEntry,
|
|
98
|
+
): Promise<boolean> {
|
|
99
|
+
if (pending.has(entry.ticketId)) {
|
|
100
|
+
return Promise.resolve(false)
|
|
101
|
+
}
|
|
102
|
+
enqueueDownloadConsent(entry)
|
|
103
|
+
return new Promise<boolean>((resolve) => {
|
|
104
|
+
pending.set(entry.ticketId, { resolve })
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Answer a ticket from the dialog. Closes the entry and resolves any
|
|
110
|
+
* awaiting engine call. Idempotent; unknown tickets are a no-op.
|
|
111
|
+
*/
|
|
112
|
+
export function decideDownloadConsent(
|
|
113
|
+
ticketId: string,
|
|
114
|
+
approved: boolean,
|
|
115
|
+
): void {
|
|
116
|
+
const waiter = pending.get(ticketId)
|
|
117
|
+
if (waiter !== undefined) {
|
|
118
|
+
pending.delete(ticketId)
|
|
119
|
+
waiter.resolve(approved)
|
|
120
|
+
}
|
|
121
|
+
closeDownloadConsent(ticketId)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Test-only: reset the singleton between tests. */
|
|
125
|
+
export function resetDownloadConsent(): void {
|
|
126
|
+
for (const waiter of pending.values()) waiter.resolve(false)
|
|
127
|
+
pending.clear()
|
|
128
|
+
setState({ queue: [] })
|
|
129
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { anchorData as anchorDataSchema } from "@hoardodile/sdk-types/schema"
|
|
2
|
+
import type { PluginRequests } from "@hoardodile/sdk-web"
|
|
3
|
+
import { pluginMethods } from "@hoardodile/sdk-web"
|
|
4
|
+
import { z } from "zod"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wire-level param schemas for every request method, shared by the real
|
|
8
|
+
* host (apps/web) and the offline mock host. Both hosts validate
|
|
9
|
+
* identical shapes, so a plugin that passes validation in the mock is
|
|
10
|
+
* guaranteed to pass in production.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wire shape of a message/danmaku anchor: the plugin location payload
|
|
15
|
+
* only. The resource id is host state — the host stamps its own binding
|
|
16
|
+
* into the anchor before it reaches the server.
|
|
17
|
+
*/
|
|
18
|
+
export const anchorData = anchorDataSchema
|
|
19
|
+
|
|
20
|
+
export const requestSchemas = {
|
|
21
|
+
[pluginMethods.readFile]: z.object({
|
|
22
|
+
path: z.string().min(1),
|
|
23
|
+
range: z
|
|
24
|
+
.object({
|
|
25
|
+
start: z.number().int().nonnegative().optional(),
|
|
26
|
+
end: z.number().int().nonnegative().optional(),
|
|
27
|
+
})
|
|
28
|
+
.optional(),
|
|
29
|
+
}),
|
|
30
|
+
[pluginMethods.listFiles]: undefined,
|
|
31
|
+
[pluginMethods.listMessages]: undefined,
|
|
32
|
+
[pluginMethods.createMessage]: z.object({
|
|
33
|
+
body: z.string().min(1),
|
|
34
|
+
anchor: anchorData.optional(),
|
|
35
|
+
}),
|
|
36
|
+
[pluginMethods.listDanmaku]: z.object({
|
|
37
|
+
// Filter keys are plugin-defined vocabulary; matching is generic
|
|
38
|
+
// strict equality against the anchor's data.
|
|
39
|
+
filter: z
|
|
40
|
+
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
|
41
|
+
.optional(),
|
|
42
|
+
}),
|
|
43
|
+
[pluginMethods.createDanmaku]: z.object({
|
|
44
|
+
text: z.string().min(1),
|
|
45
|
+
anchor: anchorData,
|
|
46
|
+
mode: z.string().optional(),
|
|
47
|
+
}),
|
|
48
|
+
[pluginMethods.setPref]: z.object({
|
|
49
|
+
key: z.string().min(1),
|
|
50
|
+
value: z.string(),
|
|
51
|
+
}),
|
|
52
|
+
[pluginMethods.setCache]: z.object({
|
|
53
|
+
key: z.string().min(1),
|
|
54
|
+
value: z.string(),
|
|
55
|
+
}),
|
|
56
|
+
[pluginMethods.invalidate]: z.object({
|
|
57
|
+
target: z.enum(["resource", "resources", "messages", "danmaku"]),
|
|
58
|
+
}),
|
|
59
|
+
// The URL is length-capped but not URI-validated here: the host parses
|
|
60
|
+
// it server-side and answers with a machine-readable POLICY error.
|
|
61
|
+
[pluginMethods.download]: z.object({
|
|
62
|
+
url: z.string().min(1).max(2048),
|
|
63
|
+
dest: z.string().min(1).max(256),
|
|
64
|
+
sha256: z
|
|
65
|
+
.string()
|
|
66
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
67
|
+
.optional(),
|
|
68
|
+
reason: z.string().max(200).optional(),
|
|
69
|
+
}),
|
|
70
|
+
[pluginMethods.deleteAsset]: z.object({
|
|
71
|
+
path: z.string().min(1).max(256),
|
|
72
|
+
}),
|
|
73
|
+
[pluginMethods.logInfo]: z.object({
|
|
74
|
+
message: z.string(),
|
|
75
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
76
|
+
}),
|
|
77
|
+
[pluginMethods.logWarn]: z.object({
|
|
78
|
+
message: z.string(),
|
|
79
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
80
|
+
}),
|
|
81
|
+
[pluginMethods.logError]: z.object({
|
|
82
|
+
message: z.string(),
|
|
83
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
84
|
+
}),
|
|
85
|
+
} satisfies Record<keyof PluginRequests, z.ZodTypeAny | undefined>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { pluginMethods } from "@hoardodile/sdk-web"
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { anchorData, requestSchemas } from "./request-schemas.ts"
|
|
4
|
+
import type { HostHandlerEntry } from "./router.ts"
|
|
5
|
+
import { createHostRouter, defineHandler } from "./router.ts"
|
|
6
|
+
|
|
7
|
+
type FakeWindow = Window & { postMessage: (...args: unknown[]) => void }
|
|
8
|
+
|
|
9
|
+
function fakeWindow(): FakeWindow {
|
|
10
|
+
const w = new EventTarget() as unknown as FakeWindow
|
|
11
|
+
w.postMessage = vi.fn() as unknown as (...args: unknown[]) => void
|
|
12
|
+
return w
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fakeMessageEvent(data: unknown, source: Window): MessageEvent {
|
|
16
|
+
return new MessageEvent("message", {
|
|
17
|
+
data,
|
|
18
|
+
source,
|
|
19
|
+
origin: "null",
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildRouter(handlers: readonly HostHandlerEntry[] = []) {
|
|
24
|
+
const respond = vi.fn()
|
|
25
|
+
const subscribe = vi.fn()
|
|
26
|
+
const router = createHostRouter(handlers, {
|
|
27
|
+
resolveSource(event) {
|
|
28
|
+
const source = event.source
|
|
29
|
+
if (source === null) return undefined
|
|
30
|
+
return { source, record: { pluginId: "p-1", resId: "r-1" } }
|
|
31
|
+
},
|
|
32
|
+
respond,
|
|
33
|
+
subscribe,
|
|
34
|
+
})
|
|
35
|
+
return { router, respond, subscribe }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.restoreAllMocks()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("createHostRouter", () => {
|
|
43
|
+
it("routes a request to its handler and responds", async () => {
|
|
44
|
+
const { router, respond } = buildRouter([
|
|
45
|
+
defineHandler(pluginMethods.listFiles, async () => ["a.png"]),
|
|
46
|
+
])
|
|
47
|
+
const source = fakeWindow()
|
|
48
|
+
router(
|
|
49
|
+
fakeMessageEvent(
|
|
50
|
+
{ type: "request", id: 1, method: pluginMethods.listFiles },
|
|
51
|
+
source,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
await vi.waitFor(() => expect(respond).toHaveBeenCalled())
|
|
55
|
+
expect(respond).toHaveBeenCalledWith(source, {
|
|
56
|
+
type: "response",
|
|
57
|
+
id: 1,
|
|
58
|
+
ok: true,
|
|
59
|
+
data: ["a.png"],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("validates params with the shared schema before the handler", async () => {
|
|
64
|
+
const handler = vi.fn(async () => "ran")
|
|
65
|
+
const { router, respond } = buildRouter([
|
|
66
|
+
defineHandler(
|
|
67
|
+
pluginMethods.readFile,
|
|
68
|
+
requestSchemas[pluginMethods.readFile],
|
|
69
|
+
handler,
|
|
70
|
+
),
|
|
71
|
+
])
|
|
72
|
+
const source = fakeWindow()
|
|
73
|
+
router(
|
|
74
|
+
fakeMessageEvent(
|
|
75
|
+
{ type: "request", id: 1, method: pluginMethods.readFile, params: {} },
|
|
76
|
+
source,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
await vi.waitFor(() => expect(respond).toHaveBeenCalled())
|
|
80
|
+
expect(handler).not.toHaveBeenCalled()
|
|
81
|
+
expect(respond).toHaveBeenCalledWith(source, {
|
|
82
|
+
type: "response",
|
|
83
|
+
id: 1,
|
|
84
|
+
ok: false,
|
|
85
|
+
error: expect.stringContaining("Invalid params"),
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("passes validated params to the handler", async () => {
|
|
90
|
+
const seen: unknown[] = []
|
|
91
|
+
const { router } = buildRouter([
|
|
92
|
+
defineHandler(
|
|
93
|
+
pluginMethods.createMessage,
|
|
94
|
+
requestSchemas[pluginMethods.createMessage],
|
|
95
|
+
async (_ctx, params) => {
|
|
96
|
+
seen.push(params)
|
|
97
|
+
return "ok"
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
])
|
|
101
|
+
const source = fakeWindow()
|
|
102
|
+
router(
|
|
103
|
+
fakeMessageEvent(
|
|
104
|
+
{
|
|
105
|
+
type: "request",
|
|
106
|
+
id: 1,
|
|
107
|
+
method: pluginMethods.createMessage,
|
|
108
|
+
params: { body: "hi", anchor: { data: { page: 3 } } },
|
|
109
|
+
},
|
|
110
|
+
source,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
await vi.waitFor(() => expect(seen).toHaveLength(1))
|
|
114
|
+
expect(seen[0]).toEqual({ body: "hi", anchor: { data: { page: 3 } } })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("drops a stale resId stamp without invoking the handler", async () => {
|
|
118
|
+
const handler = vi.fn(async () => "ran")
|
|
119
|
+
const { router, respond } = buildRouter([
|
|
120
|
+
defineHandler(
|
|
121
|
+
pluginMethods.readFile,
|
|
122
|
+
requestSchemas[pluginMethods.readFile],
|
|
123
|
+
handler,
|
|
124
|
+
),
|
|
125
|
+
])
|
|
126
|
+
const source = fakeWindow()
|
|
127
|
+
router(
|
|
128
|
+
fakeMessageEvent(
|
|
129
|
+
{
|
|
130
|
+
type: "request",
|
|
131
|
+
id: 1,
|
|
132
|
+
method: pluginMethods.readFile,
|
|
133
|
+
params: { path: "a.png" },
|
|
134
|
+
resId: "r-foreign",
|
|
135
|
+
},
|
|
136
|
+
source,
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
await vi.waitFor(() => expect(respond).toHaveBeenCalled())
|
|
140
|
+
expect(handler).not.toHaveBeenCalled()
|
|
141
|
+
expect(respond).toHaveBeenCalledWith(source, {
|
|
142
|
+
type: "response",
|
|
143
|
+
id: 1,
|
|
144
|
+
ok: true,
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("rejects unknown methods with an error response", async () => {
|
|
149
|
+
const { router, respond } = buildRouter()
|
|
150
|
+
const source = fakeWindow()
|
|
151
|
+
router(fakeMessageEvent({ type: "request", id: 1, method: "nope" }, source))
|
|
152
|
+
await vi.waitFor(() => expect(respond).toHaveBeenCalled())
|
|
153
|
+
expect(respond).toHaveBeenCalledWith(source, {
|
|
154
|
+
type: "response",
|
|
155
|
+
id: 1,
|
|
156
|
+
ok: false,
|
|
157
|
+
error: "Unknown method: nope",
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("records subscriptions without responding", async () => {
|
|
162
|
+
const { router, respond, subscribe } = buildRouter()
|
|
163
|
+
const source = fakeWindow()
|
|
164
|
+
router(fakeMessageEvent({ type: "subscribe", key: "themeChanged" }, source))
|
|
165
|
+
expect(subscribe).toHaveBeenCalledWith(source, "themeChanged")
|
|
166
|
+
expect(respond).not.toHaveBeenCalled()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("drops messages from unresolved sources", async () => {
|
|
170
|
+
const respond = vi.fn()
|
|
171
|
+
const router = createHostRouter([], {
|
|
172
|
+
resolveSource: () => undefined,
|
|
173
|
+
respond,
|
|
174
|
+
subscribe: () => {},
|
|
175
|
+
})
|
|
176
|
+
router(
|
|
177
|
+
fakeMessageEvent(
|
|
178
|
+
{ type: "request", id: 1, method: pluginMethods.listFiles },
|
|
179
|
+
fakeWindow(),
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
expect(respond).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("warns once per source about a missing protocol stamp", async () => {
|
|
186
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
187
|
+
const { router, respond } = buildRouter()
|
|
188
|
+
const source = fakeWindow()
|
|
189
|
+
const event = (data: unknown) => fakeMessageEvent(data, source)
|
|
190
|
+
router(event({ type: "subscribe", key: "themeChanged" }))
|
|
191
|
+
router(event({ type: "subscribe", key: "visibility" }))
|
|
192
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
193
|
+
expect(warnSpy.mock.calls[0]?.[0]).toContain("did not stamp")
|
|
194
|
+
expect(respond).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("warns once per source about a protocol version mismatch", async () => {
|
|
198
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
199
|
+
const { router, respond } = buildRouter()
|
|
200
|
+
const source = fakeWindow()
|
|
201
|
+
const event = (data: unknown) => fakeMessageEvent(data, source)
|
|
202
|
+
router(event({ type: "subscribe", key: "themeChanged", proto: 999 }))
|
|
203
|
+
router(event({ type: "subscribe", key: "visibility", proto: 999 }))
|
|
204
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
205
|
+
expect(warnSpy.mock.calls[0]?.[0]).toContain("protocol version 999")
|
|
206
|
+
expect(respond).not.toHaveBeenCalled()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("does not warn for matching protocol stamps", async () => {
|
|
210
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
211
|
+
const { router } = buildRouter()
|
|
212
|
+
const source = fakeWindow()
|
|
213
|
+
router(
|
|
214
|
+
fakeMessageEvent(
|
|
215
|
+
{ type: "subscribe", key: "themeChanged", proto: 1 },
|
|
216
|
+
source,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe("requestSchemas", () => {
|
|
224
|
+
it("anchorData accepts only the plugin location payload", () => {
|
|
225
|
+
expect(anchorData.safeParse({ data: { page: 1 } }).success).toBe(true)
|
|
226
|
+
expect(anchorData.safeParse({ data: 1 }).success).toBe(true)
|
|
227
|
+
expect(anchorData.safeParse({}).success).toBe(true)
|
|
228
|
+
// The resource id is host state — a plugin sending it is rejected.
|
|
229
|
+
expect(anchorData.safeParse({ resId: "r-1" }).success).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
})
|