@hoardodile/workbench 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,156 @@
1
+ import type { Server } from "node:http"
2
+ import type { ImageVariantQuery } from "@hoardodile/sdk-types/image-variant"
3
+
4
+ /**
5
+ * Server-side hook results captured against the selected resource. The
6
+ * workbench pushes these into the plugin iframe context so it renders
7
+ * exactly like the app does. Produced by `hoardodile plugin dev`; the
8
+ * workbench itself never runs a sandbox.
9
+ */
10
+ export type WorkbenchHookSnapshot = {
11
+ readonly pluginId: string
12
+ readonly detect: {
13
+ readonly ok: boolean
14
+ readonly reasons?: readonly string[]
15
+ }
16
+ readonly sourceMeta: unknown
17
+ readonly searchMeta: unknown
18
+ readonly coverLocal: string | undefined
19
+ readonly files: readonly unknown[] | undefined
20
+ readonly fileStats: {
21
+ readonly count?: number
22
+ readonly sizeBytes?: number
23
+ }
24
+ readonly imageHashes?: readonly unknown[]
25
+ readonly errors: Readonly<Record<string, string>>
26
+ readonly capturedAt: number
27
+ }
28
+
29
+ /** A resource the workbench can open, as shown in the picker. */
30
+ export type WorkbenchResource = {
31
+ readonly id: string
32
+ readonly name: string
33
+ /** Plugin that owns the resource in the source library, when known. */
34
+ readonly contentPluginId?: string
35
+ readonly fileVersion?: number
36
+ }
37
+
38
+ /**
39
+ * The plugin-visible slice of a resource's stored state. Used to seed
40
+ * the offline mock host so comments, danmaku, preferences and the
41
+ * per-resource cache are the ones the plugin would really see. Writes
42
+ * never leave the mock.
43
+ */
44
+ export type WorkbenchResourceState = {
45
+ readonly name?: string
46
+ readonly messages?: readonly unknown[]
47
+ readonly danmaku?: readonly unknown[]
48
+ readonly prefs?: Readonly<Record<string, string>>
49
+ readonly cache?: Readonly<Record<string, string>>
50
+ }
51
+
52
+ /** Bytes (or a cached path) plus the content type to serve them as. */
53
+ export type WorkbenchRendered = {
54
+ readonly contentType: string
55
+ readonly bytes?: Uint8Array
56
+ readonly path?: string
57
+ }
58
+
59
+ /** Read-only file access for the selected resource. */
60
+ export type WorkbenchFileProvider = {
61
+ readonly list: (
62
+ resId: string,
63
+ ) => Promise<readonly string[]> | readonly string[]
64
+ readonly stat: (
65
+ resId: string,
66
+ path: string,
67
+ ) =>
68
+ | Promise<{ readonly sizeBytes: number } | undefined>
69
+ | { readonly sizeBytes: number }
70
+ | undefined
71
+ readonly read: (
72
+ resId: string,
73
+ path: string,
74
+ ) => Promise<Uint8Array | undefined> | Uint8Array | undefined
75
+ }
76
+
77
+ /**
78
+ * Everything the workbench page can ask for. Each is optional: without
79
+ * a `preview` provider `?size=preview` (and the generic variant
80
+ * parameters) falls back to the original bytes, without `frame` the
81
+ * seek-preview route stays unmounted.
82
+ */
83
+ export type WorkbenchProviders = {
84
+ readonly resources: () =>
85
+ | Promise<readonly WorkbenchResource[]>
86
+ | readonly WorkbenchResource[]
87
+ readonly files?: WorkbenchFileProvider
88
+ readonly snapshot?: (
89
+ resId: string,
90
+ ) =>
91
+ | Promise<WorkbenchHookSnapshot | undefined>
92
+ | WorkbenchHookSnapshot
93
+ | undefined
94
+ readonly state?: (
95
+ resId: string,
96
+ ) =>
97
+ | Promise<WorkbenchResourceState | undefined>
98
+ | WorkbenchResourceState
99
+ | undefined
100
+ readonly preview?: (
101
+ resId: string,
102
+ path: string,
103
+ /**
104
+ * The file route's raw variant query (`size`, `fmt`, `fit`,
105
+ * `area`, `q`) — parsed and validated by the provider, so the
106
+ * workbench mount itself stays dependency-free.
107
+ */
108
+ variant?: ImageVariantQuery,
109
+ ) => Promise<WorkbenchRendered | undefined>
110
+ readonly frame?: (
111
+ resId: string,
112
+ path: string,
113
+ timeMs: number,
114
+ ) => Promise<WorkbenchRendered | undefined>
115
+ }
116
+
117
+ /** Options for {@link serveWorkbench}. */
118
+ export type ServeWorkbenchOptions = {
119
+ /** Built plugin dist dir mounted at `/plugin` (manifest + index.html). */
120
+ readonly pluginDir?: string
121
+ /**
122
+ * Data root exposed as a single resource. Shorthand for the
123
+ * directory providers; ignored when `providers` is given.
124
+ */
125
+ readonly dataDir?: string
126
+ /** Real data sources. `hoardodile plugin dev` supplies these. */
127
+ readonly providers?: WorkbenchProviders
128
+ /**
129
+ * Latest hook snapshot. Called per request so a watch-driven
130
+ * recapture is picked up without restarting. Merged into
131
+ * `providers` when both are given.
132
+ */
133
+ readonly snapshot?: (
134
+ resId: string,
135
+ ) =>
136
+ | Promise<WorkbenchHookSnapshot | undefined>
137
+ | WorkbenchHookSnapshot
138
+ | undefined
139
+ /** Port to listen on. Defaults to 5199. */
140
+ readonly port?: number
141
+ /** Bind host. Defaults to 127.0.0.1. */
142
+ readonly host?: string
143
+ }
144
+
145
+ /**
146
+ * Serve the published workbench SPA with the plugin bundle and the
147
+ * resource data mounted read-only. Resolves once the server is
148
+ * listening.
149
+ */
150
+ export function serveWorkbench(opts: ServeWorkbenchOptions): Promise<Server>
151
+
152
+ /** Providers over one plain directory, standing in for a single resource. */
153
+ export function createDirectoryProviders(
154
+ dataDir: string,
155
+ resId?: string,
156
+ ): WorkbenchProviders
package/dist/serve.mjs ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone server for the published workbench bundle: the prebuilt SPA
4
+ * from `dist/` plus the read-only mounts the page needs at runtime. The
5
+ * routing itself lives in `mounts.mjs`, shared with the vite dev server
6
+ * so the two can never drift.
7
+ *
8
+ * Usage:
9
+ * node serve.mjs --plugin <plugin-dist-dir> --data <data-dir> [--port 5199]
10
+ * [--snapshot <hooks.json>]
11
+ *
12
+ * Also exported so the plugin CLI's `dev` subcommand can serve the
13
+ * workbench with richer providers (real storage, preview variants,
14
+ * video frames): `import { serveWorkbench } from "@hoardodile/workbench"`.
15
+ */
16
+ import { existsSync, readFileSync, statSync } from "node:fs"
17
+ import { createServer } from "node:http"
18
+ import { dirname, join, resolve, sep } from "node:path"
19
+ import { fileURLToPath } from "node:url"
20
+ import {
21
+ contentTypeOf,
22
+ createDirectoryProviders,
23
+ createWorkbenchMounts,
24
+ } from "./mounts.mjs"
25
+
26
+ const DIST_DIR = resolve(dirname(fileURLToPath(import.meta.url)))
27
+
28
+ /**
29
+ * Serve the published workbench on `port`. Pass `providers` for real
30
+ * data (the CLI does), or `dataDir` for the plain-directory default.
31
+ * Returns the http.Server.
32
+ */
33
+ export function serveWorkbench(opts) {
34
+ const { pluginDir, dataDir, port = 5199, host = "127.0.0.1" } = opts
35
+ if (pluginDir === undefined) {
36
+ console.warn("[workbench] no plugin dir — pass --plugin <dist-dir>")
37
+ }
38
+ const base =
39
+ opts.providers ??
40
+ (dataDir === undefined
41
+ ? { resources: () => [] }
42
+ : createDirectoryProviders(dataDir))
43
+ if (opts.providers === undefined && dataDir === undefined) {
44
+ console.warn("[workbench] no data dir — pass --data <data-dir>")
45
+ }
46
+ // A snapshot provider passed on its own (the classic `--snapshot`
47
+ // shape) still works: it is just one more provider.
48
+ const providers =
49
+ opts.snapshot === undefined ? base : { ...base, snapshot: opts.snapshot }
50
+
51
+ // Plugin asset vault: the dev server performs the user-consented
52
+ // downloads (browser fetch would hit CORS) into this local scratch
53
+ // root. The CLI passes `<pluginDir>/.hoardodile/vault`; the
54
+ // standalone default sits next to the data dir.
55
+ const vaultRoot =
56
+ opts.vaultRoot ?? join(resolve(dataDir ?? "."), ".workbench-vault")
57
+
58
+ const mounts = createWorkbenchMounts({
59
+ pluginDir,
60
+ providers,
61
+ vault: vaultRoot,
62
+ })
63
+
64
+ const server = createServer((req, res) => {
65
+ void (async () => {
66
+ try {
67
+ for (const mount of mounts) {
68
+ if (await mount(req, res)) return
69
+ }
70
+ serveSpa(req, res)
71
+ } catch (err) {
72
+ console.error("[workbench] request failed:", err)
73
+ if (!res.headersSent) res.statusCode = 500
74
+ res.end("workbench error")
75
+ }
76
+ })()
77
+ })
78
+
79
+ return new Promise((resolveStart) => {
80
+ server.listen(port, host, () => {
81
+ console.log(`[workbench] serving on http://${host}:${port}`)
82
+ resolveStart(server)
83
+ })
84
+ })
85
+ }
86
+
87
+ function serveSpa(req, res) {
88
+ const url = new URL(req.url ?? "/", "http://workbench.local")
89
+ if (url.pathname === "/") {
90
+ res.setHeader("content-type", "text/html; charset=utf-8")
91
+ res.end(readFileSync(join(DIST_DIR, "index.html")))
92
+ return
93
+ }
94
+ const rel = decodeURIComponent(url.pathname).replace(/^\/+/, "")
95
+ const abs = resolve(DIST_DIR, rel)
96
+ if (
97
+ abs === DIST_DIR ||
98
+ !abs.startsWith(DIST_DIR + sep) ||
99
+ !existsSync(abs) ||
100
+ statSync(abs).isDirectory()
101
+ ) {
102
+ res.statusCode = 404
103
+ res.end("not found")
104
+ return
105
+ }
106
+ res.setHeader("content-type", contentTypeOf(abs))
107
+ res.end(readFileSync(abs))
108
+ }
109
+
110
+ // CLI entry when run directly.
111
+ const isMain =
112
+ process.argv[1] !== undefined &&
113
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
114
+ if (isMain) {
115
+ const args = process.argv.slice(2)
116
+ const flagValue = (name) => {
117
+ const i = args.indexOf(name)
118
+ return i !== -1 ? args[i + 1] : undefined
119
+ }
120
+ const snapshotPath = flagValue("--snapshot")
121
+ // Re-read on every request: a `plugin dev` style watcher may rewrite
122
+ // the file as the plugin rebuilds.
123
+ const snapshot =
124
+ snapshotPath === undefined
125
+ ? undefined
126
+ : () => {
127
+ try {
128
+ return JSON.parse(readFileSync(resolve(snapshotPath), "utf8"))
129
+ } catch {
130
+ return undefined
131
+ }
132
+ }
133
+ await serveWorkbench({
134
+ pluginDir: flagValue("--plugin"),
135
+ dataDir: flagValue("--data"),
136
+ port: Number(flagValue("--port") ?? 5199),
137
+ snapshot,
138
+ })
139
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@hoardodile/workbench",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "description": "Offline dev workbench for hoardodile content plugins.",
6
+ "keywords": [
7
+ "hoardodile",
8
+ "plugin",
9
+ "workbench",
10
+ "dev",
11
+ "mock"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hoardodile/hoardodile.git",
16
+ "directory": "plugins/workbench"
17
+ },
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/serve.d.mts",
22
+ "default": "./dist/serve.mjs"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "devDependencies": {
36
+ "@playwright/test": "^1.62.1",
37
+ "@rolldown/plugin-babel": "^0.2.3",
38
+ "@tailwindcss/vite": "^4.3.3",
39
+ "@types/node": "^26.2.0",
40
+ "@types/react": "^19.2.18",
41
+ "@types/react-dom": "^19.2.4",
42
+ "@vitejs/plugin-react": "^6.1.0",
43
+ "babel-plugin-react-compiler": "^1.0.0",
44
+ "i18next": "^26.4.0",
45
+ "react": "^19.2.8",
46
+ "react-dom": "^19.2.8",
47
+ "react-i18next": "^17.0.12",
48
+ "tailwindcss": "^4.3.3",
49
+ "typescript": "^7.0.2",
50
+ "vite": "^8.2.2",
51
+ "vitest": "^4.1.11",
52
+ "zod": "^4.4.3",
53
+ "@hoardodile/host-web": "0.0.0",
54
+ "@hoardodile/ui": "0.0.0",
55
+ "@hoardodile/i18n": "0.0.0",
56
+ "@hoardodile/sdk-web": "0.0.0",
57
+ "@hoardodile/sdk-types": "0.0.0"
58
+ },
59
+ "scripts": {
60
+ "dev": "node scripts/dev.mjs",
61
+ "build": "vite build && node scripts/copy-serve.mjs",
62
+ "lint": "tsc --noEmit",
63
+ "test": "vitest run"
64
+ }
65
+ }