@deepblend/dsh-blender-ui 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/lib/client.js +1226 -725
  2. package/lib/index.js +144 -0
  3. package/package.json +2 -2
package/lib/index.js CHANGED
@@ -46,6 +46,95 @@ import {
46
46
  /** Route path serving the settings card document (M0's surface, kept verbatim). */
47
47
  export const CAPABILITIES_ROUTE = '/deepblend/capabilities'
48
48
 
49
+ /** Route path serving the standalone fullscreen workbench document (SPEC §20 M6). */
50
+ export const WORKBENCH_PAGE_ROUTE = '/deepblend/workbench'
51
+
52
+ /**
53
+ * The element the standalone page reserves for the workbench.
54
+ *
55
+ * Named once, and read by the page's own bootstrap below and by
56
+ * `e2e/workbench-page.e2e.mjs`, so "the page mounted something" is answered by
57
+ * the same string on both sides.
58
+ */
59
+ export const WORKBENCH_PAGE_ROOT_ID = 'deepblend-workbench'
60
+
61
+ /**
62
+ * The bootstrap the standalone page runs.
63
+ *
64
+ * Twelve lines, and every one of them is about ARRIVING at the bundle rather
65
+ * than about the workbench:
66
+ *
67
+ * 1. `__DSH_BOOT_READY__` — the tail injection, resolved once the boot rows
68
+ * have run. Awaiting it is what makes this script safe wherever the
69
+ * injection table happens to be spliced.
70
+ * 2. `create({ boot: __DSH_BOOT__, staticModules: {} })` — the SAME module
71
+ * system the console builds, over the SAME graph global. The seed is empty
72
+ * because this page supplies no platform modules: React is a console seed
73
+ * word, the workbench core does not use it, and `client.js` reads it lazily
74
+ * precisely so that this call can pass `{}`.
75
+ * 3. `import('@deepblend/dsh-blender-ui')` — the console's own graph row,
76
+ * fetched from the console's own bundle route. There is no second copy of
77
+ * the workbench anywhere for this to find.
78
+ * 4. `mountStandalone(root)` — the one function that differs between the faces.
79
+ *
80
+ * A failure is reported INTO the page rather than only to the console, because a
81
+ * blank full-screen page is indistinguishable from a page that never booted.
82
+ *
83
+ * @param {string} rootId
84
+ * @returns {string}
85
+ */
86
+ export function workbenchPageBootstrap(rootId) {
87
+ return `<script>(async () => {
88
+ const root = document.getElementById(${JSON.stringify(rootId)})
89
+ try {
90
+ await globalThis.__DSH_BOOT_READY__?.promise
91
+ const modules = window.__ModuleLoader__.create({ boot: window.__DSH_BOOT__, staticModules: {} })
92
+ const bundle = await modules.import('@deepblend/dsh-blender-ui')
93
+ await bundle.mountStandalone(root)
94
+ root.dataset.deepblendStandalone = 'ready'
95
+ } catch (error) {
96
+ root.dataset.deepblendStandalone = 'failed'
97
+ root.textContent = 'DeepBlend 工作台未能启动:' + String((error && error.message) || error)
98
+ }
99
+ })()</script>`
100
+ }
101
+
102
+ /**
103
+ * The standalone workbench document.
104
+ *
105
+ * Deliberately minimal, and deliberately NOT the console: no shell bundle, no
106
+ * sidebar, no conversation — one root element and the bootstrap. Everything the
107
+ * page shows arrives from the bundle the bootstrap imports and from the Host
108
+ * routes that bundle reads.
109
+ *
110
+ * `<head>` and `<body>` are present because `webServer.renderIndex` splices the
111
+ * boot injections into them; without the tags the rows would be prepended to the
112
+ * document instead, and the bootstrap script would run before the module loader
113
+ * existed.
114
+ *
115
+ * @param {string} rootId
116
+ * @param {{ title?: string }} [options]
117
+ * @returns {string}
118
+ */
119
+ export function workbenchPageDocument(rootId, options = {}) {
120
+ const title = options.title ?? 'DeepBlend 工作台'
121
+ return [
122
+ '<!doctype html>',
123
+ '<html lang="zh">',
124
+ '<head>',
125
+ '<meta charset="utf-8">',
126
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
127
+ `<title>${title}</title>`,
128
+ '</head>',
129
+ `<body style="margin:0;height:100vh">`,
130
+ `<div id="${rootId}" data-deepblend-standalone="loading" style="height:100vh"></div>`,
131
+ workbenchPageBootstrap(rootId),
132
+ '</body>',
133
+ '</html>',
134
+ '',
135
+ ].join('\n')
136
+ }
137
+
49
138
  /** Service key for the UI-facing host controller. */
50
139
  export const BLENDER_UI_SERVICE = 'blenderUi'
51
140
 
@@ -96,6 +185,12 @@ export default class BlenderUiHost extends Service {
96
185
  assertKnownConfigKeys(UiConfig, config, 'deepblend-blender-ui')
97
186
  this.config = config
98
187
  this._routeDisposer = null
188
+ // Kept for ONE reason: the standalone page document has to be rendered
189
+ // through `webServer.renderIndex`, which is what splices the boot injections
190
+ // (`window.__ModuleLoader__`, `window.__DSH_BOOT__`, the bootstrap batch) into
191
+ // a body. That is the whole of this row's dependence on the server object —
192
+ // no file, no process, no path.
193
+ this._webServer = null
99
194
 
100
195
  // One handler per route id, and no other way in. A test asserts the key set
101
196
  // equals UI_ROUTES exactly, in both directions.
@@ -104,6 +199,7 @@ export default class BlenderUiHost extends Service {
104
199
  if (!config.serveRoute) return
105
200
 
106
201
  ctx.inject(['webServer'], (serverCtx) => {
202
+ this._webServer = serverCtx.webServer
107
203
  // Route registration belongs to this fiber, so unmounting the row removes
108
204
  // the route (SPEC §4.4 / lifecycle: every side effect is reversible).
109
205
  const dispose = serverCtx.webServer.register({
@@ -114,6 +210,7 @@ export default class BlenderUiHost extends Service {
114
210
  this._routeDisposer = dispose
115
211
  return () => {
116
212
  this._routeDisposer = null
213
+ this._webServer = null
117
214
  dispose()
118
215
  }
119
216
  })
@@ -156,6 +253,13 @@ export default class BlenderUiHost extends Service {
156
253
  await this._sendArtifact(request, response, matched.params)
157
254
  return
158
255
  }
256
+ // The second response that is not JSON, and the only one that is a
257
+ // document: the standalone workbench. It reads nothing from the request —
258
+ // no query, no body, no parameter — so there is no input here to validate.
259
+ if (matched.route.id === 'workbench.page') {
260
+ this._sendWorkbenchPage(request, response)
261
+ return
262
+ }
159
263
  const body = await readRequestBody(request)
160
264
  const result = await this._handlers[matched.route.id]({
161
265
  params: matched.params,
@@ -201,6 +305,46 @@ export default class BlenderUiHost extends Service {
201
305
  response.end(request.method === 'HEAD' ? undefined : artifact.bytes)
202
306
  }
203
307
 
308
+ /**
309
+ * Serve the standalone fullscreen workbench document (SPEC §20 M6).
310
+ *
311
+ * This is the only route that answers with HTML, and the only place in this
312
+ * package that asks the web server for anything other than a route seat. It
313
+ * reads no request input, touches no project and no path, and derives nothing
314
+ * from the caller: the document is a constant plus whatever the composition's
315
+ * own index injections add.
316
+ *
317
+ * `renderIndex` is what makes the page a real client surface rather than a
318
+ * static one — it splices in the boot rows (`window.__ModuleLoader__`, the
319
+ * `@deepseek-ai/dsh-client-modules` bootstrap, `window.__DSH_BOOT__`) that the
320
+ * console's own index.html gets, so the page can import this package's client
321
+ * bundle out of the graph instead of being handed a copy of it.
322
+ *
323
+ * When there is no web server there is no route either (the row only registers
324
+ * one inside `ctx.inject(['webServer'])`), so the fallback below is unreachable
325
+ * in a composed deployment; it exists so that driving `_handle` against a stub
326
+ * server in a test cannot throw on a missing method.
327
+ *
328
+ * @param {import('node:http').IncomingMessage} request
329
+ * @param {import('node:http').ServerResponse} response
330
+ */
331
+ _sendWorkbenchPage(request, response) {
332
+ const document = workbenchPageDocument(WORKBENCH_PAGE_ROOT_ID)
333
+ const server = this._webServer
334
+ const html = server !== null && typeof server.renderIndex === 'function'
335
+ ? server.renderIndex(document)
336
+ : document
337
+ const bytes = Buffer.from(html, 'utf8')
338
+ response.setHeader('content-type', 'text/html; charset=utf-8')
339
+ // Never cached, for the same reason the JSON routes are not: this document
340
+ // names the bundle revision it will load, and a cached copy would pin a page
341
+ // to a revision the process no longer serves.
342
+ response.setHeader('cache-control', 'no-store')
343
+ response.setHeader('content-length', String(bytes.length))
344
+ response.statusCode = 200
345
+ response.end(request.method === 'HEAD' ? undefined : bytes)
346
+ }
347
+
204
348
  /**
205
349
  * @param {import('node:http').ServerResponse} response
206
350
  * @param {number} status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@deepblend/dsh-blender-contracts": "0.1.0"
22
+ "@deepblend/dsh-blender-contracts": "0.2.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {