@thomas.f/prnext 0.1.15-alpha → 0.1.17-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 +387 -12
- package/build/ADAPTERS.md +27 -2
- package/build/actions.mjs +5 -0
- package/build/analyze.mjs +184 -0
- package/build/app-config.mjs +7 -8
- package/build/app-static.mjs +12 -7
- package/build/app.mjs +23 -10
- package/build/build-lock.mjs +6 -6
- package/build/bundle-runtime.mjs +20 -5
- package/build/check.mjs +56 -11
- package/build/compiler.mjs +25 -1
- package/build/concurrent.mjs +12 -0
- package/build/config.mjs +21 -13
- package/build/css-modules.mjs +15 -4
- package/build/dev-compiler-health.mjs +11 -0
- package/build/dev-css-update.mjs +83 -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 +55 -4
- package/build/edge.mjs +3 -2
- package/build/framework-imports.mjs +40 -0
- package/build/incremental.mjs +8 -4
- package/build/index.mjs +96 -101
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +37 -4
- package/build/loader-cache.mjs +53 -26
- package/build/metadata-routes.mjs +2 -3
- package/build/migrate.mjs +60 -57
- package/build/npm-packages.mjs +3 -18
- package/build/package-manager.mjs +308 -0
- package/build/persistent-cache.mjs +97 -0
- package/build/precompress.mjs +62 -30
- package/build/prerender-pages.mjs +90 -0
- package/build/prerender-worker.mjs +10 -0
- package/build/production-builder.mjs +80 -0
- package/build/scan.mjs +4 -4
- package/build/source-analysis.mjs +42 -0
- package/build/static-adapter.mjs +13 -5
- package/build/static-input.mjs +48 -0
- package/build/styles.mjs +15 -3
- package/build/transform-cache.mjs +37 -4
- package/build/type-command.mjs +41 -0
- package/build/typed-routes.mjs +91 -0
- package/build/typescript-dev-worker.mjs +18 -0
- package/build/typescript-dev.mjs +22 -0
- package/build/typescript-watch.mjs +22 -0
- package/build/typescript.mjs +5 -4
- package/build/webpack-graph.mjs +164 -27
- package/build/worker-preload.mjs +9 -0
- package/cli.mjs +105 -121
- package/compat/index.d.ts +21 -7
- package/compat/link.d.cts +4 -3
- package/compat/navigation.d.cts +4 -3
- package/compat/router.d.cts +4 -3
- package/compat/typed-route.d.cts +17 -0
- package/docs/ai/compatibility.md +106 -0
- package/docs/ai/data.md +128 -0
- package/docs/ai/deployment.md +111 -0
- package/docs/ai/manifest.json +11 -0
- package/docs/ai/quickstart.md +146 -0
- package/docs/ai/routing.md +132 -0
- package/docs/ai/troubleshooting.md +120 -0
- package/index.mjs +2 -0
- package/native/diagnostics.mjs +34 -0
- package/native/resolve.mjs +7 -2
- package/package.json +28 -10
- package/release/ai-docs.mjs +48 -0
- package/release/verify.mjs +6 -0
- package/runtime/ai-docs.mjs +202 -0
- package/runtime/app-client.mjs +17 -6
- package/runtime/app-render.mjs +12 -0
- package/runtime/cloud/archive.mjs +5 -6
- package/runtime/compile-cache.mjs +8 -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 +239 -45
- 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 +186 -0
- package/runtime/dev-issue-motion.mjs +214 -0
- package/runtime/dev-lazy-tool.mjs +18 -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 +307 -0
- package/runtime/env.mjs +2 -1
- package/runtime/pages-client.mjs +10 -1
- package/runtime/pages-navigation.mjs +6 -1
- package/runtime/persistent/client.mjs +6 -5
- package/runtime/persistent/daemon.mjs +66 -56
- package/runtime/persistent/native-proxy.mjs +63 -0
- package/runtime/preload.mjs +12 -0
- package/runtime/rsc-worker.mjs +7 -0
- package/runtime/typed-route.mjs +19 -0
- package/runtime/watch-project.mjs +12 -5
- package/runtime/worker.mjs +36 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Mouse panning for same-origin mobile previews. Real touch/pen events retain
|
|
2
|
+
// their native behavior; inputs and application-owned drag surfaces stay native.
|
|
3
|
+
export function createPreviewTouch(document) {
|
|
4
|
+
const view = document.defaultView, root = document.documentElement;
|
|
5
|
+
if (!view?.PointerEvent || !root) return { cancel() {}, dispose() {} };
|
|
6
|
+
const controller = new view.AbortController(), capture = { capture: true, signal: controller.signal };
|
|
7
|
+
const reduced = view.matchMedia('(prefers-reduced-motion: reduce)');
|
|
8
|
+
let gesture, momentum = 0, blockedClick, sendingCancel = false;
|
|
9
|
+
|
|
10
|
+
function stopMomentum() { if (momentum) { try { view.cancelAnimationFrame(momentum); } catch {} } momentum = 0; }
|
|
11
|
+
function release(state) {
|
|
12
|
+
try { if (root.hasPointerCapture(state.pointer)) root.releasePointerCapture(state.pointer); } catch {}
|
|
13
|
+
}
|
|
14
|
+
function cancel() {
|
|
15
|
+
stopMomentum(); const state = gesture; gesture = undefined; blockedClick = undefined;
|
|
16
|
+
if (state) release(state);
|
|
17
|
+
}
|
|
18
|
+
function scrollPlan(event) {
|
|
19
|
+
const elements = event.composedPath().filter(node => node?.nodeType === 1);
|
|
20
|
+
if (elements.some(node => node.matches('input,textarea,select,option,video,audio,canvas,[role="slider"],[role="spinbutton"],[draggable="true"]') || node.isContentEditable)) return;
|
|
21
|
+
const chain = []; let x = true, y = true;
|
|
22
|
+
for (const node of elements) {
|
|
23
|
+
const style = view.getComputedStyle(node), action = style.touchAction;
|
|
24
|
+
if (action === 'none') return;
|
|
25
|
+
if (/pan-/.test(action)) {
|
|
26
|
+
x &&= /pan-(x|left|right)/.test(action); y &&= /pan-(y|up|down)/.test(action);
|
|
27
|
+
}
|
|
28
|
+
const viewport = node === document.scrollingElement;
|
|
29
|
+
// With visible root overflow, body overflow controls the viewport.
|
|
30
|
+
const overflowX = viewport && style.overflowX === 'visible' ? view.getComputedStyle(document.body || node).overflowX : style.overflowX;
|
|
31
|
+
const overflowY = viewport && style.overflowY === 'visible' ? view.getComputedStyle(document.body || node).overflowY : style.overflowY;
|
|
32
|
+
const canX = (viewport ? !/hidden|clip/.test(overflowX) : /auto|scroll|overlay/.test(overflowX)) && node.scrollWidth > node.clientWidth + 1;
|
|
33
|
+
const canY = (viewport ? !/hidden|clip/.test(overflowY) : /auto|scroll|overlay/.test(overflowY)) && node.scrollHeight > node.clientHeight + 1;
|
|
34
|
+
if (canX || canY) chain.push({ node, x: canX, y: canY, overscrollX: style.overscrollBehaviorX, overscrollY: style.overscrollBehaviorY });
|
|
35
|
+
}
|
|
36
|
+
if (chain.length && (x || y)) return { chain, x, y };
|
|
37
|
+
}
|
|
38
|
+
function pan(state, distance) {
|
|
39
|
+
let remaining = distance, consumed = 0;
|
|
40
|
+
const horizontal = state.axis === 'x', property = horizontal ? 'scrollLeft' : 'scrollTop';
|
|
41
|
+
for (const entry of state.chain) {
|
|
42
|
+
if (!entry[state.axis]) continue;
|
|
43
|
+
const before = entry.node[property];
|
|
44
|
+
entry.node.scrollBy({ [horizontal ? 'left' : 'top']: remaining, behavior: 'instant' });
|
|
45
|
+
const moved = entry.node[property] - before; consumed += moved; remaining -= moved;
|
|
46
|
+
if (Math.abs(remaining) < .5 || /contain|none/.test(entry[horizontal ? 'overscrollX' : 'overscrollY'])) break;
|
|
47
|
+
}
|
|
48
|
+
return consumed;
|
|
49
|
+
}
|
|
50
|
+
function coast(state) {
|
|
51
|
+
if (reduced.matches || Math.abs(state.velocity) < .08 || view.performance.now() - state.time > 80) return;
|
|
52
|
+
const started = view.performance.now(); let previous = started, velocity = state.velocity;
|
|
53
|
+
const tick = now => {
|
|
54
|
+
momentum = 0;
|
|
55
|
+
const elapsed = Math.min(32, now - previous); previous = now;
|
|
56
|
+
if (now - started > 700 || Math.abs(velocity) < .02) return;
|
|
57
|
+
const moved = pan(state, velocity * elapsed);
|
|
58
|
+
velocity *= Math.exp(-elapsed / 160);
|
|
59
|
+
if (Math.abs(moved) > .1) momentum = view.requestAnimationFrame(tick);
|
|
60
|
+
};
|
|
61
|
+
momentum = view.requestAnimationFrame(tick);
|
|
62
|
+
}
|
|
63
|
+
function begin(event) {
|
|
64
|
+
if (event.pointerType !== 'mouse') { cancel(); return; }
|
|
65
|
+
if (event.pointerType !== 'mouse' || !event.isPrimary || event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.defaultPrevented) return;
|
|
66
|
+
cancel(); const plan = scrollPlan(event); if (!plan) return;
|
|
67
|
+
gesture = { ...plan, pointer: event.pointerId, target: event.target, startX: event.clientX, startY: event.clientY,
|
|
68
|
+
lastX: event.clientX, lastY: event.clientY, time: view.performance.now(), velocity: 0, axis: undefined };
|
|
69
|
+
}
|
|
70
|
+
function move(event) {
|
|
71
|
+
const state = gesture; if (!state || event.pointerId !== state.pointer) return;
|
|
72
|
+
const now = view.performance.now();
|
|
73
|
+
if (!state.axis) {
|
|
74
|
+
const dx = event.clientX - state.startX, dy = event.clientY - state.startY;
|
|
75
|
+
if (Math.hypot(dx, dy) < 6) return;
|
|
76
|
+
const axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
|
|
77
|
+
if (!state[axis] || !state.chain.some(entry => entry[axis])) { gesture = undefined; return; }
|
|
78
|
+
state.axis = axis;
|
|
79
|
+
// Like native panning, release the control that received pointerdown so
|
|
80
|
+
// pressed controls and custom pointer capture do not remain stuck.
|
|
81
|
+
sendingCancel = true;
|
|
82
|
+
try { state.target.dispatchEvent(new view.PointerEvent('pointercancel', { bubbles: true, composed: true, pointerId: state.pointer, pointerType: 'mouse', isPrimary: true })); }
|
|
83
|
+
finally { sendingCancel = false; }
|
|
84
|
+
if (gesture !== state) return;
|
|
85
|
+
try { root.setPointerCapture(state.pointer); } catch {}
|
|
86
|
+
}
|
|
87
|
+
event.preventDefault(); event.stopImmediatePropagation();
|
|
88
|
+
const delta = state.axis === 'x' ? state.lastX - event.clientX : state.lastY - event.clientY;
|
|
89
|
+
const moved = pan(state, delta), elapsed = Math.max(8, now - state.time);
|
|
90
|
+
const speed = Math.max(-3, Math.min(3, moved / elapsed));
|
|
91
|
+
state.velocity = speed * .7 + state.velocity * .3;
|
|
92
|
+
state.lastX = event.clientX; state.lastY = event.clientY; state.time = now;
|
|
93
|
+
}
|
|
94
|
+
function end(event) {
|
|
95
|
+
const state = gesture; if (!state || event.pointerId !== state.pointer) return;
|
|
96
|
+
gesture = undefined;
|
|
97
|
+
if (state.axis) {
|
|
98
|
+
event.preventDefault(); event.stopImmediatePropagation();
|
|
99
|
+
blockedClick = { pointer: state.pointer, time: view.performance.now() }; coast(state);
|
|
100
|
+
}
|
|
101
|
+
release(state);
|
|
102
|
+
}
|
|
103
|
+
function preventActivation(event) {
|
|
104
|
+
if (!blockedClick || event.detail === 0 || view.performance.now() - blockedClick.time > 500) return;
|
|
105
|
+
if (event.pointerId !== undefined && event.pointerId !== blockedClick.pointer) return;
|
|
106
|
+
event.preventDefault(); event.stopImmediatePropagation();
|
|
107
|
+
if (event.type === 'click') blockedClick = undefined;
|
|
108
|
+
}
|
|
109
|
+
document.addEventListener('pointerdown', begin, capture);
|
|
110
|
+
document.addEventListener('pointermove', move, { ...capture, passive: false });
|
|
111
|
+
document.addEventListener('pointerup', end, capture);
|
|
112
|
+
document.addEventListener('pointercancel', event => { if (!sendingCancel && event.pointerId === gesture?.pointer) cancel(); }, capture);
|
|
113
|
+
document.addEventListener('lostpointercapture', event => { if (event.target === root && event.pointerId === gesture?.pointer) cancel(); }, capture);
|
|
114
|
+
document.addEventListener('click', preventActivation, capture);
|
|
115
|
+
document.addEventListener('mouseup', preventActivation, capture);
|
|
116
|
+
for (const name of ['selectstart', 'dragstart']) document.addEventListener(name, event => { if (gesture) event.preventDefault(); }, capture);
|
|
117
|
+
document.addEventListener('wheel', cancel, { ...capture, passive: true });
|
|
118
|
+
document.addEventListener('keydown', cancel, capture);
|
|
119
|
+
for (const name of ['blur', 'pagehide', 'resize']) view.addEventListener(name, cancel, { signal: controller.signal });
|
|
120
|
+
reduced.addEventListener('change', cancel, { signal: controller.signal });
|
|
121
|
+
return { cancel, dispose() { cancel(); controller.abort(); } };
|
|
122
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {randomBytes} from 'node:crypto';
|
|
3
|
+
import {writeFile,rm} from 'node:fs/promises';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {EventEmitter} from 'node:events';
|
|
7
|
+
|
|
8
|
+
export function createDevRuntimePool({binary,root,home,environment={},onChild=()=>{}}){
|
|
9
|
+
let spare,disposed=false;const children=new Set();
|
|
10
|
+
function create(){
|
|
11
|
+
if(disposed)throw Error('Development runtime is closed');
|
|
12
|
+
const token=randomBytes(32).toString('base64url');
|
|
13
|
+
const child=spawn(binary,['dev-spare',root,'--node',process.execPath,'--script',fileURLToPath(new URL('./dev-runtime-spare.mjs',import.meta.url))],{cwd:root,windowsHide:true,stdio:['pipe','pipe','pipe'],env:{...process.env,...environment,NODE_ENV:'development',PRNEXT_DEV_SPARE_TOKEN:token}});
|
|
14
|
+
children.add(child);onChild(child);
|
|
15
|
+
const messages=new EventEmitter();let buffer='';child.stdout.setEncoding('utf8');child.stdout.on('data',chunk=>{buffer+=chunk;if(buffer.length>65536){child.kill();return;}for(let end;(end=buffer.indexOf('\n'))>=0;){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);try{const message=JSON.parse(line);if(message.type)messages.emit(message.type,message);}catch{}}});
|
|
16
|
+
child.once('exit',()=>{children.delete(child);if(spare?.child===child)spare=undefined;});
|
|
17
|
+
child.stdin.on('error',()=>{});
|
|
18
|
+
const wait=type=>new Promise((resolve,reject)=>{
|
|
19
|
+
const timeout=setTimeout(()=>fail(Error('Development runtime warmup timed out')),15000);
|
|
20
|
+
const clean=()=>{clearTimeout(timeout);messages.off(type,message);child.off('exit',exit);child.off('error',fail);};
|
|
21
|
+
const fail=error=>{clean();reject(error);};const exit=()=>fail(Error('Development runtime exited'));
|
|
22
|
+
const message=value=>{clean();resolve(value);};
|
|
23
|
+
messages.on(type,message);child.once('exit',exit);child.once('error',fail);
|
|
24
|
+
});
|
|
25
|
+
const ready=wait('warm');ready.catch(()=>{if(spare?.child===child)spare=undefined;void stop(child);});
|
|
26
|
+
return {child,token,ready,wait};
|
|
27
|
+
}
|
|
28
|
+
async function stop(child){
|
|
29
|
+
if(!child?.pid||child.exitCode!==null||child.signalCode)return;
|
|
30
|
+
await new Promise(resolve=>{const timer=setTimeout(()=>child.kill('SIGKILL'),30000);child.once('exit',()=>{clearTimeout(timer);resolve();});child.stdin.end();if(child.connected)child.disconnect();});
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
warm(){if(!spare)spare=create();return spare.ready;},
|
|
34
|
+
async take(directory){
|
|
35
|
+
const selected=spare||create();spare=undefined;
|
|
36
|
+
try{
|
|
37
|
+
const {port,guardPort}=await selected.ready,dist=path.join(root,directory),armed=selected.wait('armed');selected.child.stdin.write(JSON.stringify({type:'arm',dist})+'\n');await armed;
|
|
38
|
+
const descriptor=path.join(home,'transport-'+path.basename(directory)+'.json');
|
|
39
|
+
await writeFile(descriptor,JSON.stringify({project:root,dist,port,guardPort,token:selected.token}),{mode:0o600,flag:'wx'});
|
|
40
|
+
spare=create();return {child:selected.child,descriptor,pid:selected.child.pid,close:async()=>{await stop(selected.child);await rm(descriptor,{force:true});}};
|
|
41
|
+
}catch(error){await stop(selected.child);throw error;}
|
|
42
|
+
},
|
|
43
|
+
async close(){disposed=true;await Promise.all([...children].map(stop));},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// One prewarmed process becomes exactly one immutable development generation.
|
|
2
|
+
// It is owned by the CLI, never shared across builds or production projects.
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import {timingSafeEqual} from 'node:crypto';
|
|
5
|
+
import {realpath,readFile} from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {pathToFileURL} from 'node:url';
|
|
8
|
+
import {createRequire} from 'node:module';
|
|
9
|
+
const root=await realpath(process.cwd()),token=process.env.PRNEXT_DEV_SPARE_TOKEN;
|
|
10
|
+
delete process.env.PRNEXT_DEV_SPARE_TOKEN;
|
|
11
|
+
if(!token)throw Error('Private development parent required');
|
|
12
|
+
const announce=value=>process.stdout.write(JSON.stringify(value)+'\n');
|
|
13
|
+
const spareHeartbeat=setInterval(()=>process.stdout.write('alive\n'),1000);spareHeartbeat.unref();
|
|
14
|
+
// Warm framework dependencies only; no application module or data function runs.
|
|
15
|
+
const require=createRequire(path.join(root,'package.json'));
|
|
16
|
+
try{require('react');require('react-dom/server.node');}catch{/* Actual bootstrap reports missing peers. */}
|
|
17
|
+
let armed,starting,ready;
|
|
18
|
+
const controls=new Map();
|
|
19
|
+
globalThis.__PRNEXT_DEV_WORKER_PULSE__=line=>{for(const[socket,watch]of controls)if(watch&&!socket.destroyed)socket.write(line);};
|
|
20
|
+
const broker=net.createServer(socket=>{
|
|
21
|
+
socket.setNoDelay(true);socket.setTimeout(10000,()=>socket.destroy());let source='';
|
|
22
|
+
socket.on('data',chunk=>{
|
|
23
|
+
source+=chunk;if(Buffer.byteLength(source)>8192){socket.destroy();return;}
|
|
24
|
+
if(!source.includes('\n'))return;socket.removeAllListeners('data');
|
|
25
|
+
void(async()=>{
|
|
26
|
+
const input=JSON.parse(source.trim()),supplied=Buffer.from(input.token||''),expected=Buffer.from(token);
|
|
27
|
+
if(supplied.length!==expected.length||!timingSafeEqual(supplied,expected)||!armed||input.project!==root||input.dist!==armed.dist)throw Error('Invalid generation capability');
|
|
28
|
+
if(!starting)starting=(async()=>{
|
|
29
|
+
const manifest=JSON.parse(await readFile(path.join(armed.dist,'manifest.json'),'utf8'));
|
|
30
|
+
if(manifest.dev!==true)throw Error('Development build required');
|
|
31
|
+
for(const key of ['PRNEXT_CACHE_URL','PRNEXT_CACHE_TOKEN'])delete process.env[key];
|
|
32
|
+
if(input.cache){process.env.PRNEXT_CACHE_URL=input.cache.url;process.env.PRNEXT_CACHE_TOKEN=input.cache.token;}
|
|
33
|
+
process.env.PRNEXT_WORKER_SOCKET=String(Math.max(1,Math.min(512,input.lanes||1)));
|
|
34
|
+
process.env.PRNEXT_WORKER_TOKEN=token;process.env.PRNEXT_WORKER_ACTIVITY='1';
|
|
35
|
+
clearInterval(spareHeartbeat);
|
|
36
|
+
process.argv=[process.execPath,path.join(armed.dist,'runtime/worker.mjs'),root,armed.dist];
|
|
37
|
+
const announced=new Promise(resolve=>{globalThis.__PRNEXT_DEV_WORKER_READY__=resolve;});
|
|
38
|
+
await import(pathToFileURL(process.argv[1]).href);ready=await announced;
|
|
39
|
+
return ready;
|
|
40
|
+
})().catch(()=>process.exit(1));
|
|
41
|
+
const info=await starting;if(!socket.destroyed){
|
|
42
|
+
socket.setTimeout(0);controls.set(socket,input.watch===true);socket.write(JSON.stringify(info)+'\n');
|
|
43
|
+
let command='';socket.on('data',chunk=>{command+=chunk;if(command.length>16){socket.destroy();return;}if(command==='retire\n')process.stdin.emit('end');});
|
|
44
|
+
}
|
|
45
|
+
})().catch(()=>socket.destroy());
|
|
46
|
+
});
|
|
47
|
+
socket.on('error',()=>{});
|
|
48
|
+
socket.on('close',()=>{const owned=controls.delete(socket);if(owned&&!controls.size&&ready)process.stdin.emit('end');});
|
|
49
|
+
});
|
|
50
|
+
broker.maxConnections=16;
|
|
51
|
+
broker.listen(0,'127.0.0.1',()=>{broker.unref();announce({type:'warm',port:broker.address().port});});
|
|
52
|
+
let control='';const arm=chunk=>{control+=chunk;if(control.length>8192)process.exit(1);if(!control.includes('\n'))return;process.stdin.off('data',arm);
|
|
53
|
+
let message;try{message=JSON.parse(control.trim());}catch{process.exit(1);}if(message?.type!=='arm'||armed)process.exit(1);
|
|
54
|
+
void realpath(message.dist).then(dist=>{if(!dist.startsWith(root+path.sep))throw Error('Generation outside project');armed={dist};announce({type:'armed'});}).catch(()=>process.exit(1));
|
|
55
|
+
};process.stdin.on('data',arm);
|
|
56
|
+
// Parent lifetime is guarded even while this is still an unused spare.
|
|
57
|
+
process.stdin.resume();process.stdin.once('end',()=>{broker.close();if(!starting)process.exit();});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { safeRequestPath, normalizeEntries } from './dev-tools-model.mjs';
|
|
2
|
+
|
|
3
|
+
// The development-only sidecar is bound to loopback and authenticated with an
|
|
4
|
+
// ephemeral session capability. Diagnostics stay local by default; the explicit
|
|
5
|
+
// Host AI relay shares tool responses only when the connected assistant asks.
|
|
6
|
+
export function createDevToolsClient({ capture, snapshot = () => ({}), config, target = globalThis } = {}) {
|
|
7
|
+
const nativeFetch = target.fetch?.bind(target), subscribers = new Set();
|
|
8
|
+
const clientId = target.crypto?.randomUUID?.() || 'browser-' + Math.random().toString(36).slice(2);
|
|
9
|
+
const valid = config?.version === 1 && typeof config.token === 'string' && /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+$/.test(config.endpoint || '');
|
|
10
|
+
let disposed = false, data = {}, connection = valid ? 'connecting' : 'unavailable', polling = 0, pollTimer, pollingRequest;
|
|
11
|
+
let requests = [], serial = 0, logSerial = 0, reportTimer, lastSummary, lastContext, lastError;
|
|
12
|
+
let browserInFlight = 0; const browserQueue = [];
|
|
13
|
+
const pending = new Map(), originals = [], localEvents = new AbortController();
|
|
14
|
+
const base = () => target.location?.href || 'http://localhost/';
|
|
15
|
+
const state = () => ({ ...data, connection, available: valid, clientId, localRequests: requests.slice(), context: lastContext, error: lastError || data.error });
|
|
16
|
+
function notify() { for (const subscriber of subscribers) { try { subscriber(state()); } catch {} } }
|
|
17
|
+
async function request(path, body, timeout = 8000) {
|
|
18
|
+
if (!valid || disposed || !nativeFetch) throw Error('The local development tools are unavailable. Restart PRNext dev.');
|
|
19
|
+
const controller = new AbortController(); const timer = target.setTimeout(() => controller.abort(), timeout); pending.set(controller,timer);
|
|
20
|
+
try {
|
|
21
|
+
const response = await nativeFetch(config.endpoint + path, { method: 'POST', mode: 'cors', cache: 'no-store', credentials: 'omit',
|
|
22
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + config.token }, body: JSON.stringify(body), signal: controller.signal });
|
|
23
|
+
const result = await response.json(); if (!response.ok) {const error=Error(result?.error?.message || result?.error || 'Local tools request failed (' + response.status + ').');error.status=response.status;error.code=result?.code;throw error;}
|
|
24
|
+
connection = 'connected'; lastError = undefined; return result;
|
|
25
|
+
} catch (error) { if (!disposed) { connection = error.status&&![401,403].includes(error.status)?'connected':'disconnected'; lastError = error.name === 'AbortError' ? 'Local tools request timed out.' : error.message; } throw error; }
|
|
26
|
+
finally { target.clearTimeout(timer); pending.delete(controller); }
|
|
27
|
+
}
|
|
28
|
+
async function call(name, args = {}) {
|
|
29
|
+
try { return await request('/v1/tools/' + encodeURIComponent(name), args, name === 'dev_verify' ? 35000 : 10000); }
|
|
30
|
+
finally { notify(); }
|
|
31
|
+
}
|
|
32
|
+
async function refresh() {
|
|
33
|
+
if (!valid || disposed) return state();
|
|
34
|
+
if (pollingRequest) return pollingRequest;
|
|
35
|
+
pollingRequest = call('dev_snapshot').then(result => { data = result; notify(); return state(); }).catch(() => state()).finally(() => { pollingRequest = undefined; });
|
|
36
|
+
return pollingRequest;
|
|
37
|
+
}
|
|
38
|
+
function browser(kind, extras = {}) {
|
|
39
|
+
if (!valid || disposed) return Promise.resolve();
|
|
40
|
+
const info = snapshot();
|
|
41
|
+
const body = { session: config.session, clientId, buildId: info.buildId ?? null, sequence: info.sequence || 0,
|
|
42
|
+
path: target.location?.pathname || '/', issues: info.issues || 0, typeErrors: info.typeErrors || 0, building: Boolean(info.building),
|
|
43
|
+
confirmed: info.confirmed === true, hydrated: info.confirmed === true, kind, ...extras };
|
|
44
|
+
return new Promise(resolve => {
|
|
45
|
+
if (browserQueue.length >= 100) browserQueue.shift().resolve();
|
|
46
|
+
browserQueue.push({ body, resolve }); pumpBrowser();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function pumpBrowser() {
|
|
50
|
+
while (!disposed && browserInFlight < 2 && browserQueue.length) {
|
|
51
|
+
const job = browserQueue.shift(); browserInFlight++;
|
|
52
|
+
void request('/v1/browser', job.body).catch(() => {}).finally(() => { browserInFlight--;job.resolve();pumpBrowser(); });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function report() {
|
|
56
|
+
if (disposed) return;
|
|
57
|
+
const entries = capture?.snapshot().entries || [];
|
|
58
|
+
const brief = text => text.length > 8000 ? text.slice(0, 7900) + '\n[Truncated for the local AI bridge; full message remains in the browser console.]' : text;
|
|
59
|
+
const unsent = entries.filter(entry => entry.id > logSerial);
|
|
60
|
+
const fresh = unsent.slice(-20).map(entry => ({ ...entry, text: brief(entry.text) }));
|
|
61
|
+
if (fresh.length) logSerial = fresh.at(-1).id;
|
|
62
|
+
const info = snapshot(), summary = [info.buildId, info.sequence, info.issues, info.typeErrors, info.building, info.confirmed].join(':');
|
|
63
|
+
const errors = entries.filter(entry => entry.level === 'error').slice(-10).map(entry => ({ id: entry.id, text: brief(entry.text), time: entry.time }));
|
|
64
|
+
void browser(summary !== lastSummary && !info.building && info.confirmed === true ? 'applied' : info.issues || errors.length ? 'errors' : 'heartbeat', {
|
|
65
|
+
logs: fresh, droppedLogs: Math.max(0, unsent.length - 20) + (capture?.snapshot().dropped || 0), errors, issues: info.issues || 0, typeErrors: info.typeErrors || 0, building: Boolean(info.building),
|
|
66
|
+
visibility: target.document?.visibilityState || 'visible', hydrated: info.confirmed === true, confirmed: info.confirmed === true,
|
|
67
|
+
}); lastSummary = summary;
|
|
68
|
+
}
|
|
69
|
+
function scheduleReport() { if (!reportTimer && valid) reportTimer = target.setTimeout(() => { reportTimer = undefined; report(); }, 300); }
|
|
70
|
+
function recordRequest(entry) {
|
|
71
|
+
if (disposed) return;
|
|
72
|
+
const record = { ...entry, id: ++serial, source: 'browser' };
|
|
73
|
+
requests.push(record); if (requests.length > 300) requests.shift();
|
|
74
|
+
void browser('request', { request: record }); notify();
|
|
75
|
+
}
|
|
76
|
+
function ignored(url) {
|
|
77
|
+
return valid && url.origin === config.endpoint || /\/_prnext\/(?:dev|ensure|assets)\b/.test(url.pathname);
|
|
78
|
+
}
|
|
79
|
+
if (nativeFetch) {
|
|
80
|
+
const original = target.fetch;
|
|
81
|
+
const replacement = function (...args) {
|
|
82
|
+
let url, method = 'GET';
|
|
83
|
+
try { url = new URL(typeof args[0] === 'string' || args[0] instanceof URL ? String(args[0]) : args[0].url, base()); method = String(args[1]?.method || args[0]?.method || 'GET').toUpperCase(); } catch {}
|
|
84
|
+
const started = target.performance?.now?.() ?? Date.now(), time = Date.now();
|
|
85
|
+
return Reflect.apply(original, target, args).then(response => {
|
|
86
|
+
if (url && !ignored(url)) recordRequest({ method, path: safeRequestPath(url.href, base()), status: response.status, time,
|
|
87
|
+
duration: Math.max(0, (target.performance?.now?.() ?? Date.now()) - started), kind: 'fetch', failed: !response.ok });
|
|
88
|
+
return response;
|
|
89
|
+
}, error => {
|
|
90
|
+
if (url && !ignored(url)) recordRequest({ method, path: safeRequestPath(url.href, base()), status: null, time,
|
|
91
|
+
duration: Math.max(0, (target.performance?.now?.() ?? Date.now()) - started), kind: 'fetch', failed: true, error: error.name === 'AbortError' ? 'Aborted' : 'Network error' });
|
|
92
|
+
throw error;
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
try { target.fetch = replacement; originals.push(() => { if (target.fetch === replacement) target.fetch = original; }); } catch {}
|
|
96
|
+
}
|
|
97
|
+
const xhr = target.XMLHttpRequest?.prototype;
|
|
98
|
+
if (xhr) {
|
|
99
|
+
const open = xhr.open, send = xhr.send, records = new WeakMap();
|
|
100
|
+
const wrappedOpen = function (method, raw, ...args) { const result = Reflect.apply(open, this, [method, raw, ...args]); records.delete(this);try { const url = new URL(raw, base()); if (!ignored(url)) records.set(this, { method: String(method).toUpperCase(), path: safeRequestPath(url.href, base()), kind: 'xhr' }); } catch {} return result; };
|
|
101
|
+
const wrappedSend = function (...args) {
|
|
102
|
+
const entry = records.get(this); if (entry) { const start = target.performance?.now?.() ?? Date.now(), time = Date.now();
|
|
103
|
+
this.addEventListener('loadend', () => { recordRequest({ ...entry, time, status: this.status || null, failed: this.status === 0 || this.status >= 400,
|
|
104
|
+
duration: Math.max(0, (target.performance?.now?.() ?? Date.now()) - start) }); }, { once: true }); }
|
|
105
|
+
return Reflect.apply(send, this, args);
|
|
106
|
+
};
|
|
107
|
+
try { xhr.open = wrappedOpen; xhr.send = wrappedSend; originals.push(() => { if (xhr.open === wrappedOpen) xhr.open = open; if (xhr.send === wrappedSend) xhr.send = send; }); } catch {}
|
|
108
|
+
}
|
|
109
|
+
const unsubscribe = capture?.subscribe(() => { scheduleReport(); notify(); });
|
|
110
|
+
target.addEventListener?.('prnext:refresh', scheduleReport, { signal: localEvents.signal });
|
|
111
|
+
target.addEventListener?.('prnext:devtools:ready', scheduleReport, { signal: localEvents.signal });
|
|
112
|
+
target.addEventListener?.('popstate', scheduleReport, { signal: localEvents.signal });
|
|
113
|
+
target.addEventListener?.('prnext:devtools:ai', event => {
|
|
114
|
+
const detail = event.detail || {};
|
|
115
|
+
lastContext = detail.kind === 'error' ? { ...detail, text: detail.error?.message || String(detail.error || ''),
|
|
116
|
+
source: detail.source?.file ? `${detail.source.file}:${detail.source.line}:${detail.source.column}` : detail.source,
|
|
117
|
+
mappedSource: detail.source?.file ? detail.source : undefined,
|
|
118
|
+
} : detail;
|
|
119
|
+
notify(); target.dispatchEvent(new CustomEvent('prnext:devtools:context', { detail: lastContext }));
|
|
120
|
+
}, { signal: localEvents.signal });
|
|
121
|
+
const heartbeat = valid ? target.setInterval(report, 5000) : undefined;
|
|
122
|
+
if (valid) scheduleReport();
|
|
123
|
+
function watch() {
|
|
124
|
+
polling++; void refresh();
|
|
125
|
+
if (!pollTimer) pollTimer = target.setInterval(() => void refresh(), 1500);
|
|
126
|
+
let watching=true;
|
|
127
|
+
return () => { if(!watching)return;watching=false;if (--polling <= 0) { polling = 0; target.clearInterval(pollTimer); pollTimer = undefined; } };
|
|
128
|
+
}
|
|
129
|
+
function captureFor(store = capture) {
|
|
130
|
+
let clearedAt = 0;
|
|
131
|
+
return {
|
|
132
|
+
snapshot() { const captured = store?.snapshot() || { entries: [], dropped: 0 }; return { entries: normalizeEntries({ ...data, serverLogs: (data.serverLogs || []).filter(entry => (entry.time || entry.timestamp || 0) > clearedAt) }, captured.entries), dropped: captured.dropped }; },
|
|
133
|
+
subscribe(callback) { subscribers.add(callback); const stop = watch(), stopCapture = store !== capture ? store?.subscribe(callback) : undefined; return () => { subscribers.delete(callback); stop(); stopCapture?.(); }; },
|
|
134
|
+
clear() { store?.clear(); clearedAt = Date.now(); notify(); },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const unified = captureFor();
|
|
138
|
+
return {
|
|
139
|
+
call, refresh, snapshot: state, subscribe(callback) { subscribers.add(callback); return () => subscribers.delete(callback); }, watch,
|
|
140
|
+
consoleCapture: unified, captureFor, localCapture: capture, browser, report, endpoint: config?.endpoint,
|
|
141
|
+
setContext(context) { lastContext = context; notify(); },
|
|
142
|
+
async sendContext(context, prompt) {
|
|
143
|
+
const submitted=lastContext;
|
|
144
|
+
const latest=await refresh();
|
|
145
|
+
if(latest.connection!=='connected'||latest.ai?.connected!==true)throw Error('Connect your assistant before sending a request.');
|
|
146
|
+
let result;
|
|
147
|
+
try{result=await call('dev_ai_context', { kind: context.kind || 'selection', context, prompt });}
|
|
148
|
+
catch(error){if(error.code==='assistant_required'){data={...data,ai:{...data.ai,connected:false}};notify();}throw error;}
|
|
149
|
+
if(result.queued!==true||result.connected!==true){if(result.connected!==true){data={...data,ai:{...data.ai,connected:false}};notify();}throw Error('Your assistant is no longer connected. Reconnect and try again.');}
|
|
150
|
+
if(lastContext===submitted)lastContext = { ...context, sent: true }; notify(); return result;
|
|
151
|
+
},
|
|
152
|
+
dispose() { disposed = true; unsubscribe?.(); localEvents.abort(); target.clearInterval(heartbeat); target.clearInterval(pollTimer); target.clearTimeout(reportTimer);
|
|
153
|
+
for (const job of browserQueue.splice(0)) job.resolve();for (const [controller,timer] of pending) { controller.abort();target.clearTimeout(timer); } pending.clear();for (const restore of originals.reverse()) restore(); subscribers.clear(); requests = []; },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {devToolResult} from './dev-tools-result.mjs';
|
|
2
|
+
import {open,realpath,lstat} from 'node:fs/promises';
|
|
3
|
+
import {constants} from 'node:fs';
|
|
4
|
+
import {StringDecoder} from 'node:string_decoder';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {devToolDefinitions} from './dev-tools-server.mjs';
|
|
8
|
+
|
|
9
|
+
/** Read only this project's private capability, never a URL supplied by a tool. */
|
|
10
|
+
export async function readDevToolsConnection(directory){
|
|
11
|
+
const root=await realpath(directory),container=path.join(root,'.prnext-dev'),filename=path.join(container,'tools.json');
|
|
12
|
+
const parent=await lstat(container).catch(error=>{if(error.code==='ENOENT')throw Error('Start prnext dev in this project before connecting its MCP.');throw error;});
|
|
13
|
+
if(!parent.isDirectory()||parent.isSymbolicLink()||await realpath(container)!==container)throw Error('Invalid development connection directory');
|
|
14
|
+
const info=await lstat(filename).catch(error=>{if(error.code==='ENOENT')throw Error('Start prnext dev in this project before connecting its MCP.');throw error;});
|
|
15
|
+
if(!info.isFile()||info.isSymbolicLink()||info.size>4096)throw Error('Invalid development connection file');
|
|
16
|
+
if(process.platform!=='win32'&&(info.mode&0o077))throw Error('Development connection file must be private (0600)');
|
|
17
|
+
const handle=await open(filename,constants.O_RDONLY|(constants.O_NOFOLLOW||0));let value;try{const stat=await handle.stat();if(!stat.isFile()||stat.size>4096||stat.ino!==info.ino)throw Error('Development connection file changed');value=JSON.parse(await handle.readFile('utf8'));}finally{await handle.close();}
|
|
18
|
+
let endpoint;try{endpoint=new URL(value.endpoint);}catch{throw Error('Invalid development endpoint');}
|
|
19
|
+
if(value.version!==1||value.root!==root||endpoint.protocol!=='http:'||endpoint.hostname!=='127.0.0.1'||!endpoint.port||endpoint.username||endpoint.password||endpoint.pathname!=='/'||endpoint.search||endpoint.hash||!Number.isInteger(value.pid)||value.pid<=0||!/^[\w-]{40,100}$/.test(value.token||''))throw Error('Invalid development connection file');
|
|
20
|
+
try{process.kill(value.pid,0);}catch{throw Error('Development session is no longer running. Restart prnext dev.');}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export async function callLocalDevTool(directory,name,args={}, {client='PRNext stdio MCP',fetcher=fetch,assistantId,leaseId}={}){
|
|
24
|
+
if(!devToolDefinitions.some(tool=>tool.name===name))throw Error('Unknown development tool');
|
|
25
|
+
const connection=await readDevToolsConnection(directory);
|
|
26
|
+
const response=await fetcher(connection.endpoint+'/v1/tools/'+name,{method:'POST',headers:{Authorization:'Bearer '+connection.token,'Content-Type':'application/json','X-PRNext-Client':client.slice(0,100),...(assistantId?{'X-PRNext-Assistant':assistantId,'X-PRNext-Lease':leaseId}:{})},body:JSON.stringify(args),signal:AbortSignal.timeout(name==='dev_verify'?35000:30000)});
|
|
27
|
+
const result=await response.json();if(!response.ok)throw Object.assign(Error(result.error||'Local development tool failed'),{code:result.code});
|
|
28
|
+
if(result.session!==connection.session)throw Error('Development session changed; retry the tool');return result;
|
|
29
|
+
}
|
|
30
|
+
/** Initialized stdio sessions stay connected while their input pipe is alive. */
|
|
31
|
+
export function createDevAssistantLease(directory,{fetcher=fetch,heartbeatMs=10000}={}){
|
|
32
|
+
const instanceId=randomUUID();let client='PRNext MCP client',started=false,closed=false,timer,pending,lease,closing,lastBeat=0;
|
|
33
|
+
async function post(connection,body,timeout=3000){
|
|
34
|
+
const response=await fetcher(connection.endpoint+'/v1/assistant',{method:'POST',headers:{Authorization:'Bearer '+connection.token,'Content-Type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(timeout)});
|
|
35
|
+
const result=await response.json();if(!response.ok)throw Object.assign(Error(result.error||'Assistant initialization failed'),{code:result.code});
|
|
36
|
+
if(result.session!==connection.session)throw Error('Development session changed; retry initialization');return result;
|
|
37
|
+
}
|
|
38
|
+
function maintain(force=false){
|
|
39
|
+
if(closed)return Promise.resolve(false);if(pending)return pending;
|
|
40
|
+
pending=(async()=>{
|
|
41
|
+
const connection=await readDevToolsConnection(directory);
|
|
42
|
+
if(lease&&lease.connection.session!==connection.session)lease=undefined;
|
|
43
|
+
if(lease&&!force&&Date.now()-lastBeat<heartbeatMs)return true;
|
|
44
|
+
if(lease){
|
|
45
|
+
try{await post(connection,{event:'heartbeat',instanceId,leaseId:lease.id});lastBeat=Date.now();return true;}
|
|
46
|
+
catch(error){if(error.code!=='assistant_lease_expired')throw error;lease=undefined;}
|
|
47
|
+
}
|
|
48
|
+
const result=await post(connection,{event:'register',instanceId,client});
|
|
49
|
+
if(typeof result.leaseId!=='string'||!/^[\w-]{20,100}$/.test(result.leaseId))throw Error('Invalid assistant initialization reply');
|
|
50
|
+
lease={id:result.leaseId,connection};lastBeat=Date.now();return true;
|
|
51
|
+
})().catch(()=>false).finally(()=>{pending=undefined;});return pending;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
async start(info){
|
|
55
|
+
if(started||closed)return maintain();started=true;
|
|
56
|
+
if(typeof info?.name==='string'&&info.name.trim())client=info.name.replace(/[\u0000-\u001f\u007f]/g,' ').slice(0,100);
|
|
57
|
+
const ready=await maintain();if(!closed){timer=setInterval(()=>void maintain(true),heartbeatMs);timer.unref?.();}return ready;
|
|
58
|
+
},
|
|
59
|
+
async call(name,args){
|
|
60
|
+
if(closed)throw Error('MCP session closed');if(!started)throw Error('Initialize the MCP session first');
|
|
61
|
+
if(!await maintain()||!lease)throw Error('The development assistant connection is unavailable. Start prnext dev in this project.');
|
|
62
|
+
const invoke=()=>callLocalDevTool(directory,name,args,{fetcher,client:'PRNext stdio MCP',assistantId:instanceId,leaseId:lease.id});
|
|
63
|
+
try{return await invoke();}catch(error){
|
|
64
|
+
// Expired leases are rejected before a tool executes, so only this error
|
|
65
|
+
// can safely reinitialize and retry without duplicating a mutation.
|
|
66
|
+
if(error.code!=='assistant_lease_expired'||closed)throw error;lease=undefined;
|
|
67
|
+
if(!await maintain(true)||!lease)throw error;return invoke();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
close(){
|
|
71
|
+
if(closing)return closing;closed=true;clearInterval(timer);
|
|
72
|
+
closing=(async()=>{await pending;if(lease)try{await post(lease.connection,{event:'unregister',instanceId,leaseId:lease.id},1000);}catch{}lease=undefined;})();return closing;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function devMcpReply(message,invoke){
|
|
77
|
+
if(!message||message.jsonrpc!=='2.0'||typeof message.method!=='string')throw Error('Invalid JSON-RPC request');
|
|
78
|
+
if(Object.hasOwn(message,'id')&&message.id!==null&&!['string','number'].includes(typeof message.id))throw Error('Invalid JSON-RPC id');
|
|
79
|
+
if(!Object.hasOwn(message,'id'))return;
|
|
80
|
+
const reply=result=>({jsonrpc:'2.0',id:message.id,result});
|
|
81
|
+
if(message.method==='initialize')return reply({protocolVersion:['2025-11-25','2025-06-18','2025-03-26','2024-11-05'].includes(message.params?.protocolVersion)?message.params.protocolVersion:'2025-06-18',capabilities:{tools:{listChanged:false}},serverInfo:{name:'PRNext local development',version:'1.0.0'},instructions:'Local project development only. After edits, call dev_verify with the previous snapshot sequence. A compiled generation alone is not browser verification. No shell or arbitrary file access is exposed.'});
|
|
82
|
+
if(message.method==='ping')return reply({});
|
|
83
|
+
if(message.method==='tools/list')return reply({tools:devToolDefinitions});
|
|
84
|
+
if(message.method==='tools/call')return Promise.resolve().then(()=>invoke(message.params?.name,message.params?.arguments||{})).then(result=>reply(devToolResult(result)),error=>reply({content:[{type:'text',text:String(error.message).slice(0,2000)}],isError:true}));
|
|
85
|
+
return {jsonrpc:'2.0',id:message.id,error:{code:-32601,message:'Method not found'}};
|
|
86
|
+
}
|
|
87
|
+
/** MCP stdio is newline-delimited JSON. Stdout contains protocol messages only. */
|
|
88
|
+
export async function runDevMcp(directory,{input=process.stdin,output=process.stdout,invoke,assistant=createDevAssistantLease(directory)}={}){
|
|
89
|
+
const pending=new Set(),decoder=new StringDecoder('utf8');let buffer='',discard=false,initializeSeen=false,initialized=false,clientInfo,initializing,stopping=false;
|
|
90
|
+
const write=value=>{if(value)output.write(JSON.stringify(value)+'\n');};
|
|
91
|
+
async function dispatch(message){
|
|
92
|
+
if(message?.method==='initialize'){
|
|
93
|
+
const result=devMcpReply(message,invoke);if(result?.result){initializeSeen=true;clientInfo=message.params?.clientInfo;}return result;
|
|
94
|
+
}
|
|
95
|
+
if(message?.jsonrpc==='2.0'&&message.method==='notifications/initialized'&&!Object.hasOwn(message,'id')){
|
|
96
|
+
if(initializeSeen&&!initialized){initialized=true;initializing=Promise.resolve(assistant.start(clientInfo));await initializing;}return;
|
|
97
|
+
}
|
|
98
|
+
if(message?.method==='tools/list'||message?.method==='tools/call'){
|
|
99
|
+
if(!initialized)return {jsonrpc:'2.0',id:message.id??null,error:{code:-32002,message:'Initialize the MCP session first.'}};
|
|
100
|
+
await initializing;
|
|
101
|
+
}
|
|
102
|
+
return devMcpReply(message,invoke||((name,args)=>assistant.call(name,args)));
|
|
103
|
+
}
|
|
104
|
+
function line(value){
|
|
105
|
+
if(!value.trim())return;
|
|
106
|
+
let message;try{message=JSON.parse(value);}catch{write({jsonrpc:'2.0',id:null,error:{code:-32700,message:'Parse error'}});return;}
|
|
107
|
+
if(pending.size>=16){write({jsonrpc:'2.0',id:message.id??null,error:{code:-32000,message:'Too many pending local tools'}});return;}
|
|
108
|
+
// Enter initialization synchronously before accepting the next line or EOF.
|
|
109
|
+
// The actual tools remain concurrent, with the initialization promise as a barrier.
|
|
110
|
+
const task=dispatch(message).then(write,error=>write({jsonrpc:'2.0',id:message?.id??null,error:{code:-32600,message:String(error.message).slice(0,1000)}})).finally(()=>pending.delete(task));pending.add(task);
|
|
111
|
+
}
|
|
112
|
+
const shutdown=()=>{stopping=true;void assistant.close();input.destroy();};
|
|
113
|
+
if(input===process.stdin){process.once('SIGINT',shutdown);process.once('SIGTERM',shutdown);}
|
|
114
|
+
try{
|
|
115
|
+
for await(const chunk of input){
|
|
116
|
+
const next=typeof chunk==='string'?chunk:decoder.write(chunk);
|
|
117
|
+
for(const part of next.split(/(?<=\n)/)){
|
|
118
|
+
const ends=part.endsWith('\n');
|
|
119
|
+
if(!discard){buffer+=part;if(Buffer.byteLength(buffer)>256*1024){write({jsonrpc:'2.0',id:null,error:{code:-32600,message:'Request too large'}});discard=true;buffer='';}}
|
|
120
|
+
if(ends){if(!discard)line(buffer);buffer='';discard=false;}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if(buffer&&!discard)line(buffer+decoder.end());
|
|
124
|
+
}catch(error){if(!stopping)throw error;}
|
|
125
|
+
finally{await assistant.close();await Promise.allSettled(pending);if(input===process.stdin){process.off('SIGINT',shutdown);process.off('SIGTERM',shutdown);}}
|
|
126
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {readdir,lstat,open} from 'node:fs/promises';
|
|
2
|
+
import {constants} from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Lazy tails of the existing bounded inspector format. The directory is created
|
|
6
|
+
// inside this development session, never accepted from an HTTP/tool argument.
|
|
7
|
+
export function createDevMetricsCollector(directory,{since=Date.now()}={}){
|
|
8
|
+
const seen=new Set();let running,last=0;
|
|
9
|
+
return async function collect(record){
|
|
10
|
+
if(running)return running;if(Date.now()-last<700)return;
|
|
11
|
+
running=(async()=>{
|
|
12
|
+
const container=await lstat(directory).catch(()=>null);if(!container?.isDirectory()||container.isSymbolicLink())return;
|
|
13
|
+
const names=(await readdir(directory)).filter(name=>/^(?:worker-\d+-\d+|native-\d+)\.jsonl(?:\.1)?$/.test(name));
|
|
14
|
+
const files=(await Promise.all(names.slice(-64).map(async name=>({name,info:await lstat(path.join(directory,name)).catch(()=>null)})))).filter(file=>file.info?.isFile()&&!file.info.isSymbolicLink()).sort((a,b)=>b.info.mtimeMs-a.info.mtimeMs).slice(0,4);
|
|
15
|
+
for(const file of files){
|
|
16
|
+
let handle;try{
|
|
17
|
+
handle=await open(path.join(directory,file.name),constants.O_RDONLY|(constants.O_NOFOLLOW||0));const stat=await handle.stat();if(!stat.isFile()||stat.ino!==file.info.ino)continue;
|
|
18
|
+
const bytes=Buffer.alloc(Math.min(stat.size,65536)),start=Math.max(0,stat.size-bytes.length);await handle.read(bytes,0,bytes.length,start);
|
|
19
|
+
const lines=bytes.toString('utf8').split('\n');if(start)lines.shift();
|
|
20
|
+
for(const line of lines){if(line.length>4096||!line)continue;
|
|
21
|
+
let event;try{event=JSON.parse(line);}catch{continue;}
|
|
22
|
+
if(!Number.isFinite(event.time)||event.time<since||!['http','fetch'].includes(event.type))continue;
|
|
23
|
+
const id=file.name+':'+line;if(seen.has(id))continue;seen.add(id);
|
|
24
|
+
record({time:event.time,path:event.type==='fetch'?event.destination:event.route,route:event.route,method:event.method||'GET',status:event.status,duration:event.durationMs,kind:event.type,source:'server',failed:!!event.error||event.status>=400});
|
|
25
|
+
}
|
|
26
|
+
}catch{/* Inspection must not break development. */}finally{await handle?.close().catch(()=>{});}
|
|
27
|
+
}
|
|
28
|
+
if(seen.size>2000){const keep=[...seen].slice(-1000);seen.clear();for(const id of keep)seen.add(id);}
|
|
29
|
+
})().catch(()=>{}).finally(()=>{last=Date.now();running=undefined;});return running;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Small pure helpers shared by the development panels and the local bridge.
|
|
2
|
+
export function safeRequestPath(raw, base = 'http://localhost') {
|
|
3
|
+
try { const url = new URL(raw, base); return url.origin === new URL(base).origin ? url.pathname : url.origin + url.pathname; }
|
|
4
|
+
catch { return '[Unavailable URL]'; }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function routeParameters(pattern) {
|
|
8
|
+
return [...String(pattern).matchAll(/\[\[\.\.\.([^\]]+)\]\]|\[\.\.\.([^\]]+)\]|\[([^\]]+)\]/g)]
|
|
9
|
+
.map(match => ({ name: match[1] || match[2] || match[3], optional: Boolean(match[1]), catchAll: Boolean(match[1] || match[2]) }));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function materializeRoute(pattern, values = {}) {
|
|
13
|
+
let missing;
|
|
14
|
+
const path = String(pattern).replace(/\[\[\.\.\.([^\]]+)\]\]|\[\.\.\.([^\]]+)\]|\[([^\]]+)\]/g, (_match, optional, all, one) => {
|
|
15
|
+
const name = optional || all || one, value = String(values[name] ?? '').trim();
|
|
16
|
+
if (!value) { if (!optional) missing ||= name; return ''; }
|
|
17
|
+
return optional || all ? value.split('/').filter(Boolean).map(encodeURIComponent).join('/') : encodeURIComponent(value);
|
|
18
|
+
}).replace(/\/{2,}/g, '/');
|
|
19
|
+
if (missing) throw Error('Enter ' + missing + '.');
|
|
20
|
+
return path || '/';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function durationLabel(value) {
|
|
24
|
+
return typeof value === 'number' && Number.isFinite(value) ? (value < 1000 ? Math.round(value) + ' ms' : (value / 1000).toFixed(2) + ' s') : 'Not measured';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeEntries(data = {}, browser = []) {
|
|
28
|
+
const server = Array.isArray(data.serverLogs) ? data.serverLogs : [];
|
|
29
|
+
return [...browser.map(entry => ({ ...entry, id: 'browser:' + entry.id, source: 'browser' })),
|
|
30
|
+
...server.map((entry, index) => ({ ...entry, id: 'server:' + (entry.id ?? index), time: entry.time || entry.timestamp || 0,
|
|
31
|
+
level: entry.level || 'log', text: String(entry.text ?? entry.message ?? ''), source: 'server' }))]
|
|
32
|
+
.sort((a, b) => a.time - b.time).slice(-750);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatMcpConfig(server, client = 'codex') {
|
|
36
|
+
if (client !== 'codex') return JSON.stringify({ mcpServers: { 'prnext-dev': client==='cursor'?{type:'stdio',...server}:server } }, null, 2);
|
|
37
|
+
const lines = ['[mcp_servers.prnext-dev]', 'command = ' + JSON.stringify(server.command),
|
|
38
|
+
'args = [' + (server.args || []).map(argument => JSON.stringify(argument)).join(', ') + ']'];
|
|
39
|
+
if (server.env && Object.keys(server.env).length) lines.push('', '[mcp_servers.prnext-dev.env]',
|
|
40
|
+
...Object.entries(server.env).map(([key, value]) => JSON.stringify(key) + ' = ' + JSON.stringify(String(value))));
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|