@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.
- package/LICENSE +18 -0
- package/README.md +108 -0
- package/dist/assets/index-DuswH1zS.css +2 -0
- package/dist/assets/index-Rg27XFd4.js +77 -0
- package/dist/index.html +50 -0
- package/dist/mounts.mjs +730 -0
- package/dist/serve.d.mts +156 -0
- package/dist/serve.mjs +139 -0
- package/package.json +65 -0
package/dist/mounts.mjs
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workbench's HTTP surface, shared by the vite dev server
|
|
3
|
+
* (`vite.config.ts`) and the published standalone server (`serve.mjs`)
|
|
4
|
+
* so the two can never drift.
|
|
5
|
+
*
|
|
6
|
+
* Everything the page needs about a resource arrives through provider
|
|
7
|
+
* callbacks. That is what keeps this package dependency-free while
|
|
8
|
+
* still reaching real data: `hoardodile plugin dev` owns the sandbox,
|
|
9
|
+
* the storage reader and the render pipeline, and passes them in. Run
|
|
10
|
+
* standalone against a plain directory, the built-in providers below
|
|
11
|
+
* cover the offline case.
|
|
12
|
+
*
|
|
13
|
+
* Routes:
|
|
14
|
+
* GET /plugin/* built plugin bundle
|
|
15
|
+
* GET /data/<path>[?res=] raw entry bytes
|
|
16
|
+
* GET /data/?list=1[&res=] entry names
|
|
17
|
+
* GET /data/?stat=<path>[&res=] entry size
|
|
18
|
+
* GET /api/workbench/resources resource picker list
|
|
19
|
+
* GET /api/workbench/context?res=<id> hooks + seeded state
|
|
20
|
+
* GET /api/resources/:id/files/:token/* plugin file URLs
|
|
21
|
+
* …?size=preview preview variant
|
|
22
|
+
* GET /api/resources/:id/frame/:token/:name/:ms video seek frame
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto"
|
|
26
|
+
import {
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
rmSync,
|
|
33
|
+
statSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from "node:fs"
|
|
36
|
+
import { join, resolve, sep } from "node:path"
|
|
37
|
+
|
|
38
|
+
export function contentTypeOf(path) {
|
|
39
|
+
const ext = path.slice(path.lastIndexOf(".")).toLowerCase()
|
|
40
|
+
switch (ext) {
|
|
41
|
+
case ".html":
|
|
42
|
+
return "text/html; charset=utf-8"
|
|
43
|
+
case ".js":
|
|
44
|
+
case ".mjs":
|
|
45
|
+
return "text/javascript; charset=utf-8"
|
|
46
|
+
case ".css":
|
|
47
|
+
return "text/css; charset=utf-8"
|
|
48
|
+
case ".json":
|
|
49
|
+
return "application/json; charset=utf-8"
|
|
50
|
+
case ".svg":
|
|
51
|
+
return "image/svg+xml"
|
|
52
|
+
case ".png":
|
|
53
|
+
return "image/png"
|
|
54
|
+
case ".jpg":
|
|
55
|
+
case ".jpeg":
|
|
56
|
+
return "image/jpeg"
|
|
57
|
+
case ".gif":
|
|
58
|
+
return "image/gif"
|
|
59
|
+
case ".webp":
|
|
60
|
+
return "image/webp"
|
|
61
|
+
case ".avif":
|
|
62
|
+
return "image/avif"
|
|
63
|
+
case ".mp4":
|
|
64
|
+
return "video/mp4"
|
|
65
|
+
case ".webm":
|
|
66
|
+
return "video/webm"
|
|
67
|
+
case ".mp3":
|
|
68
|
+
return "audio/mpeg"
|
|
69
|
+
case ".flac":
|
|
70
|
+
return "audio/flac"
|
|
71
|
+
case ".wav":
|
|
72
|
+
return "audio/wav"
|
|
73
|
+
case ".woff2":
|
|
74
|
+
return "font/woff2"
|
|
75
|
+
case ".woff":
|
|
76
|
+
return "font/woff"
|
|
77
|
+
default:
|
|
78
|
+
return "application/octet-stream"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function walkFiles(root) {
|
|
83
|
+
const out = []
|
|
84
|
+
function walk(current, prefix) {
|
|
85
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
86
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
87
|
+
if (entry.isDirectory()) walk(join(current, entry.name), rel)
|
|
88
|
+
else if (entry.isFile()) out.push(rel)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
walk(root, "")
|
|
92
|
+
return out.sort()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve `rel` under `root`, or `undefined` when it escapes. */
|
|
96
|
+
function safeJoin(root, rel) {
|
|
97
|
+
const abs = resolve(root, rel)
|
|
98
|
+
if (abs !== root && !abs.startsWith(root + sep)) return undefined
|
|
99
|
+
return abs
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sendJson(res, value) {
|
|
103
|
+
res.setHeader("content-type", "application/json; charset=utf-8")
|
|
104
|
+
res.setHeader("cache-control", "no-store")
|
|
105
|
+
res.end(JSON.stringify(value ?? null))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sendBytes(res, contentType, bytes) {
|
|
109
|
+
res.setHeader("content-type", contentType)
|
|
110
|
+
res.end(bytes)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function notFound(res) {
|
|
114
|
+
res.statusCode = 404
|
|
115
|
+
res.end("not found")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Providers over one plain directory — the offline default, and what a
|
|
120
|
+
* standalone `serve.mjs --data <dir>` uses. The directory stands in for
|
|
121
|
+
* a single resource.
|
|
122
|
+
*/
|
|
123
|
+
export function createDirectoryProviders(dataDir, resId = "workbench") {
|
|
124
|
+
const root = resolve(dataDir)
|
|
125
|
+
return {
|
|
126
|
+
resources: () => [{ id: resId, name: "Workbench" }],
|
|
127
|
+
files: {
|
|
128
|
+
list: () => walkFiles(root),
|
|
129
|
+
stat: (_resId, path) => {
|
|
130
|
+
const abs = safeJoin(root, path)
|
|
131
|
+
if (abs === undefined || !existsSync(abs)) return undefined
|
|
132
|
+
const info = statSync(abs)
|
|
133
|
+
return info.isFile() ? { sizeBytes: info.size } : undefined
|
|
134
|
+
},
|
|
135
|
+
read: (_resId, path) => {
|
|
136
|
+
const abs = safeJoin(root, path)
|
|
137
|
+
if (abs === undefined || !existsSync(abs)) return undefined
|
|
138
|
+
if (statSync(abs).isDirectory()) return undefined
|
|
139
|
+
return readFileSync(abs)
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Mobile viewport initial-scale factor injected into every served plugin
|
|
147
|
+
* page — the host server runs `wrapHtml` with the same value
|
|
148
|
+
* (apps/server/src/infra/http/plugin-render.ts). NOTE: keep in sync with
|
|
149
|
+
* `MOBILE_INITIAL_SCALE` in `@hoardodile/ui/viewport` (single source of
|
|
150
|
+
* truth, Design.md — Layout); `mounts.test.ts` guards the alignment.
|
|
151
|
+
* This module stays dependency-free, so the constant is mirrored, not
|
|
152
|
+
* imported.
|
|
153
|
+
*/
|
|
154
|
+
const PLUGIN_SHELL_VIEWPORT_SCALE = 0.8
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wraps a plugin page in the same shell the host server produces
|
|
158
|
+
* (plugin-render.ts `wrapHtml`, kept byte-identical): the viewport meta
|
|
159
|
+
* the app injects, the overflow reset, and the postMessage bridge that
|
|
160
|
+
* exposes `__pluginContext` / `__pluginVisibility` as CustomEvents for
|
|
161
|
+
* SDK builds that predate the pure-postMessage protocol.
|
|
162
|
+
*/
|
|
163
|
+
export function wrapPluginHtml(body) {
|
|
164
|
+
return [
|
|
165
|
+
"<!DOCTYPE html>",
|
|
166
|
+
"<html>",
|
|
167
|
+
"<head>",
|
|
168
|
+
'<meta charset="utf-8">',
|
|
169
|
+
`<meta name="viewport" content="width=device-width, initial-scale=${PLUGIN_SHELL_VIEWPORT_SCALE}, maximum-scale=1.0, user-scalable=0">`,
|
|
170
|
+
'<style type="text/css">html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden}</style>',
|
|
171
|
+
"</head>",
|
|
172
|
+
"<body>",
|
|
173
|
+
`<script>(function(){window.__pluginContext=undefined;window.__pluginVisibility=undefined;window.addEventListener("message",function(e){if(e.source!==window.parent)return;if(e.data?.type==="push"){if(e.data?.key==="context"){window.__pluginContext=e.data.data;window.dispatchEvent(new CustomEvent("context-ready",{detail:e.data.data}))}else if(e.data?.key==="visibility"){window.__pluginVisibility=e.data.data;window.dispatchEvent(new CustomEvent("visibility-changed",{detail:e.data.data}))}}})})();</script>`,
|
|
174
|
+
body,
|
|
175
|
+
"</body>",
|
|
176
|
+
"</html>",
|
|
177
|
+
].join("")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read-only mount of a directory under `basePath` (the plugin bundle). */
|
|
181
|
+
function staticMount(basePath, dir) {
|
|
182
|
+
const root = resolve(dir)
|
|
183
|
+
return (req, res) => {
|
|
184
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
185
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
// Sandboxed plugin iframes have the opaque origin "null"; their
|
|
189
|
+
// asset fetches need permissive CORS, same as the real server's
|
|
190
|
+
// plugin render route.
|
|
191
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
192
|
+
const rel = decodeURIComponent(url.pathname.slice(basePath.length)).replace(
|
|
193
|
+
/^\/+/,
|
|
194
|
+
"",
|
|
195
|
+
)
|
|
196
|
+
const abs = safeJoin(root, rel)
|
|
197
|
+
if (abs === undefined) {
|
|
198
|
+
res.statusCode = 403
|
|
199
|
+
res.end("forbidden")
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
if (!existsSync(abs) || statSync(abs).isDirectory()) {
|
|
203
|
+
notFound(res)
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
const ext = abs.slice(abs.lastIndexOf(".")).toLowerCase()
|
|
207
|
+
if (ext === ".html") {
|
|
208
|
+
// Mirror the host server: the page runs in a sandboxed iframe
|
|
209
|
+
// (no allow-same-origin). The same sandbox via CSP keeps it in
|
|
210
|
+
// an opaque origin even top-level; frame-ancestors restricts
|
|
211
|
+
// embedding to the workbench origin (the page and /plugin/*
|
|
212
|
+
// share it, so the embedded workbench keeps working).
|
|
213
|
+
res.setHeader(
|
|
214
|
+
"content-security-policy",
|
|
215
|
+
"sandbox allow-scripts allow-forms allow-downloads; frame-ancestors 'self'",
|
|
216
|
+
)
|
|
217
|
+
res.setHeader("x-content-type-options", "nosniff")
|
|
218
|
+
sendBytes(
|
|
219
|
+
res,
|
|
220
|
+
contentTypeOf(abs),
|
|
221
|
+
wrapPluginHtml(readFileSync(abs, "utf-8")),
|
|
222
|
+
)
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
sendBytes(res, contentTypeOf(abs), readFileSync(abs))
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `/data` mount: entry listing, stat and bytes for the selected resource. */
|
|
231
|
+
function dataMount(files) {
|
|
232
|
+
return async (req, res) => {
|
|
233
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
234
|
+
if (url.pathname !== "/data" && !url.pathname.startsWith("/data/")) {
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
238
|
+
const resId = url.searchParams.get("res") ?? ""
|
|
239
|
+
if (url.searchParams.has("list")) {
|
|
240
|
+
sendJson(res, await files.list(resId))
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
const statPath = url.searchParams.get("stat")
|
|
244
|
+
if (statPath !== null) {
|
|
245
|
+
const stat = await files.stat(resId, decodeURIComponent(statPath))
|
|
246
|
+
sendJson(res, stat === undefined ? null : stat.sizeBytes)
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
const rel = decodeURIComponent(url.pathname.slice("/data".length)).replace(
|
|
250
|
+
/^\/+/,
|
|
251
|
+
"",
|
|
252
|
+
)
|
|
253
|
+
const bytes = await files.read(resId, rel)
|
|
254
|
+
if (bytes === undefined) {
|
|
255
|
+
notFound(res)
|
|
256
|
+
return true
|
|
257
|
+
}
|
|
258
|
+
sendBytes(res, contentTypeOf(rel), bytes)
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const FILE_URL_RE = /^\/api\/resources\/([^/]+)\/files\/(?:[^/]*)\/(.+)$/
|
|
264
|
+
const COVER_URL_RE = /^\/api\/resources\/([^/]+)\/cover$/
|
|
265
|
+
const FRAME_URL_RE =
|
|
266
|
+
/^\/api\/resources\/([^/]+)\/frame\/(?:[^/]*)\/([^/]+)\/([^/]+)$/
|
|
267
|
+
const EXTRACTED_URL_RE =
|
|
268
|
+
/^\/api\/resources\/([^/]+)\/extracted\/(?:[^/]*)\/(.+)$/
|
|
269
|
+
// NOTE: keep the token-path route family in sync with the server's auth
|
|
270
|
+
// preHandler and the web service worker (see
|
|
271
|
+
// apps/server/src/infra/http/plugin.ts). The cover route mirrors the
|
|
272
|
+
// server's token-free `GET /api/resources/:id/cover`.
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The resource cover, rendered the way the app does it (`coverLocal`
|
|
276
|
+
* pick -> thumb pipeline). Mirrors the server's
|
|
277
|
+
* `GET /api/resources/:id/cover?size=thumb`; a missing cover or render
|
|
278
|
+
* surfaces as the same placeholder-shaped 404 the app sends.
|
|
279
|
+
*/
|
|
280
|
+
function coverMount(cover) {
|
|
281
|
+
return async (req, res) => {
|
|
282
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
283
|
+
const match = url.pathname.match(COVER_URL_RE)
|
|
284
|
+
if (match === null) return false
|
|
285
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
286
|
+
const rendered = await cover(decodeURIComponent(match[1] ?? ""))
|
|
287
|
+
if (rendered === undefined) {
|
|
288
|
+
res.statusCode = 404
|
|
289
|
+
res.setHeader("content-type", "application/json")
|
|
290
|
+
res.end('{"error":"no cover","reason":"placeholder"}')
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
sendBytes(res, rendered.contentType, readRendered(rendered))
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The plugin file URL shape the real server exposes. `?size=preview`
|
|
300
|
+
* (and the generic variant parameters `fmt`/`fit`/`area`/`q`) goes
|
|
301
|
+
* through the render provider when one is wired, so the workbench
|
|
302
|
+
* serves the same derived variant production does; without a provider
|
|
303
|
+
* it falls back to the original bytes.
|
|
304
|
+
*/
|
|
305
|
+
function resourceFilesMount(files, preview) {
|
|
306
|
+
return async (req, res) => {
|
|
307
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
308
|
+
const match = url.pathname.match(FILE_URL_RE)
|
|
309
|
+
if (match === null) return false
|
|
310
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
311
|
+
const resId = decodeURIComponent(match[1] ?? "")
|
|
312
|
+
const rel = decodeURIComponent(match[2] ?? "")
|
|
313
|
+
const variantQuery = collectVariantQuery(url)
|
|
314
|
+
if (variantQuery !== undefined && preview !== undefined) {
|
|
315
|
+
const rendered = await preview(resId, rel, variantQuery)
|
|
316
|
+
if (rendered !== undefined) {
|
|
317
|
+
sendBytes(res, rendered.contentType, readRendered(rendered))
|
|
318
|
+
return true
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const bytes = await files.read(resId, rel)
|
|
322
|
+
if (bytes === undefined) {
|
|
323
|
+
notFound(res)
|
|
324
|
+
return true
|
|
325
|
+
}
|
|
326
|
+
sendBytes(res, contentTypeOf(rel), bytes)
|
|
327
|
+
return true
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The variant parameters of a file URL, or `undefined` when none are
|
|
333
|
+
* present. Raw strings pass through untouched — the render provider
|
|
334
|
+
* parses and validates them.
|
|
335
|
+
*/
|
|
336
|
+
function collectVariantQuery(url) {
|
|
337
|
+
const get = (name) => url.searchParams.get(name)
|
|
338
|
+
const size = get("size")
|
|
339
|
+
const fmt = get("fmt")
|
|
340
|
+
const fit = get("fit")
|
|
341
|
+
const area = get("area")
|
|
342
|
+
const q = get("q")
|
|
343
|
+
const requested =
|
|
344
|
+
size === "preview" ||
|
|
345
|
+
fmt !== null ||
|
|
346
|
+
fit !== null ||
|
|
347
|
+
area !== null ||
|
|
348
|
+
q !== null
|
|
349
|
+
if (!requested) return undefined
|
|
350
|
+
const query = {}
|
|
351
|
+
if (size !== null) query.size = size
|
|
352
|
+
if (fmt !== null) query.fmt = fmt
|
|
353
|
+
if (fit !== null) query.fit = fit
|
|
354
|
+
if (area !== null) query.area = area
|
|
355
|
+
if (q !== null) query.q = q
|
|
356
|
+
return query
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Video seek-preview frames, rendered on demand by the provider. */
|
|
360
|
+
function frameMount(frame) {
|
|
361
|
+
return async (req, res) => {
|
|
362
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
363
|
+
const match = url.pathname.match(FRAME_URL_RE)
|
|
364
|
+
if (match === null) return false
|
|
365
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
366
|
+
const timeMs = Number(match[3])
|
|
367
|
+
if (!Number.isFinite(timeMs) || timeMs < 0) {
|
|
368
|
+
res.statusCode = 400
|
|
369
|
+
res.end("invalid time")
|
|
370
|
+
return true
|
|
371
|
+
}
|
|
372
|
+
const rendered = await frame(
|
|
373
|
+
decodeURIComponent(match[1] ?? ""),
|
|
374
|
+
decodeURIComponent(match[2] ?? ""),
|
|
375
|
+
timeMs,
|
|
376
|
+
)
|
|
377
|
+
if (rendered === undefined) {
|
|
378
|
+
notFound(res)
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
381
|
+
sendBytes(res, rendered.contentType, readRendered(rendered))
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Files materialized by the plugin's `extractArchive` hook. The
|
|
388
|
+
* production server reads them from the extraction cache; the workbench
|
|
389
|
+
* delegates to the same provider the CLI wires.
|
|
390
|
+
*/
|
|
391
|
+
function extractedMount(files) {
|
|
392
|
+
return async (req, res) => {
|
|
393
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
394
|
+
const match = url.pathname.match(EXTRACTED_URL_RE)
|
|
395
|
+
if (match === null) return false
|
|
396
|
+
if (files.extracted === undefined) return false
|
|
397
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
398
|
+
const resId = decodeURIComponent(match[1] ?? "")
|
|
399
|
+
const rel = decodeURIComponent(match[2] ?? "")
|
|
400
|
+
const bytes = await files.extracted(resId, rel)
|
|
401
|
+
if (bytes === undefined) {
|
|
402
|
+
notFound(res)
|
|
403
|
+
return true
|
|
404
|
+
}
|
|
405
|
+
sendBytes(res, contentTypeOf(rel), bytes)
|
|
406
|
+
return true
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A render provider may answer with bytes in hand or a cached path. */
|
|
411
|
+
function readRendered(rendered) {
|
|
412
|
+
return rendered.bytes ?? readFileSync(rendered.path)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The page's own API: which resources can be opened, and everything
|
|
417
|
+
* known about the selected one (sandboxed hook results plus the
|
|
418
|
+
* plugin-visible state used to seed the mock host).
|
|
419
|
+
*/
|
|
420
|
+
function workbenchApiMount(providers) {
|
|
421
|
+
return async (req, res) => {
|
|
422
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
423
|
+
if (!url.pathname.startsWith("/api/workbench/")) return false
|
|
424
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
425
|
+
if (url.pathname === "/api/workbench/resources") {
|
|
426
|
+
sendJson(res, await providers.resources())
|
|
427
|
+
return true
|
|
428
|
+
}
|
|
429
|
+
if (url.pathname === "/api/workbench/context") {
|
|
430
|
+
const resId = url.searchParams.get("res") ?? ""
|
|
431
|
+
const [snapshot, state] = await Promise.all([
|
|
432
|
+
providers.snapshot?.(resId),
|
|
433
|
+
providers.state?.(resId),
|
|
434
|
+
])
|
|
435
|
+
sendJson(res, {
|
|
436
|
+
resId,
|
|
437
|
+
snapshot: snapshot ?? null,
|
|
438
|
+
state: state ?? null,
|
|
439
|
+
// Rendering capabilities the page surfaces in its status
|
|
440
|
+
// line, so a missing one reads as "not wired" rather than
|
|
441
|
+
// as a broken plugin.
|
|
442
|
+
capabilities: {
|
|
443
|
+
preview: providers.preview !== undefined,
|
|
444
|
+
frame: providers.frame !== undefined,
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
return true
|
|
448
|
+
}
|
|
449
|
+
notFound(res)
|
|
450
|
+
return true
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build the workbench's request handlers in match order. Each returns
|
|
456
|
+
* `true` when it handled the request.
|
|
457
|
+
*/
|
|
458
|
+
export function createWorkbenchMounts(opts) {
|
|
459
|
+
const { pluginDir, providers } = opts
|
|
460
|
+
const mounts = []
|
|
461
|
+
if (pluginDir !== undefined) mounts.push(staticMount("/plugin", pluginDir))
|
|
462
|
+
if (providers.files !== undefined) {
|
|
463
|
+
mounts.push(dataMount(providers.files))
|
|
464
|
+
mounts.push(resourceFilesMount(providers.files, providers.preview))
|
|
465
|
+
mounts.push(extractedMount(providers.files))
|
|
466
|
+
}
|
|
467
|
+
if (providers.cover !== undefined) mounts.push(coverMount(providers.cover))
|
|
468
|
+
if (providers.frame !== undefined) mounts.push(frameMount(providers.frame))
|
|
469
|
+
if (opts.vault !== undefined) {
|
|
470
|
+
mounts.push(pluginAssetsMount(opts.vault))
|
|
471
|
+
mounts.push(workbenchVaultMount(opts.vault))
|
|
472
|
+
}
|
|
473
|
+
mounts.push(workbenchApiMount(providers))
|
|
474
|
+
return mounts
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Plugin asset vault (workbench dev) ────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
const ASSET_URL_RE = /^\/api\/plugin-assets\/([^/]+)\/(?:[^/]*)\/(.+)$/
|
|
480
|
+
const VAULT_DOWNLOAD_PATH = "/api/workbench/vault/download"
|
|
481
|
+
const VAULT_DELETE_PATH = "/api/workbench/vault/delete"
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Dev-only mirror of the server's plugin asset pipeline, in the plain
|
|
485
|
+
* dependency-free module the standalone workbench ships:
|
|
486
|
+
*
|
|
487
|
+
* - `GET /api/plugin-assets/:id/:token/*` serves the local vault
|
|
488
|
+
* (token accepted verbatim — dev; the app issues HMAC-scoped ones).
|
|
489
|
+
* `nosniff`, `access-control-allow-origin: *` (opaque-origin iframe),
|
|
490
|
+
* HTML demoted to an attachment, `no-store` cache.
|
|
491
|
+
* - policy mirrors the server where it matters: http(s) only, no URL
|
|
492
|
+
* userinfo, ≤5 redirects, size cap (`WORKBENCH_VAULT_MAX_BYTES`,
|
|
493
|
+
* default 200 MiB), optional sha256 pin, atomic temp→rename write,
|
|
494
|
+
* dest confined to the plugin's vault directory.
|
|
495
|
+
*/
|
|
496
|
+
function pluginAssetsMount(vaultRoot) {
|
|
497
|
+
return async (req, res) => {
|
|
498
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
499
|
+
const match = url.pathname.match(ASSET_URL_RE)
|
|
500
|
+
if (match === null) return false
|
|
501
|
+
const abs = vaultFile(
|
|
502
|
+
vaultRoot,
|
|
503
|
+
decodeURIComponent(match[1] ?? ""),
|
|
504
|
+
decodeURIComponent(match[2] ?? ""),
|
|
505
|
+
)
|
|
506
|
+
if (abs === undefined) {
|
|
507
|
+
res.statusCode = 403
|
|
508
|
+
res.end("forbidden")
|
|
509
|
+
return true
|
|
510
|
+
}
|
|
511
|
+
let info
|
|
512
|
+
try {
|
|
513
|
+
info = statSync(abs)
|
|
514
|
+
} catch {
|
|
515
|
+
info = undefined
|
|
516
|
+
}
|
|
517
|
+
if (info === undefined || !info.isFile()) {
|
|
518
|
+
notFound(res)
|
|
519
|
+
return true
|
|
520
|
+
}
|
|
521
|
+
res.setHeader("x-content-type-options", "nosniff")
|
|
522
|
+
res.setHeader("access-control-allow-origin", "*")
|
|
523
|
+
res.setHeader("cache-control", "no-store")
|
|
524
|
+
const ext = `.${abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()}`
|
|
525
|
+
const isHtml = ext === ".html" || ext === ".htm"
|
|
526
|
+
res.setHeader(
|
|
527
|
+
"content-type",
|
|
528
|
+
isHtml ? "application/octet-stream" : contentTypeOf(abs),
|
|
529
|
+
)
|
|
530
|
+
if (isHtml) {
|
|
531
|
+
res.setHeader("content-disposition", "attachment")
|
|
532
|
+
}
|
|
533
|
+
res.end(readFileSync(abs))
|
|
534
|
+
return true
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function workbenchVaultMount(vaultRoot) {
|
|
539
|
+
return async (req, res) => {
|
|
540
|
+
const url = new URL(req.url ?? "/", "http://workbench.local")
|
|
541
|
+
if (
|
|
542
|
+
url.pathname === VAULT_DOWNLOAD_PATH &&
|
|
543
|
+
(req.method === "POST" || req.method === "PUT")
|
|
544
|
+
) {
|
|
545
|
+
await handleVaultDownload(
|
|
546
|
+
req,
|
|
547
|
+
res,
|
|
548
|
+
vaultRoot,
|
|
549
|
+
url.searchParams.get("force") === "1",
|
|
550
|
+
)
|
|
551
|
+
return true
|
|
552
|
+
}
|
|
553
|
+
if (url.pathname === VAULT_DELETE_PATH && req.method === "POST") {
|
|
554
|
+
const body = await readJsonBody(req)
|
|
555
|
+
const { pluginId, path } = body ?? {}
|
|
556
|
+
if (typeof pluginId !== "string" || typeof path !== "string") {
|
|
557
|
+
sendJson(res, { error: "pluginId and path are required" })
|
|
558
|
+
return true
|
|
559
|
+
}
|
|
560
|
+
const abs = vaultFile(vaultRoot, pluginId, path)
|
|
561
|
+
if (abs === undefined || !validVaultDest(path)) {
|
|
562
|
+
res.statusCode = 403
|
|
563
|
+
res.end("forbidden")
|
|
564
|
+
return true
|
|
565
|
+
}
|
|
566
|
+
let existed = false
|
|
567
|
+
try {
|
|
568
|
+
existed = statSync(abs).isFile()
|
|
569
|
+
} catch {
|
|
570
|
+
existed = false
|
|
571
|
+
}
|
|
572
|
+
if (existed) rmSync(abs)
|
|
573
|
+
sendJson(res, { existed })
|
|
574
|
+
return true
|
|
575
|
+
}
|
|
576
|
+
return false
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function handleVaultDownload(req, res, vaultRoot, force) {
|
|
581
|
+
const body = await readJsonBody(req)
|
|
582
|
+
const { pluginId, url, dest, sha256 } = body ?? {}
|
|
583
|
+
if (
|
|
584
|
+
typeof pluginId !== "string" ||
|
|
585
|
+
typeof url !== "string" ||
|
|
586
|
+
typeof dest !== "string"
|
|
587
|
+
) {
|
|
588
|
+
sendJson(res, { error: "pluginId, url and dest are required" })
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
const abs = vaultFile(vaultRoot, pluginId, dest)
|
|
592
|
+
if (abs === undefined || !validVaultDest(dest)) {
|
|
593
|
+
res.statusCode = 403
|
|
594
|
+
res.end("forbidden")
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Cache-first: an existing destination resolves without consent;
|
|
599
|
+
// without `force` a miss answers `missing` (the page then asks the
|
|
600
|
+
// user and re-issues with `force`).
|
|
601
|
+
let existing
|
|
602
|
+
try {
|
|
603
|
+
const info = statSync(abs)
|
|
604
|
+
existing = info.isFile() ? { path: dest, sizeBytes: info.size } : undefined
|
|
605
|
+
} catch {
|
|
606
|
+
existing = undefined
|
|
607
|
+
}
|
|
608
|
+
if (existing !== undefined) {
|
|
609
|
+
sendJson(res, {
|
|
610
|
+
status: "cached",
|
|
611
|
+
path: existing.path,
|
|
612
|
+
sizeBytes: existing.sizeBytes,
|
|
613
|
+
sha256: sha256File(abs),
|
|
614
|
+
})
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
if (!force) {
|
|
618
|
+
sendJson(res, { status: "missing" })
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const maxBytes =
|
|
623
|
+
Number(process.env.WORKBENCH_VAULT_MAX_BYTES) || 200 * 1024 * 1024
|
|
624
|
+
try {
|
|
625
|
+
const fetched = await fetchWithPolicy(url, maxBytes)
|
|
626
|
+
if (sha256 !== undefined && fetched.sha256 !== sha256) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`sha256 mismatch: expected ${sha256}, got ${fetched.sha256}`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
mkdirSync(resolve(abs, ".."), { recursive: true })
|
|
632
|
+
const tmp = `${abs}.tmp-${Date.now()}`
|
|
633
|
+
writeFileSync(tmp, fetched.bytes)
|
|
634
|
+
renameSync(tmp, abs)
|
|
635
|
+
sendJson(res, {
|
|
636
|
+
status: "downloaded",
|
|
637
|
+
path: dest,
|
|
638
|
+
sizeBytes: fetched.bytes.length,
|
|
639
|
+
sha256: fetched.sha256,
|
|
640
|
+
})
|
|
641
|
+
} catch (err) {
|
|
642
|
+
sendJson(res, {
|
|
643
|
+
status: "error",
|
|
644
|
+
error: err instanceof Error ? err.message : String(err),
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** A vault file path under `<root>/<pluginId>/<dest>`, or undefined. */
|
|
650
|
+
function vaultFile(vaultRoot, pluginId, dest) {
|
|
651
|
+
if (typeof pluginId !== "string" || pluginId.length === 0) return undefined
|
|
652
|
+
return safeJoin(resolve(vaultRoot), `${pluginId}/${dest}`)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Dev mirror of the server's dest rules: no empty/`.`, no `..`, no separators inside a segment. */
|
|
656
|
+
function validVaultDest(dest) {
|
|
657
|
+
return (
|
|
658
|
+
typeof dest === "string" &&
|
|
659
|
+
dest.length > 0 &&
|
|
660
|
+
dest
|
|
661
|
+
.split("/")
|
|
662
|
+
.every(
|
|
663
|
+
(s) =>
|
|
664
|
+
s.length > 0 &&
|
|
665
|
+
s !== "." &&
|
|
666
|
+
s !== ".." &&
|
|
667
|
+
!s.includes("\\") &&
|
|
668
|
+
!s.includes(":"),
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Fetch with the dev-policy subset: http(s), no userinfo, ≤5 redirects, size cap. */
|
|
674
|
+
async function fetchWithPolicy(rawUrl, maxBytes) {
|
|
675
|
+
let url = new URL(rawUrl)
|
|
676
|
+
function vet(next) {
|
|
677
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
678
|
+
throw new Error(`unsupported scheme: ${next.protocol}`)
|
|
679
|
+
}
|
|
680
|
+
if (next.username.length > 0 || next.password.length > 0) {
|
|
681
|
+
throw new Error("URLs must not embed credentials")
|
|
682
|
+
}
|
|
683
|
+
return next
|
|
684
|
+
}
|
|
685
|
+
vet(url)
|
|
686
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
687
|
+
const response = await fetch(url, { redirect: "manual" })
|
|
688
|
+
if (response.status >= 300 && response.status < 400) {
|
|
689
|
+
const location = response.headers.get("location")
|
|
690
|
+
if (location === null)
|
|
691
|
+
throw new Error("redirect without a Location header")
|
|
692
|
+
url = vet(new URL(location, url))
|
|
693
|
+
continue
|
|
694
|
+
}
|
|
695
|
+
if (response.status < 200 || response.status >= 300) {
|
|
696
|
+
throw new Error(`HTTP ${response.status} from ${url.href}`)
|
|
697
|
+
}
|
|
698
|
+
if (response.body === null) throw new Error("empty response body")
|
|
699
|
+
const hash = createHash("sha256")
|
|
700
|
+
const chunks = []
|
|
701
|
+
let seen = 0
|
|
702
|
+
for await (const chunk of response.body) {
|
|
703
|
+
seen += chunk.length
|
|
704
|
+
if (seen > maxBytes) {
|
|
705
|
+
throw new Error(`response exceeds the ${maxBytes}-byte cap`)
|
|
706
|
+
}
|
|
707
|
+
hash.update(chunk)
|
|
708
|
+
chunks.push(chunk)
|
|
709
|
+
}
|
|
710
|
+
const bytes = Buffer.concat(chunks)
|
|
711
|
+
return { bytes, sha256: hash.digest("hex") }
|
|
712
|
+
}
|
|
713
|
+
throw new Error("more than 5 redirects")
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function sha256File(path) {
|
|
717
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex")
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function readJsonBody(req) {
|
|
721
|
+
const chunks = []
|
|
722
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
723
|
+
const raw = Buffer.concat(chunks).toString("utf-8")
|
|
724
|
+
if (raw.trim().length === 0) return undefined
|
|
725
|
+
try {
|
|
726
|
+
return JSON.parse(raw)
|
|
727
|
+
} catch {
|
|
728
|
+
return undefined
|
|
729
|
+
}
|
|
730
|
+
}
|