@thomas.f/prnext 0.1.16-alpha → 0.1.18-alpha
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/AI.md +63 -0
- package/README.md +35 -503
- package/build/ADAPTERS.md +27 -2
- package/build/actions.mjs +6 -1
- package/build/analyze.mjs +340 -0
- package/build/app-config.mjs +20 -8
- package/build/app-static.mjs +41 -14
- package/build/app-styles.mjs +79 -0
- package/build/app.mjs +99 -31
- package/build/build-lock.mjs +6 -6
- package/build/bundle-runtime.mjs +39 -11
- package/build/cache-components.mjs +1 -1
- package/build/cache-handlers.mjs +1 -1
- package/build/check.mjs +56 -11
- package/build/compact-css.mjs +53 -0
- package/build/compilation-cache.mjs +269 -0
- package/build/compilation-store.mjs +142 -0
- package/build/compiler.mjs +51 -4
- package/build/concurrent.mjs +12 -0
- package/build/config.mjs +50 -13
- package/build/create.mjs +239 -0
- package/build/css-modules.mjs +65 -10
- package/build/deployment-assets.mjs +89 -0
- package/build/deployment-config.mjs +18 -0
- package/build/dev-compiler-health.mjs +11 -0
- package/build/dev-css-update.mjs +85 -0
- package/build/dev-dependencies.mjs +44 -0
- package/build/dev-discovery.mjs +28 -0
- package/build/dev-worker.mjs +14 -5
- package/build/dev.mjs +64 -6
- package/build/dynamic.mjs +1 -1
- package/build/edge.mjs +5 -4
- package/build/fonts.mjs +1 -1
- package/build/framework-imports.mjs +50 -0
- package/build/html-only-config.mjs +53 -0
- package/build/html-only-graph.mjs +67 -0
- package/build/incremental.mjs +8 -4
- package/build/index.mjs +140 -112
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +37 -4
- package/build/instrumentation.mjs +30 -0
- package/build/loader-cache.mjs +53 -26
- package/build/metadata-routes.mjs +3 -4
- package/build/migrate.mjs +60 -57
- package/build/migration-audit.mjs +1 -1
- package/build/native-type-config.mjs +37 -0
- package/build/npm-packages.mjs +3 -18
- package/build/package-manager.mjs +308 -0
- package/build/persistent-cache.mjs +112 -0
- package/build/precompress.mjs +62 -30
- package/build/prerender-cache.mjs +70 -0
- package/build/prerender-config.mjs +29 -0
- package/build/prerender-pages.mjs +100 -0
- package/build/prerender-pool.mjs +165 -0
- package/build/prerender-process-worker.mjs +58 -0
- package/build/prerender-worker.mjs +10 -0
- package/build/production-builder.mjs +129 -0
- package/build/react-compiler-report.mjs +225 -0
- package/build/react-compiler.mjs +139 -0
- package/build/runtime-contract.mjs +2 -2
- package/build/scan.mjs +4 -4
- package/build/source-analysis.mjs +42 -0
- package/build/static-adapter.mjs +13 -5
- package/build/static-export.mjs +4 -4
- package/build/static-input.mjs +48 -0
- package/build/styles.mjs +17 -3
- package/build/transform-cache.mjs +37 -4
- package/build/transform.mjs +1 -1
- package/build/type-command.mjs +41 -0
- package/build/typed-routes.mjs +95 -0
- package/build/typescript-dev-worker.mjs +18 -0
- package/build/typescript-dev.mjs +49 -0
- package/build/typescript-native-worker.mjs +55 -0
- package/build/typescript-native.mjs +18 -0
- package/build/typescript-toolchain.mjs +19 -0
- package/build/typescript-watch.mjs +22 -0
- package/build/typescript.mjs +11 -8
- package/build/webpack-graph.mjs +170 -30
- package/build/worker-memory.mjs +9 -0
- package/build/worker-preload.mjs +9 -0
- package/cli.mjs +162 -122
- package/compat/activity-cache.cjs +26 -0
- package/compat/after.cjs +6 -4
- package/compat/app-layout-context.cjs +52 -2
- package/compat/base64.cjs +10 -0
- package/compat/cache-handlers.cjs +12 -2
- package/compat/cache-wire.cjs +28 -0
- package/compat/cache.cjs +2 -1
- package/compat/cache.d.cts +4 -0
- package/compat/cookies.cjs +21 -5
- package/compat/data-cache.cjs +12 -8
- package/compat/flight-delta.cjs +150 -0
- package/compat/headers.cjs +4 -2
- package/compat/incremental-cache.cjs +20 -7
- package/compat/index.d.ts +89 -10
- package/compat/link.cjs +12 -9
- package/compat/link.d.cts +4 -3
- package/compat/navigation-authorization.cjs +9 -0
- package/compat/navigation-stages.cjs +19 -0
- package/compat/navigation.d.cts +4 -3
- package/compat/offline-store.cjs +11 -0
- package/compat/offline.cjs +5 -0
- package/compat/offline.d.cts +2 -0
- package/compat/redis-cache.cjs +201 -0
- package/compat/redis-cache.d.cts +37 -0
- package/compat/router.d.cts +4 -3
- package/compat/server-primitives.cjs +31 -11
- package/compat/server.d.cts +15 -2
- package/compat/static-generation.cjs +7 -0
- package/compat/typed-route.d.cts +17 -0
- package/compat/use-cache.cjs +3 -0
- package/docs/ai/compatibility.md +275 -0
- package/docs/ai/data.md +288 -0
- package/docs/ai/deployment.md +269 -0
- package/docs/ai/manifest.json +11 -0
- package/docs/ai/quickstart.md +171 -0
- package/docs/ai/routing.md +214 -0
- package/docs/ai/troubleshooting.md +155 -0
- package/index.mjs +2 -0
- package/native/diagnostics.mjs +34 -0
- package/native/resolve.mjs +7 -2
- package/package.json +44 -7
- package/release/ai-docs.mjs +48 -0
- package/release/verify.mjs +2 -0
- package/runtime/action-client.mjs +10 -4
- package/runtime/action-server.mjs +2 -0
- package/runtime/ai-docs.mjs +202 -0
- package/runtime/app-client.mjs +226 -68
- package/runtime/app-content.mjs +4 -3
- package/runtime/app-partial-client.mjs +3 -0
- package/runtime/app-partial.mjs +1 -1
- package/runtime/app-prefetch-constants.mjs +4 -0
- package/runtime/app-prefetch.mjs +2 -3
- package/runtime/app-render-key.mjs +5 -0
- package/runtime/app-render.mjs +100 -28
- package/runtime/app-routing.mjs +22 -9
- package/runtime/app-static.mjs +18 -8
- package/runtime/app-stream-html.mjs +10 -53
- package/runtime/app-style-selection.mjs +34 -0
- package/runtime/app-styles.mjs +154 -0
- package/runtime/cache-components-rsc.mjs +4 -1
- package/runtime/cache-request.mjs +2 -1
- package/runtime/cloud/archive.mjs +5 -6
- package/runtime/compile-cache.mjs +8 -0
- package/runtime/deployment-client.mjs +19 -0
- package/runtime/dev-ai-brands.mjs +26 -0
- package/runtime/dev-ai-style.mjs +99 -0
- package/runtime/dev-build-reasons.mjs +25 -0
- package/runtime/dev-cache-control.mjs +53 -0
- package/runtime/dev-client.mjs +245 -46
- package/runtime/dev-compiler-broker.mjs +42 -0
- package/runtime/dev-console-copy.mjs +19 -0
- package/runtime/dev-console.mjs +106 -0
- package/runtime/dev-dependency-graph.mjs +40 -0
- package/runtime/dev-element-picker-style.mjs +27 -0
- package/runtime/dev-element-picker.mjs +213 -0
- package/runtime/dev-error-overlay-style.mjs +41 -0
- package/runtime/dev-error-overlay.mjs +167 -0
- package/runtime/dev-error-report.mjs +121 -0
- package/runtime/dev-file-changes.mjs +1 -1
- package/runtime/dev-gateway.mjs +44 -0
- package/runtime/dev-host-relay.mjs +66 -0
- package/runtime/dev-indicator.mjs +191 -0
- package/runtime/dev-issue-motion.mjs +214 -0
- package/runtime/dev-lazy-tool.mjs +34 -0
- package/runtime/dev-logo.mjs +2 -0
- package/runtime/dev-notification.mjs +81 -0
- package/runtime/dev-popout.mjs +34 -0
- package/runtime/dev-position.mjs +107 -0
- package/runtime/dev-preview-touch.mjs +122 -0
- package/runtime/dev-runtime-pool.mjs +45 -0
- package/runtime/dev-runtime-spare.mjs +57 -0
- package/runtime/dev-tools-client.mjs +155 -0
- package/runtime/dev-tools-mcp.mjs +126 -0
- package/runtime/dev-tools-metrics.mjs +31 -0
- package/runtime/dev-tools-model.mjs +42 -0
- package/runtime/dev-tools-panel.mjs +341 -0
- package/runtime/dev-tools-result.mjs +37 -0
- package/runtime/dev-tools-server.mjs +256 -0
- package/runtime/dev-tools-source.mjs +104 -0
- package/runtime/dev-tooltip.mjs +94 -0
- package/runtime/dev-ui-icons.mjs +8 -0
- package/runtime/dev-workbench-style.mjs +106 -0
- package/runtime/dev-workbench.mjs +428 -0
- package/runtime/dev-worker-logs.mjs +31 -0
- package/runtime/development.mjs +324 -0
- package/runtime/document.mjs +1 -1
- package/runtime/edge.mjs +6 -5
- package/runtime/env.mjs +2 -1
- package/runtime/fetch-cache.mjs +10 -8
- package/runtime/flight-delta.mjs +50 -0
- package/runtime/flight-tap.mjs +28 -0
- package/runtime/html-only.mjs +27 -0
- package/runtime/http.mjs +2 -0
- package/runtime/incremental-cache.mjs +25 -4
- package/runtime/instrumentation.mjs +49 -0
- package/runtime/navigation-recovery.mjs +36 -0
- package/runtime/navigation.mjs +7 -0
- package/runtime/node-html-stream.mjs +47 -0
- package/runtime/pages-client.mjs +24 -8
- package/runtime/pages-navigation.mjs +54 -64
- package/runtime/persistent/client.mjs +6 -5
- package/runtime/persistent/daemon.mjs +66 -56
- package/runtime/persistent/native-proxy.mjs +63 -0
- package/runtime/prefetch-scheduler.mjs +110 -0
- package/runtime/preload.mjs +12 -0
- package/runtime/profile.mjs +14 -0
- package/runtime/profiles.json +2 -2
- package/runtime/render.mjs +3 -0
- package/runtime/route-pattern.mjs +44 -0
- package/runtime/rsc-worker.mjs +41 -12
- package/runtime/trace.mjs +78 -0
- package/runtime/typed-route.mjs +19 -0
- package/runtime/watch-project.mjs +29 -18
- package/runtime/worker.mjs +102 -24
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { elementPickerStyle } from './dev-element-picker-style.mjs';
|
|
2
|
+
|
|
3
|
+
const PROPERTIES = ['display', 'position', 'width', 'height', 'color', 'background-color', 'font-family', 'font-size', 'font-weight', 'line-height', 'padding', 'margin', 'gap', 'border-radius', 'align-items', 'justify-content', 'grid-template-columns'];
|
|
4
|
+
const HOVER_HOLD = 35, SMALL_HOVER_HOLD = 75, POINTER_REST = 50;
|
|
5
|
+
const pickerEpochs=new WeakMap();
|
|
6
|
+
const toolNode = node => {
|
|
7
|
+
for(let current=node;current;current=current.getRootNode?.().host)
|
|
8
|
+
if(current.closest?.('prnext-dev-tools,prnext-dev-picker,#__prnext_dev_error__'))return true;
|
|
9
|
+
return false;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function sourceLocation(element) {
|
|
13
|
+
for(let owner=element;owner;owner=owner.parentElement||owner.getRootNode?.().host) {
|
|
14
|
+
const source=owner.getAttribute?.('data-prnext-source');
|
|
15
|
+
if(source)return {source,sourceRelation:owner===element?'element':'ancestor'};
|
|
16
|
+
}
|
|
17
|
+
return {source:null,sourceRelation:null};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function describeElement(element, view = globalThis) {
|
|
21
|
+
const bounds = element.getBoundingClientRect(), style = view.getComputedStyle(element);
|
|
22
|
+
const {source,sourceRelation}=sourceLocation(element);
|
|
23
|
+
const tag = element.tagName.toLowerCase(), selector = element.id ? '#' + view.CSS.escape(element.id) : tag + [...element.classList].slice(0, 3).map(name => '.' + view.CSS.escape(name)).join('');
|
|
24
|
+
let text = '';
|
|
25
|
+
if (['input', 'textarea', 'select'].includes(tag)) text = '[Form value omitted]';
|
|
26
|
+
else {
|
|
27
|
+
const walker = view.document.createTreeWalker(element, view.NodeFilter.SHOW_TEXT);
|
|
28
|
+
while (walker.nextNode() && text.length < 4000) {
|
|
29
|
+
if (!walker.currentNode.parentElement?.closest('input,textarea,select,script,style,[hidden],[aria-hidden="true"]')) text += walker.currentNode.textContent + ' ';
|
|
30
|
+
}
|
|
31
|
+
text = text.trim().replace(/\s+/g, ' ').slice(0, 4000);
|
|
32
|
+
}
|
|
33
|
+
return { kind: 'element', path: view.location.pathname, selector, tag, text, source, sourceRelation,
|
|
34
|
+
bounds: { x: Math.round(bounds.x), y: Math.round(bounds.y), width: Math.round(bounds.width), height: Math.round(bounds.height) },
|
|
35
|
+
styles: Object.fromEntries(PROPERTIES.map(property => [property, style.getPropertyValue(property)])) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// User-triggered capture only. Form controls, scripts, frames and development UI
|
|
39
|
+
// never enter an image; a CORS/CSP failure is reported rather than hidden.
|
|
40
|
+
export async function captureElementImage(element) {
|
|
41
|
+
const { toPng } = await import('html-to-image');
|
|
42
|
+
const bounds = element.getBoundingClientRect();
|
|
43
|
+
if (!bounds.width || !bounds.height) throw Error('The element has no visible size.');
|
|
44
|
+
if (['INPUT', 'TEXTAREA', 'SELECT', 'IFRAME', 'VIDEO'].includes(element.tagName)) throw Error('Form values and embedded content are excluded from images.');
|
|
45
|
+
const scale = Math.min(1, 1400 / bounds.width, 1000 / bounds.height);
|
|
46
|
+
const result = await toPng(element, { pixelRatio: scale, skipFonts: true, cacheBust: false,
|
|
47
|
+
filter: node => !toolNode(node) && !['SCRIPT', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT', 'VIDEO', 'AUDIO'].includes(node.tagName),
|
|
48
|
+
});
|
|
49
|
+
if (result.length > 349525) throw Error('The image exceeds the 256 KB context limit. Select a smaller element.');
|
|
50
|
+
return { mimeType: 'image/png', data: result.slice(result.indexOf(',') + 1), width: Math.round(bounds.width * scale), height: Math.round(bounds.height * scale) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createElementPicker({ tools, onSelect, onCancel = () => {}, view = globalThis }) {
|
|
54
|
+
const document = view.document, controller = new AbortController(), options = { signal: controller.signal, capture: true };
|
|
55
|
+
const epoch=(pickerEpochs.get(tools)||0)+1;pickerEpochs.set(tools,epoch);
|
|
56
|
+
const host = document.createElement('prnext-dev-picker'), shadow = host.attachShadow({ mode: 'open' });
|
|
57
|
+
const style = document.createElement('style'); style.textContent=elementPickerStyle;
|
|
58
|
+
const node=(tag,className,text)=>{const result=document.createElement(tag);if(className)result.className=className;if(text!==undefined)result.textContent=text;return result;};
|
|
59
|
+
const icon=paths=>`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
60
|
+
const box = node('div','target'); box.hidden = true; box.setAttribute('aria-hidden','true');
|
|
61
|
+
for(const corner of ['tl','t','tr','r','br','b','bl','l']){const mark=node('span','corner');mark.dataset.corner=corner;box.append(mark);}
|
|
62
|
+
const tag=node('div','picker-tag');tag.hidden=true;tag.setAttribute('aria-hidden','true');
|
|
63
|
+
const tagTop=node('div','picker-tag-top'),dimensions=node('span','picker-dimensions'),source=node('span','picker-source');
|
|
64
|
+
const fileIcon=node('span','picker-file-icon'),fileName=node('span','picker-source-name'),fileLine=node('span','picker-source-line'),parentSource=node('span','picker-source-parent','Parent');
|
|
65
|
+
source.append(fileIcon,fileName,fileLine,parentSource);tagTop.append(dimensions);tag.append(tagTop,source);
|
|
66
|
+
const fileTypes={js:['javascript','JS'],jsx:['javascript','JS'],mjs:['javascript','JS'],cjs:['javascript','JS'],ts:['typescript','TS'],tsx:['typescript','TS'],mts:['typescript','TS'],cts:['typescript','TS'],html:['html','<>'],htm:['html','<>'],css:['css','#'],scss:['css','#'],json:['json','{}'],vue:['vue','V'],svelte:['svelte','S'],md:['markdown','M'],mdx:['markdown','M'],py:['python','Py'],rs:['rust','Rs']};
|
|
67
|
+
let fileIconKey;
|
|
68
|
+
function updateFileIcon(filename) {
|
|
69
|
+
const extension=filename?.split('.').at(-1)?.toLowerCase(),type=Object.hasOwn(fileTypes,extension)?fileTypes[extension]:null,key=type?.[0]||'file';
|
|
70
|
+
if(fileIconKey===key)return;fileIconKey=key;fileIcon.dataset.language=key;
|
|
71
|
+
if(type){fileIcon.textContent=type[1];fileIcon.title=key;}
|
|
72
|
+
else{fileIcon.innerHTML=icon('<path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8Z"/><path d="M14 3v5h5"/>');fileIcon.title='File';}
|
|
73
|
+
}
|
|
74
|
+
const hint=node('div','hint');hint.setAttribute('role','region');hint.setAttribute('aria-label','Element picker');
|
|
75
|
+
const instruction=node('span','picker-instruction','Pick an element'),enter=node('kbd','picker-enter','↵');enter.setAttribute('aria-hidden','true');
|
|
76
|
+
const cancelButton=node('button','picker-cancel','Cancel');cancelButton.type='button';cancelButton.setAttribute('aria-label','Cancel');
|
|
77
|
+
const escape=node('kbd','','esc');escape.setAttribute('aria-hidden','true');cancelButton.append(escape);cancelButton.onclick=()=>cancel();
|
|
78
|
+
const live=node('span','sr-only');live.setAttribute('role','status');
|
|
79
|
+
hint.append(instruction,enter,cancelButton);shadow.append(style,box,tag,hint,live);document.documentElement.append(host);
|
|
80
|
+
const previous = document.activeElement; let active, frame, pending, hoverTimer, pointer, stopped = false, viewportKey, placed = false;
|
|
81
|
+
const viewport=()=>({left:view.visualViewport?.offsetLeft||0,top:view.visualViewport?.offsetTop||0,width:view.visualViewport?.width||view.innerWidth,height:view.visualViewport?.height||view.innerHeight});
|
|
82
|
+
function placeHint() {
|
|
83
|
+
const visible=viewport(),key=[visible.left,visible.top,visible.width,visible.height].join(':');
|
|
84
|
+
if(viewportKey!==key){viewportKey=key;hint.style.maxWidth=Math.max(1,visible.width-24)+'px';}
|
|
85
|
+
const height=hint.offsetHeight,width=hint.offsetWidth;let top=Math.max(visible.top+8,visible.top+visible.height-height-16),left=visible.left+(visible.width-width)/2;
|
|
86
|
+
const hud=document.querySelector('prnext-dev-tools')?.getBoundingClientRect();
|
|
87
|
+
if(hud?.width&&left<hud.right&&left+width>hud.left&&top<hud.bottom&&top+height>hud.top){
|
|
88
|
+
const candidates=[hud.right+8,hud.left-width-8],fitting=candidates.find(candidate=>candidate>=visible.left+12&&candidate+width<=visible.left+visible.width-12);
|
|
89
|
+
if(fitting!==undefined)left=fitting;else top=Math.max(visible.top+8,hud.top-height-12);
|
|
90
|
+
}
|
|
91
|
+
hint.style.left=(left+width/2)+'px';hint.style.bottom='auto';hint.style.top=top+'px';
|
|
92
|
+
return {visible,hint:{top,height}};
|
|
93
|
+
}
|
|
94
|
+
function placeTag(rect, animate) {
|
|
95
|
+
const {visible,hint:bar}=placeHint(),margin=8,gap=9;
|
|
96
|
+
tag.style.maxWidth=Math.max(1,Math.min(280,visible.width-margin*2))+'px';
|
|
97
|
+
const size=tag.getBoundingClientRect(),minX=visible.left+margin,maxX=visible.left+visible.width-size.width-margin;
|
|
98
|
+
const maxY=Math.min(visible.top+visible.height-size.height-margin,bar.top-size.height-margin),minY=visible.top+margin;
|
|
99
|
+
if(maxY<minY){tag.hidden=true;return;}
|
|
100
|
+
const above=rect.top-size.height-gap,below=rect.bottom+gap;
|
|
101
|
+
const top=above>=minY&&above<=maxY?above:below>=minY&&below<=maxY?below:Math.min(maxY,Math.max(minY,above));
|
|
102
|
+
const left=Math.min(Math.max(minX,maxX),Math.max(minX,rect.left));
|
|
103
|
+
// A wider filename must not travel outside the viewport from its old anchor.
|
|
104
|
+
// Otherwise CSS can retarget the current animation without restarting it.
|
|
105
|
+
tag.dataset.motion=String(animate&&size.left>=minX&&size.left<=maxX&&size.top>=minY&&size.top<=maxY);
|
|
106
|
+
tag.style.translate=left+'px '+top+'px';tag.dataset.side=top+size.height<=rect.top?'above':'below';
|
|
107
|
+
}
|
|
108
|
+
function highlight(element, keyboard=false, animate=true) {
|
|
109
|
+
const node=element;
|
|
110
|
+
if (!node || toolNode(node) || node === document.documentElement || node === document.body) return;
|
|
111
|
+
active = node; const rect = node.getBoundingClientRect();
|
|
112
|
+
if(!rect.width||!rect.height){box.hidden=true;tag.hidden=true;placed=false;return;}
|
|
113
|
+
const moving=animate&&placed;
|
|
114
|
+
box.dataset.motion=String(moving);box.hidden = false; Object.assign(box.style, { translate: rect.left+'px '+rect.top+'px', width: rect.width+'px', height: rect.height+'px' });
|
|
115
|
+
const location=sourceLocation(node),parsed=location.source?.match(/^(.*?):(\d+)(?::(\d+))?$/),filename=parsed?.[1].replaceAll('\\','/').split('/').at(-1);
|
|
116
|
+
dimensions.textContent=Math.round(rect.width)+' × '+Math.round(rect.height);
|
|
117
|
+
updateFileIcon(filename);fileName.textContent=filename||location.source||'<'+node.tagName.toLowerCase()+'>';
|
|
118
|
+
fileLine.textContent=parsed?':'+parsed[2]:'';fileLine.hidden=!parsed;parentSource.hidden=location.sourceRelation!=='ancestor';
|
|
119
|
+
source.title=location.source?(location.sourceRelation==='ancestor'?'Parent source: ':'')+location.source:'Source location unavailable';source.dataset.ancestor=String(location.sourceRelation==='ancestor');
|
|
120
|
+
tag.hidden=false;placeTag(rect,moving);
|
|
121
|
+
// Commit the first rectangle in place; later targets interpolate from it.
|
|
122
|
+
// The existing layout read in placeTag also commits the frame's geometry.
|
|
123
|
+
placed=true;
|
|
124
|
+
if(keyboard)live.textContent=node.tagName.toLowerCase()+', '+Math.round(rect.width)+' by '+Math.round(rect.height)+' pixels'+(filename?', '+(location.sourceRelation==='ancestor'?'parent source ':'source ')+filename+', line '+parsed[2]:'')+'. Enter selects.';
|
|
125
|
+
}
|
|
126
|
+
function clearHover(resetPointer=true) {
|
|
127
|
+
view.clearTimeout(hoverTimer);view.cancelAnimationFrame(frame);
|
|
128
|
+
hoverTimer=undefined;frame=undefined;pending=undefined;if(resetPointer)pointer=undefined;
|
|
129
|
+
}
|
|
130
|
+
function hoverReadyAt() {
|
|
131
|
+
const minimum=pending.since+pending.hold;
|
|
132
|
+
const slow=pending.slowSince===undefined?Infinity:Math.max(minimum,pending.slowSince+pending.hold);
|
|
133
|
+
return Math.min(slow,Math.max(minimum,pointer.lastMove+POINTER_REST));
|
|
134
|
+
}
|
|
135
|
+
function queueHover() {
|
|
136
|
+
view.clearTimeout(hoverTimer);hoverTimer=undefined;
|
|
137
|
+
if(stopped||!pending)return;
|
|
138
|
+
const remaining=hoverReadyAt()-view.performance.now();
|
|
139
|
+
if(frame!==undefined){if(remaining<=0)return;view.cancelAnimationFrame(frame);frame=undefined;}
|
|
140
|
+
if(remaining>0){hoverTimer=view.setTimeout(queueHover,remaining);return;}
|
|
141
|
+
frame=view.requestAnimationFrame(()=>{
|
|
142
|
+
frame=undefined;
|
|
143
|
+
// Acceleration between the timer and paint must not commit a stale hover.
|
|
144
|
+
if(stopped||!pending)return;
|
|
145
|
+
if(hoverReadyAt()>view.performance.now()){queueHover();return;}
|
|
146
|
+
const candidate=pending.node;pending=undefined;
|
|
147
|
+
if(candidate.isConnected)highlight(candidate);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function hover(node,event) {
|
|
151
|
+
if(!node||toolNode(node)||node===document.documentElement||node===document.body){clearHover();return;}
|
|
152
|
+
const now=view.performance.now(),x=event.clientX,y=event.clientY;
|
|
153
|
+
if(!pointer)pointer={x,y,time:now,lastMove:now,speed:0,instant:0};
|
|
154
|
+
else{
|
|
155
|
+
const distance=Math.hypot(x-pointer.x,y-pointer.y),elapsed=now-pointer.time;
|
|
156
|
+
// Compare with the last meaningful position so subpixel steps accumulate.
|
|
157
|
+
if(distance>1){
|
|
158
|
+
const instant=distance/Math.max(1,Math.min(elapsed,64));
|
|
159
|
+
pointer={x,y,time:now,lastMove:now,instant,speed:elapsed>120?instant:pointer.speed*.35+instant*.65};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if(node===active){
|
|
163
|
+
clearHover(false);frame=view.requestAnimationFrame(()=>{frame=undefined;if(!stopped&&node.isConnected)highlight(node);});return;
|
|
164
|
+
}
|
|
165
|
+
if(node!==pending?.node){
|
|
166
|
+
clearHover(false);const rect=node.getBoundingClientRect();
|
|
167
|
+
pending={node,since:now,hold:Math.min(rect.width,rect.height)<28||active?.contains(node)?SMALL_HOVER_HOLD:HOVER_HOLD};
|
|
168
|
+
}
|
|
169
|
+
// Hysteresis accepts deliberate slow movement, while a sudden acceleration
|
|
170
|
+
// cancels that decision. A short pause also confirms intent after a fast move.
|
|
171
|
+
if(pointer.instant>=.85||pointer.speed>=.65)pending.slowSince=undefined;
|
|
172
|
+
else if(pointer.speed<=.45&&pending.slowSince===undefined)pending.slowSince=now;
|
|
173
|
+
queueHover();
|
|
174
|
+
}
|
|
175
|
+
function stop(restore = true) { if (stopped) return; stopped = true; controller.abort(); clearHover(); host.remove(); if (restore && previous?.isConnected) previous.focus({ preventScroll: true }); }
|
|
176
|
+
function cancel(){if(stopped)return;stop();onCancel();}
|
|
177
|
+
async function select(node) {
|
|
178
|
+
if (!node || toolNode(node)) return;
|
|
179
|
+
const context = describeElement(node, view); stop(false); onSelect(context);
|
|
180
|
+
let expectedContext,knownContext=false;
|
|
181
|
+
try{const state=tools.snapshot?.();knownContext=Boolean(state&&Object.hasOwn(state,'context'));expectedContext=state?.context;}catch{}
|
|
182
|
+
const image = captureElementImage(node).then(screenshot => ({ screenshot })).catch(error => ({ screenshot: null, screenshotError: error.message }));
|
|
183
|
+
const mapped = context.source ? tools.call('dev_source', { source: context.source }).catch(error => ({ mapped: false, error: error.message })) : Promise.resolve({ mapped: false, error: 'No source location was emitted for this element.' });
|
|
184
|
+
const [capture, source] = await Promise.all([image, mapped]);
|
|
185
|
+
if(pickerEpochs.get(tools)!==epoch)return;
|
|
186
|
+
if(knownContext){try{if(tools.snapshot().context!==expectedContext)return;}catch{return;}}
|
|
187
|
+
const complete = { ...context, ...capture, mappedSource: source }; tools.setContext(complete); void tools.browser('selection', { selection: complete }); onSelect(complete);
|
|
188
|
+
}
|
|
189
|
+
document.addEventListener('pointermove', event => {
|
|
190
|
+
hover(event.composedPath().find(item => item instanceof view.Element),event);
|
|
191
|
+
}, options);
|
|
192
|
+
document.addEventListener('pointerout',event=>{if(!event.relatedTarget)clearHover();},options);
|
|
193
|
+
document.addEventListener('pointercancel',clearHover,options);view.addEventListener('blur',clearHover,options);
|
|
194
|
+
document.addEventListener('click', event => {
|
|
195
|
+
const node = event.composedPath().find(item => item instanceof view.Element); if (toolNode(node)) return;
|
|
196
|
+
event.preventDefault(); event.stopImmediatePropagation(); void select(node);
|
|
197
|
+
}, options);
|
|
198
|
+
document.addEventListener('keydown', event => {
|
|
199
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }
|
|
200
|
+
else if (event.key === 'Enter' && active && !toolNode(event.composedPath().find(item=>item instanceof view.Element))) { event.preventDefault(); event.stopImmediatePropagation(); void select(active); }
|
|
201
|
+
}, options);
|
|
202
|
+
document.addEventListener('focusin', event => {clearHover();highlight(event.composedPath().find(item=>item instanceof view.Element),true);}, options);
|
|
203
|
+
// Scrolling and resizing keep the outline attached, without a trailing frame.
|
|
204
|
+
const reposition=()=>{clearHover();placeHint();if(active)highlight(active,false,false);};
|
|
205
|
+
view.addEventListener('scroll',reposition,options);view.addEventListener('resize',reposition,options);
|
|
206
|
+
view.visualViewport?.addEventListener('scroll',reposition,options);view.visualViewport?.addEventListener('resize',reposition,options);
|
|
207
|
+
placeHint();queueMicrotask(()=>{if(!stopped)live.textContent='Pick an element. Click, or use Tab and Enter. Escape or Cancel exits.';});
|
|
208
|
+
// Leave the inert logo menu's tab sequence. The next Tab starts with the
|
|
209
|
+
// application's first control rather than cycling back through tool buttons.
|
|
210
|
+
const tabindex=document.body.getAttribute('tabindex');document.body.tabIndex=-1;document.body.focus({preventScroll:true});
|
|
211
|
+
if(tabindex===null)document.body.removeAttribute('tabindex');else document.body.setAttribute('tabindex',tabindex);
|
|
212
|
+
return { dispose: stop };
|
|
213
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Development-only error dialog; application styles never enter this shadow root.
|
|
2
|
+
export const devErrorOverlayStyle = `
|
|
3
|
+
:host{all:initial;position:fixed;inset:0;z-index:2147483647;background:#080808;color-scheme:dark;color:#ededed;font:13px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:left;direction:ltr;-webkit-font-smoothing:antialiased}
|
|
4
|
+
*,*::before,*::after{box-sizing:border-box}[hidden]{display:none!important}
|
|
5
|
+
dialog{position:fixed;inset:0;margin:auto;width:min(960px,calc(100vw - 48px));height:fit-content;max-width:none;max-height:calc(100dvh - 48px);padding:0;border:0;border-radius:14px;background:#080808;color:#ededed;box-shadow:0 0 0 1px #242424;overflow:hidden;overscroll-behavior:contain;font:inherit;text-align:left}
|
|
6
|
+
dialog[open]{display:flex;flex-direction:column}dialog::backdrop{background:#080808}
|
|
7
|
+
.error-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px 16px;padding:14px 20px;border-bottom:1px solid #202020;flex:none}
|
|
8
|
+
.error-brand{display:flex;align-items:center;gap:8px;min-width:0;color:#aaa;font-size:12px}.error-brand strong{color:#ededed;font-weight:600}.error-brand-label::before{content:'/';margin-right:8px;color:#555}.error-count{min-width:20px;padding:1px 5px;border-radius:5px;background:#1b1b1b;color:#bbb;text-align:center;font:11px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace;font-variant-numeric:tabular-nums}
|
|
9
|
+
.error-header-actions{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:6px;margin-left:auto;max-width:100%}.icon{display:inline-flex;flex:none}.icon svg{display:block;width:14px;height:14px;flex:none}
|
|
10
|
+
button,a{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:36px;padding:7px 10px;border:1px solid #303030;border-radius:8px;background:#111;color:#ddd;font:500 12px/1.5 ui-sans-serif,system-ui;white-space:nowrap;text-decoration:none;cursor:pointer;-webkit-tap-highlight-color:transparent}
|
|
11
|
+
button:hover,a:hover{background:#1a1a1a;color:#fff}button:focus-visible,a:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:none;background:#242424;color:#fff;box-shadow:inset 0 0 0 1px #555}
|
|
12
|
+
dialog[open]:focus,dialog[open]:focus-visible{outline:none}.error-ai:focus-visible{background:#fff;color:#080808;box-shadow:inset 0 0 0 1px #777}
|
|
13
|
+
.error-dismiss{flex:none;width:36px;padding:0;background:transparent;border-color:transparent;color:#999}.error-dismiss .icon svg{width:16px;height:16px}.error-copy{min-width:108px}.error-copy[data-status=copied]{color:#a8dabd}.error-copy[data-status=error]{color:#f39199}
|
|
14
|
+
.error-ai{background:#e5e5e5;border-color:transparent;color:#111}.error-ai:hover{background:#fff;color:#080808}
|
|
15
|
+
.error-issue-region{flex:none;min-width:0;padding:8px 12px;border-bottom:1px solid #202020}.error-list{max-height:min(216px,28dvh);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#333 #080808}
|
|
16
|
+
.error-row{display:grid;grid-template-columns:126px minmax(0,1fr) 8px;align-items:center;gap:3px 12px;width:100%;min-height:40px;padding:10px 12px;border:0;border-radius:7px;background:transparent;text-align:left;font-weight:400;white-space:normal}
|
|
17
|
+
.error-row:hover{background:#111}.error-row[aria-pressed=true]{background:#191919;color:#eee}.error-row-type{display:flex;align-items:center;gap:7px;min-width:0;color:#aaa;font-size:11px;white-space:nowrap}.error-row-dot{width:5px;height:5px;border-radius:50%;background:#eb707b;flex:none}.error-row[data-recoverable=true] .error-row-dot{background:transparent;box-shadow:inset 0 0 0 1px #eb707b}
|
|
18
|
+
.error-row-message{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#c6c6c6;font-size:12px}.error-row-file{grid-column:2;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#888;font:10px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace}.error-list-note{margin:6px 12px 2px;color:#888;font-size:11px}
|
|
19
|
+
.error-row[data-kind=hydration]{grid-template-columns:minmax(0,1fr) 8px}.error-row[data-kind=hydration] .error-row-type{font-size:12px;color:#ddd}.error-row[data-kind=hydration] .error-row-file{grid-column:1}.error-row[aria-expanded=false] .error-row-file{display:none}.error-row:focus-visible{background:#242424;box-shadow:inset 0 0 0 1px #555}
|
|
20
|
+
.error-row-chevron{grid-column:-2;grid-row:1;justify-self:end;width:6px;height:6px;border-right:1px solid #888;border-bottom:1px solid #888;rotate:45deg;translate:0 -2px}.error-row[aria-expanded=true] .error-row-chevron{rotate:225deg;translate:0 1px}
|
|
21
|
+
.error-body{min-height:0;overflow:auto;overscroll-behavior:contain;padding:20px;scrollbar-width:thin;scrollbar-color:#333 #080808}
|
|
22
|
+
.error-intro{margin-bottom:18px}.error-intro h1{margin:0;color:#f0f0f0;font-size:20px;line-height:1.3;letter-spacing:-.025em;font-weight:600;text-wrap:balance}
|
|
23
|
+
.error-title-toggle{justify-content:flex-start;gap:12px;max-width:100%;min-height:32px;padding:2px 0;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;letter-spacing:inherit;white-space:normal;text-align:left}.error-title-toggle::after{content:'';width:7px;height:7px;flex:none;border-right:1px solid #888;border-bottom:1px solid #888;rotate:225deg;translate:0 2px}.error-title-toggle:hover{background:transparent;color:#fff}
|
|
24
|
+
.error-summary{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;max-width:70ch;margin:9px 0 0;color:#b8b8b8;font-size:13px;line-height:1.6;overflow-wrap:anywhere;text-wrap:pretty}
|
|
25
|
+
.error-editor-actions{display:flex;justify-content:flex-end;margin:-6px 0 18px}.error-editor-actions a{min-height:32px;padding:5px 8px;font-size:11px;background:transparent;color:#aaa;border-color:transparent}.error-editor-actions a:hover{color:#eee;background:#191919}
|
|
26
|
+
.source{margin:0 0 18px;min-width:0;border-radius:11px;background:#111;overflow:hidden;box-shadow:inset 0 0 0 1px #ffffff08}
|
|
27
|
+
.source-head{display:flex;align-items:center;gap:8px;min-width:0;padding:11px 16px;border-bottom:1px solid #222;color:#bbb}.source-head svg{width:14px;height:14px;flex:none;color:#888}.location{min-width:0;overflow-wrap:anywhere;font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}
|
|
28
|
+
.source pre{max-height:310px;margin:0;padding:14px 16px;overflow:auto;overscroll-behavior:contain;white-space:pre;overflow-wrap:normal;tab-size:2;color:#d4d4d4;font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;scrollbar-width:thin;scrollbar-color:#3b3b3b #111}
|
|
29
|
+
.source pre:focus-visible{outline-offset:-2px}
|
|
30
|
+
.error-disclosures{border-top:1px solid #202020}.error-disclosure+.error-disclosure{border-top:1px solid #202020}
|
|
31
|
+
summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:40px;padding:10px 2px;color:#aaa;font-size:12px;list-style:none;cursor:pointer}
|
|
32
|
+
summary::-webkit-details-marker{display:none}summary::after{content:'';width:6px;height:6px;flex:none;margin-right:4px;border-right:1px solid currentColor;border-bottom:1px solid currentColor;rotate:45deg;translate:0 -2px}details[open]>summary::after{rotate:225deg;translate:0 1px}summary:hover{color:#eee}
|
|
33
|
+
.error-full{max-height:min(260px,35dvh);margin:0 0 14px;padding:14px 16px;border-radius:8px;background:#111;color:#bbb;overflow:auto;overscroll-behavior:contain;white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.65 ui-sans-serif,system-ui;tab-size:2;scrollbar-width:thin;scrollbar-color:#333 #111}
|
|
34
|
+
.error-stack{white-space:pre;overflow-wrap:normal;font:11px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace}
|
|
35
|
+
.error-full:focus-visible{outline-offset:-2px}.error-source-status{margin:14px 0 0;color:#888;font-size:12px;line-height:1.5;overflow-wrap:anywhere}.error-source-status:empty{display:none}
|
|
36
|
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
37
|
+
@media(max-width:600px){dialog{width:calc(100vw - 16px);max-height:calc(100dvh - 24px);border-radius:12px}.error-header{padding:12px 16px}.error-header-actions{flex:1 1 100%;justify-content:flex-start}.error-header-actions .error-dismiss{margin-left:auto}.error-issue-region{padding:7px 8px}.error-row{grid-template-columns:minmax(0,1fr) 8px;gap:3px;padding:9px 10px}.error-row-message,.error-row-file{grid-column:1}.error-list-note{margin-inline:10px}.error-body{padding:18px 16px}.error-intro h1{font-size:18px}.source-head{padding:10px 12px}.source pre{padding:12px;max-height:260px;font-size:11px}.error-full{padding:12px}}
|
|
38
|
+
@media(pointer:coarse){button,a,summary,.error-editor-actions a,.error-row,.error-title-toggle{min-height:44px}.error-dismiss{width:44px}}
|
|
39
|
+
@media(prefers-reduced-motion:no-preference){button:not(.error-row),a,summary{transition:background-color 120ms ease-out,color 120ms ease-out}}
|
|
40
|
+
@media(forced-colors:active){:host,dialog,dialog::backdrop{background:Canvas;color:CanvasText}dialog{border:1px solid CanvasText}.error-header,.error-issue-region,.error-disclosures,.error-disclosure+.error-disclosure,.source-head{border-color:CanvasText}.error-intro h1,.error-brand,.error-brand strong,.error-count,.error-summary,.error-row-type,.error-row-message,.error-row-file,.error-list-note,summary,.error-source-status,.location{color:CanvasText}.error-count,.error-row[aria-pressed=true]{background:Canvas}.error-row[aria-pressed=true]{outline:1px solid Highlight;outline-offset:-1px}.error-row-dot,.error-row[data-recoverable=true] .error-row-dot{background:CanvasText;box-shadow:none}.source,.error-full{background:Canvas;color:CanvasText;border:1px solid CanvasText;box-shadow:none}.source pre{color:CanvasText}.error-ai,button,a{background:ButtonFace;color:ButtonText;border-color:ButtonText}button:focus-visible,a:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:1px solid Highlight;outline-offset:-2px;box-shadow:none}}
|
|
41
|
+
`;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { svg } from './dev-ui-icons.mjs';
|
|
2
|
+
import { devErrorOverlayStyle } from './dev-error-overlay-style.mjs';
|
|
3
|
+
|
|
4
|
+
const hydrationMessage = /\bHydration failed because\b|\bA tree hydrated but some attributes\b|\bText content does not match server-rendered HTML\b/i;
|
|
5
|
+
const buildSourcePrefix = /^(?:(?:[A-Za-z]:[\\/]|\/)[^:\r\n]*|[^\s:\r\n]+)\.(?:[cm]?[jt]sx?|css|scss|sass|less|json|html?|vue|svelte|astro)(?:\(\d+(?:,\d+)?\)|:\d+(?::\d+)?)?:\s*/i;
|
|
6
|
+
|
|
7
|
+
function stripBuildSourcePrefixes(line) {
|
|
8
|
+
// Compiler errors can nest an entry filename around an original source path.
|
|
9
|
+
// Remove only recognizable source prefixes, never arbitrary colon-separated text.
|
|
10
|
+
for (let count = 0; count < 16; count++) {
|
|
11
|
+
const next = line.replace(buildSourcePrefix, '');
|
|
12
|
+
if (next === line) break;
|
|
13
|
+
line = next;
|
|
14
|
+
}
|
|
15
|
+
return line;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Only the summary is shortened. The original message and stack stay in the dialog.
|
|
19
|
+
export function summarizeDevError(report, { recoverable = false, kind = 'runtime' } = {}) {
|
|
20
|
+
const message = typeof report === 'string' ? report : String(report?.message ?? '');
|
|
21
|
+
const normalizedKind = String(kind).toLowerCase();
|
|
22
|
+
const hydration = hydrationMessage.test(message);
|
|
23
|
+
const title = hydration ? 'Hydration mismatch' : ['typescript', 'type', 'typecheck'].includes(normalizedKind) ? 'TypeScript'
|
|
24
|
+
: normalizedKind === 'build' ? 'Build error' : recoverable || normalizedKind === 'recoverable' ? 'Recovered error' : 'Runtime error';
|
|
25
|
+
let firstLine = message.split(/\r?\n/).find(line => line.trim())?.replace(/^(?:Uncaught\s+)?(?:[\w.]*Error):\s*/, '').trim() || '';
|
|
26
|
+
if (normalizedKind === 'build') firstLine = stripBuildSourcePrefixes(firstLine);
|
|
27
|
+
const description = hydration ? 'The server and browser rendered different output.' : firstLine
|
|
28
|
+
|| (title === 'Build error' ? 'Compilation could not finish.' : title === 'TypeScript' ? 'TypeScript reported a problem in your project.' : 'An error occurred while running this page.');
|
|
29
|
+
return { title, description: description.length > 240 ? description.slice(0, 237).trimEnd() + '…' : description, hydration };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createDevErrorOverlay({ onDismiss = () => {}, onSendToAI = () => {}, onSelectIssue = () => {}, onCopyForAI = () => {} } = {}) {
|
|
33
|
+
const document = globalThis.document;
|
|
34
|
+
const node = (tag, className, text) => { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; };
|
|
35
|
+
const host = node('div'); host.id = '__prnext_dev_error__';
|
|
36
|
+
const shadow = host.attachShadow({ mode: 'open' }), style = node('style'); style.textContent = devErrorOverlayStyle;
|
|
37
|
+
const dialog = node('dialog'); dialog.setAttribute('role', 'alertdialog'); dialog.setAttribute('aria-label', 'PRNext development error');
|
|
38
|
+
const icon = paths => { const result = node('span', 'icon'); result.innerHTML = svg(paths); return result; };
|
|
39
|
+
const header = node('header', 'error-header'), brand = node('div', 'error-brand');
|
|
40
|
+
const count = node('span', 'error-count'); count.setAttribute('aria-live', 'polite'); count.setAttribute('aria-atomic', 'true');
|
|
41
|
+
brand.append(node('strong', '', 'PRNext'), node('span', 'error-brand-label', 'Errors'), count);
|
|
42
|
+
const actions = node('div', 'error-header-actions');
|
|
43
|
+
const copy = node('button', 'error-copy'), copyIcon = icon(''), copyLabel = node('span', '', 'Copy for AI'); copy.type = 'button'; copy.id = 'copy-for-ai'; copy.append(copyIcon, copyLabel);
|
|
44
|
+
const ai = node('button', 'error-ai'); ai.type = 'button'; ai.append(icon('<path d="m12 3 2.2 6.8L21 12l-6.8 2.2L12 21l-2.2-6.8L3 12l6.8-2.2L12 3Z"/>'), node('span', '', 'Send to AI'));
|
|
45
|
+
const dismiss = node('button', 'error-dismiss'); dismiss.type = 'button'; dismiss.autofocus = true; dismiss.setAttribute('aria-label', 'Dismiss'); dismiss.title = 'Dismiss (Esc)'; dismiss.append(icon('<path d="m6 6 12 12M6 18 18 6"/>'));
|
|
46
|
+
const copyStatus = node('span', 'sr-only'); copyStatus.id = 'error-copy-status'; copyStatus.setAttribute('role', 'status'); copyStatus.setAttribute('aria-atomic', 'true');
|
|
47
|
+
actions.append(copy, ai, dismiss); header.append(brand, actions, copyStatus);
|
|
48
|
+
const issueRegion = node('section', 'error-issue-region');
|
|
49
|
+
const issueList = node('div', 'error-list'); issueList.setAttribute('role', 'group'); issueList.setAttribute('aria-label', 'Errors');
|
|
50
|
+
const omittedNote = node('p', 'error-list-note'); omittedNote.hidden = true; issueRegion.append(issueList, omittedNote);
|
|
51
|
+
const body = node('div', 'error-body'), intro = node('div', 'error-intro'), heading = node('h1'), title = node('button', 'error-title-toggle'), summary = node('p', 'error-summary');
|
|
52
|
+
body.id = 'error-details'; body.hidden = true; body.setAttribute('role', 'region'); body.setAttribute('aria-labelledby', 'error-title');
|
|
53
|
+
title.type = 'button'; title.id = 'error-title'; title.setAttribute('aria-controls', body.id); title.setAttribute('aria-expanded', 'false');
|
|
54
|
+
summary.id = 'error-summary'; heading.append(title); intro.append(heading, summary);
|
|
55
|
+
const editorActions = node('div', 'error-editor-actions'), editor = node('a'); editor.id = 'editor'; editor.hidden = true; editorActions.hidden = true;
|
|
56
|
+
editor.append(icon('<path d="M14 3h7v7m0-7L10 14"/><path d="M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5"/>'), node('span', '', 'Open in editor'));
|
|
57
|
+
editorActions.append(editor);
|
|
58
|
+
const source = node('section', 'source'); source.id = 'source'; source.hidden = true; source.setAttribute('aria-label', 'Source');
|
|
59
|
+
const sourceHead = node('div', 'source-head'), location = node('code', 'location'); location.id = 'source-location';
|
|
60
|
+
sourceHead.append(icon('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6Z"/><path d="M14 2v6h6"/>'), location);
|
|
61
|
+
const sourceFrame = node('pre'); sourceFrame.id = 'source-frame'; sourceFrame.tabIndex = 0; sourceFrame.setAttribute('aria-label', 'Source code');
|
|
62
|
+
source.append(sourceHead, sourceFrame);
|
|
63
|
+
const disclosures = node('div', 'error-disclosures');
|
|
64
|
+
const messageDisclosure = node('details', 'error-disclosure'); messageDisclosure.id = 'message-disclosure';
|
|
65
|
+
const fullMessage = node('pre', 'error-full'); fullMessage.id = 'message'; fullMessage.tabIndex = 0; fullMessage.setAttribute('aria-label', 'Full error message');
|
|
66
|
+
messageDisclosure.append(node('summary', '', 'Full message'), fullMessage);
|
|
67
|
+
const stackDisclosure = node('details', 'error-disclosure'); stackDisclosure.id = 'stack-disclosure';
|
|
68
|
+
const stack = node('pre', 'error-full error-stack'); stack.id = 'details'; stack.tabIndex = 0; stack.setAttribute('aria-label', 'Error stack trace');
|
|
69
|
+
stackDisclosure.append(node('summary', '', 'Stack trace'), stack); disclosures.append(messageDisclosure, stackDisclosure);
|
|
70
|
+
const sourceStatus = node('p', 'error-source-status'); sourceStatus.id = 'source-feedback'; sourceStatus.setAttribute('role', 'status');
|
|
71
|
+
body.append(intro, source, editorActions, disclosures, sourceStatus); dialog.append(header, issueRegion, body); shadow.append(style, dialog);
|
|
72
|
+
const rows = new Map();
|
|
73
|
+
let disposed = false, lastReport, selectedId, dismissing = false, expanded = false;
|
|
74
|
+
function setExpanded(value) {
|
|
75
|
+
expanded = Boolean(value);
|
|
76
|
+
const restoreFocus = !expanded && body.contains(shadow.activeElement);
|
|
77
|
+
body.hidden = !expanded; title.setAttribute('aria-expanded', String(expanded));
|
|
78
|
+
for (const [id, row] of rows) row.button.setAttribute('aria-expanded', String(expanded && id === selectedId));
|
|
79
|
+
if (expanded) dialog.setAttribute('aria-describedby', summary.id); else dialog.removeAttribute('aria-describedby');
|
|
80
|
+
if (restoreFocus) rows.get(selectedId)?.button.focus({ preventScroll: true });
|
|
81
|
+
}
|
|
82
|
+
title.onclick = () => { if (!disposed) setExpanded(!expanded); };
|
|
83
|
+
function requestDismiss() {
|
|
84
|
+
if (disposed || dismissing) return;
|
|
85
|
+
dismissing = true; if (dialog.open) dialog.close(); onDismiss();
|
|
86
|
+
}
|
|
87
|
+
dismiss.onclick = requestDismiss;
|
|
88
|
+
dialog.addEventListener('cancel', event => { event.preventDefault(); requestDismiss(); });
|
|
89
|
+
ai.onclick = () => { if (!disposed) onSendToAI(); };
|
|
90
|
+
copy.onclick = () => { if (!disposed) onCopyForAI(); };
|
|
91
|
+
function setCopyStatus(value = 'idle') {
|
|
92
|
+
if (disposed) return;
|
|
93
|
+
const status = value === 'copied' || value === 'error' ? value : 'idle';
|
|
94
|
+
copy.dataset.status = status;
|
|
95
|
+
copyLabel.textContent = status === 'copied' ? 'Copied' : status === 'error' ? 'Copy failed' : 'Copy for AI';
|
|
96
|
+
copy.setAttribute('aria-label', status === 'idle' ? 'Copy for AI' : `Copy for AI. ${copyLabel.textContent}.`);
|
|
97
|
+
copyIcon.innerHTML = svg(status === 'copied' ? '<path d="m5 12 4 4L19 6"/>' : status === 'error' ? '<circle cx="12" cy="12" r="9"/><path d="M12 7v6m0 3h.01"/>' : '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V4H4v12h4"/>');
|
|
98
|
+
copyStatus.textContent = status === 'copied' ? 'Copied for AI.' : status === 'error' ? 'Could not copy for AI.' : '';
|
|
99
|
+
}
|
|
100
|
+
function syncIssues(report, options) {
|
|
101
|
+
const issues = Array.isArray(options.issues) && options.issues.length ? options.issues : [{ id: 'current', report, kind: options.kind, recoverable: options.recoverable }];
|
|
102
|
+
const nextSelected = issues.find(issue => issue.id === options.selectedId)?.id ?? issues.find(issue => issue.report === report)?.id ?? issues[0].id;
|
|
103
|
+
const live = new Set(issues.map(issue => issue.id));
|
|
104
|
+
for (const [id, row] of rows) if (!live.has(id)) { row.button.remove(); rows.delete(id); }
|
|
105
|
+
issues.forEach((issue, index) => {
|
|
106
|
+
let row = rows.get(issue.id);
|
|
107
|
+
if (!row) {
|
|
108
|
+
const button = node('button', 'error-row'); button.type = 'button'; button.dataset.issueId = String(issue.id);
|
|
109
|
+
const type = node('span', 'error-row-type'), dot = node('span', 'error-row-dot'), label = node('span'); dot.setAttribute('aria-hidden', 'true'); type.append(dot, label);
|
|
110
|
+
const message = node('span', 'error-row-message'), file = node('code', 'error-row-file'); file.hidden = true;
|
|
111
|
+
const chevron = node('span', 'error-row-chevron'); chevron.setAttribute('aria-hidden', 'true');
|
|
112
|
+
button.setAttribute('aria-controls', body.id);
|
|
113
|
+
button.append(type, message, file, chevron); button.onclick = () => {
|
|
114
|
+
if (disposed) return;
|
|
115
|
+
const next = selectedId !== issue.id || !expanded;
|
|
116
|
+
onSelectIssue(issue.id); setExpanded(next);
|
|
117
|
+
};
|
|
118
|
+
row = { button, label, message, file }; rows.set(issue.id, row);
|
|
119
|
+
}
|
|
120
|
+
const next = summarizeDevError(issue.report, issue);
|
|
121
|
+
const message = typeof issue.report === 'string' ? issue.report : String(issue.report?.message ?? ''), stack = String(issue.report?.stack ?? '');
|
|
122
|
+
if (row.reportMessage !== message || row.reportStack !== stack || row.kind !== issue.kind) { row.file.textContent = ''; row.file.hidden = true; }
|
|
123
|
+
row.reportMessage = message; row.reportStack = stack; row.kind = issue.kind; row.label.textContent = next.title; row.message.textContent = next.description; row.message.title = next.description;
|
|
124
|
+
row.message.hidden = next.hydration;
|
|
125
|
+
row.button.dataset.kind = next.hydration ? 'hydration' : issue.kind || 'runtime'; row.button.dataset.recoverable = String(issue.recoverable === true); row.button.setAttribute('aria-pressed', String(issue.id === nextSelected));
|
|
126
|
+
row.button.setAttribute('aria-expanded', String(expanded && issue.id === nextSelected));
|
|
127
|
+
if (issueList.children[index] !== row.button) issueList.insertBefore(row.button, issueList.children[index] || null);
|
|
128
|
+
});
|
|
129
|
+
const omitted = Number.isFinite(options.omitted) ? Math.max(0, Math.trunc(options.omitted)) : 0, total = issues.length + omitted;
|
|
130
|
+
count.textContent = String(total); count.setAttribute('aria-label', `${total} ${total === 1 ? 'error' : 'errors'}`);
|
|
131
|
+
omittedNote.hidden = omitted === 0; omittedNote.textContent = omitted ? `${issues.length} shown · ${omitted} more` : '';
|
|
132
|
+
return nextSelected;
|
|
133
|
+
}
|
|
134
|
+
function setSourceStatus(text) { if (!disposed) sourceStatus.textContent = text || ''; }
|
|
135
|
+
function setSource(value) {
|
|
136
|
+
if (disposed) return;
|
|
137
|
+
const file = typeof value?.file === 'string' ? value.file : '';
|
|
138
|
+
const line = Number.isFinite(value?.line) ? value.line : undefined, column = Number.isFinite(value?.column) ? value.column : undefined;
|
|
139
|
+
location.textContent = file + (line ? ':' + line : '') + (column ? ':' + column : '');
|
|
140
|
+
location.title = location.textContent;
|
|
141
|
+
const frame = typeof value?.frame === 'string' ? value.frame : Array.isArray(value?.excerpt) ? value.excerpt.map(entry => `${entry.line === line ? '>' : ' '} ${entry.line} | ${entry.text}`).join('\n') : '';
|
|
142
|
+
sourceFrame.textContent = frame; sourceFrame.hidden = !frame; sourceHead.hidden = !file; source.hidden = !file && !frame;
|
|
143
|
+
editor.hidden = !/^(?:vscode|cursor):\/\/file\//.test(value?.editorUrl || '');
|
|
144
|
+
if (editor.hidden) editor.removeAttribute('href'); else editor.href = value.editorUrl;
|
|
145
|
+
editorActions.hidden = editor.hidden;
|
|
146
|
+
const row = rows.get(selectedId); if (row) { row.file.textContent = location.textContent; row.file.title = location.textContent; row.file.hidden = !file; }
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
host,
|
|
150
|
+
render(report, options = {}) {
|
|
151
|
+
if (disposed) return;
|
|
152
|
+
const nextSelected = syncIssues(report, options), next = summarizeDevError(report, options), changed = report !== lastReport || nextSelected !== selectedId;
|
|
153
|
+
selectedId = nextSelected;
|
|
154
|
+
title.textContent = next.title; summary.textContent = next.description;
|
|
155
|
+
fullMessage.textContent = typeof report === 'string' ? report : String(report?.message ?? '');
|
|
156
|
+
stack.textContent = String(report?.stack ?? ''); stackDisclosure.hidden = !stack.textContent;
|
|
157
|
+
dialog.dataset.kind = next.hydration ? 'hydration' : options.kind || 'runtime'; dialog.dataset.recoverable = String(options.recoverable === true);
|
|
158
|
+
if (changed) { setExpanded(false); messageDisclosure.open = false; stackDisclosure.open = false; setSource(undefined); setSourceStatus(''); setCopyStatus('idle'); body.scrollTop = 0; }
|
|
159
|
+
lastReport = report; dismissing = false;
|
|
160
|
+
if (!host.isConnected) document.documentElement.append(host);
|
|
161
|
+
if (!dialog.open) { dialog.showModal(); dismiss.focus({ preventScroll: true }); }
|
|
162
|
+
},
|
|
163
|
+
setSource, setSourceStatus, setCopyStatus,
|
|
164
|
+
focusDismiss() { if (!disposed && dialog.open) dismiss.focus({ preventScroll: true }); },
|
|
165
|
+
dispose() { if (disposed) return; disposed = true; rows.clear(); if (dialog.open) dialog.close(); host.remove(); },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Pure diagnostic snapshots and clipboard reports; no browser or connection state.
|
|
2
|
+
export const MAX_DEV_ISSUES = 50;
|
|
3
|
+
export const MAX_DEV_ISSUE_TEXT = 48 * 1024;
|
|
4
|
+
export const MAX_DEV_SOURCE_BYTES = 48 * 1024;
|
|
5
|
+
export const MAX_DEV_AI_REPORT_BYTES = 512 * 1024;
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
const read = (value, key) => { try { return value?.[key]; } catch { return undefined; } };
|
|
9
|
+
const string = value => { try { return String(value ?? ''); } catch { return '[unavailable diagnostic]'; } };
|
|
10
|
+
const provided = value => value !== undefined && value !== null;
|
|
11
|
+
|
|
12
|
+
function diagnostic(error) {
|
|
13
|
+
const name = read(error, 'name'), message = read(error, 'message'), stack = read(error, 'stack');
|
|
14
|
+
return { name: typeof name === 'string' ? name : 'Error', message: string(message || error), stack: string(stack || message || error) };
|
|
15
|
+
}
|
|
16
|
+
function boundedReport(value) {
|
|
17
|
+
return { name: value.name.slice(0, 256), message: value.message.slice(0, MAX_DEV_ISSUE_TEXT), stack: value.stack.slice(0, MAX_DEV_ISSUE_TEXT) };
|
|
18
|
+
}
|
|
19
|
+
const report = error => boundedReport(diagnostic(error));
|
|
20
|
+
function hash(value) {
|
|
21
|
+
let result = 2166136261;
|
|
22
|
+
for (let index = 0; index < value.length; index++) result = Math.imul(result ^ value.charCodeAt(index), 16777619);
|
|
23
|
+
return (result >>> 0).toString(36);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Current issues only. Preserve the original error identity separately from its bounded snapshot. */
|
|
27
|
+
export function collectDevIssues({ critical, criticalKind = 'runtime', typeErrors = [], recoverable } = {}) {
|
|
28
|
+
const types = [], unique = new Set(), groups = new Map();
|
|
29
|
+
for (const error of Array.isArray(typeErrors) ? typeErrors : []) {
|
|
30
|
+
const value = diagnostic(error), key = JSON.stringify([value.name, value.message, value.stack]);
|
|
31
|
+
if (unique.has(key)) continue;
|
|
32
|
+
unique.add(key);
|
|
33
|
+
const base = 'ts-' + hash(key), item = { key, base, error, report: boundedReport(value) };
|
|
34
|
+
types.push(item);
|
|
35
|
+
if (!groups.has(base)) groups.set(base, []);
|
|
36
|
+
groups.get(base).push(item);
|
|
37
|
+
}
|
|
38
|
+
// Rare short-hash collisions get deterministic suffixes independent of display order.
|
|
39
|
+
for (const group of groups.values()) {
|
|
40
|
+
group.sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0);
|
|
41
|
+
for (let index = 0; index < group.length; index++) group[index].id = group[index].base + (index ? '-' + (index + 1) : '');
|
|
42
|
+
}
|
|
43
|
+
const issues = [], hasCritical = provided(critical), hasRecoverable = provided(recoverable);
|
|
44
|
+
if (hasCritical) issues.push({ id: 'critical', error: critical, report: report(critical), kind: string(criticalKind || 'runtime').slice(0, 64), recoverable: false });
|
|
45
|
+
const available = MAX_DEV_ISSUES - Number(hasCritical) - Number(hasRecoverable);
|
|
46
|
+
for (const item of types.slice(0, available)) issues.push({ id: item.id, error: item.error, report: item.report, kind: 'typescript', recoverable: false });
|
|
47
|
+
if (hasRecoverable) issues.push({ id: 'recoverable', error: recoverable, report: report(recoverable), kind: 'recoverable', recoverable: true });
|
|
48
|
+
return { issues, omitted: Math.max(0, types.length - available) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pathname(value) {
|
|
52
|
+
try { return new URL(string(value || '/'), 'http://prnext.local').pathname.slice(0, 2048) || '/'; } catch { return '/'; }
|
|
53
|
+
}
|
|
54
|
+
const bytes = value => encoder.encode(value).length;
|
|
55
|
+
function clipBytes(value, limit) {
|
|
56
|
+
if (limit <= 0) return '';
|
|
57
|
+
if (bytes(value) <= limit) return value;
|
|
58
|
+
let low = 0, high = Math.min(value.length, limit);
|
|
59
|
+
while (low < high) {
|
|
60
|
+
const middle = Math.ceil((low + high) / 2);
|
|
61
|
+
if (bytes(value.slice(0, middle)) <= limit) low = middle; else high = middle - 1;
|
|
62
|
+
}
|
|
63
|
+
// Never split a UTF-16 pair at the byte boundary.
|
|
64
|
+
if (low && /[\uD800-\uDBFF]/.test(value[low - 1]) && /[\uDC00-\uDFFF]/.test(value[low] || '')) low--;
|
|
65
|
+
return value.slice(0, low);
|
|
66
|
+
}
|
|
67
|
+
function stackWithoutMessage({ name, message, stack }) {
|
|
68
|
+
for (const prefix of [name + ': ' + message, message]) {
|
|
69
|
+
if (stack === prefix || prefix.startsWith(stack)) return '';
|
|
70
|
+
for (const newline of ['\r\n', '\n']) if (stack.startsWith(prefix + newline)) return stack.slice(prefix.length + newline.length);
|
|
71
|
+
}
|
|
72
|
+
return stack;
|
|
73
|
+
}
|
|
74
|
+
function sourceFor(sourceById, id) {
|
|
75
|
+
try {
|
|
76
|
+
if (sourceById instanceof Map) return sourceById.get(id);
|
|
77
|
+
return sourceById && Object.hasOwn(sourceById, id) ? sourceById[id] : undefined;
|
|
78
|
+
} catch { return undefined; }
|
|
79
|
+
}
|
|
80
|
+
function sourceText(source) {
|
|
81
|
+
if (!source || typeof source !== 'object') return '';
|
|
82
|
+
const file = read(source, 'file'), frame = read(source, 'frame');
|
|
83
|
+
if (typeof file !== 'string' && typeof frame !== 'string') return '';
|
|
84
|
+
const line = read(source, 'line'), column = read(source, 'column');
|
|
85
|
+
let location = typeof file === 'string' ? file.slice(0, 2048) : '';
|
|
86
|
+
if (location && Number.isSafeInteger(line) && line > 0) {
|
|
87
|
+
location += ':' + line;
|
|
88
|
+
if (Number.isSafeInteger(column) && column > 0) location += ':' + column;
|
|
89
|
+
}
|
|
90
|
+
const original = [location ? 'Source: ' + location : '', typeof frame === 'string' ? frame : ''].filter(Boolean).join('\n');
|
|
91
|
+
if (bytes(original) <= MAX_DEV_SOURCE_BYTES) return original;
|
|
92
|
+
const note = '\n[Source frame truncated at 48 KiB.]';
|
|
93
|
+
return clipBytes(original, MAX_DEV_SOURCE_BYTES - bytes(note)) + note;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** This preserves recorded diagnostic text; it does not claim to sanitize user-logged secrets. */
|
|
97
|
+
export function formatDevIssuesForAI(issues, { route = '/', sourceById, omitted = 0 } = {}) {
|
|
98
|
+
const values = Array.isArray(issues) ? issues : [], selected = values.slice(0, MAX_DEV_ISSUES);
|
|
99
|
+
const knownOmitted = Number.isSafeInteger(omitted) && omitted > 0 ? omitted : 0;
|
|
100
|
+
let omittedCount = knownOmitted + Math.max(0, values.length - selected.length), truncated = false, limitedFields = false;
|
|
101
|
+
let output = 'Fix all errors below.\n\nRoute: ' + pathname(route);
|
|
102
|
+
// Reserve enough UTF-8 bytes for explicit omission/truncation notices.
|
|
103
|
+
const budget = MAX_DEV_AI_REPORT_BYTES - 512;
|
|
104
|
+
for (let index = 0; index < selected.length; index++) {
|
|
105
|
+
const issue = selected[index] || {}, detail = report(read(issue, 'report') || read(issue, 'error'));
|
|
106
|
+
const stack = stackWithoutMessage(detail), source = sourceText(sourceFor(sourceById, read(issue, 'id')));
|
|
107
|
+
limitedFields ||= detail.message.length === MAX_DEV_ISSUE_TEXT || detail.stack.length === MAX_DEV_ISSUE_TEXT;
|
|
108
|
+
const block = '\n\n' + (index + 1) + '. ' + string(read(issue, 'kind') || 'runtime').slice(0, 64) + ' — ' + detail.name
|
|
109
|
+
+ '\n' + detail.message + (stack ? '\n\nStack:\n' + stack : '') + (source ? '\n\n' + source : '');
|
|
110
|
+
const remaining = budget - bytes(output);
|
|
111
|
+
if (bytes(block) > remaining) {
|
|
112
|
+
output += clipBytes(block, remaining);truncated = true;omittedCount += selected.length - index - 1;break;
|
|
113
|
+
}
|
|
114
|
+
output += block;
|
|
115
|
+
}
|
|
116
|
+
if (!selected.length) output += '\n\nNo current errors.';
|
|
117
|
+
if (limitedFields) output += '\n\n[Diagnostic message and stack fields are limited to 48 KiB each.]';
|
|
118
|
+
if (truncated) output += '\n\n[Report truncated at 512 KiB.]';
|
|
119
|
+
if (omittedCount) output += '\n\n[' + omittedCount + ' additional current issue' + (omittedCount === 1 ? '' : 's') + ' omitted.]';
|
|
120
|
+
return output;
|
|
121
|
+
}
|
|
@@ -105,6 +105,6 @@ export function createDevChangeFilter(root, {maxEntries=512,maxBytes=1024*1024}=
|
|
|
105
105
|
}
|
|
106
106
|
return changed;
|
|
107
107
|
};
|
|
108
|
-
filter.acceptInputs=inputs=>{for(const [file,value] of inputs)remember(file,value);};
|
|
108
|
+
filter.acceptInputs=inputs=>{const changed=[];for(const [file,value] of inputs){if(fingerprints.get(file)?.value!==value)changed.push(file);remember(file,value);}return changed;};
|
|
109
109
|
return filter;
|
|
110
110
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export class DevGateway {
|
|
4
|
+
constructor(binary,root,{hostname,port,onEnsure,onOpen,onClose,onFatal}) {
|
|
5
|
+
this.next=0;this.pending=new Map();this.stopping=false;
|
|
6
|
+
this.child=spawn(binary,['dev-gateway',root,'--hostname',hostname,'--port',String(port)],{stdio:['pipe','pipe','inherit'],windowsHide:true});
|
|
7
|
+
this.closed=new Promise(resolve=>this.child.once('close',resolve));
|
|
8
|
+
this.ready=new Promise((resolve,reject)=>{
|
|
9
|
+
const timer=setTimeout(()=>{reject(Error('Development listener did not start'));this.child.kill();},15000);
|
|
10
|
+
let buffer='';this.child.stdout.setEncoding('utf8');
|
|
11
|
+
this.child.stdout.on('data',chunk=>{
|
|
12
|
+
buffer+=chunk;if(buffer.length>1024*1024){this.child.kill();return;}
|
|
13
|
+
for(let end;(end=buffer.indexOf('\n'))>=0;){
|
|
14
|
+
const line=buffer.slice(0,end);buffer=buffer.slice(end+1);let message;
|
|
15
|
+
try{message=JSON.parse(line);}catch{this.child.kill();return;}
|
|
16
|
+
if(message.type==='ready'){clearTimeout(timer);resolve(message);continue;}
|
|
17
|
+
if(message.type==='open'){onOpen(message);continue;}
|
|
18
|
+
if(message.type==='close'){onClose(message);continue;}
|
|
19
|
+
if(message.type==='ensure'){
|
|
20
|
+
Promise.resolve().then(()=>onEnsure(message.path,message.client)).then(value=>this.request('result',{request:message.id,value}),error=>this.request('result',{request:message.id,value:{error:String(error.message).slice(0,8000)}})).catch(error=>{if(!this.stopping)onFatal(error);});continue;
|
|
21
|
+
}
|
|
22
|
+
const pending=this.pending.get(message.id);if(!pending)continue;
|
|
23
|
+
this.pending.delete(message.id);clearTimeout(pending.timer);message.ok?pending.resolve(message.value):pending.reject(Error(message.error));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
const failed=error=>{clearTimeout(timer);reject(error);for(const pending of this.pending.values()){clearTimeout(pending.timer);pending.reject(error);}this.pending.clear();if(!this.stopping)onFatal(error);};
|
|
27
|
+
this.child.once('error',failed);this.child.once('exit',(code,signal)=>failed(Error(`Development listener exited ${signal||code}`)));
|
|
28
|
+
});
|
|
29
|
+
this.child.stdin.on('error',()=>{});
|
|
30
|
+
}
|
|
31
|
+
async request(command,values={}) {
|
|
32
|
+
await this.ready;
|
|
33
|
+
if(this.stopping||this.child.exitCode!==null)throw Error('Development listener is closed');
|
|
34
|
+
const id=++this.next;
|
|
35
|
+
const line=JSON.stringify({id,command,...values})+'\n';
|
|
36
|
+
if(Buffer.byteLength(line)>65536)throw Error(`Development ${command} control exceeds the 64 KiB frame budget`);
|
|
37
|
+
return new Promise((resolve,reject)=>{
|
|
38
|
+
const timer=setTimeout(()=>{this.pending.delete(id);reject(Error(`Development ${command} control timed out`));this.child.kill();},35000);
|
|
39
|
+
this.pending.set(id,{resolve,reject,timer});
|
|
40
|
+
this.child.stdin.write(line);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async close(){this.stopping=true;this.child.stdin.end();const timer=setTimeout(()=>this.child.kill('SIGKILL'),6000);try{await this.closed;}finally{clearTimeout(timer);}}
|
|
44
|
+
}
|