@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/src/mock/host.ts
ADDED
|
@@ -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
|
+
}
|