dsh-webui-studio 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 (51) hide show
  1. package/PRODUCT.md +11 -7
  2. package/README.md +69 -21
  3. package/README.zh-CN.md +64 -20
  4. package/dist/bridge.js +10 -10
  5. package/dist/studio.css +1 -1
  6. package/dist/studio.js +16691 -10202
  7. package/docs/bidirectional-connection-handoff.md +729 -0
  8. package/docs/harmony-api-requirements.md +17 -13
  9. package/docs/remote-development.md +80 -0
  10. package/lib/bridge/element-style-selector.d.ts +1 -0
  11. package/lib/bridge/element-style-selector.js +53 -0
  12. package/lib/contracts.d.ts +152 -83
  13. package/lib/contracts.js +0 -2
  14. package/lib/host/agent.d.ts +15 -13
  15. package/lib/host/agent.js +213 -56
  16. package/lib/host/automatic-patch.d.ts +9 -0
  17. package/lib/host/automatic-patch.js +433 -0
  18. package/lib/host/backend.d.ts +134 -4
  19. package/lib/host/backend.js +465 -114
  20. package/lib/host/drafts.d.ts +1 -1
  21. package/lib/host/drafts.js +65 -15
  22. package/lib/host/element-source.d.ts +9 -0
  23. package/lib/host/element-source.js +295 -0
  24. package/lib/host/mcp.d.ts +4 -0
  25. package/lib/host/mcp.js +97 -0
  26. package/lib/host/preview-draft.d.ts +22 -0
  27. package/lib/host/preview-draft.js +162 -0
  28. package/lib/host/preview-port.d.ts +8 -0
  29. package/lib/host/preview-port.js +32 -0
  30. package/lib/host/preview-worker.d.ts +65 -2
  31. package/lib/host/preview-worker.js +203 -76
  32. package/lib/host/preview.d.ts +26 -5
  33. package/lib/host/preview.js +130 -49
  34. package/lib/host/readiness.d.ts +2 -2
  35. package/lib/host/readiness.js +14 -17
  36. package/lib/host/routes.d.ts +1 -7
  37. package/lib/host/routes.js +41 -50
  38. package/lib/host/runtime-profile.d.ts +2 -1
  39. package/lib/host/runtime-profile.js +30 -8
  40. package/lib/host/source-resolution.d.ts +11 -1
  41. package/lib/host/source-resolution.js +69 -24
  42. package/lib/host/studio-service.d.ts +129 -0
  43. package/lib/host/studio-service.js +53 -0
  44. package/lib/index.d.ts +5 -0
  45. package/lib/index.js +64 -30
  46. package/lib/studio-remote.d.ts +126 -0
  47. package/lib/studio-remote.js +188 -0
  48. package/lib/variable-tree.d.ts +2 -0
  49. package/lib/variable-tree.js +13 -0
  50. package/package.json +62 -25
  51. package/studio.patch.yml +12 -0
@@ -1,9 +1,13 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { basename, dirname, resolve } from 'node:path';
3
5
  import { fileURLToPath } from 'node:url';
4
- import { STUDIO_PREVIEW_API_PATH } from '../contracts.js';
5
- import { installDraftDependencies, materializeDraftProfile, terminalCommandLine } from './runtime-profile.js';
6
- const START_TIMEOUT_MS = 30_000;
6
+ import { NodePeerClient } from 'the-binding-of-dsh';
7
+ import { STUDIO_PREVIEW_REMOTE, } from './preview-worker.js';
8
+ import { studioPreviewPortPool } from './preview-port.js';
9
+ import { buildDraft, installDraftDependencies, materializeDraftProfile, terminalCommandLine } from './runtime-profile.js';
10
+ const START_TIMEOUT_MS = 60_000;
7
11
  const LOG_LIMIT = 64_000;
8
12
  function appendLog(current, chunk) {
9
13
  return `${current}${chunk.toString()}`.slice(-LOG_LIMIT);
@@ -11,6 +15,22 @@ function appendLog(current, chunk) {
11
15
  function studioPackageRoot() {
12
16
  return fileURLToPath(new URL('../../', import.meta.url));
13
17
  }
18
+ function harmonyPackageRoot(harmonyBinEntry) {
19
+ return dirname(dirname(harmonyBinEntry));
20
+ }
21
+ export function dshPackageModules(harmonyBinEntry) {
22
+ const configured = process.env.DSH_HARMONY_DSH_ENTRY;
23
+ const dshEntry = configured === undefined
24
+ ? createRequire(harmonyBinEntry).resolve('@deepseek-ai/dsh/lib/bin.js')
25
+ : resolve(configured);
26
+ let directory = dirname(dshEntry);
27
+ while (dirname(directory) !== directory) {
28
+ if (basename(directory) === 'node_modules')
29
+ return directory;
30
+ directory = dirname(directory);
31
+ }
32
+ throw new Error(`harmony-studio: cannot locate node_modules for ${JSON.stringify(dshEntry)}`);
33
+ }
14
34
  function waitForExit(child, timeoutMs) {
15
35
  if (child.exitCode !== null || child.signalCode !== null)
16
36
  return Promise.resolve(true);
@@ -33,18 +53,21 @@ export class StudioPreviewSupervisor {
33
53
  commands;
34
54
  harmonyBinEntry;
35
55
  stopTimeoutMs;
56
+ portPool;
36
57
  child;
37
- controlToken;
58
+ peerClient;
38
59
  runtime = { state: 'stopped', log: '' };
39
60
  startAbort;
40
61
  startPromise;
41
- constructor(draft, mainProfileDir, parentOrigin, commands, harmonyBinEntry, stopTimeoutMs = 5_000) {
62
+ previewPort;
63
+ constructor(draft, mainProfileDir, parentOrigin, commands, harmonyBinEntry, stopTimeoutMs = 5_000, portPool = studioPreviewPortPool) {
42
64
  this.draft = draft;
43
65
  this.mainProfileDir = mainProfileDir;
44
66
  this.parentOrigin = parentOrigin;
45
67
  this.commands = commands;
46
68
  this.harmonyBinEntry = harmonyBinEntry;
47
69
  this.stopTimeoutMs = stopTimeoutMs;
70
+ this.portPool = portPool;
48
71
  }
49
72
  snapshot() {
50
73
  return { ...this.runtime };
@@ -72,22 +95,22 @@ export class StudioPreviewSupervisor {
72
95
  this.runtime = { state: 'starting', log: '[studio] Preparing Draft dependencies and isolated profile\n' };
73
96
  try {
74
97
  await installDraftDependencies(this.draft, this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
75
- await materializeDraftProfile(this.draft, this.mainProfileDir, studioPackageRoot(), this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
98
+ await buildDraft(this.draft, this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
99
+ await materializeDraftProfile(this.draft, this.mainProfileDir, studioPackageRoot(), harmonyPackageRoot(this.harmonyBinEntry), this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
76
100
  signal.throwIfAborted();
77
- const hostArgs = [this.harmonyBinEntry, 'web', '--port', '0'];
101
+ this.previewPort = this.portPool.claim();
102
+ const hostArgs = [this.harmonyBinEntry, 'web', '--port', String(this.previewPort ?? 0), '--no-open'];
78
103
  this.runtime.log = appendLog(this.runtime.log, `[studio] Profile dependencies ready\n[studio] Starting Preview Host\nDSH_HOME=${this.draft.runtimeHome}\n${terminalCommandLine(this.draft.worktreeDir, process.execPath, hostArgs)}`);
79
- const controlToken = randomBytes(32).toString('hex');
80
104
  const bridgeCapability = randomBytes(24).toString('base64url');
81
- this.controlToken = controlToken;
82
105
  const child = spawn(process.execPath, hostArgs, {
83
106
  cwd: this.draft.worktreeDir,
84
107
  env: {
85
108
  ...process.env,
86
109
  DSH_HOME: this.draft.runtimeHome,
87
110
  DSH_STUDIO_PREVIEW_DRAFT_ROOT: this.draft.root,
88
- DSH_STUDIO_PREVIEW_CONTROL_TOKEN: controlToken,
89
111
  DSH_STUDIO_PREVIEW_PARENT_ORIGIN: this.parentOrigin,
90
112
  DSH_STUDIO_PREVIEW_BRIDGE_CAPABILITY: bridgeCapability,
113
+ DSH_STUDIO_PREVIEW_PACKAGE_DIRS: JSON.stringify([dshPackageModules(this.harmonyBinEntry)]),
91
114
  DSH_HARMONY_REACT_TRACE: '1',
92
115
  },
93
116
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -97,12 +120,11 @@ export class StudioPreviewSupervisor {
97
120
  this.runtime.log = appendLog(this.runtime.log, chunk);
98
121
  const match = this.runtime.log.match(/dsh web:\s+(http:\/\/127\.0\.0\.1:\d+)/);
99
122
  if (match?.[1] !== undefined) {
123
+ const { error: _error, ...runtime } = this.runtime;
100
124
  this.runtime = {
101
- ...this.runtime,
102
- state: 'running',
125
+ ...runtime,
103
126
  previewUrl: `${match[1]}/#dsh-studio-preview=${encodeURIComponent(bridgeCapability)}`,
104
127
  bridgeCapability,
105
- error: undefined,
106
128
  };
107
129
  }
108
130
  });
@@ -111,18 +133,28 @@ export class StudioPreviewSupervisor {
111
133
  if (this.child !== child)
112
134
  return;
113
135
  this.child = undefined;
114
- this.controlToken = undefined;
136
+ const peerClient = this.peerClient;
137
+ this.peerClient = undefined;
138
+ void peerClient?.close();
139
+ this.releasePreviewPort();
115
140
  if (this.runtime.state === 'stopped')
116
141
  return;
117
142
  const error = `Preview Host exited (${signal ?? code ?? 'unknown'})`;
118
143
  this.runtime = { state: 'failed', error, log: this.runtime.log };
119
144
  });
120
- await this.waitUntilRunning(child, signal);
145
+ await this.waitForPreviewUrl(child, signal);
146
+ const peerClient = new NodePeerClient({
147
+ baseUrl: new URL('/', this.runtime.previewUrl),
148
+ contribution: STUDIO_PREVIEW_REMOTE,
149
+ });
150
+ this.peerClient = peerClient;
121
151
  await this.waitForWorker(child, signal);
122
- await this.worker('state', {}, signal);
152
+ await this.invoke(this.remote().state(signal));
153
+ this.runtime = { ...this.runtime, state: 'running' };
123
154
  return this.snapshot();
124
155
  }
125
156
  catch (error) {
157
+ await this.closePeer();
126
158
  await this.terminateChild();
127
159
  if (signal.aborted) {
128
160
  this.runtime = { state: 'stopped', log: this.runtime.log };
@@ -135,8 +167,8 @@ export class StudioPreviewSupervisor {
135
167
  async stop() {
136
168
  this.startAbort?.abort(new Error('Preview start canceled'));
137
169
  const start = this.startPromise;
138
- this.controlToken = undefined;
139
170
  this.runtime = { state: 'stopped', log: this.runtime.log };
171
+ await this.closePeer();
140
172
  await this.terminateChild();
141
173
  if (start !== undefined) {
142
174
  try {
@@ -144,7 +176,6 @@ export class StudioPreviewSupervisor {
144
176
  }
145
177
  catch { }
146
178
  }
147
- this.controlToken = undefined;
148
179
  this.runtime = { state: 'stopped', log: this.runtime.log };
149
180
  return this.snapshot();
150
181
  }
@@ -163,72 +194,122 @@ export class StudioPreviewSupervisor {
163
194
  }
164
195
  if (this.child === child)
165
196
  this.child = undefined;
197
+ this.releasePreviewPort();
198
+ }
199
+ releasePreviewPort() {
200
+ this.portPool.release(this.previewPort);
201
+ this.previewPort = undefined;
166
202
  }
167
203
  async state() {
168
- return (await this.worker('state', {})).project;
204
+ return this.invoke(this.remote().state());
169
205
  }
170
206
  async activate(graphRev) {
171
- return (await this.worker('activate', { graphRev })).project;
207
+ return this.invoke(this.remote().activate(graphRev));
172
208
  }
173
209
  async applyBuild() {
174
- return (await this.worker('apply-build', {})).project;
210
+ await this.commands.run(process.execPath, [this.harmonyBinEntry, 'harmony', 'reload', this.draft.name], this.draft.worktreeDir, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, undefined, { ...process.env, DSH_HOME: this.draft.runtimeHome });
211
+ await this.reconnectPeer();
212
+ return this.invoke(this.remote().applyBuild(randomUUID()));
175
213
  }
176
214
  async inspect(input = {}) {
177
- return this.worker('inspect', input);
215
+ return this.invoke(this.remote().inspect(input));
216
+ }
217
+ async profile() {
218
+ return this.invoke(this.remote().profile());
219
+ }
220
+ async updateProfile(input) {
221
+ const operation = { ...input, operationId: randomUUID() };
222
+ try {
223
+ return await this.invoke(this.remote().updateProfile(operation));
224
+ }
225
+ catch (error) {
226
+ if (!this.isGenerationLoss(error))
227
+ throw error;
228
+ }
229
+ await this.reconnectPeer();
230
+ return this.invoke(this.remote().updateProfile(operation));
178
231
  }
179
232
  async resolveSource(source) {
180
- return this.worker('resolve-source', { source });
233
+ return this.invoke(this.remote().resolveSource(source));
181
234
  }
182
235
  async readDependencySource(packageName, file) {
183
- return this.worker('read-source', { package: packageName, file });
236
+ return this.invoke(this.remote().readSource(packageName, file));
237
+ }
238
+ async readPatchTarget(packageName, file) {
239
+ return this.invoke(this.remote().readPatchTarget(packageName, file));
184
240
  }
185
241
  async dispose() {
186
242
  await this.stop();
187
243
  }
188
- async waitUntilRunning(child, signal) {
244
+ async waitForPreviewUrl(child, signal) {
189
245
  const started = Date.now();
190
- while (this.child === child && this.runtime.state === 'starting' && Date.now() - started < START_TIMEOUT_MS) {
246
+ while (this.child === child && this.runtime.previewUrl === undefined && Date.now() - started < START_TIMEOUT_MS) {
191
247
  await this.delay(50, signal);
192
248
  }
193
249
  signal.throwIfAborted();
194
- if (this.runtime.state !== 'running')
195
- throw new Error(this.runtime.error ?? 'Preview Host did not publish its URL before timeout');
250
+ if (this.runtime.previewUrl === undefined) {
251
+ throw new Error(`${this.runtime.error ?? 'Preview Host did not publish its URL before timeout'}\n${this.runtime.log}`);
252
+ }
196
253
  }
197
254
  async waitForWorker(child, signal) {
198
255
  const started = Date.now();
199
- let lastError = 'worker route was not reachable';
256
+ let lastError = 'Preview worker is still preparing the Draft';
200
257
  while (this.child === child && Date.now() - started < START_TIMEOUT_MS) {
201
258
  try {
202
259
  const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(1_000)]);
203
- await this.worker('health', {}, requestSignal);
204
- return;
260
+ await this.peerClient?.connect(requestSignal);
261
+ const health = await this.invoke(this.remote().health(requestSignal));
262
+ if (health.ready)
263
+ return;
264
+ if (health.error !== undefined)
265
+ throw new Error(health.error);
205
266
  }
206
267
  catch (error) {
207
268
  signal.throwIfAborted();
208
269
  lastError = error instanceof Error ? error.message : String(error);
209
- await this.delay(50, signal);
210
270
  }
271
+ await this.delay(50, signal);
211
272
  }
212
273
  signal.throwIfAborted();
213
274
  throw new Error(`Preview worker did not become ready before timeout: ${lastError}`);
214
275
  }
215
- async worker(method, payload, signal) {
216
- if (this.runtime.previewUrl === undefined || this.controlToken === undefined)
276
+ remote() {
277
+ if (this.peerClient === undefined)
217
278
  throw new Error('Preview Host is not running');
218
- const endpoint = new URL(`${STUDIO_PREVIEW_API_PATH}/${method}`, this.runtime.previewUrl);
219
- const response = await fetch(endpoint, {
220
- method: 'POST',
221
- headers: { authorization: `Bearer ${this.controlToken}`, 'content-type': 'application/json' },
222
- body: JSON.stringify(payload),
223
- signal,
224
- });
225
- const text = await response.text();
226
- if (text === '')
227
- throw new Error(`Preview worker returned an empty HTTP ${response.status} response`);
228
- const body = JSON.parse(text);
229
- if (!response.ok || !body.ok)
230
- throw new Error(body.ok ? `Preview worker failed with HTTP ${response.status}` : body.error);
231
- return body.value;
279
+ return this.peerClient.remote.studioPreviewWorker;
280
+ }
281
+ async invoke(result) {
282
+ const settled = await result;
283
+ if (settled.ok)
284
+ return settled.value;
285
+ throw new Error(`${settled.error.code}: ${settled.error.message}`);
286
+ }
287
+ async closePeer() {
288
+ const peerClient = this.peerClient;
289
+ this.peerClient = undefined;
290
+ await peerClient?.close();
291
+ }
292
+ isGenerationLoss(error) {
293
+ return error instanceof Error && error.message.includes('Node peer connection closed');
294
+ }
295
+ async reconnectPeer() {
296
+ const peerClient = this.peerClient;
297
+ if (peerClient === undefined || this.runtime.state !== 'running') {
298
+ throw new Error('Preview Host is not running');
299
+ }
300
+ const signal = AbortSignal.timeout(START_TIMEOUT_MS);
301
+ let lastError;
302
+ while (!signal.aborted) {
303
+ try {
304
+ await peerClient.connect(signal);
305
+ return;
306
+ }
307
+ catch (error) {
308
+ lastError = error;
309
+ }
310
+ await this.delay(50, signal);
311
+ }
312
+ throw new Error('Preview peer did not reconnect after Harmony reload', { cause: lastError });
232
313
  }
233
314
  async delay(milliseconds, signal) {
234
315
  signal.throwIfAborted();
@@ -1,6 +1,6 @@
1
1
  import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
2
- import type { StudioHarmonyInspection, StudioPackResult, StudioReadinessReport, StudioPatchDependency } from '../contracts.js';
3
- export declare function inspectReadiness(root: string, projectName: string, inspection: StudioHarmonyInspection, profileDir: string, dependencies?: StudioPatchDependency[]): StudioReadinessReport;
2
+ import type { StudioHarmonyInspection, StudioPackResult, StudioReadinessReport } from '../contracts.js';
3
+ export declare function inspectReadiness(root: string, projectName: string, inspection: StudioHarmonyInspection, profileDir: string): StudioReadinessReport;
4
4
  export declare class StudioPackRunner {
5
5
  private readonly subprocess;
6
6
  private readonly timeoutMs;
@@ -43,7 +43,7 @@ function artifactFinding(root, path, label) {
43
43
  function readManifest(root) {
44
44
  return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
45
45
  }
46
- export function inspectReadiness(root, projectName, inspection, profileDir, dependencies = []) {
46
+ export function inspectReadiness(root, projectName, inspection, profileDir) {
47
47
  const manifest = readManifest(root);
48
48
  const findings = [];
49
49
  if (typeof manifest.name !== 'string' || manifest.name === '') {
@@ -110,7 +110,6 @@ export function inspectReadiness(root, projectName, inspection, profileDir, depe
110
110
  }
111
111
  }
112
112
  const declared = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]);
113
- const declaredAfter = new Set(stringArray(harmony?.after));
114
113
  for (const dependency of stringArray(manifest.dsh?.client?.inject)) {
115
114
  if (!declared.has(dependency)) {
116
115
  findings.push(finding('warning', 'ambient-client-service', `Client inject ${JSON.stringify(dependency)} is supplied by the current profile but is not declared as a dependency or peer dependency`));
@@ -123,28 +122,26 @@ export function inspectReadiness(root, projectName, inspection, profileDir, depe
123
122
  }
124
123
  const patches = inspection.patches.filter(patch => patch.owner === projectName);
125
124
  for (const patch of patches) {
126
- if (!declared.has(patch.target.package)) {
127
- findings.push(finding('warning', 'ambient-patch-target', `Patch target ${JSON.stringify(patch.target.package)} is available in this Preview but is not declared as a dependency or peer dependency`, { patch: patch.key }));
128
- }
129
- if (patch.target.version === undefined) {
130
- findings.push(finding('warning', 'unbounded-target-version', `Patch ${JSON.stringify(patch.key)} does not constrain the target package version`, { patch: patch.key }));
125
+ const file = patch.targets[0]?.file;
126
+ const targets = new Map(patch.targets.map(target => [`${target.package}\0${target.version ?? ''}`, target]));
127
+ for (const target of targets.values()) {
128
+ if (!declared.has(target.package)) {
129
+ findings.push(finding('warning', 'ambient-patch-target', `Patch target ${JSON.stringify(target.package)} is available in this Preview but is not declared as a dependency or peer dependency`, { patch: patch.key }));
130
+ }
131
+ if (target.version === undefined) {
132
+ findings.push(finding('warning', 'unbounded-target-version', `Patch ${JSON.stringify(patch.key)} does not constrain target package ${JSON.stringify(target.package)}`, { patch: patch.key }));
133
+ }
131
134
  }
132
135
  if (patch.state === 'failed') {
133
- findings.push(finding('error', 'patch-failed', patch.error ?? `Patch ${JSON.stringify(patch.key)} failed against the current provider stack`, { patch: patch.key, file: patch.file }));
136
+ findings.push(finding('error', 'patch-failed', patch.error ?? `Patch ${JSON.stringify(patch.key)} failed against the current provider stack`, { patch: patch.key, file }));
134
137
  }
135
138
  else if (patch.state === 'disabled') {
136
- findings.push(finding('warning', 'patch-disabled', `Patch ${JSON.stringify(patch.key)} is disabled in the current profile`, { patch: patch.key, file: patch.file }));
139
+ findings.push(finding('warning', 'patch-disabled', `Patch ${JSON.stringify(patch.key)} is disabled in the current profile`, { patch: patch.key, file }));
137
140
  }
138
- else if (patch.state === 'pending' || !patch.loaded) {
139
- findings.push(finding('warning', 'patch-unverified', `Patch ${JSON.stringify(patch.key)} has not been exercised by the current Preview`, { patch: patch.key, file: patch.file }));
141
+ else if (patch.state === 'pending') {
142
+ findings.push(finding('warning', 'patch-unverified', `Patch ${JSON.stringify(patch.key)} has not been exercised by the current Preview`, { patch: patch.key, file }));
140
143
  }
141
144
  }
142
- for (const dependency of dependencies) {
143
- const explicit = dependency.providerCandidates.filter(provider => declared.has(provider) && declaredAfter.has(provider));
144
- if (dependency.providerCandidates.length === 1 && explicit.length === 1)
145
- continue;
146
- findings.push(finding('warning', 'differential-provider-stack', `Patch ${JSON.stringify(dependency.patch)} fails against the base target but succeeds against the current transformed stack. Earlier provider candidates: ${dependency.providerCandidates.map(provider => JSON.stringify(provider)).join(', ')}. Inspect the ordered Patch steps before declaring a dependency or dsh.harmony.after relationship`, { patch: dependency.patch, file: dependency.target.file }));
147
- }
148
145
  const orderPath = join(profileDir, 'harmony.json');
149
146
  if (existsSync(orderPath)) {
150
147
  const order = JSON.parse(readFileSync(orderPath, 'utf8')).order;
@@ -1,6 +1,5 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
3
- import type { StudioBackend } from './backend.js';
4
3
  export interface StudioAssets {
5
4
  script: Buffer;
6
5
  style: Buffer;
@@ -8,10 +7,5 @@ export interface StudioAssets {
8
7
  icon: Buffer;
9
8
  iconMono: Buffer;
10
9
  }
11
- export interface StudioRouteSecurity {
12
- token: string;
13
- origin: string;
14
- host: string;
15
- }
16
10
  export declare function isTrustedStudioRequest(request: IncomingMessage): boolean;
17
- export declare function createStudioRoutes(backend: StudioBackend, assets: StudioAssets, security: StudioRouteSecurity): WebRoute[];
11
+ export declare function createStudioRoutes(assets: StudioAssets, ready?: () => boolean): WebRoute[];
@@ -1,5 +1,4 @@
1
- import { STUDIO_API_PATH, STUDIO_PATH } from '../contracts.js';
2
- const MAX_BODY_BYTES = 1024 * 1024;
1
+ import { STUDIO_PATH } from '../contracts.js';
3
2
  function remoteIsLoopback(request) {
4
3
  const address = request.socket.remoteAddress;
5
4
  return address === '::1' || address === '127.0.0.1' || address?.startsWith('127.') === true
@@ -11,18 +10,6 @@ export function isTrustedStudioRequest(request) {
11
10
  const fetchSite = request.headers['sec-fetch-site'];
12
11
  return fetchSite === undefined || fetchSite === 'same-origin' || fetchSite === 'none';
13
12
  }
14
- async function readJson(request) {
15
- const chunks = [];
16
- let size = 0;
17
- for await (const chunk of request) {
18
- const buffer = Buffer.from(chunk);
19
- size += buffer.length;
20
- if (size > MAX_BODY_BYTES)
21
- throw new Error('request body is too large');
22
- chunks.push(buffer);
23
- }
24
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
25
- }
26
13
  function sendJson(response, status, value) {
27
14
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
28
15
  response.end(JSON.stringify(value));
@@ -35,9 +22,9 @@ function sendAsset(request, response, contentType, body) {
35
22
  });
36
23
  response.end(request.method === 'HEAD' ? undefined : body);
37
24
  }
38
- function documentHtml(token) {
25
+ function documentHtml() {
39
26
  return `<!doctype html>
40
- <html lang="zh-CN">
27
+ <html lang="en">
41
28
  <head>
42
29
  <meta charset="UTF-8" />
43
30
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -45,7 +32,6 @@ function documentHtml(token) {
45
32
  <meta name="referrer" content="no-referrer" />
46
33
  <title>DeepSeek WebUI Studio</title>
47
34
  <link rel="stylesheet" href="${STUDIO_PATH}/assets/studio.css" />
48
- <script>window.__DSH_STUDIO__={token:${JSON.stringify(token)}};</script>
49
35
  </head>
50
36
  <body>
51
37
  <div id="root"></div>
@@ -53,10 +39,30 @@ function documentHtml(token) {
53
39
  </body>
54
40
  </html>`;
55
41
  }
56
- function hasStudioCapability(request, security) {
57
- return request.headers.host === security.host
58
- && request.headers.origin === security.origin
59
- && request.headers['x-dsh-studio-token'] === security.token;
42
+ function harmonySetupHtml() {
43
+ return `<!doctype html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="UTF-8" />
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
48
+ <meta name="color-scheme" content="light dark" />
49
+ <title>DeepSeek WebUI Studio</title>
50
+ <style>html,body,iframe{width:100%;height:100%;margin:0;border:0}body{overflow:hidden}</style>
51
+ </head>
52
+ <body>
53
+ <iframe src="/" title="Install Harmony for DeepSeek WebUI Studio"></iframe>
54
+ <script>
55
+ const waitForHarmony = async () => {
56
+ try {
57
+ const status = await fetch('/dsh-harmony/runtime').then(response => response.json())
58
+ if (status.state === 'active') return location.reload()
59
+ } catch {}
60
+ setTimeout(waitForHarmony, 750)
61
+ }
62
+ waitForHarmony()
63
+ </script>
64
+ </body>
65
+ </html>`;
60
66
  }
61
67
  function rejectUntrusted(request, response) {
62
68
  if (isTrustedStudioRequest(request))
@@ -64,43 +70,29 @@ function rejectUntrusted(request, response) {
64
70
  sendJson(response, 403, { error: 'Studio is available from the local machine only.' });
65
71
  return true;
66
72
  }
67
- export function createStudioRoutes(backend, assets, security) {
68
- const page = Buffer.from(documentHtml(security.token));
69
- const apiHandler = async (request, response) => {
70
- if (rejectUntrusted(request, response))
71
- return;
72
- if (!hasStudioCapability(request, security))
73
- return sendJson(response, 403, { error: 'invalid Studio capability' });
74
- if (request.method !== 'POST')
75
- return sendJson(response, 405, { error: 'method not allowed' });
76
- if ((request.headers['content-type'] ?? '').split(';')[0] !== 'application/json') {
77
- return sendJson(response, 415, { error: 'content-type must be application/json' });
78
- }
79
- const path = new URL(request.url ?? '/', 'http://localhost').pathname;
80
- const method = path.slice(`${STUDIO_API_PATH}/`.length);
81
- try {
82
- const body = await readJson(request);
83
- const candidate = body;
84
- if (candidate.type !== 'client-request' || typeof candidate.rpcId !== 'string'
85
- || candidate.method !== method) {
86
- return sendJson(response, 400, { error: 'invalid client-request' });
87
- }
88
- sendJson(response, 200, await backend.call(candidate));
89
- }
90
- catch (error) {
91
- sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
92
- }
93
- };
73
+ export function createStudioRoutes(assets, ready = () => true) {
74
+ const page = Buffer.from(documentHtml());
75
+ const setupPage = Buffer.from(harmonySetupHtml());
94
76
  return [
77
+ {
78
+ kind: 'prefix',
79
+ path: `${STUDIO_PATH}/api`,
80
+ handler(_request, response) {
81
+ sendJson(response, 404, { error: 'not found' });
82
+ },
83
+ },
95
84
  {
96
85
  kind: 'exact',
97
86
  path: STUDIO_PATH,
98
87
  handler(request, response) {
99
88
  if (rejectUntrusted(request, response))
100
89
  return;
90
+ if (new URL(request.url ?? '/', 'http://localhost').pathname !== STUDIO_PATH) {
91
+ return sendJson(response, 404, { error: 'not found' });
92
+ }
101
93
  if (request.method !== 'GET' && request.method !== 'HEAD')
102
94
  return sendJson(response, 405, { error: 'method not allowed' });
103
- sendAsset(request, response, 'text/html; charset=utf-8', page);
95
+ sendAsset(request, response, 'text/html; charset=utf-8', ready() ? page : setupPage);
104
96
  },
105
97
  },
106
98
  {
@@ -158,6 +150,5 @@ export function createStudioRoutes(backend, assets, security) {
158
150
  sendAsset(request, response, 'image/png', assets.iconMono);
159
151
  },
160
152
  },
161
- { kind: 'prefix', path: STUDIO_API_PATH, handler: apiHandler },
162
153
  ];
163
154
  }
@@ -12,5 +12,6 @@ export declare function bundledPnpmCommand(args: readonly string[]): [string, st
12
12
  export declare function terminalCommandLine(cwd: string, command: string, args: readonly string[]): string;
13
13
  export declare function assertDraftPackageIdentity(draft: StudioDraftRecord): Promise<DraftManifest>;
14
14
  export declare function installDraftDependencies(draft: StudioDraftRecord, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<void>;
15
- export declare function materializeDraftProfile(draft: StudioDraftRecord, mainProfileDir: string, studioPackageRoot: string, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<string>;
15
+ export declare function buildDraft(draft: StudioDraftRecord, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<void>;
16
+ export declare function materializeDraftProfile(draft: StudioDraftRecord, mainProfileDir: string, studioPackageRoot: string, harmonyPackageRoot: string, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<string>;
16
17
  export {};
@@ -1,10 +1,11 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
- import { resolvePackageManager } from './build.js';
4
+ import { resolveBuildArgv, resolvePackageManager } from './build.js';
5
5
  const PROFILE_FILES = ['cordis.patch.yml', 'cordis.yml', 'harmony.json', 'pnpm-workspace.yaml'];
6
6
  const require = createRequire(import.meta.url);
7
7
  const PNPM_ENTRY = join(dirname(require.resolve('pnpm')), 'bin', 'pnpm.cjs');
8
+ const BINDING_PACKAGE_ROOT = process.env.DSH_STUDIO_BINDING_ROOT ?? dirname(require.resolve('the-binding-of-dsh/package.json'));
8
9
  export function bundledPnpmCommand(args) {
9
10
  return [process.execPath, [PNPM_ENTRY, ...args]];
10
11
  }
@@ -31,7 +32,7 @@ export async function installDraftDependencies(draft, commands, onOutput, signal
31
32
  if (!hasDependencies(manifest))
32
33
  return;
33
34
  const manager = resolvePackageManager(draft.root, manifest);
34
- const [command, args] = manager === 'pnpm' ? bundledPnpmCommand(['install']) : [manager, ['install']];
35
+ const [command, args] = manager === 'pnpm' ? bundledPnpmCommand(['install', '--prefer-offline']) : [manager, ['install']];
35
36
  onOutput?.(terminalCommandLine(draft.root, command, args));
36
37
  try {
37
38
  await commands.run(command, args, draft.root, onOutput, signal);
@@ -43,27 +44,48 @@ export async function installDraftDependencies(draft, commands, onOutput, signal
43
44
  throw new Error('Draft dependency installation failed. Check the startup terminal for details.');
44
45
  }
45
46
  }
47
+ export async function buildDraft(draft, commands, onOutput, signal) {
48
+ signal?.throwIfAborted();
49
+ const [manager, ...args] = resolveBuildArgv(draft.root);
50
+ const [command, commandArgs] = manager === 'pnpm' ? bundledPnpmCommand(args) : [manager, args];
51
+ onOutput?.(terminalCommandLine(draft.root, command, commandArgs));
52
+ try {
53
+ await commands.run(command, commandArgs, draft.root, onOutput, signal);
54
+ }
55
+ catch (error) {
56
+ signal?.throwIfAborted();
57
+ const message = (error instanceof Error ? error.message : String(error)).split('\n', 1)[0];
58
+ onOutput?.(`[studio] ${message}\n`);
59
+ throw new Error('Initial Draft build failed. Check the startup terminal for details.');
60
+ }
61
+ }
46
62
  function absoluteLink(spec, profileDir) {
47
63
  if (!spec.startsWith('link:'))
48
64
  return spec;
49
65
  const target = spec.slice('link:'.length);
50
66
  return `link:${isAbsolute(target) ? target : resolve(profileDir, target)}`;
51
67
  }
52
- export async function materializeDraftProfile(draft, mainProfileDir, studioPackageRoot, commands, onOutput, signal) {
68
+ export async function materializeDraftProfile(draft, mainProfileDir, studioPackageRoot, harmonyPackageRoot, commands, onOutput, signal) {
53
69
  signal?.throwIfAborted();
54
- if (draft.profileMode !== 'main-home')
55
- throw new Error('Custom Draft profiles are not implemented yet');
70
+ let sourceProfileDir = mainProfileDir;
71
+ if (draft.profileMode === 'custom') {
72
+ if (draft.profileDirectory === undefined)
73
+ throw new Error('Custom Draft profile folder is missing');
74
+ sourceProfileDir = draft.profileDirectory;
75
+ }
56
76
  const profileDir = join(draft.runtimeHome, 'profiles', 'web');
57
77
  await rm(profileDir, { recursive: true, force: true });
58
78
  await mkdir(profileDir, { recursive: true });
59
- const manifest = JSON.parse(await readFile(join(mainProfileDir, 'package.json'), 'utf8'));
60
- const dependencies = Object.fromEntries(Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => [name, absoluteLink(spec, mainProfileDir)]));
79
+ const manifest = JSON.parse(await readFile(join(sourceProfileDir, 'package.json'), 'utf8'));
80
+ const dependencies = Object.fromEntries(Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => [name, absoluteLink(spec, sourceProfileDir)]));
61
81
  dependencies[draft.name] = `link:${draft.root}`;
62
82
  dependencies['dsh-webui-studio'] = `link:${studioPackageRoot}`;
83
+ dependencies['dsh-harmony'] = `link:${harmonyPackageRoot}`;
84
+ dependencies['the-binding-of-dsh'] = `link:${BINDING_PACKAGE_ROOT}`;
63
85
  await writeFile(join(profileDir, 'package.json'), `${JSON.stringify({ ...manifest, dependencies }, null, 2)}\n`);
64
86
  for (const file of PROFILE_FILES) {
65
87
  try {
66
- await cp(join(mainProfileDir, file), join(profileDir, file));
88
+ await cp(join(sourceProfileDir, file), join(profileDir, file));
67
89
  }
68
90
  catch (error) {
69
91
  if (error.code !== 'ENOENT')
@@ -1,7 +1,17 @@
1
1
  import type { StudioSourceCandidate, StudioSourceLocation } from '../contracts.js';
2
2
  export declare class StudioSourceResolver {
3
3
  #private;
4
- constructor(draftRoot: string, profileDir: string);
4
+ private readonly draftRoot;
5
+ private readonly profileDir;
6
+ private readonly packageDirs;
7
+ constructor(draftRoot: string | undefined, profileDir: string, packageDirs?: string[]);
8
+ private roots;
5
9
  resolve(source: StudioSourceLocation): Promise<StudioSourceCandidate>;
6
10
  readDependency(packageName: string, file: string): Promise<string>;
11
+ readDependencyTarget(packageName: string, file: string): Promise<{
12
+ package: string;
13
+ file: string;
14
+ version: string;
15
+ source: string;
16
+ }>;
7
17
  }