@zseven-w/dsh-openpencil 0.1.0-rc.1
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 +21 -0
- package/README.md +134 -0
- package/THIRD_PARTY_NOTICES.md +69 -0
- package/cordis.patch.yml +6 -0
- package/docs/images/dsh-openpencil-overview.png +0 -0
- package/lib/client/details-compat.d.ts +28 -0
- package/lib/client/details-compat.js +10 -0
- package/lib/client/editor-bridge.d.ts +84 -0
- package/lib/client/editor-bridge.js +127 -0
- package/lib/client/editor-dock-layout.d.ts +15 -0
- package/lib/client/editor-dock-layout.js +43 -0
- package/lib/client/editor-recovery.d.ts +27 -0
- package/lib/client/editor-recovery.js +83 -0
- package/lib/client/editor-successor.d.ts +28 -0
- package/lib/client/editor-successor.js +109 -0
- package/lib/client/presentation-hydration.d.ts +36 -0
- package/lib/client/presentation-hydration.js +168 -0
- package/lib/client/selection-polling.d.ts +24 -0
- package/lib/client/selection-polling.js +79 -0
- package/lib/client/selection-store.d.ts +26 -0
- package/lib/client/selection-store.js +85 -0
- package/lib/client.js +4033 -0
- package/lib/design-tools.d.ts +21 -0
- package/lib/design-tools.js +214 -0
- package/lib/editor-host.d.ts +62 -0
- package/lib/editor-host.js +1147 -0
- package/lib/editor-recovery.d.ts +38 -0
- package/lib/editor-recovery.js +319 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +132 -0
- package/lib/mcp-client.d.ts +47 -0
- package/lib/mcp-client.js +148 -0
- package/lib/new-tool.d.ts +23 -0
- package/lib/new-tool.js +136 -0
- package/lib/presentation-hydration.d.ts +55 -0
- package/lib/presentation-hydration.js +561 -0
- package/lib/renderer.d.ts +203 -0
- package/lib/renderer.js +772 -0
- package/lib/tool-names.d.ts +13 -0
- package/lib/tool-names.js +19 -0
- package/lib/tool.d.ts +27 -0
- package/lib/tool.js +221 -0
- package/lib/types/client/details-compat.d.ts +28 -0
- package/lib/types/client/editor-bridge.d.ts +84 -0
- package/lib/types/client/editor-dock-layout.d.ts +15 -0
- package/lib/types/client/editor-modal.d.ts +54 -0
- package/lib/types/client/editor-panel.d.ts +127 -0
- package/lib/types/client/editor-recovery.d.ts +27 -0
- package/lib/types/client/editor-successor.d.ts +28 -0
- package/lib/types/client/editor-workbench-host.d.ts +43 -0
- package/lib/types/client/frame-gallery.d.ts +98 -0
- package/lib/types/client/index.d.ts +176 -0
- package/lib/types/client/presentation-hydration.d.ts +36 -0
- package/lib/types/client/selection-dock.d.ts +18 -0
- package/lib/types/client/selection-polling.d.ts +24 -0
- package/lib/types/client/selection-store.d.ts +26 -0
- package/lib/types/tool-names.d.ts +13 -0
- package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
- package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
- package/lib/viewer-assets/manifest.json +22 -0
- package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
- package/lib/viewer-assets/sdk.js +225 -0
- package/lib/viewer-assets.d.ts +74 -0
- package/lib/viewer-assets.js +303 -0
- package/package.json +95 -0
- package/scripts/build-client.mjs +27 -0
- package/scripts/run-tool.mjs +63 -0
- package/scripts/sync-viewer-assets.mjs +304 -0
- package/scripts/test-host.mjs +311 -0
- package/scripts/test-viewer-assets.mjs +54 -0
- package/src/client/details-compat.ts +44 -0
- package/src/client/editor-bridge.ts +170 -0
- package/src/client/editor-dock-layout.ts +56 -0
- package/src/client/editor-modal.tsx +607 -0
- package/src/client/editor-panel.tsx +823 -0
- package/src/client/editor-recovery.ts +116 -0
- package/src/client/editor-successor.ts +136 -0
- package/src/client/editor-workbench-host.tsx +280 -0
- package/src/client/frame-gallery.tsx +546 -0
- package/src/client/index.tsx +987 -0
- package/src/client/presentation-hydration.ts +210 -0
- package/src/client/react-dom-compat.d.ts +22 -0
- package/src/client/selection-dock.tsx +89 -0
- package/src/client/selection-polling.ts +93 -0
- package/src/client/selection-store.ts +108 -0
- package/src/design-tools.ts +242 -0
- package/src/editor-host.ts +1238 -0
- package/src/editor-recovery.ts +374 -0
- package/src/index.ts +219 -0
- package/src/mcp-client.ts +193 -0
- package/src/new-tool.ts +160 -0
- package/src/presentation-hydration.ts +634 -0
- package/src/renderer.ts +884 -0
- package/src/tool-names.ts +21 -0
- package/src/tool.ts +258 -0
- package/src/viewer-assets.ts +353 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Canonical model-facing OpenPencil tool names. */
|
|
2
|
+
export const OPENPENCIL_RENDER_TOOL_NAME = 'openpencil_render' as const
|
|
3
|
+
export const OPENPENCIL_SELECTION_TOOL_NAME = 'openpencil_selection' as const
|
|
4
|
+
export const OPENPENCIL_NEW_TOOL_NAME = 'openpencil_new' as const
|
|
5
|
+
export const OPENPENCIL_CREATE_TOOL_NAME = 'openpencil_create' as const
|
|
6
|
+
export const OPENPENCIL_EDIT_TOOL_NAME = 'openpencil_edit' as const
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Historical render name retained only by the browser presentation layer so
|
|
10
|
+
* existing conversation cards and details panels remain replayable. The host
|
|
11
|
+
* deliberately does not register this alias as a model-facing tool.
|
|
12
|
+
*/
|
|
13
|
+
export const LEGACY_DESIGN_RENDER_TOOL_NAME = 'design_render' as const
|
|
14
|
+
|
|
15
|
+
export const OPENPENCIL_TOOL_NAMES = [
|
|
16
|
+
OPENPENCIL_RENDER_TOOL_NAME,
|
|
17
|
+
OPENPENCIL_SELECTION_TOOL_NAME,
|
|
18
|
+
OPENPENCIL_NEW_TOOL_NAME,
|
|
19
|
+
OPENPENCIL_CREATE_TOOL_NAME,
|
|
20
|
+
OPENPENCIL_EDIT_TOOL_NAME,
|
|
21
|
+
] as const
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `openpencil_render` — render a `.op` design document to PNG without a
|
|
3
|
+
* window. OpenPencil's own exporter is the exact primary path; Jian is an
|
|
4
|
+
* explicitly-labelled runtime-preview fallback when that binary is absent.
|
|
5
|
+
*
|
|
6
|
+
* The tool returns plain JSON (never an `ImageBlock` — the DeepSeek
|
|
7
|
+
* adapter rejects image blocks anywhere in a request). A browser-only
|
|
8
|
+
* envelope with a signed preview URL rides `output.presentationMeta`
|
|
9
|
+
* into `ToolCallBlock.meta`, where the keyed `tool.call.toolview`
|
|
10
|
+
* component recognizes it and renders `<img>`.
|
|
11
|
+
* @module dsh-openpencil/tool
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineTool, type JsonValue, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { basename } from 'node:path'
|
|
16
|
+
import {
|
|
17
|
+
RenderAccessController,
|
|
18
|
+
RendererBinaryMissingError,
|
|
19
|
+
createDocumentSnapshot,
|
|
20
|
+
createRenderOutput,
|
|
21
|
+
findOpenPencilBinary,
|
|
22
|
+
findJianBinary,
|
|
23
|
+
projectRenderGrant,
|
|
24
|
+
resolveInputFile,
|
|
25
|
+
runOpenPencilRender,
|
|
26
|
+
runJianRender,
|
|
27
|
+
verifyRenderOutput,
|
|
28
|
+
type RenderFrame,
|
|
29
|
+
type RenderResult,
|
|
30
|
+
} from './renderer.js'
|
|
31
|
+
import type { ViewerAssetController } from './viewer-assets.js'
|
|
32
|
+
import type { EditorHostController } from './editor-host.js'
|
|
33
|
+
import { OPENPENCIL_RENDER_TOOL_NAME } from './tool-names.js'
|
|
34
|
+
|
|
35
|
+
/** Session workspace the caller resolves paths against (mirrors first-party tools). */
|
|
36
|
+
function sessionWorkspace(exec: ToolRunContext): string {
|
|
37
|
+
return exec.agent?.session.header.cwd ?? process.cwd()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const renderJson = (_args: unknown, value: unknown): [{ type: 'text'; text: string }] => [{
|
|
41
|
+
type: 'text',
|
|
42
|
+
text: JSON.stringify(value, null, 2),
|
|
43
|
+
}]
|
|
44
|
+
|
|
45
|
+
/** Pure, replayable presentation projection — the browser-only envelope. */
|
|
46
|
+
function makePresentationMeta(
|
|
47
|
+
controller: RenderAccessController,
|
|
48
|
+
viewerAssets?: ViewerAssetController,
|
|
49
|
+
editorHost?: EditorHostController,
|
|
50
|
+
) {
|
|
51
|
+
return (_args: unknown, value: JsonValue): JsonValue => {
|
|
52
|
+
const result = value as unknown as RenderResult
|
|
53
|
+
const editor = result.editable === true
|
|
54
|
+
? editorHost?.grantFor(result.sourcePath, result.document?.sha256)
|
|
55
|
+
: undefined
|
|
56
|
+
return projectRenderGrant(value, controller, viewerAssets?.viewerGrant, editor)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DesignRenderArgs {
|
|
61
|
+
path: string
|
|
62
|
+
width?: number
|
|
63
|
+
height?: number
|
|
64
|
+
scale?: number
|
|
65
|
+
/** Explicitly expose the original source to the managed sidebar editor. */
|
|
66
|
+
editable?: boolean
|
|
67
|
+
/** Expand the editor once on the live result card; intended for openpencil_new follow-ups. */
|
|
68
|
+
autoOpen?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Create the `openpencil_render` tool definition bound to one controller. */
|
|
72
|
+
export function createDesignRenderTool(
|
|
73
|
+
controller: RenderAccessController,
|
|
74
|
+
viewerAssets?: ViewerAssetController,
|
|
75
|
+
editorHost?: EditorHostController,
|
|
76
|
+
) {
|
|
77
|
+
return defineTool({
|
|
78
|
+
name: OPENPENCIL_RENDER_TOOL_NAME,
|
|
79
|
+
description: 'Render an existing OpenPencil .op design document exactly as the design canvas, '
|
|
80
|
+
+ 'then show a PNG and an optional interactive read-only canvas in the conversation. '
|
|
81
|
+
+ 'Give the absolute path to a .op file (or a path relative to the session workspace). '
|
|
82
|
+
+ 'For a new design when no .op file exists, call openpencil_new first. '
|
|
83
|
+
+ 'The image appears directly in the chat; the file path is returned for further use. '
|
|
84
|
+
+ 'Set editable=true when the user asks for an editable design. For the immediate render after openpencil_new, set editable=true and autoOpen=true; no extra confirmation is needed. '
|
|
85
|
+
+ 'Leave width/height unset for design-accurate output. Width/height are only supported '
|
|
86
|
+
+ 'by the lower-fidelity Jian runtime fallback.',
|
|
87
|
+
parameters: {
|
|
88
|
+
path: { type: 'string', required: true, description: 'Path to the .op design document to render.' },
|
|
89
|
+
width: { type: 'number', description: 'Explicit logical viewport width in pixels. Omit to use the document size.' },
|
|
90
|
+
height: { type: 'number', description: 'Explicit logical viewport height in pixels. Omit to use the document size.' },
|
|
91
|
+
scale: { type: 'number', description: 'Pixel scale factor applied to the output (device-pixel ratio). Default 1.' },
|
|
92
|
+
editable: { type: 'boolean', description: 'Expose an Edit in sidebar action for the original .op source. Default false.' },
|
|
93
|
+
autoOpen: { type: 'boolean', description: 'Automatically expand the editor once when this is the immediate render after openpencil_new. Default false.' },
|
|
94
|
+
},
|
|
95
|
+
output: {
|
|
96
|
+
schema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
properties: {
|
|
100
|
+
path: { type: 'string', required: true },
|
|
101
|
+
filename: { type: 'string', required: true },
|
|
102
|
+
mimeType: { type: 'string', const: 'image/png', required: true },
|
|
103
|
+
kind: { type: 'string', const: 'image', required: true },
|
|
104
|
+
description: { type: 'string', required: true },
|
|
105
|
+
sourceTool: { type: 'string', const: OPENPENCIL_RENDER_TOOL_NAME, required: true },
|
|
106
|
+
previewIntent: { type: 'string', const: 'image', required: true },
|
|
107
|
+
bytes: { type: 'integer', required: true },
|
|
108
|
+
width: { type: 'integer' },
|
|
109
|
+
height: { type: 'integer' },
|
|
110
|
+
sha256: { type: 'string' },
|
|
111
|
+
sourcePath: { type: 'string' },
|
|
112
|
+
renderer: { type: 'string' },
|
|
113
|
+
rendererBinary: { type: 'string' },
|
|
114
|
+
fidelity: { type: 'string' },
|
|
115
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
116
|
+
frames: {
|
|
117
|
+
type: 'array',
|
|
118
|
+
items: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
properties: {
|
|
122
|
+
path: { type: 'string', required: true },
|
|
123
|
+
filename: { type: 'string', required: true },
|
|
124
|
+
mimeType: { type: 'string', const: 'image/png', required: true },
|
|
125
|
+
bytes: { type: 'integer', required: true },
|
|
126
|
+
width: { type: 'integer', required: true },
|
|
127
|
+
height: { type: 'integer', required: true },
|
|
128
|
+
sha256: { type: 'string', required: true },
|
|
129
|
+
id: { type: 'string' },
|
|
130
|
+
name: { type: 'string' },
|
|
131
|
+
index: { type: 'integer' },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
frameCount: { type: 'integer' },
|
|
136
|
+
editable: { type: 'boolean' },
|
|
137
|
+
autoOpenEditor: { type: 'boolean' },
|
|
138
|
+
document: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
properties: {
|
|
142
|
+
path: { type: 'string', required: true },
|
|
143
|
+
filename: { type: 'string', required: true },
|
|
144
|
+
mimeType: { type: 'string', const: 'application/json', required: true },
|
|
145
|
+
bytes: { type: 'integer', required: true },
|
|
146
|
+
sha256: { type: 'string', required: true },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
note: { type: 'string' },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
render: renderJson,
|
|
153
|
+
presentationMeta: makePresentationMeta(controller, viewerAssets, editorHost),
|
|
154
|
+
},
|
|
155
|
+
async execute(args: DesignRenderArgs, exec): Promise<RenderResult> {
|
|
156
|
+
const input = await resolveInputFile(args.path, sessionWorkspace(exec))
|
|
157
|
+
const document = await createDocumentSnapshot(input)
|
|
158
|
+
const warnings: string[] = []
|
|
159
|
+
let out: string
|
|
160
|
+
let renderer: 'openpencil' | 'jian'
|
|
161
|
+
let rendererBinary: string
|
|
162
|
+
let fidelity: 'exact' | 'runtime-preview'
|
|
163
|
+
let exactFrames: Awaited<ReturnType<typeof runOpenPencilRender>>['frames'] | undefined
|
|
164
|
+
const openPencil = findOpenPencilBinary()
|
|
165
|
+
if (openPencil !== undefined) {
|
|
166
|
+
if (args.width !== undefined || args.height !== undefined) {
|
|
167
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: width/height are not supported by the exact OpenPencil renderer; omit them and use scale`)
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const exact = await runOpenPencilRender({
|
|
171
|
+
binary: openPencil,
|
|
172
|
+
input: document.path,
|
|
173
|
+
scale: args.scale,
|
|
174
|
+
signal: exec.signal,
|
|
175
|
+
})
|
|
176
|
+
out = exact.png
|
|
177
|
+
exactFrames = exact.frames
|
|
178
|
+
warnings.push(...exact.warnings)
|
|
179
|
+
renderer = 'openpencil'
|
|
180
|
+
rendererBinary = openPencil
|
|
181
|
+
fidelity = 'exact'
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!(error instanceof RendererBinaryMissingError)) throw error
|
|
184
|
+
const fallback = await createRenderOutput()
|
|
185
|
+
const jian = findJianBinary()
|
|
186
|
+
await runJianRender({
|
|
187
|
+
binary: jian, input: document.path, out: fallback,
|
|
188
|
+
width: args.width, height: args.height, scale: args.scale, signal: exec.signal,
|
|
189
|
+
})
|
|
190
|
+
out = fallback
|
|
191
|
+
renderer = 'jian'
|
|
192
|
+
rendererBinary = jian
|
|
193
|
+
fidelity = 'runtime-preview'
|
|
194
|
+
warnings.push('OpenPencil exact renderer was unavailable; using lower-fidelity Jian runtime preview.')
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
const fallback = await createRenderOutput()
|
|
198
|
+
const jian = findJianBinary()
|
|
199
|
+
await runJianRender({
|
|
200
|
+
binary: jian, input: document.path, out: fallback,
|
|
201
|
+
width: args.width, height: args.height, scale: args.scale, signal: exec.signal,
|
|
202
|
+
})
|
|
203
|
+
out = fallback
|
|
204
|
+
renderer = 'jian'
|
|
205
|
+
rendererBinary = jian
|
|
206
|
+
fidelity = 'runtime-preview'
|
|
207
|
+
warnings.push('OpenPencil exact renderer was not found; using lower-fidelity Jian runtime preview.')
|
|
208
|
+
}
|
|
209
|
+
const verified = await verifyRenderOutput(out)
|
|
210
|
+
const frames: RenderFrame[] | undefined = exactFrames === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: await Promise.all(exactFrames.map(async frame => {
|
|
213
|
+
const frameVerified = frame.png === out ? verified : await verifyRenderOutput(frame.png)
|
|
214
|
+
return {
|
|
215
|
+
path: frame.png,
|
|
216
|
+
filename: basename(frame.png),
|
|
217
|
+
mimeType: 'image/png' as const,
|
|
218
|
+
bytes: frameVerified.bytes,
|
|
219
|
+
width: frameVerified.width,
|
|
220
|
+
height: frameVerified.height,
|
|
221
|
+
sha256: frameVerified.sha256,
|
|
222
|
+
...(frame.id === undefined ? {} : { id: frame.id }),
|
|
223
|
+
...(frame.name === undefined ? {} : { name: frame.name }),
|
|
224
|
+
index: frame.index,
|
|
225
|
+
}
|
|
226
|
+
}))
|
|
227
|
+
const result: RenderResult = {
|
|
228
|
+
path: out,
|
|
229
|
+
filename: basename(out),
|
|
230
|
+
mimeType: 'image/png',
|
|
231
|
+
kind: 'image',
|
|
232
|
+
description: `Rendered ${input} with ${renderer} (${fidelity})`,
|
|
233
|
+
sourceTool: OPENPENCIL_RENDER_TOOL_NAME,
|
|
234
|
+
previewIntent: 'image',
|
|
235
|
+
bytes: verified.bytes,
|
|
236
|
+
width: verified.width,
|
|
237
|
+
height: verified.height,
|
|
238
|
+
sha256: verified.sha256,
|
|
239
|
+
sourcePath: input,
|
|
240
|
+
renderer,
|
|
241
|
+
rendererBinary,
|
|
242
|
+
fidelity,
|
|
243
|
+
warnings,
|
|
244
|
+
...(frames === undefined ? {} : { frames, frameCount: frames.length }),
|
|
245
|
+
editable: args.editable === true,
|
|
246
|
+
...(args.editable === true && args.autoOpen === true ? { autoOpenEditor: true } : {}),
|
|
247
|
+
document,
|
|
248
|
+
}
|
|
249
|
+
return result
|
|
250
|
+
},
|
|
251
|
+
presentCall: (args: DesignRenderArgs) => ({
|
|
252
|
+
card: 'generic',
|
|
253
|
+
title: `Render ${args.path}`,
|
|
254
|
+
kind: 'execute',
|
|
255
|
+
locations: [{ path: args.path }],
|
|
256
|
+
}),
|
|
257
|
+
})
|
|
258
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged OpenPencil Web SDK assets + same-origin HTTP delivery.
|
|
3
|
+
*
|
|
4
|
+
* DSH only publishes the plugin's `client.js` automatically. The OpenPencil
|
|
5
|
+
* viewer additionally needs its browser ESM bundle, the renderer WASM, and
|
|
6
|
+
* CanvasKit's JS/WASM pair. `scripts/sync-viewer-assets.mjs` copies/builds
|
|
7
|
+
* those files into `lib/viewer-assets`; this module validates that immutable
|
|
8
|
+
* staging manifest and exposes a fixed, traversal-proof route.
|
|
9
|
+
*
|
|
10
|
+
* The controller deliberately degrades to `available === false` when the
|
|
11
|
+
* default staging directory is absent. A headless or source-only install can
|
|
12
|
+
* therefore keep using the PNG presentation path. An explicitly configured
|
|
13
|
+
* invalid directory is treated as a configuration error and throws.
|
|
14
|
+
* @module dsh-openpencil/viewer-assets
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { constants as fsConstants } from 'node:fs'
|
|
19
|
+
import type { Stats } from 'node:fs'
|
|
20
|
+
import type { FileHandle } from 'node:fs/promises'
|
|
21
|
+
import { lstat, open, readFile, realpath } from 'node:fs/promises'
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
23
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
|
|
26
|
+
/** HTTP namespace owned by the read-only OpenPencil viewer assets. */
|
|
27
|
+
export const VIEWER_ASSET_ROUTE_PREFIX = '/_dsh/dsh-openpencil/viewer-assets'
|
|
28
|
+
|
|
29
|
+
/** Environment override for an externally staged viewer-asset directory. */
|
|
30
|
+
export const VIEWER_ASSET_DIR_ENV = 'DSH_OPENPENCIL_VIEWER_ASSET_DIR'
|
|
31
|
+
|
|
32
|
+
const MANIFEST_FILENAME = 'manifest.json'
|
|
33
|
+
const REVISION_PATTERN = /^[a-f0-9]{16,64}$/
|
|
34
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
|
35
|
+
|
|
36
|
+
/** The browser assets expected by the DSH read-only viewer. */
|
|
37
|
+
export const VIEWER_ASSET_FILES = [
|
|
38
|
+
'sdk.js',
|
|
39
|
+
'op_web_sdk_bg.wasm',
|
|
40
|
+
'canvaskit/canvaskit.js',
|
|
41
|
+
'canvaskit/canvaskit.wasm',
|
|
42
|
+
] as const
|
|
43
|
+
|
|
44
|
+
export type ViewerAssetName = typeof VIEWER_ASSET_FILES[number]
|
|
45
|
+
|
|
46
|
+
/** URLs handed to the browser half of the plugin. */
|
|
47
|
+
export interface ViewerGrant {
|
|
48
|
+
sdkUrl: string
|
|
49
|
+
wasmUrl: string
|
|
50
|
+
canvasKitBaseUrl: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ManifestFile {
|
|
54
|
+
bytes: number
|
|
55
|
+
sha256: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ViewerAssetManifest {
|
|
59
|
+
version: 1
|
|
60
|
+
revision: string
|
|
61
|
+
files: Record<ViewerAssetName, ManifestFile>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface VerifiedAsset extends ManifestFile {
|
|
65
|
+
name: ViewerAssetName
|
|
66
|
+
path: string
|
|
67
|
+
mimeType: string
|
|
68
|
+
dev: number
|
|
69
|
+
ino: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Optional discovery controls, mainly useful to hosts and tests. */
|
|
73
|
+
export interface ViewerAssetOptions {
|
|
74
|
+
assetDir?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
78
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mimeType(name: ViewerAssetName): string {
|
|
82
|
+
if (name.endsWith('.wasm')) return 'application/wasm'
|
|
83
|
+
return 'text/javascript; charset=utf-8'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sha256(bytes: Uint8Array): string {
|
|
87
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseManifest(value: unknown): ViewerAssetManifest {
|
|
91
|
+
if (!isRecord(value) || value.version !== 1 || typeof value.revision !== 'string' || !REVISION_PATTERN.test(value.revision)) {
|
|
92
|
+
throw new Error('viewer asset manifest has an invalid version or revision')
|
|
93
|
+
}
|
|
94
|
+
if (!isRecord(value.files)) throw new Error('viewer asset manifest has no files table')
|
|
95
|
+
const files = {} as Record<ViewerAssetName, ManifestFile>
|
|
96
|
+
for (const name of VIEWER_ASSET_FILES) {
|
|
97
|
+
const descriptor = value.files[name]
|
|
98
|
+
if (
|
|
99
|
+
!isRecord(descriptor)
|
|
100
|
+
|| typeof descriptor.bytes !== 'number'
|
|
101
|
+
|| !Number.isSafeInteger(descriptor.bytes)
|
|
102
|
+
|| descriptor.bytes <= 0
|
|
103
|
+
|| typeof descriptor.sha256 !== 'string'
|
|
104
|
+
|| !SHA256_PATTERN.test(descriptor.sha256)
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(`viewer asset manifest entry is invalid: ${name}`)
|
|
107
|
+
}
|
|
108
|
+
files[name] = { bytes: descriptor.bytes, sha256: descriptor.sha256 }
|
|
109
|
+
}
|
|
110
|
+
return { version: 1, revision: value.revision, files }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isContained(root: string, path: string): boolean {
|
|
114
|
+
const rel = relative(root, path)
|
|
115
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function verifyAssetDirectory(assetDir: string): Promise<{
|
|
119
|
+
directory: string
|
|
120
|
+
manifest: ViewerAssetManifest
|
|
121
|
+
assets: ReadonlyMap<ViewerAssetName, VerifiedAsset>
|
|
122
|
+
}> {
|
|
123
|
+
const directory = await realpath(assetDir)
|
|
124
|
+
const rootInfo = await lstat(directory)
|
|
125
|
+
if (!rootInfo.isDirectory()) throw new Error('viewer asset root is not a directory')
|
|
126
|
+
const manifestPath = join(directory, MANIFEST_FILENAME)
|
|
127
|
+
const manifestInfo = await lstat(manifestPath)
|
|
128
|
+
if (manifestInfo.isSymbolicLink() || !manifestInfo.isFile()) {
|
|
129
|
+
throw new Error('viewer asset manifest is not a regular file')
|
|
130
|
+
}
|
|
131
|
+
const manifest = parseManifest(JSON.parse(await readFile(manifestPath, 'utf8')))
|
|
132
|
+
const assets = new Map<ViewerAssetName, VerifiedAsset>()
|
|
133
|
+
for (const name of VIEWER_ASSET_FILES) {
|
|
134
|
+
const unresolved = join(directory, ...name.split('/'))
|
|
135
|
+
const pathInfo = await lstat(unresolved)
|
|
136
|
+
if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) {
|
|
137
|
+
throw new Error(`viewer asset is not a regular file: ${name}`)
|
|
138
|
+
}
|
|
139
|
+
const path = await realpath(unresolved)
|
|
140
|
+
if (!isContained(directory, path)) throw new Error(`viewer asset escaped its root: ${name}`)
|
|
141
|
+
const bytes = await readFile(path)
|
|
142
|
+
const descriptor = manifest.files[name]
|
|
143
|
+
if (bytes.byteLength !== descriptor.bytes || sha256(bytes) !== descriptor.sha256) {
|
|
144
|
+
throw new Error(`viewer asset does not match its manifest: ${name}`)
|
|
145
|
+
}
|
|
146
|
+
assets.set(name, {
|
|
147
|
+
...descriptor,
|
|
148
|
+
name,
|
|
149
|
+
path,
|
|
150
|
+
mimeType: mimeType(name),
|
|
151
|
+
dev: pathInfo.dev,
|
|
152
|
+
ino: pathInfo.ino,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
return { directory, manifest, assets }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function candidateDirectories(explicit?: string): { explicit: boolean; paths: string[] } {
|
|
159
|
+
const configured = explicit?.trim() || process.env[VIEWER_ASSET_DIR_ENV]?.trim()
|
|
160
|
+
if (configured) return { explicit: true, paths: [resolve(configured)] }
|
|
161
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url))
|
|
162
|
+
return {
|
|
163
|
+
explicit: false,
|
|
164
|
+
paths: [...new Set([
|
|
165
|
+
join(moduleDir, 'viewer-assets'),
|
|
166
|
+
join(moduleDir, '..', 'lib', 'viewer-assets'),
|
|
167
|
+
join(process.cwd(), 'lib', 'viewer-assets'),
|
|
168
|
+
].map(path => resolve(path)))],
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function baseSecurityHeaders(res: ServerResponse): void {
|
|
173
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
174
|
+
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
175
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
|
|
176
|
+
res.setHeader('Cache-Control', 'private, max-age=31536000, immutable')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sameFile(info: Stats, asset: VerifiedAsset): boolean {
|
|
180
|
+
if (info.dev === 0 || info.ino === 0 || asset.dev === 0 || asset.ino === 0) return true
|
|
181
|
+
return info.dev === asset.dev && info.ino === asset.ino
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function openVerifiedAsset(asset: VerifiedAsset): Promise<FileHandle> {
|
|
185
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
|
|
186
|
+
const handle = await open(asset.path, fsConstants.O_RDONLY | noFollow)
|
|
187
|
+
try {
|
|
188
|
+
const info = await handle.stat()
|
|
189
|
+
if (!info.isFile() || info.size !== asset.bytes || !sameFile(info, asset)) {
|
|
190
|
+
throw new Error('viewer asset changed after startup validation')
|
|
191
|
+
}
|
|
192
|
+
return handle
|
|
193
|
+
} catch (error) {
|
|
194
|
+
await handle.close().catch(() => {})
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Fixed-file controller for the staged SDK bundle.
|
|
201
|
+
*
|
|
202
|
+
* Use `prepareViewerAssets()` instead of constructing this directly. The
|
|
203
|
+
* route attachment count mirrors `RenderAccessController`: browser grants
|
|
204
|
+
* only appear while an HTTP route is actually mounted.
|
|
205
|
+
*/
|
|
206
|
+
export class ViewerAssetController {
|
|
207
|
+
private routeCount = 0
|
|
208
|
+
|
|
209
|
+
private constructor(
|
|
210
|
+
readonly assetDirectory: string | undefined,
|
|
211
|
+
readonly unavailableReason: string | undefined,
|
|
212
|
+
private readonly revision: string | undefined,
|
|
213
|
+
private readonly assets: ReadonlyMap<ViewerAssetName, VerifiedAsset>,
|
|
214
|
+
) {}
|
|
215
|
+
|
|
216
|
+
static unavailable(reason: string): ViewerAssetController {
|
|
217
|
+
return new ViewerAssetController(undefined, reason, undefined, new Map())
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
static ready(
|
|
221
|
+
directory: string,
|
|
222
|
+
revision: string,
|
|
223
|
+
assets: ReadonlyMap<ViewerAssetName, VerifiedAsset>,
|
|
224
|
+
): ViewerAssetController {
|
|
225
|
+
return new ViewerAssetController(directory, undefined, revision, assets)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Whether a complete, hash-verified SDK asset set was discovered. */
|
|
229
|
+
get available(): boolean {
|
|
230
|
+
return this.revision !== undefined && this.assets.size === VIEWER_ASSET_FILES.length
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Whether at least one HTTP carrier currently owns the asset route. */
|
|
234
|
+
get routeAvailable(): boolean {
|
|
235
|
+
return this.available && this.routeCount > 0
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Browser URLs, omitted unless both staging and the DSH route are ready. */
|
|
239
|
+
get viewerGrant(): ViewerGrant | undefined {
|
|
240
|
+
if (!this.routeAvailable || this.revision === undefined) return undefined
|
|
241
|
+
const base = `${VIEWER_ASSET_ROUTE_PREFIX}/${this.revision}`
|
|
242
|
+
return {
|
|
243
|
+
sdkUrl: `${base}/sdk.js`,
|
|
244
|
+
wasmUrl: `${base}/op_web_sdk_bg.wasm`,
|
|
245
|
+
canvasKitBaseUrl: `${base}/canvaskit/`,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Mark one registered prefix route; dispose it when the host route unloads. */
|
|
250
|
+
attachRoute(): () => void {
|
|
251
|
+
if (!this.available) throw new Error(this.unavailableReason ?? 'OpenPencil viewer assets are unavailable')
|
|
252
|
+
this.routeCount += 1
|
|
253
|
+
let active = true
|
|
254
|
+
return () => {
|
|
255
|
+
if (!active) return
|
|
256
|
+
active = false
|
|
257
|
+
this.routeCount -= 1
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Serve one allow-listed immutable asset over GET or HEAD. */
|
|
262
|
+
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
263
|
+
baseSecurityHeaders(res)
|
|
264
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
265
|
+
res.setHeader('Allow', 'GET, HEAD')
|
|
266
|
+
res.writeHead(405)
|
|
267
|
+
res.end()
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (!this.available || this.revision === undefined) {
|
|
271
|
+
res.writeHead(404)
|
|
272
|
+
res.end()
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
let url: URL
|
|
276
|
+
try {
|
|
277
|
+
url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
278
|
+
} catch {
|
|
279
|
+
res.writeHead(400)
|
|
280
|
+
res.end()
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (url.search.length > 0) {
|
|
284
|
+
res.writeHead(400)
|
|
285
|
+
res.end()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const routeBase = `${VIEWER_ASSET_ROUTE_PREFIX}/${this.revision}/`
|
|
289
|
+
if (!url.pathname.startsWith(routeBase)) {
|
|
290
|
+
res.writeHead(404)
|
|
291
|
+
res.end()
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
let name: string
|
|
295
|
+
try {
|
|
296
|
+
name = decodeURIComponent(url.pathname.slice(routeBase.length))
|
|
297
|
+
} catch {
|
|
298
|
+
res.writeHead(404)
|
|
299
|
+
res.end()
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const asset = this.assets.get(name as ViewerAssetName)
|
|
303
|
+
if (asset === undefined || asset.name !== name) {
|
|
304
|
+
res.writeHead(404)
|
|
305
|
+
res.end()
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
let handle: FileHandle
|
|
309
|
+
try {
|
|
310
|
+
handle = await openVerifiedAsset(asset)
|
|
311
|
+
} catch {
|
|
312
|
+
res.writeHead(404)
|
|
313
|
+
res.end()
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
res.setHeader('Content-Type', asset.mimeType)
|
|
317
|
+
res.setHeader('Content-Length', String(asset.bytes))
|
|
318
|
+
res.setHeader('Content-Disposition', 'inline')
|
|
319
|
+
res.writeHead(200)
|
|
320
|
+
if (req.method === 'HEAD') {
|
|
321
|
+
await handle.close().catch(() => {})
|
|
322
|
+
res.end()
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
const stream = handle.createReadStream({ autoClose: true })
|
|
326
|
+
stream.on('error', () => {
|
|
327
|
+
if (!res.headersSent) res.writeHead(500)
|
|
328
|
+
res.destroy()
|
|
329
|
+
})
|
|
330
|
+
stream.pipe(res)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Discover and hash-verify the staged Web SDK assets. */
|
|
335
|
+
export async function prepareViewerAssets(options: ViewerAssetOptions = {}): Promise<ViewerAssetController> {
|
|
336
|
+
const candidates = candidateDirectories(options.assetDir)
|
|
337
|
+
const failures: string[] = []
|
|
338
|
+
for (const path of candidates.paths) {
|
|
339
|
+
try {
|
|
340
|
+
const verified = await verifyAssetDirectory(path)
|
|
341
|
+
return ViewerAssetController.ready(verified.directory, verified.manifest.revision, verified.assets)
|
|
342
|
+
} catch (error) {
|
|
343
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
344
|
+
if (!candidates.explicit && (code === 'ENOENT' || code === 'ENOTDIR')) continue
|
|
345
|
+
failures.push(`${path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const reason = failures.length > 0
|
|
349
|
+
? failures.join('; ')
|
|
350
|
+
: `run scripts/sync-viewer-assets.mjs to stage OpenPencil Web SDK assets under lib/viewer-assets`
|
|
351
|
+
if (candidates.explicit) throw new Error(reason)
|
|
352
|
+
return ViewerAssetController.unavailable(reason)
|
|
353
|
+
}
|