@pygmalionjs/pygmalion 0.8.1 → 0.10.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/README.md +34 -0
- package/binding-source.d.ts +25 -0
- package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BUOlJcUa.js} +17 -17
- package/dist-lib/pygmalion.js +24524 -12555
- package/dist-lib/{runtime-BAy8W1mJ.js → runtime-BOfDlGPR.js} +12438 -11118
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +19 -19
- package/dist-lib/types/binding/catalog.d.ts +10 -0
- package/dist-lib/types/binding/contracts.d.ts +202 -0
- package/dist-lib/types/binding/documentCommands.d.ts +38 -0
- package/dist-lib/types/binding/duplicateMetadata.d.ts +8 -0
- package/dist-lib/types/binding/index.d.ts +7 -0
- package/dist-lib/types/binding/preview.d.ts +37 -0
- package/dist-lib/types/binding/source.d.ts +109 -0
- package/dist-lib/types/binding/useDataProjection.d.ts +9 -0
- package/dist-lib/types/binding/validate.d.ts +8 -0
- package/dist-lib/types/core/runtime.d.ts +6 -0
- package/dist-lib/types/document/appearance.d.ts +112 -0
- package/dist-lib/types/document/assets.d.ts +204 -0
- package/dist-lib/types/document/components.d.ts +169 -0
- package/dist-lib/types/document/contracts.d.ts +171 -0
- package/dist-lib/types/document/engine.d.ts +20 -0
- package/dist-lib/types/document/exportSettings.d.ts +45 -0
- package/dist-lib/types/document/geometry.d.ts +11 -0
- package/dist-lib/types/document/layout.d.ts +53 -0
- package/dist-lib/types/document/prototype.d.ts +424 -0
- package/dist-lib/types/document/prototypeReferences.d.ts +3 -0
- package/dist-lib/types/document/richText.d.ts +185 -0
- package/dist-lib/types/document/styles.d.ts +216 -0
- package/dist-lib/types/document/svgImport.d.ts +17 -0
- package/dist-lib/types/document/vector.d.ts +222 -0
- package/dist-lib/types/editor/designCompiler.d.ts +7 -0
- package/dist-lib/types/editor/designImport.d.ts +7 -0
- package/dist-lib/types/editor/inspect.d.ts +8 -0
- package/dist-lib/types/editor/revisionCatalogInstall.d.ts +1 -1
- package/dist-lib/types/editor/screenInteractions.d.ts +3 -3
- package/dist-lib/types/editor/store.d.ts +1 -1
- package/dist-lib/types/host/project.d.ts +44 -0
- package/dist-lib/types/host/registryConnections.d.ts +11 -0
- package/dist-lib/types/host/sourceFilePlan.d.ts +15 -0
- package/dist-lib/types/host/sourceWriteback.d.ts +50 -0
- package/dist-lib/types/lib.d.ts +103 -3
- package/dist-lib/types/token-library/bindings.d.ts +62 -0
- package/dist-lib/types/token-library/contracts.d.ts +117 -0
- package/dist-lib/types/token-library/engine.d.ts +13 -0
- package/dist-lib/types/token-library/valueResolver.d.ts +19 -0
- package/dist-lib/types/workspace/WorkspaceShell.d.ts +9 -0
- package/dist-lib/types/workspace/application/ApplicationWorkspace.d.ts +3 -0
- package/dist-lib/types/workspace/configuration.d.ts +47 -0
- package/dist-lib/types/workspace/contracts.d.ts +64 -0
- package/dist-lib/types/workspace/controller.d.ts +35 -0
- package/dist-lib/types/workspace/documentSessions.d.ts +42 -0
- package/dist-lib/types/workspace/edit/AppearancePanel.d.ts +23 -0
- package/dist-lib/types/workspace/edit/ArrangeLayers.d.ts +10 -0
- package/dist-lib/types/workspace/edit/AssetsPanel.d.ts +16 -0
- package/dist-lib/types/workspace/edit/CanvasGuides.d.ts +19 -0
- package/dist-lib/types/workspace/edit/ComponentPanel.d.ts +11 -0
- package/dist-lib/types/workspace/edit/DataPanel.d.ts +16 -0
- package/dist-lib/types/workspace/edit/DesignWorkspace.d.ts +10 -0
- package/dist-lib/types/workspace/edit/LayerTree.d.ts +14 -0
- package/dist-lib/types/workspace/edit/LayoutPanel.d.ts +13 -0
- package/dist-lib/types/workspace/edit/PrototypePanel.d.ts +12 -0
- package/dist-lib/types/workspace/edit/ScopeTransferDialog.d.ts +40 -0
- package/dist-lib/types/workspace/edit/SelectionHandles.d.ts +52 -0
- package/dist-lib/types/workspace/edit/StylesPanel.d.ts +8 -0
- package/dist-lib/types/workspace/edit/TextEditor.d.ts +50 -0
- package/dist-lib/types/workspace/edit/VectorEditor.d.ts +89 -0
- package/dist-lib/types/workspace/edit/VectorToolbar.d.ts +5 -0
- package/dist-lib/types/workspace/edit/canvasGuideModel.d.ts +102 -0
- package/dist-lib/types/workspace/edit/clipboard.d.ts +35 -0
- package/dist-lib/types/workspace/edit/duplicateMetadata.d.ts +2 -0
- package/dist-lib/types/workspace/edit/geometryHandles.d.ts +224 -0
- package/dist-lib/types/workspace/edit/layerArrangement.d.ts +39 -0
- package/dist-lib/types/workspace/edit/layerMoves.d.ts +44 -0
- package/dist-lib/types/workspace/edit/projection.d.ts +12 -0
- package/dist-lib/types/workspace/edit/scopeTransfer.d.ts +96 -0
- package/dist-lib/types/workspace/edit/textStyles.d.ts +75 -0
- package/dist-lib/types/workspace/edit/vectorTools.d.ts +136 -0
- package/dist-lib/types/workspace/legacyModeAdapter.d.ts +13 -0
- package/dist-lib/types/workspace/source/ResponsiveLayoutPanel.d.ts +20 -0
- package/dist-lib/types/workspace/source/SourceReview.d.ts +11 -0
- package/dist-lib/types/workspace/source/appearanceSource.d.ts +19 -0
- package/dist-lib/types/workspace/source/compiler.d.ts +5 -0
- package/dist-lib/types/workspace/source/contracts.d.ts +156 -0
- package/dist-lib/types/workspace/source/controller.d.ts +50 -0
- package/dist-lib/types/workspace/source/documentRevision.d.ts +2 -0
- package/dist-lib/types/workspace/source/draftPreviewClient.d.ts +13 -0
- package/dist-lib/types/workspace/source/draftRecipe.d.ts +30 -0
- package/dist-lib/types/workspace/source/flowContracts.d.ts +72 -0
- package/dist-lib/types/workspace/source/prototypeSource.d.ts +4 -0
- package/dist-lib/types/workspace/source/responsive.d.ts +13 -0
- package/dist-lib/types/workspace/source/responsiveContracts.d.ts +78 -0
- package/dist-lib/types/workspace/source/structure.d.ts +21 -0
- package/dist-lib/types/workspace/source/textSource.d.ts +28 -0
- package/dist-lib/types/workspace/tokens/TokenLibraryContext.d.ts +11 -0
- package/dist-lib/types/workspace/tokens/TokenLibraryWorkspace.d.ts +14 -0
- package/dist-lib/types/workspace/tokens/contracts.d.ts +74 -0
- package/dist-lib/types/workspace/tokens/controller.d.ts +44 -0
- package/dist-lib/types/workspace/view/PrototypePlayer.d.ts +17 -0
- package/dist-lib/types/workspace/view/StoryboardWorkspace.d.ts +3 -0
- package/dist-lib/types/workspace/view/prototypePlayback.d.ts +46 -0
- package/draft-preview.d.ts +54 -0
- package/host-runtime.d.ts +73 -0
- package/inspect.d.ts +2 -0
- package/jsx-source-reconciler.d.ts +35 -0
- package/node/design-session.mjs +3 -0
- package/node/host-runtime.mjs +4 -0
- package/node/inspect-plugin.mjs +3 -1
- package/node/inspect-writeback.mjs +18 -2
- package/node/jsx-source-reconciler.mjs +155 -0
- package/node/preview-artifact-store.mjs +10 -7
- package/node/revision-catalog-resolver.mjs +159 -0
- package/node/source-archive-runtime.mjs +63 -0
- package/node/source-archive.mjs +88 -0
- package/node/source-bindings.mjs +65 -0
- package/node/source-file-lock.mjs +15 -0
- package/node/source-file-plan.mjs +162 -0
- package/node/source-file-proofs.mjs +44 -0
- package/node/source-module.mjs +17 -0
- package/node/source-responsive-codec.d.mts +17 -0
- package/node/source-responsive-codec.mjs +133 -0
- package/node/source-responsive.mjs +189 -0
- package/node/source-service-inputs.mjs +81 -0
- package/node/source-service-plugin.mjs +57 -0
- package/node/source-service.mjs +4 -0
- package/node/source-structure-reconcile.mjs +186 -0
- package/node/source-structure-wrapper-codec.d.mts +20 -0
- package/node/source-structure-wrapper-codec.mjs +205 -0
- package/node/source-structure.mjs +404 -0
- package/node/token-source-references.mjs +136 -0
- package/node/token-source-service.mjs +151 -0
- package/node/workspace-draft-entry.mjs +52 -0
- package/node/workspace-draft-preview-plugin.mjs +70 -0
- package/node/workspace-draft-preview.mjs +232 -0
- package/node/workspace-draft-process.mjs +129 -0
- package/node/workspace-draft-runtime.mjs +67 -0
- package/node/workspace-source-identity.d.mts +1 -0
- package/node/workspace-source-identity.mjs +21 -0
- package/node/workspace-source-plan.mjs +195 -0
- package/node/workspace-source-session.mjs +187 -0
- package/package.json +90 -6
- package/source-files.d.ts +35 -0
- package/source-module.d.ts +16 -0
- package/source-responsive.d.ts +10 -0
- package/source-service.d.ts +92 -0
- package/source-structure.d.ts +155 -0
- package/workspace-draft-entry.d.ts +15 -0
- package/workspace-source-plan.d.ts +44 -0
- package/docs/coverage-contracts.md +0 -444
- package/docs/screen-state-contract.md +0 -252
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { workspaceSourceFingerprint as fingerprint } from './workspace-source-identity.mjs';
|
|
3
|
+
import { applySourceFilePlan, previewSourceFilePlan, captureSourceFilePlan } from './source-file-plan.mjs';
|
|
4
|
+
|
|
5
|
+
const capture = value => JSON.parse(fingerprint(value));
|
|
6
|
+
const keyOf = value => createHash('sha256').update(fingerprint(value)).digest('hex');
|
|
7
|
+
const expired = receipt => { const now = Date.now(); return now < receipt.createdAt || now - receipt.createdAt >= 30 * 60_000; };
|
|
8
|
+
function completePreview(result, plan) {
|
|
9
|
+
const changed = new Set(plan.changes.map(change => change.file));
|
|
10
|
+
const complete = entries => Array.isArray(entries) && entries.length === changed.size
|
|
11
|
+
&& new Set(entries).size === changed.size && entries.every(file => changed.has(file));
|
|
12
|
+
if (!result || !Array.isArray(result.files) || !complete(result.files.map(entry => entry?.file))
|
|
13
|
+
|| !complete(result.affectedFiles) || result.files.some(entry => typeof entry.diff !== 'string' || !entry.diff.trim())) {
|
|
14
|
+
throw new Error('Review must include each changed file exactly once with a nonempty diff and affected-file entry.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Token codecs describe the product files; this service owns receipts, review, cancellation, and source application. */
|
|
19
|
+
export function createTokenSourceService({
|
|
20
|
+
appRoot, loadCodec, assertRevision, assertTarget, archive, loadSource, compilePlan, listConsumers,
|
|
21
|
+
previewPlan = previewSourceFilePlan, applyPlan = applySourceFilePlan, currentRevision,
|
|
22
|
+
regenerate, install, applyMessage = 'The reviewed token source changes were applied.',
|
|
23
|
+
regenerationFailureMessage = error => `Source changes were applied, but regeneration failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
24
|
+
}) {
|
|
25
|
+
if (![loadCodec, assertRevision, assertTarget, archive, loadSource, compilePlan, listConsumers, currentRevision, regenerate, install].every(callback => typeof callback === 'function')) {
|
|
26
|
+
throw new Error('A token source service requires explicit codec, source, revision, and build callbacks.');
|
|
27
|
+
}
|
|
28
|
+
const receipts = new Map();
|
|
29
|
+
let busy = false; let revision = 0; let disposed = false;
|
|
30
|
+
const checkActive = signal => {
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
if (disposed) throw new Error('The token source service has been disposed.');
|
|
33
|
+
};
|
|
34
|
+
const checkRevision = async (value, signal) => {
|
|
35
|
+
checkActive(signal);
|
|
36
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{40}$/.test(value)) throw new Error('An exact source revision is required.');
|
|
37
|
+
await assertRevision(value, signal); checkActive(signal);
|
|
38
|
+
};
|
|
39
|
+
const receiptKey = document => keyOf({ sourceRevision: document.sourceRevision, document });
|
|
40
|
+
const validateRequest = async (input, signal, requireReview = true) => {
|
|
41
|
+
const request = capture(input);
|
|
42
|
+
await checkRevision(request?.target?.sourceRevision, signal);
|
|
43
|
+
if (request.sourceKind !== 'file-plan') throw new Error('A compiled file-plan request is required.');
|
|
44
|
+
await assertTarget(request.target, signal); checkActive(signal);
|
|
45
|
+
const receipt = receipts.get(receiptKey(request.document));
|
|
46
|
+
if (!receipt || receipt.consumed || expired(receipt)) throw new Error('The token source receipt is unavailable or expired. Compile and review again.');
|
|
47
|
+
if (request.target.sourceRevision !== request.document.sourceRevision || receipt.plan.sourceRevision !== request.target.sourceRevision) throw new Error('The target, token document, and source plan revisions differ.');
|
|
48
|
+
const beforeFingerprint = fingerprint(receipt.before);
|
|
49
|
+
const parts = { version: 1, target: request.target, documentId: request.document.id, beforeFingerprint,
|
|
50
|
+
afterFingerprint: fingerprint(request.document),
|
|
51
|
+
mappingFingerprint: fingerprint({ version: 1, target: request.target, documentId: request.document.id, baselineFingerprint: beforeFingerprint, files: receipt.plan.proofs }),
|
|
52
|
+
payloadFingerprint: fingerprint(receipt.plan) };
|
|
53
|
+
if (fingerprint(request.payload) !== parts.payloadFingerprint || fingerprint(request.identity) !== fingerprint({ ...parts, fingerprint: fingerprint(parts) })) {
|
|
54
|
+
throw new Error('The complete token document and source plan differ from the reviewed request.');
|
|
55
|
+
}
|
|
56
|
+
if (requireReview && !receipt.reviewed.has(request.identity.fingerprint)) throw new Error('Review the complete token source diff for this target first.');
|
|
57
|
+
return { request, receipt };
|
|
58
|
+
};
|
|
59
|
+
const service = {
|
|
60
|
+
async load(sourceRevision, signal) {
|
|
61
|
+
await checkRevision(sourceRevision, signal);
|
|
62
|
+
const codec = await loadCodec();
|
|
63
|
+
checkActive(signal);
|
|
64
|
+
const result = await archive(sourceRevision, signal, async ({ appRoot: root }) => {
|
|
65
|
+
const baseline = await loadSource({ appRoot: root, sourceRevision, codec, signal });
|
|
66
|
+
checkActive(signal);
|
|
67
|
+
return capture({ document: baseline.document, proofs: baseline.proofs, sourceFiles: baseline.sourceFiles, ledger: baseline.ledger });
|
|
68
|
+
});
|
|
69
|
+
checkActive(signal);
|
|
70
|
+
return result;
|
|
71
|
+
},
|
|
72
|
+
async compile(input, signal) {
|
|
73
|
+
const { baseline, document } = capture(input);
|
|
74
|
+
receipts.delete(receiptKey(document));
|
|
75
|
+
await checkRevision(document.sourceRevision, signal);
|
|
76
|
+
const codec = await loadCodec();
|
|
77
|
+
checkActive(signal);
|
|
78
|
+
const receipt = await archive(document.sourceRevision, signal, async ({ appRoot: root }) => {
|
|
79
|
+
const actual = await loadSource({ appRoot: root, sourceRevision: document.sourceRevision, codec, signal });
|
|
80
|
+
checkActive(signal);
|
|
81
|
+
if (fingerprint(baseline) !== fingerprint({ document: actual.document, proofs: actual.proofs })) throw new Error('The token source baseline differs from the loaded source.');
|
|
82
|
+
if (document.id !== actual.document.id || actual.document.sourceRevision !== document.sourceRevision) throw new Error('The edited token library differs from the source identity.');
|
|
83
|
+
await install(root, signal); checkActive(signal);
|
|
84
|
+
const compiled = await compilePlan({ appRoot: root, sourceRevision: document.sourceRevision, before: actual.document, after: document, codec, signal, dependenciesReady: true });
|
|
85
|
+
checkActive(signal);
|
|
86
|
+
const plan = captureSourceFilePlan(compiled, { sourceRevision: document.sourceRevision, allowedFiles: actual.proofs.map(proof => proof.file) });
|
|
87
|
+
if (plan.version !== 1 || plan.sourceRevision !== document.sourceRevision || fingerprint(plan.proofs) !== fingerprint(actual.proofs)) throw new Error('The compiled plan proofs differ from the loaded source.');
|
|
88
|
+
if (!plan.changes.length) throw new Error('There are no token source changes to apply.');
|
|
89
|
+
return { before: capture(actual.document), plan: capture(plan), consumerFiles: [...actual.consumerFiles], reviewed: new Set(), consumed: false, createdAt: Date.now() };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
checkActive(signal);
|
|
93
|
+
const key = receiptKey(document); receipts.delete(key); receipts.set(key, receipt);
|
|
94
|
+
for (const [oldKey, oldReceipt] of receipts) if (expired(oldReceipt)) receipts.delete(oldKey);
|
|
95
|
+
while (receipts.size > 8) receipts.delete(receipts.keys().next().value);
|
|
96
|
+
return capture(receipt.plan);
|
|
97
|
+
},
|
|
98
|
+
async preview(input, signal) {
|
|
99
|
+
const { request, receipt } = await validateRequest(input, signal, false);
|
|
100
|
+
receipt.reviewed.delete(request.identity.fingerprint);
|
|
101
|
+
const result = await archive(request.target.sourceRevision, signal, ({ appRoot: root }) => previewPlan(root, capture(receipt.plan), {
|
|
102
|
+
sourceRevision: request.target.sourceRevision, allowedFiles: receipt.plan.changes.map(change => change.file), signal,
|
|
103
|
+
}));
|
|
104
|
+
checkActive(signal);
|
|
105
|
+
if (receipts.get(receiptKey(request.document)) !== receipt || receipt.consumed || expired(receipt)) throw new Error('The token source receipt changed or expired during review. Review again.');
|
|
106
|
+
completePreview(result, receipt.plan);
|
|
107
|
+
receipt.reviewed.add(request.identity.fingerprint);
|
|
108
|
+
return { ...result, revision: ++revision };
|
|
109
|
+
},
|
|
110
|
+
async validatePreviewRequest(input, signal) {
|
|
111
|
+
const { request } = await validateRequest(input, signal);
|
|
112
|
+
return request;
|
|
113
|
+
},
|
|
114
|
+
async apply(input, signal) {
|
|
115
|
+
const { request, receipt } = await validateRequest(input, signal);
|
|
116
|
+
receipt.consumed = true; receipts.clear();
|
|
117
|
+
if (await currentRevision() !== request.target.sourceRevision) throw new Error('The working source revision differs from the selected revision.');
|
|
118
|
+
checkActive(signal);
|
|
119
|
+
if (fingerprint(await listConsumers(appRoot, signal)) !== fingerprint(receipt.consumerFiles)) throw new Error('The token consumer file set changed after review. Compile and review again.');
|
|
120
|
+
checkActive(signal);
|
|
121
|
+
const result = await applyPlan(appRoot, capture(receipt.plan), {
|
|
122
|
+
sourceRevision: request.target.sourceRevision, allowedFiles: receipt.plan.changes.map(change => change.file), signal,
|
|
123
|
+
});
|
|
124
|
+
receipts.clear();
|
|
125
|
+
checkActive(signal);
|
|
126
|
+
if (!result || !Number.isSafeInteger(result.applied) || result.applied < 1) throw new Error('The source writer did not record the reviewed changes. Compile and review again.');
|
|
127
|
+
try { await regenerate(signal); }
|
|
128
|
+
catch (error) { return { applied: result.applied, message: regenerationFailureMessage(error) }; }
|
|
129
|
+
return { applied: result.applied, message: applyMessage };
|
|
130
|
+
},
|
|
131
|
+
dispose() { disposed = true; receipts.clear(); },
|
|
132
|
+
};
|
|
133
|
+
const operations = new Set();
|
|
134
|
+
return Object.freeze(Object.fromEntries(Object.entries(service).map(([name, operation]) => [name,
|
|
135
|
+
name === 'dispose' ? () => {
|
|
136
|
+
for (const controller of operations) controller.abort(new Error('The token source service has been disposed.'));
|
|
137
|
+
operation();
|
|
138
|
+
} : async (input, signal) => {
|
|
139
|
+
if (disposed) throw new Error('The token source service has been disposed.');
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
const exclusive = name !== 'load';
|
|
142
|
+
if (exclusive && busy) throw new Error('Another token source operation is in progress.');
|
|
143
|
+
if (exclusive) busy = true;
|
|
144
|
+
const controller = new AbortController(); operations.add(controller);
|
|
145
|
+
const abort = () => controller.abort(signal.reason);
|
|
146
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
147
|
+
try { const result = await operation(input, controller.signal); checkActive(controller.signal); return result; }
|
|
148
|
+
finally { signal?.removeEventListener('abort', abort); operations.delete(controller); if (exclusive) busy = false; }
|
|
149
|
+
},
|
|
150
|
+
])));
|
|
151
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function text(value, name) {
|
|
2
|
+
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`);
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function literal(value) {
|
|
7
|
+
const serialized = workspaceSourceFingerprint(value);
|
|
8
|
+
return serialized.replace(/[<\u2028\u2029]/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Creates a browser module that reports a mounted draft recipe through the workspace readiness protocol. */
|
|
12
|
+
export function createWorkspaceDraftEntry({
|
|
13
|
+
documentRevision,
|
|
14
|
+
recipe,
|
|
15
|
+
sdkModule = '@pygmalionjs/pygmalion',
|
|
16
|
+
desiredStateAdapter,
|
|
17
|
+
failureMessage = 'Draft state could not be reproduced: ',
|
|
18
|
+
}) {
|
|
19
|
+
text(documentRevision, 'documentRevision');
|
|
20
|
+
text(sdkModule, 'sdkModule');
|
|
21
|
+
text(failureMessage, 'failureMessage');
|
|
22
|
+
if (!recipe || typeof recipe !== 'object' || Array.isArray(recipe)) throw new TypeError('recipe must be an object.');
|
|
23
|
+
if (desiredStateAdapter) {
|
|
24
|
+
text(desiredStateAdapter.module, 'desiredStateAdapter.module');
|
|
25
|
+
text(desiredStateAdapter.exportName, 'desiredStateAdapter.exportName');
|
|
26
|
+
}
|
|
27
|
+
return `import { runWorkspaceDraftRecipe } from ${literal(sdkModule)};
|
|
28
|
+
${desiredStateAdapter ? `import * as desiredStateAdapter from ${literal(desiredStateAdapter.module)};` : ''}
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
addEventListener('pagehide', () => controller.abort(), { once: true });
|
|
31
|
+
document.documentElement.dataset.pygmalionDraftStatus = 'preparing';
|
|
32
|
+
Promise.resolve().then(() => {
|
|
33
|
+
controller.signal.throwIfAborted();
|
|
34
|
+
return runWorkspaceDraftRecipe({ document, window }, JSON.parse(${literal(workspaceSourceFingerprint(recipe))}), {
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
${desiredStateAdapter ? `applyDesiredState(state, context) { return desiredStateAdapter[${literal(desiredStateAdapter.exportName)}](state, context); },` : ''}
|
|
37
|
+
});
|
|
38
|
+
}).then(() => {
|
|
39
|
+
if (controller.signal.aborted) return;
|
|
40
|
+
document.documentElement.dataset.pygmalionDraftStatus = 'ready';
|
|
41
|
+
parent.postMessage({ type: 'pygmalion:workspace-draft-ready', documentRevision: ${literal(documentRevision)}, status: 'ready' }, '*');
|
|
42
|
+
}, error => {
|
|
43
|
+
if (controller.signal.aborted) return;
|
|
44
|
+
document.documentElement.dataset.pygmalionDraftStatus = 'failed';
|
|
45
|
+
const message = document.createElement('div');
|
|
46
|
+
message.setAttribute('role', 'alert');
|
|
47
|
+
message.textContent = ${literal(failureMessage)} + String(error?.message ?? error);
|
|
48
|
+
document.body.prepend(message);
|
|
49
|
+
parent.postMessage({ type: 'pygmalion:workspace-draft-ready', documentRevision: ${literal(documentRevision)}, status: 'failed', message: message.textContent }, '*');
|
|
50
|
+
});`;
|
|
51
|
+
}
|
|
52
|
+
import { workspaceSourceFingerprint } from './workspace-source-identity.mjs';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Local transport for a host-configured isolated builder. The browser supplies no build commands. */
|
|
4
|
+
export function workspaceDraftPreviewPlugin({ service, resolveRequest, waitForReady = false, endpoint = '/__pygmalion-workspace-draft-preview' }) {
|
|
5
|
+
if (!/^\/[A-Za-z0-9/_-]+$/.test(endpoint) || typeof resolveRequest !== 'function') {
|
|
6
|
+
throw new Error('Draft preview requires a local endpoint and a host request resolver.');
|
|
7
|
+
}
|
|
8
|
+
const leases = new Map();
|
|
9
|
+
return {
|
|
10
|
+
name: 'pygmalion-workspace-draft-preview',
|
|
11
|
+
apply: 'serve',
|
|
12
|
+
configureServer(server) {
|
|
13
|
+
server.httpServer?.once('close', () => {
|
|
14
|
+
for (const controller of leases.values()) controller.abort();
|
|
15
|
+
leases.clear();
|
|
16
|
+
void service.dispose().catch(error => server.config.logger.error(String(error)));
|
|
17
|
+
});
|
|
18
|
+
server.middlewares.use(async (req, res, next) => {
|
|
19
|
+
if (req.url?.split('?')[0] !== endpoint) return next();
|
|
20
|
+
try {
|
|
21
|
+
if (req.headers.origin && new URL(req.headers.origin).host !== req.headers.host) {
|
|
22
|
+
res.statusCode = 403; res.end(JSON.stringify({ error: 'Draft preview requires the editor origin.' })); return;
|
|
23
|
+
}
|
|
24
|
+
} catch { res.statusCode = 403; res.end(); return; }
|
|
25
|
+
if (req.method === 'DELETE') {
|
|
26
|
+
const lease = new URL(req.url, 'http://local.invalid').searchParams.get('lease');
|
|
27
|
+
leases.get(lease)?.abort(); leases.delete(lease);
|
|
28
|
+
res.statusCode = 204; res.end(); return;
|
|
29
|
+
}
|
|
30
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return; }
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const abort = () => controller.abort();
|
|
33
|
+
req.once('aborted', abort);
|
|
34
|
+
res.once('close', abort);
|
|
35
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
36
|
+
res.setHeader('cache-control', 'no-store');
|
|
37
|
+
try {
|
|
38
|
+
if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? '')) {
|
|
39
|
+
res.statusCode = 415; res.end(JSON.stringify({ error: 'Draft preview requires a JSON request.' })); return;
|
|
40
|
+
}
|
|
41
|
+
const chunks = []; let bytes = 0;
|
|
42
|
+
for await (const chunk of req) {
|
|
43
|
+
bytes += chunk.length;
|
|
44
|
+
if (bytes > 10 * 1024 * 1024) throw new Error('Draft preview request exceeds the size limit.');
|
|
45
|
+
chunks.push(chunk);
|
|
46
|
+
}
|
|
47
|
+
controller.signal.throwIfAborted();
|
|
48
|
+
const input = await resolveRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')), { signal: controller.signal });
|
|
49
|
+
controller.signal.throwIfAborted();
|
|
50
|
+
const surface = await service.prepare(input, { signal: controller.signal });
|
|
51
|
+
controller.signal.throwIfAborted();
|
|
52
|
+
const lease = randomUUID();
|
|
53
|
+
leases.set(lease, controller);
|
|
54
|
+
controller.signal.addEventListener('abort', () => leases.delete(lease), { once: true });
|
|
55
|
+
res.setHeader('x-pygmalion-preview-lease', lease);
|
|
56
|
+
res.end(JSON.stringify({ surface: waitForReady ? { ...surface, waitForReady: true } : surface, lease }));
|
|
57
|
+
} catch (error) {
|
|
58
|
+
controller.abort();
|
|
59
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
60
|
+
res.statusCode = 409;
|
|
61
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
req.removeListener('aborted', abort);
|
|
65
|
+
res.removeListener('close', abort);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { materializeSourceArchive } from './source-archive.mjs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { writeInspectChanges } from './inspect-writeback.mjs';
|
|
8
|
+
import { validateInspectSourceProofs } from './source-file-proofs.mjs';
|
|
9
|
+
import { applySourceFilePlan } from './source-file-plan.mjs';
|
|
10
|
+
import { workspaceSourceFingerprint } from './workspace-source-identity.mjs';
|
|
11
|
+
import { WORKSPACE_DRAFT_TEMP_PREFIX, WORKSPACE_DRAFT_OUTPUT_DIRECTORY } from './workspace-draft-runtime.mjs';
|
|
12
|
+
|
|
13
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
14
|
+
const same = (left, right) => workspaceSourceFingerprint(left) === workspaceSourceFingerprint(right);
|
|
15
|
+
function freeze(value) {
|
|
16
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(freeze); Object.freeze(value); }
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function relative(value) {
|
|
20
|
+
return typeof value === 'string' && value.length > 0 && !/[\\\x00-\x1f\x7f]/.test(value)
|
|
21
|
+
&& !path.posix.isAbsolute(value) && !/^[a-z]:/i.test(value)
|
|
22
|
+
&& !value.split('/').some(part => !part || part === '.' || part === '..' || part === '.git');
|
|
23
|
+
}
|
|
24
|
+
function positive(value, fallback) {
|
|
25
|
+
const selected = value ?? fallback;
|
|
26
|
+
if (!Number.isSafeInteger(selected) || selected < 1) throw new Error('Preview resource limits must be positive integers.');
|
|
27
|
+
return selected;
|
|
28
|
+
}
|
|
29
|
+
function validateRoute(route) {
|
|
30
|
+
if (typeof route !== 'string' || !route.startsWith('/') || route.startsWith('//') || /[\\\x00-\x20\x7f]/.test(route)) throw new Error('The preview route must be an application-relative URL.');
|
|
31
|
+
const rawPath = decodeURIComponent(route.split(/[?#]/)[0]);
|
|
32
|
+
if (rawPath.split('/').some(part => part === '.' || part === '..') || rawPath.includes('\\')) throw new Error('The preview route cannot escape the application.');
|
|
33
|
+
return route;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Verify the complete browser capture before materializing source or invoking a build. */
|
|
37
|
+
function captureInput(input) {
|
|
38
|
+
const captured = JSON.parse(workspaceSourceFingerprint(input));
|
|
39
|
+
const { request, documentRevision, route } = captured ?? {};
|
|
40
|
+
const { target, document, identity, payload } = request ?? {};
|
|
41
|
+
if (request?.sourceKind !== undefined && request.sourceKind !== 'file-plan') throw new Error('The draft preview request has an unsupported source kind.');
|
|
42
|
+
if (!target || !document || !identity || !payload || !/^(?:[a-f\d]{40}|[a-f\d]{64})$/.test(target.sourceRevision ?? '')
|
|
43
|
+
|| typeof target.screenId !== 'string' || !target.screenId.trim() || identity.version !== 1
|
|
44
|
+
|| document.schemaVersion !== 1 || document.id !== identity.documentId || document.sourceRevision !== target.sourceRevision
|
|
45
|
+
|| !same(identity.target, target) || identity.afterFingerprint !== workspaceSourceFingerprint(document)
|
|
46
|
+
|| identity.payloadFingerprint !== workspaceSourceFingerprint(payload)) throw new Error('The draft preview request has inconsistent document, payload, or source identity.');
|
|
47
|
+
const { fingerprint, ...parts } = identity;
|
|
48
|
+
if (fingerprint !== workspaceSourceFingerprint(parts) || documentRevision !== hash(identity.afterFingerprint)) throw new Error('The draft revision must hash the complete captured document identity.');
|
|
49
|
+
let before; let mapping;
|
|
50
|
+
try {
|
|
51
|
+
before = JSON.parse(identity.beforeFingerprint);
|
|
52
|
+
mapping = JSON.parse(identity.mappingFingerprint);
|
|
53
|
+
} catch { throw new Error('The draft preview requires complete source baseline and mapping fingerprints.'); }
|
|
54
|
+
if (workspaceSourceFingerprint(before) !== identity.beforeFingerprint || workspaceSourceFingerprint(mapping) !== identity.mappingFingerprint
|
|
55
|
+
|| before.id !== document.id || before.sourceRevision !== target.sourceRevision || mapping.version !== 1
|
|
56
|
+
|| mapping.documentId !== document.id || mapping.baselineFingerprint !== identity.beforeFingerprint || !same(mapping.target, target)) throw new Error('The captured source baseline, mapping, and file proofs must agree.');
|
|
57
|
+
if (request.sourceKind === 'file-plan') {
|
|
58
|
+
if (payload.version !== 1 || payload.sourceRevision !== target.sourceRevision || !Array.isArray(payload.proofs)
|
|
59
|
+
|| !same(mapping.files, payload.proofs)) throw new Error('The captured file plan, mapping, and file proofs must agree.');
|
|
60
|
+
} else if (!payload.sourceProof || payload.sourceProof.sourceRevision !== target.sourceRevision || !same(mapping.files, payload.sourceProof.files)) {
|
|
61
|
+
throw new Error('The captured source baseline, mapping, and file proofs must agree.');
|
|
62
|
+
}
|
|
63
|
+
validateRoute(route);
|
|
64
|
+
return freeze(captured);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function captureOutput(outputDirectory, { maxOutputFiles, maxOutputBytes }, signal) {
|
|
68
|
+
const files = new Map(); let total = 0;
|
|
69
|
+
const visit = async (directory, prefix) => {
|
|
70
|
+
const directoryStat = await fsp.lstat(directory);
|
|
71
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error('Build output directories must not be symbolic links.');
|
|
72
|
+
for (const entry of await fsp.readdir(directory, { withFileTypes: true })) {
|
|
73
|
+
signal.throwIfAborted();
|
|
74
|
+
const key = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
75
|
+
if (!relative(key)) throw new Error('Build output contains an unsafe path.');
|
|
76
|
+
const absolute = path.join(directory, entry.name);
|
|
77
|
+
const stat = await fsp.lstat(absolute);
|
|
78
|
+
if (stat.isDirectory()) { await visit(absolute, key); continue; }
|
|
79
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Build output must contain regular files only.');
|
|
80
|
+
if (files.size >= maxOutputFiles || total + stat.size > maxOutputBytes) throw new Error('The built preview exceeds its output resource limits.');
|
|
81
|
+
const bytes = await fsp.readFile(absolute, { signal });
|
|
82
|
+
if (bytes.length !== stat.size) throw new Error('Build output changed while it was captured.');
|
|
83
|
+
total += bytes.length; files.set(key, bytes);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
await visit(outputDirectory, '');
|
|
87
|
+
if (!files.has('index.html')) throw new Error('The built application requires index.html.');
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mimeTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
92
|
+
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.map': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
93
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon',
|
|
94
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm', '.txt': 'text/plain; charset=utf-8' };
|
|
95
|
+
function closeServer(server) {
|
|
96
|
+
if (!server) return Promise.resolve();
|
|
97
|
+
return new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build in a disposable archive and serve only captured bytes from a separate loopback origin.
|
|
102
|
+
* The host must first validate file-plan requests against its canonical source compiler.
|
|
103
|
+
* Identity consistency and archived file proofs do not establish a document-to-source mapping.
|
|
104
|
+
* The archive is the request's exact commit; a request for another revision than the
|
|
105
|
+
* checked-out one builds that revision's files, never the working tree relabelled.
|
|
106
|
+
*/
|
|
107
|
+
export function createWorkspaceDraftPreviewService(options) {
|
|
108
|
+
if (!options || typeof options.projectRoot !== 'string' || typeof options.build !== 'function') throw new Error('A draft preview service requires a project root and trusted build callback.');
|
|
109
|
+
if (options.prepareSource !== undefined && typeof options.prepareSource !== 'function') throw new Error('Source preparation must be a trusted host callback.');
|
|
110
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
111
|
+
const appDirectory = options.appDirectory ?? '.';
|
|
112
|
+
if (appDirectory !== '.' && !relative(appDirectory)) throw new Error('The application directory must be project-relative.');
|
|
113
|
+
const maxRuns = positive(options.maxRuns, 3);
|
|
114
|
+
const limits = { maxOutputFiles: positive(options.maxOutputFiles, 10000), maxOutputBytes: positive(options.maxOutputBytes, 128 * 1024 * 1024) };
|
|
115
|
+
const build = options.build; const handleRequest = options.handleRequest; const prepareSource = options.prepareSource;
|
|
116
|
+
const dependenciesRoot = options.dependenciesRoot ? path.resolve(options.dependenciesRoot) : null;
|
|
117
|
+
const runs = new Set(); let disposed = false; let disposal;
|
|
118
|
+
|
|
119
|
+
const prepare = (input, { signal } = {}) => {
|
|
120
|
+
if (disposed) return Promise.reject(new Error('The draft preview service has been disposed.'));
|
|
121
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('Draft preview was aborted.'));
|
|
122
|
+
if (runs.size >= maxRuns) return Promise.reject(new Error('The draft preview service has reached its active run limit.'));
|
|
123
|
+
let captured;
|
|
124
|
+
try { captured = captureInput(input); } catch (error) { return Promise.reject(error); }
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const run = { controller, server: null, files: null, temporary: null, done: null };
|
|
127
|
+
const abort = () => { controller.abort(signal?.reason); void closeServer(run.server); };
|
|
128
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
129
|
+
runs.add(run);
|
|
130
|
+
run.done = (async () => {
|
|
131
|
+
let ready = false;
|
|
132
|
+
try {
|
|
133
|
+
run.temporary = await fsp.mkdtemp(path.join(os.tmpdir(), WORKSPACE_DRAFT_TEMP_PREFIX));
|
|
134
|
+
controller.signal.throwIfAborted();
|
|
135
|
+
// The archive holds exactly the requested commit, whatever the repository's
|
|
136
|
+
// working tree or HEAD currently contains. Every host callback receives that
|
|
137
|
+
// commit so a build for one revision can never be labelled as another.
|
|
138
|
+
const sourceRevision = captured.request.target.sourceRevision;
|
|
139
|
+
const appRoot = await materializeSourceArchive(projectRoot, appDirectory, sourceRevision, run.temporary, controller.signal);
|
|
140
|
+
controller.signal.throwIfAborted();
|
|
141
|
+
// A trusted host may stage its reviewed working-copy inputs in this archive.
|
|
142
|
+
// Full request proofs are still checked below, after the callback settles.
|
|
143
|
+
if (prepareSource) await prepareSource({ appRoot, request: captured.request, sourceRevision, signal: controller.signal });
|
|
144
|
+
controller.signal.throwIfAborted();
|
|
145
|
+
if (captured.request.sourceKind === 'file-plan') {
|
|
146
|
+
await applySourceFilePlan(appRoot, captured.request.payload, {
|
|
147
|
+
sourceRevision: captured.request.target.sourceRevision,
|
|
148
|
+
allowedFiles: captured.request.payload.changes.map(change => change.file),
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
await validateInspectSourceProofs(appRoot, captured.request.payload, { sourceRevision: captured.request.target.sourceRevision });
|
|
153
|
+
controller.signal.throwIfAborted();
|
|
154
|
+
await writeInspectChanges(appRoot, captured.request.payload);
|
|
155
|
+
}
|
|
156
|
+
controller.signal.throwIfAborted();
|
|
157
|
+
if (dependenciesRoot) {
|
|
158
|
+
const dependencies = await fsp.realpath(dependenciesRoot);
|
|
159
|
+
if (!(await fsp.stat(dependencies)).isDirectory()) throw new Error('The dependency root must identify an existing node_modules directory.');
|
|
160
|
+
await fsp.symlink(dependencies, path.join(appRoot, 'node_modules'), 'dir');
|
|
161
|
+
}
|
|
162
|
+
const outputDirectory = path.join(run.temporary, WORKSPACE_DRAFT_OUTPUT_DIRECTORY);
|
|
163
|
+
await fsp.mkdir(outputDirectory);
|
|
164
|
+
await build({ appRoot, outputDirectory, request: captured.request, sourceRevision, signal: controller.signal });
|
|
165
|
+
controller.signal.throwIfAborted();
|
|
166
|
+
run.files = await captureOutput(outputDirectory, limits, controller.signal);
|
|
167
|
+
await fsp.rm(run.temporary, { recursive: true, force: true }); run.temporary = null;
|
|
168
|
+
controller.signal.throwIfAborted();
|
|
169
|
+
let authority;
|
|
170
|
+
const server = http.createServer((request, response) => {
|
|
171
|
+
void (async () => {
|
|
172
|
+
response.setHeader('cache-control', 'no-store'); response.setHeader('x-content-type-options', 'nosniff');
|
|
173
|
+
if (request.headers.host !== authority) { response.writeHead(403).end('Unexpected preview origin.'); return; }
|
|
174
|
+
if (handleRequest) {
|
|
175
|
+
const handled = await handleRequest(request, response, { request: captured.request, sourceRevision });
|
|
176
|
+
if (handled || response.headersSent || response.writableEnded) return;
|
|
177
|
+
}
|
|
178
|
+
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { allow: 'GET, HEAD' }).end(); return; }
|
|
179
|
+
let pathname;
|
|
180
|
+
try { pathname = decodeURIComponent(new URL(request.url, `http://${authority}`).pathname); }
|
|
181
|
+
catch { response.writeHead(400).end(); return; }
|
|
182
|
+
if (pathname.includes('\\') || pathname.split('/').some(part => part === '..' || part === '.')) { response.writeHead(400).end(); return; }
|
|
183
|
+
let key = pathname.slice(1) || 'index.html';
|
|
184
|
+
let bytes = run.files?.get(key);
|
|
185
|
+
if (!bytes && !path.posix.extname(key)) { key = 'index.html'; bytes = run.files?.get(key); }
|
|
186
|
+
if (!bytes) { response.writeHead(404).end(); return; }
|
|
187
|
+
response.writeHead(200, { 'content-type': mimeTypes[path.posix.extname(key)] ?? 'application/octet-stream', 'content-length': bytes.length });
|
|
188
|
+
response.end(request.method === 'HEAD' ? undefined : bytes);
|
|
189
|
+
})().catch(() => { if (response.headersSent) response.destroy(); else response.writeHead(500).end('Preview request failed.'); });
|
|
190
|
+
});
|
|
191
|
+
run.server = server;
|
|
192
|
+
await new Promise((resolve, reject) => {
|
|
193
|
+
const aborted = () => reject(controller.signal.reason ?? new Error('Draft preview was aborted.'));
|
|
194
|
+
const cleanup = () => { controller.signal.removeEventListener('abort', aborted); server.off('error', failed); };
|
|
195
|
+
const failed = error => { cleanup(); reject(error); };
|
|
196
|
+
controller.signal.addEventListener('abort', aborted, { once: true });
|
|
197
|
+
server.once('error', failed);
|
|
198
|
+
server.listen({ host: '127.0.0.1', port: 0, signal: controller.signal }, () => { cleanup(); resolve(); });
|
|
199
|
+
});
|
|
200
|
+
controller.signal.throwIfAborted();
|
|
201
|
+
authority = `127.0.0.1:${server.address().port}`;
|
|
202
|
+
ready = true;
|
|
203
|
+
return Object.freeze({ url: `http://${authority}${captured.route}`, documentRevision: captured.documentRevision });
|
|
204
|
+
} finally {
|
|
205
|
+
if (!ready) {
|
|
206
|
+
await closeServer(run.server);
|
|
207
|
+
if (run.temporary) await fsp.rm(run.temporary, { recursive: true, force: true });
|
|
208
|
+
run.files?.clear(); runs.delete(run); signal?.removeEventListener('abort', abort);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
controller.signal.addEventListener('abort', () => {
|
|
213
|
+
void closeServer(run.server).then(() => {
|
|
214
|
+
if (!run.temporary) { run.files?.clear(); runs.delete(run); signal?.removeEventListener('abort', abort); }
|
|
215
|
+
});
|
|
216
|
+
}, { once: true });
|
|
217
|
+
return run.done;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return Object.freeze({ prepare, dispose() {
|
|
221
|
+
if (disposal) return disposal;
|
|
222
|
+
disposed = true;
|
|
223
|
+
const active = [...runs]; active.forEach(run => run.controller.abort(new Error('The draft preview service was disposed.')));
|
|
224
|
+
disposal = (async () => {
|
|
225
|
+
await Promise.allSettled(active.map(run => run.done));
|
|
226
|
+
await Promise.all(active.map(async run => { await closeServer(run.server); if (run.temporary) await fsp.rm(run.temporary, { recursive: true, force: true }); run.files?.clear(); runs.delete(run); }));
|
|
227
|
+
})();
|
|
228
|
+
return disposal;
|
|
229
|
+
} });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export { workspaceDraftPreviewPlugin } from './workspace-draft-preview-plugin.mjs';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execute = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
function signalGroup(pid, signal) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(-pid, signal);
|
|
11
|
+
return true;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error.code === 'ESRCH') return false;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function hasRunningGroup(pid) {
|
|
19
|
+
if (!signalGroup(pid, 0)) return false;
|
|
20
|
+
let stdout;
|
|
21
|
+
try {
|
|
22
|
+
({ stdout } = await execute('ps', ['-ax', '-o', 'pgid=,stat='], { timeout: 5_000 }));
|
|
23
|
+
} catch {
|
|
24
|
+
if (process.platform === 'linux') {
|
|
25
|
+
try {
|
|
26
|
+
for (const entry of await readdir('/proc')) {
|
|
27
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
28
|
+
const stat = await readFile(`/proc/${entry}/stat`, 'utf8').catch(error => {
|
|
29
|
+
if (error.code === 'ENOENT' || error.code === 'ESRCH') return '';
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
// Parenthesized command names may contain spaces and closing parentheses.
|
|
33
|
+
const [state, , group] = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/);
|
|
34
|
+
if (Number(group) === pid && state !== 'Z') return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
} catch { /* Keep the process group alive until its state can be determined. */ }
|
|
38
|
+
}
|
|
39
|
+
// An unavailable inspector cannot authorize cleanup while descendants remain.
|
|
40
|
+
return signalGroup(pid, 0);
|
|
41
|
+
}
|
|
42
|
+
return stdout.split('\n').some(line => {
|
|
43
|
+
const [group, status] = line.trim().split(/\s+/);
|
|
44
|
+
// Reaping belongs to the operating system; zombies cannot write source files.
|
|
45
|
+
return Number(group) === pid && status && !status.startsWith('Z');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function terminateGroup(pid, graceMs) {
|
|
50
|
+
if (!pid || !signalGroup(pid, 'SIGTERM')) return;
|
|
51
|
+
const deadline = Date.now() + graceMs;
|
|
52
|
+
while (await hasRunningGroup(pid)) {
|
|
53
|
+
if (Date.now() >= deadline) signalGroup(pid, 'SIGKILL');
|
|
54
|
+
await delay(25);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolve or reject only after the command and its process group stop writing. */
|
|
59
|
+
export async function executeWorkspaceDraftProcess(command, args, {
|
|
60
|
+
signal, timeout = 240_000, terminationGraceMs = 1_000, maxBuffer = 8 * 1024 * 1024, ...options
|
|
61
|
+
} = {}) {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
if (process.platform === 'win32') {
|
|
64
|
+
throw new Error('Draft process group cleanup supports macOS and Linux.');
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isFinite(timeout) || timeout < 0 || timeout > 2 ** 31 - 1
|
|
67
|
+
|| !Number.isFinite(terminationGraceMs) || terminationGraceMs < 0
|
|
68
|
+
|| !Number.isSafeInteger(maxBuffer) || maxBuffer < 0) {
|
|
69
|
+
throw new TypeError('Process timeout, termination grace, and output limits must be finite non-negative values.');
|
|
70
|
+
}
|
|
71
|
+
let child;
|
|
72
|
+
let termination;
|
|
73
|
+
let cancellation;
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
let timeoutHandle;
|
|
76
|
+
const terminate = () => {
|
|
77
|
+
termination ??= terminateGroup(child?.pid, terminationGraceMs);
|
|
78
|
+
// Observe termination failure while the process close event is still pending.
|
|
79
|
+
void termination.catch(() => {});
|
|
80
|
+
return termination;
|
|
81
|
+
};
|
|
82
|
+
const cancel = reason => {
|
|
83
|
+
if (!cancelled) { cancelled = true; cancellation = reason; }
|
|
84
|
+
void terminate();
|
|
85
|
+
};
|
|
86
|
+
const onAbort = () => cancel(signal.reason ?? new DOMException('Draft execution cancelled.', 'AbortError'));
|
|
87
|
+
const closed = new Promise(resolve => {
|
|
88
|
+
// spawn owns the detached process group; execFile does not forward detached.
|
|
89
|
+
child = spawn(command, args, { ...options, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
90
|
+
const stdout = []; const stderr = [];
|
|
91
|
+
let stdoutSize = 0; let stderrSize = 0; let spawnError;
|
|
92
|
+
const append = (chunks, chunk, size) => {
|
|
93
|
+
if (size > maxBuffer) {
|
|
94
|
+
const error = Object.assign(new Error('Draft process output exceeded its byte limit.'), {
|
|
95
|
+
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
|
96
|
+
});
|
|
97
|
+
cancel(error);
|
|
98
|
+
} else chunks.push(chunk);
|
|
99
|
+
};
|
|
100
|
+
child.stdout.on('data', chunk => { stdoutSize += chunk.length; append(stdout, chunk, stdoutSize); });
|
|
101
|
+
child.stderr.on('data', chunk => { stderrSize += chunk.length; append(stderr, chunk, stderrSize); });
|
|
102
|
+
child.once('error', error => { spawnError = error; });
|
|
103
|
+
child.once('close', (code, exitSignal) => {
|
|
104
|
+
const out = Buffer.concat(stdout).toString(); const err = Buffer.concat(stderr).toString();
|
|
105
|
+
const error = spawnError ?? (code === 0 ? undefined : Object.assign(
|
|
106
|
+
new Error(`Draft process failed (${exitSignal ?? code}).${err ? `\n${err}` : ''}`),
|
|
107
|
+
{ code, signal: exitSignal, stdout: out, stderr: err },
|
|
108
|
+
));
|
|
109
|
+
resolve({ error, stdout: out, stderr: err });
|
|
110
|
+
});
|
|
111
|
+
// Successful or failed leaders may leave descendants behind as well.
|
|
112
|
+
child.once('exit', terminate);
|
|
113
|
+
});
|
|
114
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
115
|
+
if (signal?.aborted) onAbort();
|
|
116
|
+
if (timeout > 0) {
|
|
117
|
+
timeoutHandle = setTimeout(() => cancel(new DOMException('Draft execution timed out.', 'TimeoutError')), timeout);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const result = await closed;
|
|
121
|
+
await terminate();
|
|
122
|
+
if (cancelled) throw cancellation;
|
|
123
|
+
if (result.error) throw result.error;
|
|
124
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(timeoutHandle);
|
|
127
|
+
signal?.removeEventListener('abort', onAbort);
|
|
128
|
+
}
|
|
129
|
+
}
|