@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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/THIRD_PARTY_NOTICES.md +69 -0
  4. package/cordis.patch.yml +6 -0
  5. package/docs/images/dsh-openpencil-overview.png +0 -0
  6. package/lib/client/details-compat.d.ts +28 -0
  7. package/lib/client/details-compat.js +10 -0
  8. package/lib/client/editor-bridge.d.ts +84 -0
  9. package/lib/client/editor-bridge.js +127 -0
  10. package/lib/client/editor-dock-layout.d.ts +15 -0
  11. package/lib/client/editor-dock-layout.js +43 -0
  12. package/lib/client/editor-recovery.d.ts +27 -0
  13. package/lib/client/editor-recovery.js +83 -0
  14. package/lib/client/editor-successor.d.ts +28 -0
  15. package/lib/client/editor-successor.js +109 -0
  16. package/lib/client/presentation-hydration.d.ts +36 -0
  17. package/lib/client/presentation-hydration.js +168 -0
  18. package/lib/client/selection-polling.d.ts +24 -0
  19. package/lib/client/selection-polling.js +79 -0
  20. package/lib/client/selection-store.d.ts +26 -0
  21. package/lib/client/selection-store.js +85 -0
  22. package/lib/client.js +4033 -0
  23. package/lib/design-tools.d.ts +21 -0
  24. package/lib/design-tools.js +214 -0
  25. package/lib/editor-host.d.ts +62 -0
  26. package/lib/editor-host.js +1147 -0
  27. package/lib/editor-recovery.d.ts +38 -0
  28. package/lib/editor-recovery.js +319 -0
  29. package/lib/index.d.ts +24 -0
  30. package/lib/index.js +132 -0
  31. package/lib/mcp-client.d.ts +47 -0
  32. package/lib/mcp-client.js +148 -0
  33. package/lib/new-tool.d.ts +23 -0
  34. package/lib/new-tool.js +136 -0
  35. package/lib/presentation-hydration.d.ts +55 -0
  36. package/lib/presentation-hydration.js +561 -0
  37. package/lib/renderer.d.ts +203 -0
  38. package/lib/renderer.js +772 -0
  39. package/lib/tool-names.d.ts +13 -0
  40. package/lib/tool-names.js +19 -0
  41. package/lib/tool.d.ts +27 -0
  42. package/lib/tool.js +221 -0
  43. package/lib/types/client/details-compat.d.ts +28 -0
  44. package/lib/types/client/editor-bridge.d.ts +84 -0
  45. package/lib/types/client/editor-dock-layout.d.ts +15 -0
  46. package/lib/types/client/editor-modal.d.ts +54 -0
  47. package/lib/types/client/editor-panel.d.ts +127 -0
  48. package/lib/types/client/editor-recovery.d.ts +27 -0
  49. package/lib/types/client/editor-successor.d.ts +28 -0
  50. package/lib/types/client/editor-workbench-host.d.ts +43 -0
  51. package/lib/types/client/frame-gallery.d.ts +98 -0
  52. package/lib/types/client/index.d.ts +176 -0
  53. package/lib/types/client/presentation-hydration.d.ts +36 -0
  54. package/lib/types/client/selection-dock.d.ts +18 -0
  55. package/lib/types/client/selection-polling.d.ts +24 -0
  56. package/lib/types/client/selection-store.d.ts +26 -0
  57. package/lib/types/tool-names.d.ts +13 -0
  58. package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
  59. package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
  60. package/lib/viewer-assets/manifest.json +22 -0
  61. package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
  62. package/lib/viewer-assets/sdk.js +225 -0
  63. package/lib/viewer-assets.d.ts +74 -0
  64. package/lib/viewer-assets.js +303 -0
  65. package/package.json +95 -0
  66. package/scripts/build-client.mjs +27 -0
  67. package/scripts/run-tool.mjs +63 -0
  68. package/scripts/sync-viewer-assets.mjs +304 -0
  69. package/scripts/test-host.mjs +311 -0
  70. package/scripts/test-viewer-assets.mjs +54 -0
  71. package/src/client/details-compat.ts +44 -0
  72. package/src/client/editor-bridge.ts +170 -0
  73. package/src/client/editor-dock-layout.ts +56 -0
  74. package/src/client/editor-modal.tsx +607 -0
  75. package/src/client/editor-panel.tsx +823 -0
  76. package/src/client/editor-recovery.ts +116 -0
  77. package/src/client/editor-successor.ts +136 -0
  78. package/src/client/editor-workbench-host.tsx +280 -0
  79. package/src/client/frame-gallery.tsx +546 -0
  80. package/src/client/index.tsx +987 -0
  81. package/src/client/presentation-hydration.ts +210 -0
  82. package/src/client/react-dom-compat.d.ts +22 -0
  83. package/src/client/selection-dock.tsx +89 -0
  84. package/src/client/selection-polling.ts +93 -0
  85. package/src/client/selection-store.ts +108 -0
  86. package/src/design-tools.ts +242 -0
  87. package/src/editor-host.ts +1238 -0
  88. package/src/editor-recovery.ts +374 -0
  89. package/src/index.ts +219 -0
  90. package/src/mcp-client.ts +193 -0
  91. package/src/new-tool.ts +160 -0
  92. package/src/presentation-hydration.ts +634 -0
  93. package/src/renderer.ts +884 -0
  94. package/src/tool-names.ts +21 -0
  95. package/src/tool.ts +258 -0
  96. 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
+ }