@pygmalionjs/pygmalion 0.8.0 → 0.9.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/binding-source.d.ts +25 -0
- package/dist-lib/{CameraLayer-nkNrazn8.js → CameraLayer-BdOko8XE.js} +17 -17
- package/dist-lib/pygmalion.js +24599 -12746
- package/dist-lib/{runtime-DdYnT6k4.js → runtime-Dxb-IaLN.js} +12427 -11116
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +10 -10
- 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/index.d.ts +6 -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/editor/surfaceDemands.d.ts +3 -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 +101 -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/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 +39 -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/inspect.d.ts +2 -0
- package/node/design-session.mjs +3 -0
- package/node/inspect-plugin.mjs +3 -1
- package/node/inspect-writeback.mjs +18 -2
- package/node/preview-artifact-store.mjs +10 -7
- 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-responsive-codec.d.mts +17 -0
- package/node/source-responsive-codec.mjs +133 -0
- package/node/source-responsive.mjs +189 -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/workspace-draft-preview-plugin.mjs +70 -0
- package/node/workspace-draft-preview.mjs +231 -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/package.json +52 -6
- package/source-files.d.ts +35 -0
- package/source-responsive.d.ts +10 -0
- package/source-structure.d.ts +155 -0
- package/workspace-source-plan.d.ts +44 -0
- package/docs/coverage-contracts.md +0 -431
- package/docs/screen-state-contract.md +0 -252
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
|
|
4
|
+
const names = /--[A-Za-z0-9_\-\u0080-\uFFFF]+/g;
|
|
5
|
+
const unwrap = node => node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) && node.type.getText() === 'const') ? unwrap(node.expression) : node;
|
|
6
|
+
const walk = (node, visit) => { visit(node); ts.forEachChild(node, child => walk(child, visit)); };
|
|
7
|
+
|
|
8
|
+
/** Conservative source coverage: any diagnostic returns the original source and no edits. */
|
|
9
|
+
export function rewriteTokenSourceReferences({ file, source, renames }) {
|
|
10
|
+
const diagnostics = []; const references = []; const proposed = new Map(); const handled = new Set();
|
|
11
|
+
const changes = new Map();
|
|
12
|
+
const report = (code, message, start, name) => diagnostics.push({ code, message, file, ...(start === undefined ? {} : { start }), ...(name ? { name } : {}) });
|
|
13
|
+
if (typeof source !== 'string' || typeof file !== 'string' || !Array.isArray(renames)) throw new TypeError('Source reference scanning requires a file, source, and rename list.');
|
|
14
|
+
for (const { from, to } of renames) {
|
|
15
|
+
if (!/^--[A-Za-z0-9_-]+$/.test(from) || to !== null && !/^--[A-Za-z0-9_-]+$/.test(to) || changes.has(from)) report('invalid-rename', 'Rename entries require unique, exact supported custom-property names.');
|
|
16
|
+
else changes.set(from, to);
|
|
17
|
+
}
|
|
18
|
+
const destinations = [...changes.values()].filter(value => value !== null);
|
|
19
|
+
if (new Set(destinations).size !== destinations.length) report('rename-collision', 'Two custom properties cannot be renamed to the same name.');
|
|
20
|
+
const edit = (start, end, after) => {
|
|
21
|
+
const before = source.slice(start, end); const prior = proposed.get(start);
|
|
22
|
+
if (prior && (prior.end !== end || prior.after !== after)) report('overlapping-edit', 'Shared source requires incompatible replacements.', start);
|
|
23
|
+
else if (before !== after) proposed.set(start, { start, end, before, after });
|
|
24
|
+
};
|
|
25
|
+
const reference = (name, start, end, kind = 'static') => {
|
|
26
|
+
references.push({ name, start, end, kind }); handled.add(start);
|
|
27
|
+
if (!changes.has(name)) return;
|
|
28
|
+
const to = changes.get(name);
|
|
29
|
+
if (to === null) report('token-in-use', 'A removed custom property still has a source reference.', start, name);
|
|
30
|
+
else if (kind === 'static') edit(start, end, to);
|
|
31
|
+
};
|
|
32
|
+
const overlaps = (prefix, suffix = '') => [...changes.keys()].some(name => name.startsWith(prefix) && name.endsWith(suffix));
|
|
33
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.JSX, source);
|
|
34
|
+
const comments = [];
|
|
35
|
+
while (scanner.scan() !== ts.SyntaxKind.EndOfFileToken) {
|
|
36
|
+
if ([ts.SyntaxKind.SingleLineCommentTrivia, ts.SyntaxKind.MultiLineCommentTrivia].includes(scanner.getToken())) comments.push([scanner.getTokenPos(), scanner.getTextPos()]);
|
|
37
|
+
}
|
|
38
|
+
const inComment = offset => comments.some(([start, end]) => offset >= start && offset < end);
|
|
39
|
+
const extension = path.extname(file).toLowerCase();
|
|
40
|
+
if (['.css', '.scss'].includes(extension)) {
|
|
41
|
+
const quoted = [...source.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g)].map(match => [match.index, match.index + match[0].length]);
|
|
42
|
+
for (const match of source.matchAll(/var\(([^)]*)\)/g)) {
|
|
43
|
+
if (!inComment(match.index) && /\$|#\{|\\/.test(match[1]) && overlaps(match[1].match(/^\s*(--[\w-]*)/)?.[1] ?? '')) report('source-coverage', 'Dynamic or escaped stylesheet names require a host-resolved source contract.', match.index);
|
|
44
|
+
}
|
|
45
|
+
for (const match of source.matchAll(/(--[\w-]*)(?:#\{[^}]*\}|\$[\w-]+)/g)) {
|
|
46
|
+
if (!inComment(match.index) && overlaps(match[1])) report('source-coverage', 'Interpolated stylesheet names require a host-resolved source contract.', match.index);
|
|
47
|
+
}
|
|
48
|
+
for (const match of source.matchAll(names)) {
|
|
49
|
+
const start = match.index; const end = start + match[0].length;
|
|
50
|
+
if (inComment(start)) continue;
|
|
51
|
+
const literal = quoted.some(([left, right]) => start >= left && end <= right);
|
|
52
|
+
const before = source.slice(0, start); const after = source.slice(end);
|
|
53
|
+
if (!literal && (/var\(\s*$/.test(before) || /^\s*:/.test(after) || /@property\s+$/.test(before))) reference(match[0], start, end);
|
|
54
|
+
}
|
|
55
|
+
} else if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts'].includes(extension)) {
|
|
56
|
+
const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ['.tsx', '.jsx'].includes(extension) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
57
|
+
if (/** @type {ts.SourceFile & { parseDiagnostics: unknown[] }} */ (parsed).parseDiagnostics.length) report('source-coverage', 'The source cannot be parsed completely.');
|
|
58
|
+
const contexts = new Map();
|
|
59
|
+
walk(parsed, node => {
|
|
60
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
61
|
+
const raw = node.getText(parsed); const prefix = raw.match(/--[\w-]*/)?.[0] ?? '';
|
|
62
|
+
if ((raw.includes('var(') || prefix) && overlaps(prefix)) report('source-coverage', 'Concatenated custom-property names require a host-resolved source contract.', node.getStart(parsed));
|
|
63
|
+
}
|
|
64
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
65
|
+
const start = node.getStart(parsed) + 1; const raw = source.slice(start, node.end - 1);
|
|
66
|
+
const parent = node.parent;
|
|
67
|
+
const propertyName = ts.isPropertyAssignment(parent) && parent.name === node;
|
|
68
|
+
const apiName = ts.isCallExpression(parent) && parent.arguments[0] === node && ts.isPropertyAccessExpression(parent.expression)
|
|
69
|
+
&& ['setProperty', 'getPropertyValue', 'removeProperty'].includes(parent.expression.name.text);
|
|
70
|
+
for (const match of node.text.matchAll(names)) {
|
|
71
|
+
const position = start + match.index;
|
|
72
|
+
if (raw !== node.text) { if (changes.has(match[0])) report('source-coverage', 'Escaped string references require an explicit source contract.', start, match[0]); continue; }
|
|
73
|
+
if (/var\(\s*$/.test(raw.slice(0, match.index)) || match[0] === raw && (propertyName || apiName)) reference(match[0], position, position + match[0].length);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!ts.isTemplateExpression(node)) return;
|
|
77
|
+
const prefixMatch = node.head.text.match(/(?:var\(\s*)?(--[\w-]*)$/);
|
|
78
|
+
if (!prefixMatch && !node.head.text.includes('var(')) return;
|
|
79
|
+
const prefix = prefixMatch?.[1] ?? '';
|
|
80
|
+
if (!overlaps(prefix)) return;
|
|
81
|
+
const span = node.templateSpans[0];
|
|
82
|
+
const suffix = node.templateSpans.length === 1 && span.literal.text.match(/^([\w-]*)\s*\)$/);
|
|
83
|
+
let callback = node.parent;
|
|
84
|
+
while (callback && !ts.isFunctionLike(callback)) callback = callback.parent;
|
|
85
|
+
let argument = callback;
|
|
86
|
+
while (argument?.parent && ts.isParenthesizedExpression(argument.parent)) argument = argument.parent;
|
|
87
|
+
const call = argument?.parent;
|
|
88
|
+
const binding = callback && ts.isArrowFunction(callback) && callback.parameters[0]?.name;
|
|
89
|
+
const first = binding && ts.isArrayBindingPattern(binding) && binding.elements[0];
|
|
90
|
+
const variable = first && ts.isBindingElement(first) && !first.initializer && !first.dotDotDotToken && ts.isIdentifier(first.name) ? first.name : null;
|
|
91
|
+
const receiver = call && ts.isCallExpression(call) && unwrap(call.arguments[0]) === callback && ts.isPropertyAccessExpression(call.expression)
|
|
92
|
+
&& call.expression.name.text === 'map' ? unwrap(call.expression.expression) : null;
|
|
93
|
+
const rows = receiver && ts.isArrayLiteralExpression(receiver) ? receiver.elements.map(unwrap) : [];
|
|
94
|
+
if (ts.isTaggedTemplateExpression(node.parent) || !/^var\(\s*--[\w-]*$/.test(node.head.text) || !suffix || !variable || !ts.isIdentifier(span.expression) || span.expression.text !== variable.text
|
|
95
|
+
|| !rows.length || rows.some(row => !ts.isArrayLiteralExpression(row) || !row.elements.length || !ts.isStringLiteral(row.elements[0]))) {
|
|
96
|
+
report('source-coverage', 'Dynamic custom properties require a direct literal tuple map and one bound interpolation.', node.getStart(parsed)); return;
|
|
97
|
+
}
|
|
98
|
+
const context = contexts.get(callback) ?? { callback, variable, rows, uses: [] };
|
|
99
|
+
context.uses.push({ prefix, suffix: suffix[1], node, expression: span.expression }); contexts.set(callback, context);
|
|
100
|
+
});
|
|
101
|
+
for (const { callback, variable, rows, uses } of contexts.values()) {
|
|
102
|
+
const replacements = [];
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
const item = row.elements[0]; const start = item.getStart(parsed) + 1; const values = new Set();
|
|
105
|
+
for (const use of uses) {
|
|
106
|
+
const name = use.prefix + item.text + use.suffix; reference(name, start, item.end - 1, 'finite');
|
|
107
|
+
const to = changes.has(name) ? changes.get(name) : name;
|
|
108
|
+
if (to === null) continue;
|
|
109
|
+
if (!to.startsWith(use.prefix) || !to.endsWith(use.suffix)) { report('source-coverage', 'The renamed property cannot use the existing template prefix and suffix.', start, name); continue; }
|
|
110
|
+
values.add(to.slice(use.prefix.length, use.suffix ? -use.suffix.length : undefined));
|
|
111
|
+
}
|
|
112
|
+
if (values.size > 1) report('source-coverage', 'A shared tuple key would change another custom-property reference.', start);
|
|
113
|
+
const value = [...values][0] ?? item.text; replacements.push(value);
|
|
114
|
+
if (value !== item.text) {
|
|
115
|
+
if (!/^[\w-]+$/.test(value) || source.slice(start, item.end - 1) !== item.text) report('source-coverage', 'Tuple edits require plain literal keys.', start);
|
|
116
|
+
else edit(start, item.end - 1, value);
|
|
117
|
+
walk(callback.body, node => {
|
|
118
|
+
if (ts.isIdentifier(node) && node.text === variable.text && !uses.some(use => use.expression === node)) report('source-coverage', 'The tuple key has another use that this rename cannot prove.', node.getStart(parsed));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (replacements.some((value, index) => replacements.indexOf(value) !== index && rows[replacements.indexOf(value)].elements[0].text !== rows[index].elements[0].text)) report('rename-collision', 'Renaming would merge distinct tuple keys.');
|
|
123
|
+
}
|
|
124
|
+
} else report('source-coverage', 'This source language has no custom-property reference contract.');
|
|
125
|
+
for (const match of source.matchAll(names)) {
|
|
126
|
+
if (changes.has(match[0]) && !handled.has(match.index) && !inComment(match.index)) report('source-coverage', 'A custom-property occurrence is outside a proven CSS reference.', match.index, match[0]);
|
|
127
|
+
}
|
|
128
|
+
for (const { name, start } of references) {
|
|
129
|
+
if (destinations.includes(name) && !changes.has(name)) report('rename-collision', 'The rename target already has another source reference.', start, name);
|
|
130
|
+
}
|
|
131
|
+
const edits = [...proposed.values()].sort((left, right) => left.start - right.start);
|
|
132
|
+
if (edits.some((entry, index) => index && entry.start < edits[index - 1].end)) report('overlapping-edit', 'Source replacements overlap.');
|
|
133
|
+
if (diagnostics.length) return { source, edits: [], diagnostics, references };
|
|
134
|
+
for (const entry of edits.toReversed()) source = source.slice(0, entry.start) + entry.after + source.slice(entry.end);
|
|
135
|
+
return { source, edits, diagnostics, references };
|
|
136
|
+
}
|
|
@@ -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,231 @@
|
|
|
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
|
+
|
|
12
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const same = (left, right) => workspaceSourceFingerprint(left) === workspaceSourceFingerprint(right);
|
|
14
|
+
function freeze(value) {
|
|
15
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(freeze); Object.freeze(value); }
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function relative(value) {
|
|
19
|
+
return typeof value === 'string' && value.length > 0 && !/[\\\x00-\x1f\x7f]/.test(value)
|
|
20
|
+
&& !path.posix.isAbsolute(value) && !/^[a-z]:/i.test(value)
|
|
21
|
+
&& !value.split('/').some(part => !part || part === '.' || part === '..' || part === '.git');
|
|
22
|
+
}
|
|
23
|
+
function positive(value, fallback) {
|
|
24
|
+
const selected = value ?? fallback;
|
|
25
|
+
if (!Number.isSafeInteger(selected) || selected < 1) throw new Error('Preview resource limits must be positive integers.');
|
|
26
|
+
return selected;
|
|
27
|
+
}
|
|
28
|
+
function validateRoute(route) {
|
|
29
|
+
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.');
|
|
30
|
+
const rawPath = decodeURIComponent(route.split(/[?#]/)[0]);
|
|
31
|
+
if (rawPath.split('/').some(part => part === '.' || part === '..') || rawPath.includes('\\')) throw new Error('The preview route cannot escape the application.');
|
|
32
|
+
return route;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Verify the complete browser capture before materializing source or invoking a build. */
|
|
36
|
+
function captureInput(input) {
|
|
37
|
+
const captured = JSON.parse(workspaceSourceFingerprint(input));
|
|
38
|
+
const { request, documentRevision, route } = captured ?? {};
|
|
39
|
+
const { target, document, identity, payload } = request ?? {};
|
|
40
|
+
if (request?.sourceKind !== undefined && request.sourceKind !== 'file-plan') throw new Error('The draft preview request has an unsupported source kind.');
|
|
41
|
+
if (!target || !document || !identity || !payload || !/^(?:[a-f\d]{40}|[a-f\d]{64})$/.test(target.sourceRevision ?? '')
|
|
42
|
+
|| typeof target.screenId !== 'string' || !target.screenId.trim() || identity.version !== 1
|
|
43
|
+
|| document.schemaVersion !== 1 || document.id !== identity.documentId || document.sourceRevision !== target.sourceRevision
|
|
44
|
+
|| !same(identity.target, target) || identity.afterFingerprint !== workspaceSourceFingerprint(document)
|
|
45
|
+
|| identity.payloadFingerprint !== workspaceSourceFingerprint(payload)) throw new Error('The draft preview request has inconsistent document, payload, or source identity.');
|
|
46
|
+
const { fingerprint, ...parts } = identity;
|
|
47
|
+
if (fingerprint !== workspaceSourceFingerprint(parts) || documentRevision !== hash(identity.afterFingerprint)) throw new Error('The draft revision must hash the complete captured document identity.');
|
|
48
|
+
let before; let mapping;
|
|
49
|
+
try {
|
|
50
|
+
before = JSON.parse(identity.beforeFingerprint);
|
|
51
|
+
mapping = JSON.parse(identity.mappingFingerprint);
|
|
52
|
+
} catch { throw new Error('The draft preview requires complete source baseline and mapping fingerprints.'); }
|
|
53
|
+
if (workspaceSourceFingerprint(before) !== identity.beforeFingerprint || workspaceSourceFingerprint(mapping) !== identity.mappingFingerprint
|
|
54
|
+
|| before.id !== document.id || before.sourceRevision !== target.sourceRevision || mapping.version !== 1
|
|
55
|
+
|| 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.');
|
|
56
|
+
if (request.sourceKind === 'file-plan') {
|
|
57
|
+
if (payload.version !== 1 || payload.sourceRevision !== target.sourceRevision || !Array.isArray(payload.proofs)
|
|
58
|
+
|| !same(mapping.files, payload.proofs)) throw new Error('The captured file plan, mapping, and file proofs must agree.');
|
|
59
|
+
} else if (!payload.sourceProof || payload.sourceProof.sourceRevision !== target.sourceRevision || !same(mapping.files, payload.sourceProof.files)) {
|
|
60
|
+
throw new Error('The captured source baseline, mapping, and file proofs must agree.');
|
|
61
|
+
}
|
|
62
|
+
validateRoute(route);
|
|
63
|
+
return freeze(captured);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function captureOutput(outputDirectory, { maxOutputFiles, maxOutputBytes }, signal) {
|
|
67
|
+
const files = new Map(); let total = 0;
|
|
68
|
+
const visit = async (directory, prefix) => {
|
|
69
|
+
const directoryStat = await fsp.lstat(directory);
|
|
70
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error('Build output directories must not be symbolic links.');
|
|
71
|
+
for (const entry of await fsp.readdir(directory, { withFileTypes: true })) {
|
|
72
|
+
signal.throwIfAborted();
|
|
73
|
+
const key = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
74
|
+
if (!relative(key)) throw new Error('Build output contains an unsafe path.');
|
|
75
|
+
const absolute = path.join(directory, entry.name);
|
|
76
|
+
const stat = await fsp.lstat(absolute);
|
|
77
|
+
if (stat.isDirectory()) { await visit(absolute, key); continue; }
|
|
78
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Build output must contain regular files only.');
|
|
79
|
+
if (files.size >= maxOutputFiles || total + stat.size > maxOutputBytes) throw new Error('The built preview exceeds its output resource limits.');
|
|
80
|
+
const bytes = await fsp.readFile(absolute, { signal });
|
|
81
|
+
if (bytes.length !== stat.size) throw new Error('Build output changed while it was captured.');
|
|
82
|
+
total += bytes.length; files.set(key, bytes);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
await visit(outputDirectory, '');
|
|
86
|
+
if (!files.has('index.html')) throw new Error('The built application requires index.html.');
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const mimeTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
91
|
+
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.map': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
92
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon',
|
|
93
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm', '.txt': 'text/plain; charset=utf-8' };
|
|
94
|
+
function closeServer(server) {
|
|
95
|
+
if (!server) return Promise.resolve();
|
|
96
|
+
return new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build in a disposable archive and serve only captured bytes from a separate loopback origin.
|
|
101
|
+
* The host must first validate file-plan requests against its canonical source compiler.
|
|
102
|
+
* Identity consistency and archived file proofs do not establish a document-to-source mapping.
|
|
103
|
+
* The archive is the request's exact commit; a request for another revision than the
|
|
104
|
+
* checked-out one builds that revision's files, never the working tree relabelled.
|
|
105
|
+
*/
|
|
106
|
+
export function createWorkspaceDraftPreviewService(options) {
|
|
107
|
+
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.');
|
|
108
|
+
if (options.prepareSource !== undefined && typeof options.prepareSource !== 'function') throw new Error('Source preparation must be a trusted host callback.');
|
|
109
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
110
|
+
const appDirectory = options.appDirectory ?? '.';
|
|
111
|
+
if (appDirectory !== '.' && !relative(appDirectory)) throw new Error('The application directory must be project-relative.');
|
|
112
|
+
const maxRuns = positive(options.maxRuns, 3);
|
|
113
|
+
const limits = { maxOutputFiles: positive(options.maxOutputFiles, 10000), maxOutputBytes: positive(options.maxOutputBytes, 128 * 1024 * 1024) };
|
|
114
|
+
const build = options.build; const handleRequest = options.handleRequest; const prepareSource = options.prepareSource;
|
|
115
|
+
const dependenciesRoot = options.dependenciesRoot ? path.resolve(options.dependenciesRoot) : null;
|
|
116
|
+
const runs = new Set(); let disposed = false; let disposal;
|
|
117
|
+
|
|
118
|
+
const prepare = (input, { signal } = {}) => {
|
|
119
|
+
if (disposed) return Promise.reject(new Error('The draft preview service has been disposed.'));
|
|
120
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('Draft preview was aborted.'));
|
|
121
|
+
if (runs.size >= maxRuns) return Promise.reject(new Error('The draft preview service has reached its active run limit.'));
|
|
122
|
+
let captured;
|
|
123
|
+
try { captured = captureInput(input); } catch (error) { return Promise.reject(error); }
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const run = { controller, server: null, files: null, temporary: null, done: null };
|
|
126
|
+
const abort = () => { controller.abort(signal?.reason); void closeServer(run.server); };
|
|
127
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
128
|
+
runs.add(run);
|
|
129
|
+
run.done = (async () => {
|
|
130
|
+
let ready = false;
|
|
131
|
+
try {
|
|
132
|
+
run.temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'pygmalion-draft-preview-'));
|
|
133
|
+
controller.signal.throwIfAborted();
|
|
134
|
+
// The archive holds exactly the requested commit, whatever the repository's
|
|
135
|
+
// working tree or HEAD currently contains. Every host callback receives that
|
|
136
|
+
// commit so a build for one revision can never be labelled as another.
|
|
137
|
+
const sourceRevision = captured.request.target.sourceRevision;
|
|
138
|
+
const appRoot = await materializeSourceArchive(projectRoot, appDirectory, sourceRevision, run.temporary, controller.signal);
|
|
139
|
+
controller.signal.throwIfAborted();
|
|
140
|
+
// A trusted host may stage its reviewed working-copy inputs in this archive.
|
|
141
|
+
// Full request proofs are still checked below, after the callback settles.
|
|
142
|
+
if (prepareSource) await prepareSource({ appRoot, request: captured.request, sourceRevision, signal: controller.signal });
|
|
143
|
+
controller.signal.throwIfAborted();
|
|
144
|
+
if (captured.request.sourceKind === 'file-plan') {
|
|
145
|
+
await applySourceFilePlan(appRoot, captured.request.payload, {
|
|
146
|
+
sourceRevision: captured.request.target.sourceRevision,
|
|
147
|
+
allowedFiles: captured.request.payload.changes.map(change => change.file),
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
await validateInspectSourceProofs(appRoot, captured.request.payload, { sourceRevision: captured.request.target.sourceRevision });
|
|
152
|
+
controller.signal.throwIfAborted();
|
|
153
|
+
await writeInspectChanges(appRoot, captured.request.payload);
|
|
154
|
+
}
|
|
155
|
+
controller.signal.throwIfAborted();
|
|
156
|
+
if (dependenciesRoot) {
|
|
157
|
+
const dependencies = await fsp.realpath(dependenciesRoot);
|
|
158
|
+
if (!(await fsp.stat(dependencies)).isDirectory()) throw new Error('The dependency root must identify an existing node_modules directory.');
|
|
159
|
+
await fsp.symlink(dependencies, path.join(appRoot, 'node_modules'), 'dir');
|
|
160
|
+
}
|
|
161
|
+
const outputDirectory = path.join(run.temporary, 'output');
|
|
162
|
+
await fsp.mkdir(outputDirectory);
|
|
163
|
+
await build({ appRoot, outputDirectory, request: captured.request, sourceRevision, signal: controller.signal });
|
|
164
|
+
controller.signal.throwIfAborted();
|
|
165
|
+
run.files = await captureOutput(outputDirectory, limits, controller.signal);
|
|
166
|
+
await fsp.rm(run.temporary, { recursive: true, force: true }); run.temporary = null;
|
|
167
|
+
controller.signal.throwIfAborted();
|
|
168
|
+
let authority;
|
|
169
|
+
const server = http.createServer((request, response) => {
|
|
170
|
+
void (async () => {
|
|
171
|
+
response.setHeader('cache-control', 'no-store'); response.setHeader('x-content-type-options', 'nosniff');
|
|
172
|
+
if (request.headers.host !== authority) { response.writeHead(403).end('Unexpected preview origin.'); return; }
|
|
173
|
+
if (handleRequest) {
|
|
174
|
+
const handled = await handleRequest(request, response, { request: captured.request, sourceRevision });
|
|
175
|
+
if (handled || response.headersSent || response.writableEnded) return;
|
|
176
|
+
}
|
|
177
|
+
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { allow: 'GET, HEAD' }).end(); return; }
|
|
178
|
+
let pathname;
|
|
179
|
+
try { pathname = decodeURIComponent(new URL(request.url, `http://${authority}`).pathname); }
|
|
180
|
+
catch { response.writeHead(400).end(); return; }
|
|
181
|
+
if (pathname.includes('\\') || pathname.split('/').some(part => part === '..' || part === '.')) { response.writeHead(400).end(); return; }
|
|
182
|
+
let key = pathname.slice(1) || 'index.html';
|
|
183
|
+
let bytes = run.files?.get(key);
|
|
184
|
+
if (!bytes && !path.posix.extname(key)) { key = 'index.html'; bytes = run.files?.get(key); }
|
|
185
|
+
if (!bytes) { response.writeHead(404).end(); return; }
|
|
186
|
+
response.writeHead(200, { 'content-type': mimeTypes[path.posix.extname(key)] ?? 'application/octet-stream', 'content-length': bytes.length });
|
|
187
|
+
response.end(request.method === 'HEAD' ? undefined : bytes);
|
|
188
|
+
})().catch(() => { if (response.headersSent) response.destroy(); else response.writeHead(500).end('Preview request failed.'); });
|
|
189
|
+
});
|
|
190
|
+
run.server = server;
|
|
191
|
+
await new Promise((resolve, reject) => {
|
|
192
|
+
const aborted = () => reject(controller.signal.reason ?? new Error('Draft preview was aborted.'));
|
|
193
|
+
const cleanup = () => { controller.signal.removeEventListener('abort', aborted); server.off('error', failed); };
|
|
194
|
+
const failed = error => { cleanup(); reject(error); };
|
|
195
|
+
controller.signal.addEventListener('abort', aborted, { once: true });
|
|
196
|
+
server.once('error', failed);
|
|
197
|
+
server.listen({ host: '127.0.0.1', port: 0, signal: controller.signal }, () => { cleanup(); resolve(); });
|
|
198
|
+
});
|
|
199
|
+
controller.signal.throwIfAborted();
|
|
200
|
+
authority = `127.0.0.1:${server.address().port}`;
|
|
201
|
+
ready = true;
|
|
202
|
+
return Object.freeze({ url: `http://${authority}${captured.route}`, documentRevision: captured.documentRevision });
|
|
203
|
+
} finally {
|
|
204
|
+
if (!ready) {
|
|
205
|
+
await closeServer(run.server);
|
|
206
|
+
if (run.temporary) await fsp.rm(run.temporary, { recursive: true, force: true });
|
|
207
|
+
run.files?.clear(); runs.delete(run); signal?.removeEventListener('abort', abort);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
})();
|
|
211
|
+
controller.signal.addEventListener('abort', () => {
|
|
212
|
+
void closeServer(run.server).then(() => {
|
|
213
|
+
if (!run.temporary) { run.files?.clear(); runs.delete(run); signal?.removeEventListener('abort', abort); }
|
|
214
|
+
});
|
|
215
|
+
}, { once: true });
|
|
216
|
+
return run.done;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return Object.freeze({ prepare, dispose() {
|
|
220
|
+
if (disposal) return disposal;
|
|
221
|
+
disposed = true;
|
|
222
|
+
const active = [...runs]; active.forEach(run => run.controller.abort(new Error('The draft preview service was disposed.')));
|
|
223
|
+
disposal = (async () => {
|
|
224
|
+
await Promise.allSettled(active.map(run => run.done));
|
|
225
|
+
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); }));
|
|
226
|
+
})();
|
|
227
|
+
return disposal;
|
|
228
|
+
} });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export { workspaceDraftPreviewPlugin } from './workspace-draft-preview-plugin.mjs';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function workspaceSourceFingerprint(value: unknown): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Full JSON identity with sorted object keys; array order and every value are retained. */
|
|
2
|
+
export function workspaceSourceFingerprint(value) {
|
|
3
|
+
const ancestors = new Set();
|
|
4
|
+
const encode = (item) => {
|
|
5
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean') return JSON.stringify(item);
|
|
6
|
+
if (typeof item === 'number' && Number.isFinite(item)) return Object.is(item, -0) ? '-0' : String(item);
|
|
7
|
+
if (!item || typeof item !== 'object' || ancestors.has(item)
|
|
8
|
+
|| (!Array.isArray(item) && ![Object.prototype, null].includes(Object.getPrototypeOf(item)))) throw new Error('Source fingerprints require complete, finite JSON values.');
|
|
9
|
+
ancestors.add(item);
|
|
10
|
+
try {
|
|
11
|
+
if (Object.getOwnPropertySymbols(item).length) throw new Error('Source fingerprints cannot omit symbol properties.');
|
|
12
|
+
if (Array.isArray(item)) {
|
|
13
|
+
if (Object.keys(item).length !== item.length || Array.from({ length: item.length }, (_, index) => index).some((index) => !Object.hasOwn(item, index))) throw new Error('Source fingerprints require dense JSON arrays.');
|
|
14
|
+
return `[${item.map(encode).join(',')}]`;
|
|
15
|
+
}
|
|
16
|
+
return `{${Object.keys(item).sort().map((key) => `${JSON.stringify(key)}:${encode(item[key])}`).join(',')}}`;
|
|
17
|
+
} finally { ancestors.delete(item); }
|
|
18
|
+
};
|
|
19
|
+
return encode(value);
|
|
20
|
+
}
|
|
21
|
+
|