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.
- package/PRODUCT.md +11 -7
- package/README.md +69 -21
- package/README.zh-CN.md +64 -20
- package/dist/bridge.js +10 -10
- package/dist/studio.css +1 -1
- package/dist/studio.js +16691 -10202
- package/docs/bidirectional-connection-handoff.md +729 -0
- package/docs/harmony-api-requirements.md +17 -13
- package/docs/remote-development.md +80 -0
- package/lib/bridge/element-style-selector.d.ts +1 -0
- package/lib/bridge/element-style-selector.js +53 -0
- package/lib/contracts.d.ts +152 -83
- package/lib/contracts.js +0 -2
- package/lib/host/agent.d.ts +15 -13
- package/lib/host/agent.js +213 -56
- package/lib/host/automatic-patch.d.ts +9 -0
- package/lib/host/automatic-patch.js +433 -0
- package/lib/host/backend.d.ts +134 -4
- package/lib/host/backend.js +465 -114
- package/lib/host/drafts.d.ts +1 -1
- package/lib/host/drafts.js +65 -15
- package/lib/host/element-source.d.ts +9 -0
- package/lib/host/element-source.js +295 -0
- package/lib/host/mcp.d.ts +4 -0
- package/lib/host/mcp.js +97 -0
- package/lib/host/preview-draft.d.ts +22 -0
- package/lib/host/preview-draft.js +162 -0
- package/lib/host/preview-port.d.ts +8 -0
- package/lib/host/preview-port.js +32 -0
- package/lib/host/preview-worker.d.ts +65 -2
- package/lib/host/preview-worker.js +203 -76
- package/lib/host/preview.d.ts +26 -5
- package/lib/host/preview.js +130 -49
- package/lib/host/readiness.d.ts +2 -2
- package/lib/host/readiness.js +14 -17
- package/lib/host/routes.d.ts +1 -7
- package/lib/host/routes.js +41 -50
- package/lib/host/runtime-profile.d.ts +2 -1
- package/lib/host/runtime-profile.js +30 -8
- package/lib/host/source-resolution.d.ts +11 -1
- package/lib/host/source-resolution.js +69 -24
- package/lib/host/studio-service.d.ts +129 -0
- package/lib/host/studio-service.js +53 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +64 -30
- package/lib/studio-remote.d.ts +126 -0
- package/lib/studio-remote.js +188 -0
- package/lib/variable-tree.d.ts +2 -0
- package/lib/variable-tree.js +13 -0
- package/package.json +62 -25
- package/studio.patch.yml +12 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { createRequire, findPackageJSON } from 'node:module';
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { studioCommands } from './drafts.js';
|
|
6
|
+
const CLIENT_ENTRY_TIMEOUT_MS = 10_000;
|
|
7
|
+
const HARMONY_BIN_ENTRY = fileURLToPath(import.meta.resolve('dsh-harmony/bin'));
|
|
8
|
+
function packageNameOf(specifier) {
|
|
9
|
+
const clean = specifier.replace(/\?dsh-harmony=\d+$/, '');
|
|
10
|
+
if (clean.startsWith('.') || clean.startsWith('/') || clean.startsWith('file:') || clean.includes(':'))
|
|
11
|
+
return undefined;
|
|
12
|
+
return clean.startsWith('@') ? clean.split('/').slice(0, 2).join('/') : clean.split('/')[0];
|
|
13
|
+
}
|
|
14
|
+
function resolveDraft(profileDir, inputRoot) {
|
|
15
|
+
if (!isAbsolute(inputRoot))
|
|
16
|
+
throw new Error('harmony-studio: Draft root must be an absolute path');
|
|
17
|
+
const root = realpathSync(inputRoot);
|
|
18
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
19
|
+
if (typeof manifest.name !== 'string' || manifest.name.length === 0) {
|
|
20
|
+
throw new Error('harmony-studio: Draft package name must be a non-empty string');
|
|
21
|
+
}
|
|
22
|
+
if (manifest.dsh?.client?.platform !== 'web') {
|
|
23
|
+
throw new Error(`harmony-studio: Draft ${JSON.stringify(manifest.name)} must declare dsh.client.platform as "web"`);
|
|
24
|
+
}
|
|
25
|
+
const profileManifest = join(profileDir, 'package.json');
|
|
26
|
+
const profile = JSON.parse(readFileSync(profileManifest, 'utf8'));
|
|
27
|
+
if (!(manifest.name in (profile.dependencies ?? {}))) {
|
|
28
|
+
throw new Error(`harmony-studio: Draft ${JSON.stringify(manifest.name)} is not a dependency of the Preview profile`);
|
|
29
|
+
}
|
|
30
|
+
const installedManifest = findPackageJSON(manifest.name, pathToFileURL(profileManifest));
|
|
31
|
+
if (installedManifest === undefined || realpathSync(dirname(installedManifest)) !== root) {
|
|
32
|
+
throw new Error(`harmony-studio: Draft ${JSON.stringify(manifest.name)} is not linked to the selected root`);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
createRequire(profileManifest).resolve(`${manifest.name}/client`);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error(`harmony-studio: Draft ${JSON.stringify(manifest.name)} must export "./client"`);
|
|
39
|
+
}
|
|
40
|
+
return { name: manifest.name, root };
|
|
41
|
+
}
|
|
42
|
+
export class StudioPreviewDraft {
|
|
43
|
+
ctx;
|
|
44
|
+
harmony;
|
|
45
|
+
commands;
|
|
46
|
+
name;
|
|
47
|
+
root;
|
|
48
|
+
entryId;
|
|
49
|
+
createdEntry = false;
|
|
50
|
+
project;
|
|
51
|
+
constructor(ctx, harmony, root, commands = studioCommands) {
|
|
52
|
+
this.ctx = ctx;
|
|
53
|
+
this.harmony = harmony;
|
|
54
|
+
this.commands = commands;
|
|
55
|
+
const draft = resolveDraft(harmony.profile().dir, root);
|
|
56
|
+
this.name = draft.name;
|
|
57
|
+
this.root = draft.root;
|
|
58
|
+
}
|
|
59
|
+
async open() {
|
|
60
|
+
const entries = [...this.ctx.loader.entries()].filter(entry => packageNameOf(entry.options.name) === this.name);
|
|
61
|
+
if (entries.length > 1)
|
|
62
|
+
throw new Error(`harmony-studio: Draft ${JSON.stringify(this.name)} has multiple Loader entries`);
|
|
63
|
+
try {
|
|
64
|
+
if (entries.length === 1) {
|
|
65
|
+
this.entryId = entries[0].id;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
this.entryId = await this.ctx.loader.create({ name: this.name });
|
|
69
|
+
this.createdEntry = true;
|
|
70
|
+
}
|
|
71
|
+
await this.waitForClientEntry();
|
|
72
|
+
await this.reload();
|
|
73
|
+
const graph = this.ctx.clientModules.graph();
|
|
74
|
+
if (!graph.entries.some(entry => entry.id === this.name)) {
|
|
75
|
+
throw new Error(`harmony-studio: Draft ${JSON.stringify(this.name)} left the client graph while loading its Patches`);
|
|
76
|
+
}
|
|
77
|
+
this.project = { name: this.name, root: this.root, state: 'preview-pending', graphRev: graph.rev };
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (!this.createdEntry || this.entryId === undefined)
|
|
82
|
+
throw error;
|
|
83
|
+
const cleanupErrors = [];
|
|
84
|
+
try {
|
|
85
|
+
await this.ctx.loader.remove(this.entryId);
|
|
86
|
+
}
|
|
87
|
+
catch (cleanupError) {
|
|
88
|
+
cleanupErrors.push(cleanupError);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await this.reload();
|
|
92
|
+
}
|
|
93
|
+
catch (cleanupError) {
|
|
94
|
+
cleanupErrors.push(cleanupError);
|
|
95
|
+
}
|
|
96
|
+
if (cleanupErrors.length > 0) {
|
|
97
|
+
throw new AggregateError([error, ...cleanupErrors], 'harmony-studio: failed to clean up Draft after Preview startup failed');
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
snapshot() {
|
|
103
|
+
if (this.project === undefined)
|
|
104
|
+
throw new Error('harmony-studio: Draft Preview is still preparing');
|
|
105
|
+
return { ...this.project };
|
|
106
|
+
}
|
|
107
|
+
runtimePlugins() {
|
|
108
|
+
return [...this.ctx.loader.entries()]
|
|
109
|
+
.filter(entry => !entry.options.group)
|
|
110
|
+
.map(entry => ({ entryId: entry.id, moduleName: entry.options.name, enabled: !entry.disabled }));
|
|
111
|
+
}
|
|
112
|
+
activate(graphRev) {
|
|
113
|
+
if (this.project?.state !== 'preview-pending')
|
|
114
|
+
throw new Error('harmony-studio: Draft is not waiting for Preview confirmation');
|
|
115
|
+
const graph = this.ctx.clientModules.graph();
|
|
116
|
+
if (graph.rev !== graphRev || !graph.entries.some(entry => entry.id === this.name)) {
|
|
117
|
+
throw new Error('harmony-studio: Preview did not confirm the current Draft client graph');
|
|
118
|
+
}
|
|
119
|
+
this.project = { ...this.project, state: 'active', graphRev };
|
|
120
|
+
return this.snapshot();
|
|
121
|
+
}
|
|
122
|
+
async applyBuild() {
|
|
123
|
+
if (this.project?.state !== 'active')
|
|
124
|
+
throw new Error('harmony-studio: Draft is not active');
|
|
125
|
+
const graph = this.ctx.clientModules.graph();
|
|
126
|
+
if (!graph.entries.some(entry => entry.id === this.name)) {
|
|
127
|
+
throw new Error(`harmony-studio: Draft ${JSON.stringify(this.name)} left the client graph while applying its build`);
|
|
128
|
+
}
|
|
129
|
+
this.project = { ...this.project, state: 'preview-pending', graphRev: graph.rev };
|
|
130
|
+
return this.snapshot();
|
|
131
|
+
}
|
|
132
|
+
async close() {
|
|
133
|
+
if (this.project?.state === 'closed')
|
|
134
|
+
return;
|
|
135
|
+
if (this.createdEntry && this.entryId !== undefined) {
|
|
136
|
+
await this.ctx.loader.remove(this.entryId);
|
|
137
|
+
await this.reload();
|
|
138
|
+
}
|
|
139
|
+
this.project = { name: this.name, root: this.root, state: 'closed', graphRev: this.project?.graphRev ?? '' };
|
|
140
|
+
}
|
|
141
|
+
async waitForClientEntry() {
|
|
142
|
+
const modules = this.ctx.clientModules;
|
|
143
|
+
if (modules.graph().entries.some(entry => entry.id === this.name))
|
|
144
|
+
return;
|
|
145
|
+
await new Promise((resolve, reject) => {
|
|
146
|
+
const timeout = setTimeout(() => {
|
|
147
|
+
stop();
|
|
148
|
+
reject(new Error(`harmony-studio: Draft ${JSON.stringify(this.name)} did not enter the client graph`));
|
|
149
|
+
}, CLIENT_ENTRY_TIMEOUT_MS);
|
|
150
|
+
const stop = modules.onGraphChanged(() => {
|
|
151
|
+
if (!modules.graph().entries.some(entry => entry.id === this.name))
|
|
152
|
+
return;
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
stop();
|
|
155
|
+
resolve();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
reload() {
|
|
160
|
+
return this.commands.run(process.execPath, [HARMONY_BIN_ENTRY, 'harmony', 'reload', this.name]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare class StudioPreviewPortPool {
|
|
2
|
+
private readonly ports?;
|
|
3
|
+
private readonly claimed;
|
|
4
|
+
constructor(range?: string);
|
|
5
|
+
claim(): number | undefined;
|
|
6
|
+
release(port: number | undefined): void;
|
|
7
|
+
}
|
|
8
|
+
export declare const studioPreviewPortPool: StudioPreviewPortPool;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const PORT_RANGE = /^(\d+)-(\d+)$/;
|
|
2
|
+
export class StudioPreviewPortPool {
|
|
3
|
+
ports;
|
|
4
|
+
claimed = new Set();
|
|
5
|
+
constructor(range) {
|
|
6
|
+
if (range === undefined || range === '')
|
|
7
|
+
return;
|
|
8
|
+
const match = PORT_RANGE.exec(range);
|
|
9
|
+
if (match === null)
|
|
10
|
+
throw new Error('DSH_STUDIO_PREVIEW_PORT_RANGE must use start-end syntax');
|
|
11
|
+
const start = Number(match[1]);
|
|
12
|
+
const end = Number(match[2]);
|
|
13
|
+
if (start < 1 || end > 65_535 || start > end) {
|
|
14
|
+
throw new Error('DSH_STUDIO_PREVIEW_PORT_RANGE must contain an ascending TCP port range');
|
|
15
|
+
}
|
|
16
|
+
this.ports = Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
|
17
|
+
}
|
|
18
|
+
claim() {
|
|
19
|
+
if (this.ports === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
const port = this.ports.find(candidate => !this.claimed.has(candidate));
|
|
22
|
+
if (port === undefined)
|
|
23
|
+
throw new Error('No free Studio Preview port remains in DSH_STUDIO_PREVIEW_PORT_RANGE');
|
|
24
|
+
this.claimed.add(port);
|
|
25
|
+
return port;
|
|
26
|
+
}
|
|
27
|
+
release(port) {
|
|
28
|
+
if (port !== undefined)
|
|
29
|
+
this.claimed.delete(port);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const studioPreviewPortPool = new StudioPreviewPortPool(process.env.DSH_STUDIO_PREVIEW_PORT_RANGE);
|
|
@@ -1,11 +1,74 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
-
import { type
|
|
2
|
+
import { TypertRemoteService, type InvocationDescriptor, type RemoteResult, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol';
|
|
3
|
+
import { type StudioHarmonyProfile, type StudioHarmonyProfileUpdateResult, type StudioHarmonyService, type StudioPreviewInspection, type StudioProjectState, type StudioSourceCandidate, type StudioSourceLocation } from '../contracts.js';
|
|
3
4
|
interface PreviewWorkerOptions {
|
|
4
5
|
root: string;
|
|
5
|
-
|
|
6
|
+
packageDirs: string[];
|
|
6
7
|
parentOrigin: string;
|
|
7
8
|
bridgeCapability: string;
|
|
8
9
|
bridge: Buffer;
|
|
9
10
|
}
|
|
11
|
+
export interface StudioPreviewHealth {
|
|
12
|
+
ready: boolean;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface StudioPreviewWorkerRemote {
|
|
16
|
+
health(signal?: AbortSignal): Promise<RemoteResult<StudioPreviewHealth>>;
|
|
17
|
+
state(signal?: AbortSignal): Promise<RemoteResult<StudioProjectState>>;
|
|
18
|
+
activate(graphRev: string, signal?: AbortSignal): Promise<RemoteResult<StudioProjectState>>;
|
|
19
|
+
applyBuild(operationId: string, signal?: AbortSignal): Promise<RemoteResult<StudioProjectState>>;
|
|
20
|
+
inspect(input: {
|
|
21
|
+
package?: string;
|
|
22
|
+
file?: string;
|
|
23
|
+
}, signal?: AbortSignal): Promise<RemoteResult<StudioPreviewInspection>>;
|
|
24
|
+
profile(signal?: AbortSignal): Promise<RemoteResult<StudioHarmonyProfile>>;
|
|
25
|
+
updateProfile(input: {
|
|
26
|
+
operationId: string;
|
|
27
|
+
order?: string[];
|
|
28
|
+
patchOrder?: string[];
|
|
29
|
+
disabled?: string[];
|
|
30
|
+
}, signal?: AbortSignal): Promise<RemoteResult<StudioHarmonyProfileUpdateResult>>;
|
|
31
|
+
resolveSource(source: StudioSourceLocation, signal?: AbortSignal): Promise<RemoteResult<StudioSourceCandidate>>;
|
|
32
|
+
readSource(packageName: string, file: string, signal?: AbortSignal): Promise<RemoteResult<string>>;
|
|
33
|
+
readPatchTarget(packageName: string, file: string, signal?: AbortSignal): Promise<RemoteResult<{
|
|
34
|
+
package: string;
|
|
35
|
+
file: string;
|
|
36
|
+
version: string;
|
|
37
|
+
source: string;
|
|
38
|
+
}>>;
|
|
39
|
+
}
|
|
40
|
+
export declare const STUDIO_PREVIEW_INVOCATIONS: readonly InvocationDescriptor[];
|
|
41
|
+
export declare const STUDIO_PREVIEW_REMOTE: TypertRemoteContribution;
|
|
42
|
+
export declare class StudioPreviewWorkerService extends TypertRemoteService {
|
|
43
|
+
private readonly harmony;
|
|
44
|
+
private readiness;
|
|
45
|
+
private readonly ready;
|
|
46
|
+
private readonly sources;
|
|
47
|
+
constructor(ctx: Context, harmony: StudioHarmonyService, options: Pick<PreviewWorkerOptions, 'root' | 'packageDirs'>);
|
|
48
|
+
health(signal: AbortSignal): Promise<StudioPreviewHealth>;
|
|
49
|
+
state(signal: AbortSignal): Promise<StudioProjectState>;
|
|
50
|
+
activate(graphRev: string, signal: AbortSignal): Promise<StudioProjectState>;
|
|
51
|
+
applyBuild(operationId: string, signal: AbortSignal): Promise<StudioProjectState>;
|
|
52
|
+
inspect(input: {
|
|
53
|
+
package?: string;
|
|
54
|
+
file?: string;
|
|
55
|
+
}, signal: AbortSignal): Promise<StudioPreviewInspection>;
|
|
56
|
+
profile(signal: AbortSignal): Promise<StudioHarmonyProfile>;
|
|
57
|
+
updateProfile(input: {
|
|
58
|
+
operationId: string;
|
|
59
|
+
order?: string[];
|
|
60
|
+
patchOrder?: string[];
|
|
61
|
+
disabled?: string[];
|
|
62
|
+
}, signal: AbortSignal): Promise<StudioHarmonyProfileUpdateResult>;
|
|
63
|
+
resolveSource(source: StudioSourceLocation, signal: AbortSignal): Promise<StudioSourceCandidate>;
|
|
64
|
+
readSource(packageName: string, file: string, signal: AbortSignal): Promise<string>;
|
|
65
|
+
readPatchTarget(packageName: string, file: string, signal: AbortSignal): Promise<{
|
|
66
|
+
package: string;
|
|
67
|
+
file: string;
|
|
68
|
+
version: string;
|
|
69
|
+
source: string;
|
|
70
|
+
}>;
|
|
71
|
+
close(): Promise<void>;
|
|
72
|
+
}
|
|
10
73
|
export declare function applyPreviewWorker(ctx: Context, harmony: StudioHarmonyService, options: PreviewWorkerOptions): void;
|
|
11
74
|
export {};
|
|
@@ -1,5 +1,194 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TypertRemoteService, } from '@deepseek-ai/dsh-typert-protocol';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { STUDIO_PATH, } from '../contracts.js';
|
|
4
|
+
import { StudioPreviewDraft } from './preview-draft.js';
|
|
2
5
|
import { StudioSourceResolver } from './source-resolution.js';
|
|
6
|
+
const projectStateSchema = z.object({
|
|
7
|
+
name: z.string(),
|
|
8
|
+
root: z.string(),
|
|
9
|
+
state: z.enum(['active', 'preview-pending', 'closed']),
|
|
10
|
+
graphRev: z.string(),
|
|
11
|
+
});
|
|
12
|
+
const sourceLocationSchema = z.object({
|
|
13
|
+
file: z.string().min(1),
|
|
14
|
+
line: z.number().int().min(1).optional(),
|
|
15
|
+
column: z.number().int().min(1).optional(),
|
|
16
|
+
});
|
|
17
|
+
const sourceCandidateSchema = sourceLocationSchema.extend({
|
|
18
|
+
package: z.string().optional(),
|
|
19
|
+
kind: z.enum(['draft', 'dependency', 'generated', 'unknown']),
|
|
20
|
+
confidence: z.enum(['exact', 'candidate']),
|
|
21
|
+
});
|
|
22
|
+
const stringListSchema = z.array(z.string().min(1));
|
|
23
|
+
const profileUpdateSchema = z.object({
|
|
24
|
+
operationId: z.string().min(1),
|
|
25
|
+
order: stringListSchema.optional(),
|
|
26
|
+
patchOrder: stringListSchema.optional(),
|
|
27
|
+
disabled: stringListSchema.optional(),
|
|
28
|
+
});
|
|
29
|
+
const inspectInputSchema = z.object({ package: z.string().optional(), file: z.string().optional() });
|
|
30
|
+
const healthSchema = z.object({ ready: z.boolean(), error: z.string().optional() });
|
|
31
|
+
const previewInspectionSchema = z.object({ harmony: z.unknown() });
|
|
32
|
+
const patchTargetSchema = z.object({
|
|
33
|
+
package: z.string(),
|
|
34
|
+
file: z.string(),
|
|
35
|
+
version: z.string(),
|
|
36
|
+
source: z.string(),
|
|
37
|
+
});
|
|
38
|
+
function jsonTransport(value) {
|
|
39
|
+
return JSON.parse(JSON.stringify(value));
|
|
40
|
+
}
|
|
41
|
+
const profileUpdateOperations = new Map();
|
|
42
|
+
const applyBuildOperations = new Map();
|
|
43
|
+
const PROFILE_UPDATE_OPERATION_LIMIT = 32;
|
|
44
|
+
function codec(typeSymbol, schema) {
|
|
45
|
+
return { mode: 'strict', typeSymbol, schema };
|
|
46
|
+
}
|
|
47
|
+
function parameter(name, schema) {
|
|
48
|
+
return { name, wire: name, source: 'json', codec: codec(`dsh-webui-studio#${name}`, schema) };
|
|
49
|
+
}
|
|
50
|
+
function invocation(method, parameters, result) {
|
|
51
|
+
return {
|
|
52
|
+
id: `dsh-webui-studio#studioPreviewWorker/${method}`,
|
|
53
|
+
service: 'studioPreviewWorker',
|
|
54
|
+
namespace: 'studioPreviewWorker',
|
|
55
|
+
method,
|
|
56
|
+
invocation: { kind: 'direct' },
|
|
57
|
+
parameters,
|
|
58
|
+
cancellation: { parameter: 'signal' },
|
|
59
|
+
result: codec(`dsh-webui-studio#studioPreviewWorker/${method}:result`, result),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export const STUDIO_PREVIEW_INVOCATIONS = [
|
|
63
|
+
invocation('health', [], healthSchema),
|
|
64
|
+
invocation('state', [], projectStateSchema),
|
|
65
|
+
invocation('activate', [parameter('graphRev', z.string().min(1))], projectStateSchema),
|
|
66
|
+
invocation('applyBuild', [parameter('operationId', z.string().min(1))], projectStateSchema),
|
|
67
|
+
invocation('inspect', [parameter('input', inspectInputSchema)], previewInspectionSchema),
|
|
68
|
+
invocation('profile', [], z.unknown()),
|
|
69
|
+
invocation('updateProfile', [parameter('input', profileUpdateSchema)], z.unknown()),
|
|
70
|
+
invocation('resolveSource', [parameter('source', sourceLocationSchema)], sourceCandidateSchema),
|
|
71
|
+
invocation('readSource', [parameter('packageName', z.string().min(1)), parameter('file', z.string().min(1))], z.string()),
|
|
72
|
+
invocation('readPatchTarget', [parameter('packageName', z.string().min(1)), parameter('file', z.string().min(1))], patchTargetSchema),
|
|
73
|
+
];
|
|
74
|
+
export const STUDIO_PREVIEW_REMOTE = {
|
|
75
|
+
package: 'dsh-webui-studio/preview-worker',
|
|
76
|
+
descriptors: STUDIO_PREVIEW_INVOCATIONS,
|
|
77
|
+
};
|
|
78
|
+
const STUDIO_PREVIEW_LOCAL = {
|
|
79
|
+
package: 'dsh-webui-studio/preview-worker',
|
|
80
|
+
face: 'host',
|
|
81
|
+
schemas: [],
|
|
82
|
+
model: { services: [], events: [], objects: [] },
|
|
83
|
+
invocations: STUDIO_PREVIEW_INVOCATIONS,
|
|
84
|
+
};
|
|
85
|
+
export class StudioPreviewWorkerService extends TypertRemoteService {
|
|
86
|
+
harmony;
|
|
87
|
+
readiness = { state: 'starting' };
|
|
88
|
+
ready;
|
|
89
|
+
sources;
|
|
90
|
+
constructor(ctx, harmony, options) {
|
|
91
|
+
super(ctx, 'studioPreviewWorker');
|
|
92
|
+
this.harmony = harmony;
|
|
93
|
+
this.ready = Promise.resolve().then(() => new StudioPreviewDraft(ctx, harmony, options.root).open());
|
|
94
|
+
void this.ready.then(() => { this.readiness = { state: 'ready' }; }, error => { this.readiness = { state: 'failed', error }; });
|
|
95
|
+
this.sources = new StudioSourceResolver(options.root, harmony.profile().dir, options.packageDirs);
|
|
96
|
+
}
|
|
97
|
+
async health(signal) {
|
|
98
|
+
signal.throwIfAborted();
|
|
99
|
+
if (this.readiness.state === 'starting')
|
|
100
|
+
return { ready: false };
|
|
101
|
+
if (this.readiness.state === 'failed') {
|
|
102
|
+
return {
|
|
103
|
+
ready: false,
|
|
104
|
+
error: this.readiness.error instanceof Error
|
|
105
|
+
? this.readiness.error.message
|
|
106
|
+
: String(this.readiness.error),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { ready: true };
|
|
110
|
+
}
|
|
111
|
+
async state(signal) {
|
|
112
|
+
signal.throwIfAborted();
|
|
113
|
+
return (await this.ready).snapshot();
|
|
114
|
+
}
|
|
115
|
+
async activate(graphRev, signal) {
|
|
116
|
+
signal.throwIfAborted();
|
|
117
|
+
return (await this.ready).activate(graphRev);
|
|
118
|
+
}
|
|
119
|
+
async applyBuild(operationId, signal) {
|
|
120
|
+
signal.throwIfAborted();
|
|
121
|
+
const existing = applyBuildOperations.get(operationId);
|
|
122
|
+
if (existing !== undefined)
|
|
123
|
+
return existing;
|
|
124
|
+
const result = this.ready.then(opened => opened.applyBuild()).then(jsonTransport);
|
|
125
|
+
applyBuildOperations.set(operationId, result);
|
|
126
|
+
while (applyBuildOperations.size > PROFILE_UPDATE_OPERATION_LIMIT) {
|
|
127
|
+
const oldest = applyBuildOperations.keys().next().value;
|
|
128
|
+
if (oldest === undefined)
|
|
129
|
+
break;
|
|
130
|
+
applyBuildOperations.delete(oldest);
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
async inspect(input, signal) {
|
|
135
|
+
signal.throwIfAborted();
|
|
136
|
+
await this.ready;
|
|
137
|
+
return jsonTransport({
|
|
138
|
+
harmony: this.harmony.inspect(input),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async profile(signal) {
|
|
142
|
+
signal.throwIfAborted();
|
|
143
|
+
const opened = await this.ready;
|
|
144
|
+
return jsonTransport({ ...this.harmony.profile(), runtimePlugins: opened.runtimePlugins() });
|
|
145
|
+
}
|
|
146
|
+
async updateProfile(input, signal) {
|
|
147
|
+
signal.throwIfAborted();
|
|
148
|
+
await this.ready;
|
|
149
|
+
const { operationId, ...update } = input;
|
|
150
|
+
const serialized = JSON.stringify(update);
|
|
151
|
+
const existing = profileUpdateOperations.get(operationId);
|
|
152
|
+
if (existing !== undefined) {
|
|
153
|
+
if (existing.input !== serialized)
|
|
154
|
+
throw new Error('Profile update operation input changed');
|
|
155
|
+
return existing.result;
|
|
156
|
+
}
|
|
157
|
+
const result = this.harmony.updateProfile(update).then(async (result) => {
|
|
158
|
+
const opened = await this.ready;
|
|
159
|
+
return jsonTransport({
|
|
160
|
+
...result,
|
|
161
|
+
profile: { ...result.profile, runtimePlugins: opened.runtimePlugins() },
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
profileUpdateOperations.set(operationId, { input: serialized, result });
|
|
165
|
+
while (profileUpdateOperations.size > PROFILE_UPDATE_OPERATION_LIMIT) {
|
|
166
|
+
const oldest = profileUpdateOperations.keys().next().value;
|
|
167
|
+
if (oldest === undefined)
|
|
168
|
+
break;
|
|
169
|
+
profileUpdateOperations.delete(oldest);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
async resolveSource(source, signal) {
|
|
174
|
+
signal.throwIfAborted();
|
|
175
|
+
await this.ready;
|
|
176
|
+
return jsonTransport(await this.sources.resolve(source));
|
|
177
|
+
}
|
|
178
|
+
async readSource(packageName, file, signal) {
|
|
179
|
+
signal.throwIfAborted();
|
|
180
|
+
await this.ready;
|
|
181
|
+
return this.sources.readDependency(packageName, file);
|
|
182
|
+
}
|
|
183
|
+
async readPatchTarget(packageName, file, signal) {
|
|
184
|
+
signal.throwIfAborted();
|
|
185
|
+
await this.ready;
|
|
186
|
+
return jsonTransport(await this.sources.readDependencyTarget(packageName, file));
|
|
187
|
+
}
|
|
188
|
+
async close() {
|
|
189
|
+
await this.ready.then(opened => opened.close(), () => undefined);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
3
192
|
function loopback(request) {
|
|
4
193
|
const address = request.socket.remoteAddress;
|
|
5
194
|
return address === '::1' || address === '127.0.0.1' || address?.startsWith('::ffff:127.') === true;
|
|
@@ -8,80 +197,10 @@ function json(response, status, body) {
|
|
|
8
197
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
9
198
|
response.end(JSON.stringify(body));
|
|
10
199
|
}
|
|
11
|
-
async function readJson(request) {
|
|
12
|
-
const chunks = [];
|
|
13
|
-
for await (const chunk of request)
|
|
14
|
-
chunks.push(Buffer.from(chunk));
|
|
15
|
-
if (Buffer.concat(chunks).byteLength > 1024 * 1024)
|
|
16
|
-
throw new Error('request body is too large');
|
|
17
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
18
|
-
}
|
|
19
200
|
export function applyPreviewWorker(ctx, harmony, options) {
|
|
20
201
|
ctx.effect(() => {
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const sources = new StudioSourceResolver(options.root, harmony.profileDir);
|
|
24
|
-
const worker = {
|
|
25
|
-
kind: 'prefix',
|
|
26
|
-
path: STUDIO_PREVIEW_API_PATH,
|
|
27
|
-
async handler(request, response) {
|
|
28
|
-
if (!loopback(request) || request.headers.authorization !== `Bearer ${options.controlToken}`) {
|
|
29
|
-
return json(response, 403, { ok: false, error: 'invalid Preview worker capability' });
|
|
30
|
-
}
|
|
31
|
-
if (request.method !== 'POST')
|
|
32
|
-
return json(response, 405, { ok: false, error: 'method not allowed' });
|
|
33
|
-
try {
|
|
34
|
-
const payload = await readJson(request);
|
|
35
|
-
const method = new URL(request.url ?? '/', 'http://localhost').pathname.slice(`${STUDIO_PREVIEW_API_PATH}/`.length);
|
|
36
|
-
if (method === 'health')
|
|
37
|
-
return json(response, 200, { ok: true, value: { ready: true } });
|
|
38
|
-
const draft = await ready;
|
|
39
|
-
if (method === 'state')
|
|
40
|
-
return json(response, 200, { ok: true, value: { project: draft.snapshot() } });
|
|
41
|
-
if (method === 'activate') {
|
|
42
|
-
if (typeof payload.graphRev !== 'string')
|
|
43
|
-
throw new Error('graphRev is required');
|
|
44
|
-
return json(response, 200, { ok: true, value: { project: await draft.activateAfterPreviewReady(payload.graphRev) } });
|
|
45
|
-
}
|
|
46
|
-
if (method === 'apply-build') {
|
|
47
|
-
return json(response, 200, { ok: true, value: { project: await draft.applyBuild() } });
|
|
48
|
-
}
|
|
49
|
-
if (method === 'inspect') {
|
|
50
|
-
const packageName = typeof payload.package === 'string' ? payload.package : undefined;
|
|
51
|
-
const file = typeof payload.file === 'string' ? payload.file : undefined;
|
|
52
|
-
return json(response, 200, {
|
|
53
|
-
ok: true,
|
|
54
|
-
value: {
|
|
55
|
-
harmony: harmony.inspect({ ...(packageName === undefined ? {} : { package: packageName }), ...(file === undefined ? {} : { file }) }),
|
|
56
|
-
dependencies: harmony.inspectDependencies(draft.snapshot().name),
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
if (method === 'resolve-source') {
|
|
61
|
-
const source = payload.source;
|
|
62
|
-
if (typeof source?.file !== 'string' || source.file === ''
|
|
63
|
-
|| (source.line !== undefined && (!Number.isInteger(source.line) || source.line < 1))
|
|
64
|
-
|| (source.column !== undefined && (!Number.isInteger(source.column) || source.column < 1))) {
|
|
65
|
-
throw new Error('source location is invalid');
|
|
66
|
-
}
|
|
67
|
-
return json(response, 200, { ok: true, value: await sources.resolve(source) });
|
|
68
|
-
}
|
|
69
|
-
if (method === 'read-source') {
|
|
70
|
-
if (typeof payload.package !== 'string' || typeof payload.file !== 'string') {
|
|
71
|
-
throw new Error('dependency package and file are required');
|
|
72
|
-
}
|
|
73
|
-
return json(response, 200, {
|
|
74
|
-
ok: true,
|
|
75
|
-
value: await sources.readDependency(payload.package, payload.file),
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
return json(response, 404, { ok: false, error: `unknown Preview worker method ${method}` });
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
return json(response, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
};
|
|
202
|
+
const worker = new StudioPreviewWorkerService(ctx, harmony, options);
|
|
203
|
+
const disposeContribution = ctx.typert.register(STUDIO_PREVIEW_LOCAL);
|
|
85
204
|
const bridge = {
|
|
86
205
|
kind: 'exact',
|
|
87
206
|
path: `${STUDIO_PATH}/bridge.js`,
|
|
@@ -92,7 +211,15 @@ export function applyPreviewWorker(ctx, harmony, options) {
|
|
|
92
211
|
response.end(request.method === 'HEAD' ? undefined : options.bridge);
|
|
93
212
|
},
|
|
94
213
|
};
|
|
95
|
-
const
|
|
214
|
+
const removedApi = {
|
|
215
|
+
kind: 'prefix',
|
|
216
|
+
path: '/dsh-harmony/studio-preview/api',
|
|
217
|
+
handler(_request, response) {
|
|
218
|
+
response.writeHead(404);
|
|
219
|
+
response.end('not found');
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const dispose = [ctx.webServer.register(bridge), ctx.webServer.register(removedApi), ctx.webServer.tapIndex(html => {
|
|
96
223
|
const config = `<script>window.__DSH_STUDIO_PREVIEW__=${JSON.stringify({
|
|
97
224
|
parentOrigin: options.parentOrigin,
|
|
98
225
|
capability: options.bridgeCapability,
|
|
@@ -103,8 +230,8 @@ export function applyPreviewWorker(ctx, harmony, options) {
|
|
|
103
230
|
return async () => {
|
|
104
231
|
for (const stop of dispose.reverse())
|
|
105
232
|
stop();
|
|
106
|
-
await
|
|
107
|
-
await
|
|
233
|
+
await disposeContribution();
|
|
234
|
+
await worker.close();
|
|
108
235
|
};
|
|
109
236
|
}, 'harmony-studio: Preview worker');
|
|
110
237
|
}
|
package/lib/host/preview.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { StudioDraftRecord, StudioPreviewInspection, StudioProjectState, StudioSourceCandidate, StudioSourceLocation } from '../contracts.js';
|
|
1
|
+
import type { StudioDraftRecord, StudioHarmonyProfile, StudioHarmonyProfileUpdateResult, StudioPreviewInspection, StudioProjectState, StudioSourceCandidate, StudioSourceLocation } from '../contracts.js';
|
|
2
2
|
import type { StudioCommandRunner } from './drafts.js';
|
|
3
|
+
import { type StudioPreviewPortPool } from './preview-port.js';
|
|
3
4
|
export interface StudioPreviewRuntime {
|
|
4
5
|
state: 'stopped' | 'starting' | 'running' | 'failed';
|
|
5
6
|
previewUrl?: string;
|
|
@@ -7,6 +8,7 @@ export interface StudioPreviewRuntime {
|
|
|
7
8
|
error?: string;
|
|
8
9
|
log: string;
|
|
9
10
|
}
|
|
11
|
+
export declare function dshPackageModules(harmonyBinEntry: string): string;
|
|
10
12
|
export declare class StudioPreviewSupervisor {
|
|
11
13
|
readonly draft: StudioDraftRecord;
|
|
12
14
|
private readonly mainProfileDir;
|
|
@@ -14,17 +16,20 @@ export declare class StudioPreviewSupervisor {
|
|
|
14
16
|
private readonly commands;
|
|
15
17
|
private readonly harmonyBinEntry;
|
|
16
18
|
private readonly stopTimeoutMs;
|
|
19
|
+
private readonly portPool;
|
|
17
20
|
private child?;
|
|
18
|
-
private
|
|
21
|
+
private peerClient?;
|
|
19
22
|
private runtime;
|
|
20
23
|
private startAbort?;
|
|
21
24
|
private startPromise?;
|
|
22
|
-
|
|
25
|
+
private previewPort?;
|
|
26
|
+
constructor(draft: StudioDraftRecord, mainProfileDir: string, parentOrigin: string, commands: StudioCommandRunner, harmonyBinEntry: string, stopTimeoutMs?: number, portPool?: StudioPreviewPortPool);
|
|
23
27
|
snapshot(): StudioPreviewRuntime;
|
|
24
28
|
start(): Promise<StudioPreviewRuntime>;
|
|
25
29
|
private startRuntime;
|
|
26
30
|
stop(): Promise<StudioPreviewRuntime>;
|
|
27
31
|
private terminateChild;
|
|
32
|
+
private releasePreviewPort;
|
|
28
33
|
state(): Promise<StudioProjectState>;
|
|
29
34
|
activate(graphRev: string): Promise<StudioProjectState>;
|
|
30
35
|
applyBuild(): Promise<StudioProjectState>;
|
|
@@ -32,11 +37,27 @@ export declare class StudioPreviewSupervisor {
|
|
|
32
37
|
package?: string;
|
|
33
38
|
file?: string;
|
|
34
39
|
}): Promise<StudioPreviewInspection>;
|
|
40
|
+
profile(): Promise<StudioHarmonyProfile>;
|
|
41
|
+
updateProfile(input: {
|
|
42
|
+
order?: string[];
|
|
43
|
+
patchOrder?: string[];
|
|
44
|
+
disabled?: string[];
|
|
45
|
+
}): Promise<StudioHarmonyProfileUpdateResult>;
|
|
35
46
|
resolveSource(source: StudioSourceLocation): Promise<StudioSourceCandidate>;
|
|
36
47
|
readDependencySource(packageName: string, file: string): Promise<string>;
|
|
48
|
+
readPatchTarget(packageName: string, file: string): Promise<{
|
|
49
|
+
package: string;
|
|
50
|
+
file: string;
|
|
51
|
+
version: string;
|
|
52
|
+
source: string;
|
|
53
|
+
}>;
|
|
37
54
|
dispose(): Promise<void>;
|
|
38
|
-
private
|
|
55
|
+
private waitForPreviewUrl;
|
|
39
56
|
private waitForWorker;
|
|
40
|
-
private
|
|
57
|
+
private remote;
|
|
58
|
+
private invoke;
|
|
59
|
+
private closePeer;
|
|
60
|
+
private isGenerationLoss;
|
|
61
|
+
private reconnectPeer;
|
|
41
62
|
private delay;
|
|
42
63
|
}
|