@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.
Files changed (215) hide show
  1. package/AI.md +63 -0
  2. package/README.md +35 -503
  3. package/build/ADAPTERS.md +27 -2
  4. package/build/actions.mjs +6 -1
  5. package/build/analyze.mjs +340 -0
  6. package/build/app-config.mjs +20 -8
  7. package/build/app-static.mjs +41 -14
  8. package/build/app-styles.mjs +79 -0
  9. package/build/app.mjs +99 -31
  10. package/build/build-lock.mjs +6 -6
  11. package/build/bundle-runtime.mjs +39 -11
  12. package/build/cache-components.mjs +1 -1
  13. package/build/cache-handlers.mjs +1 -1
  14. package/build/check.mjs +56 -11
  15. package/build/compact-css.mjs +53 -0
  16. package/build/compilation-cache.mjs +269 -0
  17. package/build/compilation-store.mjs +142 -0
  18. package/build/compiler.mjs +51 -4
  19. package/build/concurrent.mjs +12 -0
  20. package/build/config.mjs +50 -13
  21. package/build/create.mjs +239 -0
  22. package/build/css-modules.mjs +65 -10
  23. package/build/deployment-assets.mjs +89 -0
  24. package/build/deployment-config.mjs +18 -0
  25. package/build/dev-compiler-health.mjs +11 -0
  26. package/build/dev-css-update.mjs +85 -0
  27. package/build/dev-dependencies.mjs +44 -0
  28. package/build/dev-discovery.mjs +28 -0
  29. package/build/dev-worker.mjs +14 -5
  30. package/build/dev.mjs +64 -6
  31. package/build/dynamic.mjs +1 -1
  32. package/build/edge.mjs +5 -4
  33. package/build/fonts.mjs +1 -1
  34. package/build/framework-imports.mjs +50 -0
  35. package/build/html-only-config.mjs +53 -0
  36. package/build/html-only-graph.mjs +67 -0
  37. package/build/incremental.mjs +8 -4
  38. package/build/index.mjs +140 -112
  39. package/build/inspect.mjs +3 -0
  40. package/build/inspection-summary.mjs +37 -4
  41. package/build/instrumentation.mjs +30 -0
  42. package/build/loader-cache.mjs +53 -26
  43. package/build/metadata-routes.mjs +3 -4
  44. package/build/migrate.mjs +60 -57
  45. package/build/migration-audit.mjs +1 -1
  46. package/build/native-type-config.mjs +37 -0
  47. package/build/npm-packages.mjs +3 -18
  48. package/build/package-manager.mjs +308 -0
  49. package/build/persistent-cache.mjs +112 -0
  50. package/build/precompress.mjs +62 -30
  51. package/build/prerender-cache.mjs +70 -0
  52. package/build/prerender-config.mjs +29 -0
  53. package/build/prerender-pages.mjs +100 -0
  54. package/build/prerender-pool.mjs +165 -0
  55. package/build/prerender-process-worker.mjs +58 -0
  56. package/build/prerender-worker.mjs +10 -0
  57. package/build/production-builder.mjs +129 -0
  58. package/build/react-compiler-report.mjs +225 -0
  59. package/build/react-compiler.mjs +139 -0
  60. package/build/runtime-contract.mjs +2 -2
  61. package/build/scan.mjs +4 -4
  62. package/build/source-analysis.mjs +42 -0
  63. package/build/static-adapter.mjs +13 -5
  64. package/build/static-export.mjs +4 -4
  65. package/build/static-input.mjs +48 -0
  66. package/build/styles.mjs +17 -3
  67. package/build/transform-cache.mjs +37 -4
  68. package/build/transform.mjs +1 -1
  69. package/build/type-command.mjs +41 -0
  70. package/build/typed-routes.mjs +95 -0
  71. package/build/typescript-dev-worker.mjs +18 -0
  72. package/build/typescript-dev.mjs +49 -0
  73. package/build/typescript-native-worker.mjs +55 -0
  74. package/build/typescript-native.mjs +18 -0
  75. package/build/typescript-toolchain.mjs +19 -0
  76. package/build/typescript-watch.mjs +22 -0
  77. package/build/typescript.mjs +11 -8
  78. package/build/webpack-graph.mjs +170 -30
  79. package/build/worker-memory.mjs +9 -0
  80. package/build/worker-preload.mjs +9 -0
  81. package/cli.mjs +162 -122
  82. package/compat/activity-cache.cjs +26 -0
  83. package/compat/after.cjs +6 -4
  84. package/compat/app-layout-context.cjs +52 -2
  85. package/compat/base64.cjs +10 -0
  86. package/compat/cache-handlers.cjs +12 -2
  87. package/compat/cache-wire.cjs +28 -0
  88. package/compat/cache.cjs +2 -1
  89. package/compat/cache.d.cts +4 -0
  90. package/compat/cookies.cjs +21 -5
  91. package/compat/data-cache.cjs +12 -8
  92. package/compat/flight-delta.cjs +150 -0
  93. package/compat/headers.cjs +4 -2
  94. package/compat/incremental-cache.cjs +20 -7
  95. package/compat/index.d.ts +89 -10
  96. package/compat/link.cjs +12 -9
  97. package/compat/link.d.cts +4 -3
  98. package/compat/navigation-authorization.cjs +9 -0
  99. package/compat/navigation-stages.cjs +19 -0
  100. package/compat/navigation.d.cts +4 -3
  101. package/compat/offline-store.cjs +11 -0
  102. package/compat/offline.cjs +5 -0
  103. package/compat/offline.d.cts +2 -0
  104. package/compat/redis-cache.cjs +201 -0
  105. package/compat/redis-cache.d.cts +37 -0
  106. package/compat/router.d.cts +4 -3
  107. package/compat/server-primitives.cjs +31 -11
  108. package/compat/server.d.cts +15 -2
  109. package/compat/static-generation.cjs +7 -0
  110. package/compat/typed-route.d.cts +17 -0
  111. package/compat/use-cache.cjs +3 -0
  112. package/docs/ai/compatibility.md +275 -0
  113. package/docs/ai/data.md +288 -0
  114. package/docs/ai/deployment.md +269 -0
  115. package/docs/ai/manifest.json +11 -0
  116. package/docs/ai/quickstart.md +171 -0
  117. package/docs/ai/routing.md +214 -0
  118. package/docs/ai/troubleshooting.md +155 -0
  119. package/index.mjs +2 -0
  120. package/native/diagnostics.mjs +34 -0
  121. package/native/resolve.mjs +7 -2
  122. package/package.json +44 -7
  123. package/release/ai-docs.mjs +48 -0
  124. package/release/verify.mjs +2 -0
  125. package/runtime/action-client.mjs +10 -4
  126. package/runtime/action-server.mjs +2 -0
  127. package/runtime/ai-docs.mjs +202 -0
  128. package/runtime/app-client.mjs +226 -68
  129. package/runtime/app-content.mjs +4 -3
  130. package/runtime/app-partial-client.mjs +3 -0
  131. package/runtime/app-partial.mjs +1 -1
  132. package/runtime/app-prefetch-constants.mjs +4 -0
  133. package/runtime/app-prefetch.mjs +2 -3
  134. package/runtime/app-render-key.mjs +5 -0
  135. package/runtime/app-render.mjs +100 -28
  136. package/runtime/app-routing.mjs +22 -9
  137. package/runtime/app-static.mjs +18 -8
  138. package/runtime/app-stream-html.mjs +10 -53
  139. package/runtime/app-style-selection.mjs +34 -0
  140. package/runtime/app-styles.mjs +154 -0
  141. package/runtime/cache-components-rsc.mjs +4 -1
  142. package/runtime/cache-request.mjs +2 -1
  143. package/runtime/cloud/archive.mjs +5 -6
  144. package/runtime/compile-cache.mjs +8 -0
  145. package/runtime/deployment-client.mjs +19 -0
  146. package/runtime/dev-ai-brands.mjs +26 -0
  147. package/runtime/dev-ai-style.mjs +99 -0
  148. package/runtime/dev-build-reasons.mjs +25 -0
  149. package/runtime/dev-cache-control.mjs +53 -0
  150. package/runtime/dev-client.mjs +245 -46
  151. package/runtime/dev-compiler-broker.mjs +42 -0
  152. package/runtime/dev-console-copy.mjs +19 -0
  153. package/runtime/dev-console.mjs +106 -0
  154. package/runtime/dev-dependency-graph.mjs +40 -0
  155. package/runtime/dev-element-picker-style.mjs +27 -0
  156. package/runtime/dev-element-picker.mjs +213 -0
  157. package/runtime/dev-error-overlay-style.mjs +41 -0
  158. package/runtime/dev-error-overlay.mjs +167 -0
  159. package/runtime/dev-error-report.mjs +121 -0
  160. package/runtime/dev-file-changes.mjs +1 -1
  161. package/runtime/dev-gateway.mjs +44 -0
  162. package/runtime/dev-host-relay.mjs +66 -0
  163. package/runtime/dev-indicator.mjs +191 -0
  164. package/runtime/dev-issue-motion.mjs +214 -0
  165. package/runtime/dev-lazy-tool.mjs +34 -0
  166. package/runtime/dev-logo.mjs +2 -0
  167. package/runtime/dev-notification.mjs +81 -0
  168. package/runtime/dev-popout.mjs +34 -0
  169. package/runtime/dev-position.mjs +107 -0
  170. package/runtime/dev-preview-touch.mjs +122 -0
  171. package/runtime/dev-runtime-pool.mjs +45 -0
  172. package/runtime/dev-runtime-spare.mjs +57 -0
  173. package/runtime/dev-tools-client.mjs +155 -0
  174. package/runtime/dev-tools-mcp.mjs +126 -0
  175. package/runtime/dev-tools-metrics.mjs +31 -0
  176. package/runtime/dev-tools-model.mjs +42 -0
  177. package/runtime/dev-tools-panel.mjs +341 -0
  178. package/runtime/dev-tools-result.mjs +37 -0
  179. package/runtime/dev-tools-server.mjs +256 -0
  180. package/runtime/dev-tools-source.mjs +104 -0
  181. package/runtime/dev-tooltip.mjs +94 -0
  182. package/runtime/dev-ui-icons.mjs +8 -0
  183. package/runtime/dev-workbench-style.mjs +106 -0
  184. package/runtime/dev-workbench.mjs +428 -0
  185. package/runtime/dev-worker-logs.mjs +31 -0
  186. package/runtime/development.mjs +324 -0
  187. package/runtime/document.mjs +1 -1
  188. package/runtime/edge.mjs +6 -5
  189. package/runtime/env.mjs +2 -1
  190. package/runtime/fetch-cache.mjs +10 -8
  191. package/runtime/flight-delta.mjs +50 -0
  192. package/runtime/flight-tap.mjs +28 -0
  193. package/runtime/html-only.mjs +27 -0
  194. package/runtime/http.mjs +2 -0
  195. package/runtime/incremental-cache.mjs +25 -4
  196. package/runtime/instrumentation.mjs +49 -0
  197. package/runtime/navigation-recovery.mjs +36 -0
  198. package/runtime/navigation.mjs +7 -0
  199. package/runtime/node-html-stream.mjs +47 -0
  200. package/runtime/pages-client.mjs +24 -8
  201. package/runtime/pages-navigation.mjs +54 -64
  202. package/runtime/persistent/client.mjs +6 -5
  203. package/runtime/persistent/daemon.mjs +66 -56
  204. package/runtime/persistent/native-proxy.mjs +63 -0
  205. package/runtime/prefetch-scheduler.mjs +110 -0
  206. package/runtime/preload.mjs +12 -0
  207. package/runtime/profile.mjs +14 -0
  208. package/runtime/profiles.json +2 -2
  209. package/runtime/render.mjs +3 -0
  210. package/runtime/route-pattern.mjs +44 -0
  211. package/runtime/rsc-worker.mjs +41 -12
  212. package/runtime/trace.mjs +78 -0
  213. package/runtime/typed-route.mjs +19 -0
  214. package/runtime/watch-project.mjs +29 -18
  215. package/runtime/worker.mjs +102 -24
@@ -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
+ }
@@ -0,0 +1,341 @@
1
+ import { createElementPicker } from './dev-element-picker.mjs';
2
+ import { durationLabel, materializeRoute, routeParameters, formatMcpConfig } from './dev-tools-model.mjs';
3
+ import { svg } from './dev-ui-icons.mjs';
4
+ import { workbenchStyle } from './dev-workbench-style.mjs';
5
+ import { devAiStyle } from './dev-ai-style.mjs';
6
+ import { devAiBrands, devMcpBrand } from './dev-ai-brands.mjs';
7
+
8
+ const headings = { requests: 'Requests', performance: 'Performance', routes: 'Routes & cache', ai: 'AI connection' };
9
+ const mcpGuides = {
10
+ codex: { text: 'Merge this TOML into .codex/config.toml in a trusted project, or ~/.codex/config.toml for all projects. Restart your Codex session after saving.', url: 'https://learn.chatgpt.com/docs/extend/mcp?surface=cli' },
11
+ cursor: { text: 'Merge this JSON into .cursor/mcp.json at your project root, then enable the server in Cursor’s MCP settings.', url: 'https://cursor.com/docs/mcp' },
12
+ claude: { text: 'In Claude Desktop, open Settings → Developer → Edit Config. Merge this JSON into claude_desktop_config.json, then fully quit and reopen Claude Desktop.', url: 'https://modelcontextprotocol.io/docs/develop/connect-local-servers' },
13
+ other: { text: 'Add a local stdio MCP server using this command and its arguments. If your client supports mcpServers JSON, merge this entry into its configuration.', url: 'https://modelcontextprotocol.io/docs/develop/connect-local-servers' },
14
+ };
15
+
16
+ export function createDevToolsPanel({ shadow, tools, restoreFocus }) {
17
+ const document = shadow.ownerDocument, view = document.defaultView;
18
+ const node = (tag, className, text) => { const result = document.createElement(tag); if (className) result.className = className; if (text !== undefined) result.textContent = text; return result; };
19
+ const button = (label, onClick, className = '') => { const result = node('button', className, label); result.type = 'button'; result.onclick = onClick; return result; };
20
+ const dialog = node('dialog', 'wb wb-tools'); dialog.setAttribute('aria-label', 'Development inspector');
21
+ const style = node('style'); style.textContent = workbenchStyle + devAiStyle; shadow.append(style);
22
+ const header = node('div', 'wb-head'), title = node('strong', 'wb-title'), status = node('span', 'wb-context');
23
+ const refresh = button('Refresh', () => load(true), 'wb-push');
24
+ const close = button('', () => dialog.close(), 'wb-close'); close.innerHTML = svg('<path d="m6 6 12 12M6 18 18 6"/>'); close.setAttribute('aria-label', 'Close inspector');
25
+ header.append(title, status, refresh, close);
26
+ const nav = node('nav', 'wb-tools-nav'); nav.setAttribute('aria-label', 'Development panels');
27
+ for (const [key, label] of Object.entries(headings)) { const item = button(label, () => open(key)); item.dataset.panel = key; nav.append(item); }
28
+ const filter = node('input', 'wb-tools-search'); filter.type = 'search'; filter.placeholder = 'Filter requests…'; filter.setAttribute('aria-label', 'Filter requests');
29
+ const content = node('div', 'wb-tools-content');
30
+ const feedback = node('div', 'wb-tools-feedback'); feedback.setAttribute('role', 'status');
31
+ dialog.append(header, nav, filter, content, feedback); shadow.append(dialog);
32
+ let active = 'requests', stop, unsubscribe, disposed = false, busy = false, context, picker, disposeAiView, aiViewId=0;
33
+ let selectedRoute, routeValues = {}, connection, refreshing = false, lastSignature, instruction = '', mcpClient = 'codex', syncAiView, pickEpoch = 0;
34
+ const query = () => filter.value.toLocaleLowerCase();
35
+ const assistantConnected = (data=tools.snapshot()) => data.connection==='connected'&&data.ai?.connected===true;
36
+ function setFeedback(message) { feedback.textContent = message || ''; }
37
+ function disclosure(trigger, reveal, inner, details) {
38
+ let expanded=false,timer;
39
+ const finish=()=>{view.clearTimeout(timer);if(details&&!expanded)details.open=false;reveal.style.height=expanded?'auto':'0px';};
40
+ const settle=()=>{view.clearTimeout(timer);timer=view.setTimeout(finish,260);};
41
+ function set(open,{animate=true}={}) {
42
+ open=Boolean(open);if(open===expanded&&(!details||details.open===open))return;
43
+ const height=reveal.getBoundingClientRect().height;
44
+ expanded=open;if(details){details.open=true;details.dataset.expanded=String(open);}
45
+ if(!open&&reveal.contains(shadow.activeElement))trigger.focus({preventScroll:true});
46
+ reveal.inert=!open;reveal.setAttribute('aria-hidden',String(!open));trigger.setAttribute('aria-expanded',String(open));
47
+ const immediate=!animate||view.matchMedia('(prefers-reduced-motion: reduce)').matches;
48
+ if(immediate)reveal.style.transition='none';
49
+ reveal.style.height=height+'px';void reveal.offsetHeight;
50
+ reveal.dataset.expanded=String(open);reveal.style.height=(open?inner.getBoundingClientRect().height:0)+'px';
51
+ if(immediate){finish();void reveal.offsetHeight;reveal.style.removeProperty('transition');}else settle();
52
+ }
53
+ const toggle=event=>{event.preventDefault();set(!expanded);};
54
+ const complete=event=>{if(event.target===reveal&&event.propertyName==='height'&&Math.abs(reveal.getBoundingClientRect().height-(expanded?inner.getBoundingClientRect().height:0))<1)finish();};
55
+ const observer=view.ResizeObserver?new view.ResizeObserver(()=>{if(expanded&&reveal.style.height!=='auto'){reveal.style.height=inner.getBoundingClientRect().height+'px';settle();}}):undefined;
56
+ reveal.style.height='0px';reveal.dataset.expanded='false';reveal.inert=true;reveal.setAttribute('aria-hidden','true');trigger.setAttribute('aria-expanded','false');
57
+ if(details){details.dataset.expanded='false';details.prnextExpand=set;}
58
+ trigger.addEventListener('click',toggle);reveal.addEventListener('transitionend',complete);observer?.observe(inner);
59
+ return {set,dispose(){view.clearTimeout(timer);observer?.disconnect();trigger.removeEventListener('click',toggle);reveal.removeEventListener('transitionend',complete);}};
60
+ }
61
+ async function action(control, task, success) {
62
+ if (busy) return; busy = true; control.disabled = true; control.setAttribute('aria-busy', 'true'); setFeedback('');if(active==='ai')syncAiView?.(tools.snapshot());
63
+ try { const result = await task(); if (success) setFeedback(typeof success === 'function' ? success(result) : success); return result; }
64
+ catch (error) { setFeedback(error.message || 'The action failed.'); }
65
+ finally { busy = false; control.disabled = false; control.removeAttribute('aria-busy');if(active==='ai'&&!disposed)syncAiView?.(tools.snapshot()); }
66
+ }
67
+ async function openEditor(args) {
68
+ const result = await tools.call('dev_open_editor', args);
69
+ if (!/^(?:vscode|cursor):\/\//.test(result.editorUrl || '')) throw Error('The editor link is unavailable.');
70
+ const link = node('a'); link.href = result.editorUrl; link.click(); return result;
71
+ }
72
+ function empty(text) { return node('p', 'wb-empty', text); }
73
+ function meta(text) { return node('span', 'wb-muted', text); }
74
+ function pair(label, value) { const row = node('div', 'wb-tools-pair'); row.append(meta(label), node('span', '', value)); return row; }
75
+ function section(label) { const section = node('section', 'wb-tools-section'); section.append(node('h3', '', label)); return section; }
76
+ function tableHead(labels) { const row = node('div', 'wb-request-grid wb-tools-labels'); for (const label of labels) row.append(meta(label)); return row; }
77
+ function requests(data) {
78
+ const records = [...(data.requests || []).filter(entry => entry.source === 'server' || entry.clientId !== data.clientId).map(entry => ({ ...entry, source: entry.source || 'browser' })), ...(data.localRequests || [])]
79
+ .filter(entry => !query() || [entry.method, entry.path, entry.status, entry.source].join(' ').toLocaleLowerCase().includes(query()))
80
+ .sort((a, b) => (b.time || 0) - (a.time || 0)).slice(0, 300);
81
+ const body = node('div'); body.append(tableHead(['Request', 'Status', 'Time', 'Source']));
82
+ for (const record of records) {
83
+ const details = node('details', 'wb-request'), summary = node('summary', 'wb-request-grid'); details.dataset.key = 'request:' + record.source + ':' + record.id;
84
+ const path = node('span', 'wb-request-path'); path.append(node('b', '', record.method || 'GET'), document.createTextNode(' ' + (record.path || '/')));
85
+ const statusText = record.status == null ? record.error || 'Failed' : String(record.status);
86
+ summary.append(path, node('span', record.failed || record.status >= 400 ? 'wb-error-text' : '', statusText), meta(durationLabel(record.duration)), meta(record.source));
87
+ const info = node('div', 'wb-request-info');
88
+ info.append(pair('Started', record.time ? new Date(record.time).toLocaleTimeString() : 'Not recorded'), pair('Kind', record.kind || record.type || 'HTTP'), pair('Duration', durationLabel(record.duration)));
89
+ if (record.id) info.append(pair('Request ID', String(record.id)));
90
+ if (record.error) info.append(node('pre', '', String(record.error)));
91
+ info.append(meta('Headers, request bodies and URL query values are not recorded.'));
92
+ const related = (data.serverLogs || []).filter(entry => record.requestId && entry.requestId === record.requestId);
93
+ if (related.length) info.append(node('pre', '', related.map(entry => entry.text || entry.message).join('\n')));
94
+ details.append(summary, info); body.append(details);
95
+ }
96
+ if (!records.length) body.append(empty(query() ? 'No matching requests.' : 'No requests recorded yet. Use the page to see fetch, XHR and server requests.'));
97
+ return body;
98
+ }
99
+ function metric(label, value, caption) { const metric = node('div', 'wb-metric'); metric.append(meta(label), node('strong', '', durationLabel(value))); if (caption) metric.append(meta(caption)); return metric; }
100
+ function performance(data) {
101
+ const body = node('div'), metrics = node('div', 'wb-metrics');
102
+ const navigation = view.performance.getEntriesByType('navigation')[0];
103
+ const builds = (data.builds || []).slice().reverse(), latest = builds[0];
104
+ metrics.append(metric('Latest build', latest?.duration), metric('Page response', navigation ? navigation.responseStart - navigation.requestStart : undefined, 'Time to first byte'),
105
+ metric('DOM ready', navigation?.domContentLoadedEventEnd || undefined));
106
+ body.append(metrics);
107
+ const build = section('Build timeline');
108
+ if (!builds.length) build.append(empty('Build timings appear after the next compilation.'));
109
+ for (const entry of builds.slice(0, 6)) {
110
+ const details = node('details', 'wb-build'); details.dataset.key = 'build:' + (entry.buildId || entry.startedAt);details.open = entry === latest;
111
+ const summary = node('summary'); summary.append(node('span', '', entry.reason || entry.kind || 'Compilation'), meta(durationLabel(entry.duration)));
112
+ details.append(summary);
113
+ for (const [name, duration] of Object.entries(entry.phases || {})) {
114
+ if (typeof duration !== 'number') continue;
115
+ const phase = node('div', 'wb-phase'), meter = node('span', 'wb-phase-meter'); meter.style.setProperty('--amount', Math.min(100, entry.duration ? duration / entry.duration * 100 : 0) + '%');
116
+ phase.append(node('span', '', name), meter, meta(durationLabel(duration))); details.append(phase);
117
+ }
118
+ if (!Object.keys(entry.phases || {}).length) details.append(meta('This build has no phase measurements.'));
119
+ build.append(details);
120
+ }
121
+ body.append(build);
122
+ const network = section('Slowest resource loads');
123
+ const resources = view.performance.getEntriesByType('resource').filter(entry => !/\/_prnext\/(?:dev|ensure)/.test(entry.name) && (!data.available || !entry.name.startsWith(tools.endpoint || 'http://invalid.invalid')))
124
+ .sort((a, b) => b.duration - a.duration).slice(0, 8);
125
+ for (const resource of resources) { const row = node('div', 'wb-tools-pair'); let path; try { path = new URL(resource.name).pathname; } catch { path = resource.name; } row.append(node('span', 'wb-truncate', path), meta(durationLabel(resource.duration))); network.append(row); }
126
+ if (!resources.length) network.append(empty('No resource timings recorded.'));
127
+ network.append(meta('Browser resource duration includes loading. Server render time is shown only when recorded by the runtime.'));
128
+ body.append(network);
129
+ const runtime = section('Runtime requests');
130
+ const observed = (data.requests || []).filter(entry=>entry.source==='server').slice().sort((a,b)=>(b.duration||0)-(a.duration||0)).slice(0,8);
131
+ for(const entry of observed) { const row=node('div','wb-tools-pair');row.append(node('span','wb-truncate',(entry.kind==='fetch'?'Server fetch · ':'HTTP · ')+(entry.path||'/')),meta(durationLabel(entry.duration)));runtime.append(row); }
132
+ if(!observed.length)runtime.append(empty('No runtime request timings recorded yet.'));
133
+ runtime.append(meta('Measured until response headers are ready; streamed body completion is not included.'));body.append(runtime);return body;
134
+ }
135
+ function routes(data) {
136
+ const body = node('div'), records = (data.routes || []).filter(route => !query() || [route.path, route.kind, route.source].join(' ').toLocaleLowerCase().includes(query()));
137
+ const cache = data.cache || {}, cacheSummary = node('div', 'wb-cache-summary');
138
+ for (const [key, value] of Object.entries(cache)) if (['string', 'number', 'boolean'].includes(typeof value)) cacheSummary.append(pair(key.replace(/([A-Z])/g, ' $1'), String(value)));
139
+ if (cacheSummary.childElementCount) body.append(cacheSummary);
140
+ for (const route of records) {
141
+ const details = node('details', 'wb-route'); details.dataset.key = 'route:' + route.path;details.open = selectedRoute === route.path;
142
+ const summary = node('summary'); summary.append(node('code', '', route.path), meta(route.kind || 'route'), meta(route.compiled ? 'Compiled' : 'On demand'));
143
+ details.addEventListener('toggle', () => { if (details.open) selectedRoute = route.path; else if (selectedRoute === route.path) selectedRoute = undefined; });
144
+ const inner = node('div', 'wb-route-actions'), values = routeValues[route.path] ||= {};
145
+ for (const parameter of routeParameters(route.path)) {
146
+ const label = node('label', 'wb-route-param', parameter.name + (parameter.optional ? ' (optional)' : ''));
147
+ const input = node('input'); input.value = values[parameter.name] || ''; input.placeholder = parameter.catchAll ? 'one/two' : parameter.name; input.autocomplete = 'off';
148
+ input.addEventListener('input', () => { values[parameter.name] = input.value; }); label.append(input); inner.append(label);
149
+ }
150
+ const actions = node('div', 'wb-button-row');
151
+ const open = button('Open route', () => action(open, async () => { const path = materializeRoute(route.path, values); await tools.call('dev_route_ensure', { path }); view.location.assign(path); }));
152
+ const invalidate = button('Clear route cache', () => action(invalidate, async () => {
153
+ const path = materializeRoute(route.path, values), result = await tools.call('dev_cache_invalidate', { path }); await tools.refresh(); return result;
154
+ }, result => result?.invalidated === true ? 'Development cache invalidated for this route.' : result?.reason || 'No active development cache was invalidated.'));
155
+ actions.append(open, invalidate);
156
+ if (route.source) {
157
+ const editor = button('Open source', () => action(editor, () => openEditor({ file: route.source, line: 1, column: 1 }), 'Editor link requested.')); actions.append(editor);
158
+ }
159
+ inner.append(actions); if (route.source) inner.append(meta(route.source)); details.append(summary, inner); body.append(details);
160
+ }
161
+ if (!records.length) body.append(empty(query() ? 'No matching routes.' : data.available ? 'No routes reported yet.' : 'Start PRNext dev to browse routes.'));
162
+ return body;
163
+ }
164
+ const drawings = {
165
+ target:'<path d="M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3M3 16v3a2 2 0 0 0 2 2h3m8 0h3a2 2 0 0 0 2-2v-3"/><path d="m9 8 6 4-3 1-1 3-2-8Z"/>',
166
+ copy:'<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V4H4v12h4"/>',
167
+ arrow:'<path d="M5 12h14m-5-5 5 5-5 5"/>',
168
+ check:'<path d="m5 12 4 4L19 6"/>',
169
+ plug:'<path d="M9 3v5m6-5v5M7 8h10v3a5 5 0 0 1-10 0V8Zm5 8v5"/>',
170
+ };
171
+ function mark(paths, className='') {const result=node('span',className);result.innerHTML=svg(paths);return result;}
172
+ async function copyText(text) {
173
+ if (!view.navigator.clipboard?.writeText) throw Error('Clipboard unavailable. Select and copy the text below.');
174
+ await view.navigator.clipboard.writeText(text);
175
+ }
176
+ function selectionView(initial) {
177
+ const selection=node('section','wb-component');selection.setAttribute('aria-label',initial.kind==='error'?'Error context':'Selected element');
178
+ const heading=node('div','wb-component-head'), identity=node('div','wb-component-identity'), name=node('h3'), kind=node('code','wb-component-kind');
179
+ const labels=node('div');labels.append(name,kind);identity.append(mark(drawings.target,'wb-component-mark'),labels);
180
+ const reselect=button('Reselect',()=>pick(),'wb-component-reselect');reselect.setAttribute('aria-label','Select another element');reselect.prepend(mark(drawings.target));heading.append(identity,reselect);
181
+ const visual=node('div','wb-component-visual'), image=node('img','wb-selection-image');image.alt='Captured selected element';
182
+ const placeholder=node('span','wb-component-placeholder');visual.append(image,placeholder);
183
+ const sourceRow=node('div','wb-component-source'), location=node('code','wb-component-location'), editor=node('a','wb-component-editor','Open in editor');sourceRow.append(location,editor);
184
+ const composer=node('div','wb-component-composer'), label=node('label','wb-component-label','What should change?');
185
+ const prompt=node('textarea');prompt.rows=3;prompt.maxLength=4000;prompt.setAttribute('aria-label','Instruction for your assistant');prompt.placeholder='Describe the change…';prompt.value=instruction;prompt.addEventListener('input',()=>{instruction=prompt.value;});label.append(prompt);
186
+ const footer=node('div','wb-component-compose-foot'), parts=node('span','wb-component-parts');
187
+ const send=button('Send context to AI',()=>action(send,()=>tools.sendContext(tools.snapshot().context||context||initial,prompt.value),'Context is available to your assistant through PRNext MCP. Ask it to use the latest context.'),'wb-ai-primary');send.append(mark(drawings.arrow));footer.append(parts,send);composer.append(label,footer);
188
+ const sourceDetails=node('details','wb-component-details');sourceDetails.dataset.key='component-source';sourceDetails.append(node('summary','','Source & styles'));
189
+ const sourceCode=node('pre','wb-source-code'), styles=node('dl','wb-component-styles');sourceDetails.append(sourceCode,styles);
190
+ selection.append(heading,visual,sourceRow,composer,sourceDetails);
191
+ let lastImage,lastStyles;
192
+ function sync(value) {
193
+ const mapped=value.mappedSource,source=mapped?.file||(typeof value.source==='string'?value.source:''),file=source.split(/[\\/]/).pop()?.replace(/:\d+(?::\d+)?$/,'');
194
+ name.textContent=file||(value.kind==='error'?'Runtime error':'Selected element');
195
+ kind.textContent=value.kind==='error'?'Error':`<${value.tag||'element'}>${value.bounds?' · '+value.bounds.width+' × '+value.bounds.height:''}`;
196
+ const png=value.screenshot?.mimeType==='image/png'&&typeof value.screenshot.data==='string'?value.screenshot:undefined;
197
+ image.hidden=!png;placeholder.hidden=!!png;
198
+ if(png&&lastImage!==png.data){image.src='data:image/png;base64,'+png.data;lastImage=png.data;}
199
+ const pending=value.kind==='element'&&!Object.hasOwn(value,'screenshot');
200
+ placeholder.textContent=value.kind==='error'?value.error?.message||value.text||'Runtime error':value.screenshotError||'Preparing preview…';
201
+ visual.dataset.state=pending?'loading':value.kind==='error'?'error':png?'ready':'unavailable';
202
+ location.textContent=mapped?.file?`${mapped.file}:${mapped.line}:${mapped.column}`:source||'Source unavailable';location.title=location.textContent;
203
+ editor.hidden=!/^(?:vscode|cursor):\/\//.test(mapped?.editorUrl||'');if(!editor.hidden)editor.href=mapped.editorUrl;
204
+ sourceCode.textContent=(value.kind==='error'&&value.error?.stack?value.error.stack+'\n\n':'')+(mapped?.frame||(Array.isArray(mapped?.excerpt)?mapped.excerpt.map(line=>`${line.line} ${line.text}`).join('\n'):mapped?.error||'Source is being resolved.'));
205
+ const nextStyles=JSON.stringify(value.styles||{});if(nextStyles!==lastStyles){lastStyles=nextStyles;styles.replaceChildren();for(const [key,text]of Object.entries(value.styles||{}))styles.append(node('dt','',key),node('dd','',text));}
206
+ parts.textContent=pending?'Preparing context…':[mapped?.file&&'Source',value.styles&&'Styles',png&&'Screenshot'].filter(Boolean).join(' · ')||'Error context';
207
+ const connected=assistantConnected();reselect.disabled=busy;
208
+ send.disabled=busy||pending||!connected;send.title=!connected?'Connect your assistant first.':pending?'Wait for the source and preview to finish.':'Queue this context for your assistant';
209
+ }
210
+ sync(initial);return {node:selection,sync};
211
+ }
212
+ function ai(data) {
213
+ const body=node('div','wb-ai-layout'),current={...(connection||{}),...(data.ai||{})},currentContext=data.context||context;
214
+ const state=node('section','wb-ai-connect'),hero=node('div','wb-ai-hero'),heading=node('h3','wb-ai-heading'),description=node('p','wb-ai-description');
215
+ const badge=node('span','wb-ai-status'),badgeText=node('span','wb-ai-status-label');badge.append(node('i'),badgeText);badge.setAttribute('aria-live','polite');badge.setAttribute('aria-atomic','true');
216
+ hero.append(heading,description,badge);state.append(hero);
217
+ const setup=node('details','wb-ai-setup'),setupSummary=node('summary','','Connection settings'),setupContent=node('div','wb-ai-setup-content');setup.dataset.key='ai-setup';setup.append(setupSummary,setupContent);state.append(setup);
218
+ const host=node('div','wb-host-connect'),hostHead=node('div','wb-host-heading');
219
+ hostHead.append(mark(drawings.plug),node('strong','','Use your PRNext MCP'));
220
+ const hostState=node('span','wb-host-state',current.hostRelay?.linked?'Relay ready':'Existing connection');hostHead.append(hostState);
221
+ const command=node('div','wb-host-command');command.append(node('code','','prnext dev --host-ai'));
222
+ const copyCommand=button('',()=>action(copyCommand,()=>copyText('prnext dev --host-ai'),'Host command copied.'),'wb-host-copy');copyCommand.setAttribute('aria-label','Copy Host command');copyCommand.title='Copy command';copyCommand.append(mark(drawings.copy));command.append(copyCommand);
223
+ const relayError=node('p','wb-error-text wb-host-error');relayError.hidden=true;
224
+ host.append(hostHead,command,node('p','wb-host-note','Shares this dev session through your existing PRNext login.'));setupContent.append(host);state.append(relayError);
225
+ const divider=node('div','wb-ai-divider');divider.append(node('span','','Or connect this project'));setupContent.append(divider);
226
+ const chooser=node('div','wb-ai-clients');chooser.setAttribute('role','group');chooser.setAttribute('aria-label','AI client');
227
+ const details=node('details','wb-mcp-config wb-local-setup');details.dataset.key='mcp';
228
+ const summary=node('summary','','Local MCP configuration'),controls=node('div','wb-mcp-controls'),format=node('span','wb-mcp-format'),code=node('pre','wb-mcp-code');
229
+ const reveal=node('div','wb-mcp-reveal'),mcpContent=node('div','wb-mcp-content'),snippet=node('div','wb-mcp-snippet');
230
+ const help=button('',undefined,'wb-mcp-help-toggle');help.setAttribute('aria-label','MCP setup help');help.title='Where to put this configuration';help.append(mark('<circle cx="12" cy="12" r="8"/><path d="M12 11v5m0-9v.01"/>'));
231
+ const helpReveal=node('div','wb-mcp-help-reveal'),helpContent=node('div','wb-mcp-help'),helpText=node('p'),helpNote=node('p','','Keep prnext dev running on this computer.'),helpLink=node('a','','Setup guide ↗');
232
+ helpReveal.id='prnext-mcp-help-'+(++aiViewId);help.setAttribute('aria-controls',helpReveal.id);helpLink.target='_blank';helpLink.rel='noopener noreferrer';helpContent.append(helpText,helpNote,helpLink);helpReveal.append(helpContent);
233
+ code.tabIndex=0;code.setAttribute('role','region');code.setAttribute('aria-label','MCP configuration');
234
+ let mcp=current.mcp,mcpSignature=JSON.stringify(mcp);
235
+ const copy=button('',()=>action(copy,()=>copyText(formatMcpConfig(mcp,mcpClient)),'MCP configuration copied.'),'wb-config-copy');copy.setAttribute('aria-label','Copy MCP configuration');copy.title='Copy configuration';copy.prepend(mark(drawings.copy));
236
+ controls.append(format,help);snippet.append(code,copy);mcpContent.append(controls,helpReveal,snippet);reveal.append(mcpContent);details.append(summary,reveal);
237
+ const configDisclosure=disclosure(summary,reveal,mcpContent,details),helpDisclosure=disclosure(help,helpReveal,helpContent);disposeAiView=()=>{configDisclosure.dispose();helpDisclosure.dispose();};
238
+ const providerButtons=new Map(),formats={codex:'Codex · TOML',cursor:'Cursor · JSON',claude:'Claude Desktop · JSON',other:'Other MCP · JSON'};
239
+ function syncProvider(){
240
+ for(const[id,control]of providerButtons)control.setAttribute('aria-pressed',String(id===mcpClient));
241
+ format.textContent=formats[mcpClient];code.textContent=mcp?formatMcpConfig(mcp,mcpClient):'Start PRNext dev to get the local configuration.';
242
+ const guide=mcpGuides[mcpClient];helpText.textContent=guide.text;helpLink.href=guide.url;code.scrollTop=0;
243
+ copy.disabled=!mcp;
244
+ }
245
+ function choose(id){const changed=id!==mcpClient;mcpClient=id;syncProvider();configDisclosure.set(true);if(changed&&!view.matchMedia('(prefers-reduced-motion: reduce)').matches){mcpContent.style.transition='none';mcpContent.dataset.swapping='true';void mcpContent.offsetHeight;mcpContent.style.removeProperty('transition');mcpContent.dataset.swapping='false';}}
246
+ for(const brand of devAiBrands){
247
+ const label=brand.id==='claude'?'Claude':brand.label,control=button('',()=>choose(brand.id),'wb-ai-client');control.setAttribute('aria-label',label);control.dataset.client=brand.id;
248
+ const logo=node('span','wb-ai-client-logo');logo.innerHTML=brand.icon;
249
+ const tick=mark(drawings.check,'wb-ai-client-check');control.append(logo,node('span','wb-ai-client-label',label),tick);control.disabled=!current.mcp;chooser.append(control);providerButtons.set(brand.id,control);
250
+ }
251
+ const other=button('Other MCP',()=>choose('other'),'wb-ai-other');other.setAttribute('aria-label','Other MCP');const otherLogo=node('span');otherLogo.innerHTML=devMcpBrand.icon;other.prepend(otherLogo);other.disabled=!current.mcp;providerButtons.set('other',other);
252
+ syncProvider();setupContent.append(chooser,other,details);body.append(state);
253
+ const workspace=node('section','wb-ai-workspace'),ready=node('div','wb-ai-ready');
254
+ const select=button('Select an element',()=>pick(),'wb-ai-primary wb-ai-select');select.prepend(mark(drawings.target));
255
+ ready.append(select,node('p','','Choose a component to describe a change.'));workspace.append(ready);body.append(workspace);
256
+ let selection,wasConnected,resumePrompt=false;
257
+ const verify=node('section','wb-ai-verify'),verifyText=node('div');verifyText.append(node('strong','','Check your changes'),node('p','','Confirm the latest build in your browser.'));
258
+ const check=button('Verify page',()=>action(check,()=>{const latest=tools.snapshot();if(!assistantConnected(latest))throw Error('Connect your assistant first.');return tools.call('dev_verify',{path:view.location.pathname,sinceSequence:Math.max(0,(latest.sequence||1)-1),clientId:latest.clientId,timeoutMs:25000});},result=>result.verified===true||result.ok===true||result.state==='verified'?'Verified: the browser applied the build and no current errors were reported.':result.reason||result.message||'Verification is incomplete. Check the browser and current errors.'),'wb-ai-verify-button');check.prepend(mark(drawings.check));verify.append(verifyText,check);body.append(verify);
259
+ syncAiView=latest=>{
260
+ const live={...(connection||{}),...(latest.ai||{})},online=latest.connection==='connected',connected=assistantConnected(latest),value=latest.context||context||currentContext;
261
+ heading.textContent=connected?'Assistant connected':'Connect your assistant';
262
+ const signature=JSON.stringify(live.mcp);if(signature!==mcpSignature){mcp=live.mcp;mcpSignature=signature;syncProvider();for(const control of providerButtons.values())control.disabled=!mcp;}
263
+ description.textContent=connected?String(live.client||'Local MCP session'):!online&&live.connected?'Reconnect your local dev session.':'Connect to send your selection to an assistant.';
264
+ badge.dataset.connected=String(connected);badgeText.textContent=!online?latest.connection==='unavailable'?'Unavailable':'Reconnecting':connected?'Connected':live.hostRelay?.linked?'Waiting for assistant':'Not connected';
265
+ hostState.textContent=online&&live.hostRelay?.linked?'Relay ready':'Existing connection';relayError.textContent=live.hostRelay?.error||'';relayError.hidden=!relayError.textContent;
266
+ let focusSetup=false,focusWorkspace=false;
267
+ if(wasConnected!==connected){
268
+ focusWorkspace=connected&&wasConnected===false&&setup.contains(shadow.activeElement);
269
+ setup.open=!connected;
270
+ if(wasConnected===true&&!connected){
271
+ resumePrompt=workspace.contains(shadow.activeElement);focusSetup=resumePrompt||verify.contains(shadow.activeElement)||shadow.activeElement===setupSummary;
272
+ setFeedback('Assistant disconnected. Reconnect to continue.');
273
+ }
274
+ }
275
+ setupSummary.hidden=!connected;workspace.hidden=!connected;verify.hidden=!connected;check.disabled=!connected||busy;select.disabled=!connected||busy;
276
+ body.dataset.connected=String(connected);body.classList.toggle('wb-ai-has-selection',connected&&!!value);
277
+ if(connected&&value){if(!selection){selection=selectionView(value);workspace.replaceChildren(selection.node);}selection.sync(value);}
278
+ else if(selection&&value)selection.sync(value);
279
+ if(focusSetup)copyCommand.focus({preventScroll:true});
280
+ if(connected&&resumePrompt){(workspace.querySelector('textarea')||workspace.querySelector('button'))?.focus({preventScroll:true});resumePrompt=false;}
281
+ else if(focusWorkspace)(workspace.querySelector('textarea')||workspace.querySelector('button'))?.focus({preventScroll:true});
282
+ if(connected&&wasConnected===false&&/^(?:Assistant disconnected\.|Connect your assistant\b|Your assistant is no longer connected\.)/.test(feedback.textContent))setFeedback('');
283
+ wasConnected=connected;
284
+ };syncAiView(data);return body;
285
+ }
286
+ function render(force = false) {
287
+ if (disposed || !dialog.open) return;
288
+ const data = tools.snapshot(); status.textContent = data.connection === 'connected' ? 'Local' : data.connection === 'unavailable' ? 'Unavailable' : 'Reconnecting';
289
+ const selectionReady=active==='ai'&&assistantConnected(data)&&!!(data.context||context);
290
+ dialog.dataset.panel=active;dialog.dataset.selection=String(selectionReady);
291
+ title.textContent=selectionReady?(data.context||context).kind==='error'?'Debug with AI':'Selected component':headings[active];
292
+ if(active==='ai')syncAiView?.(data);
293
+ // Avoid replacing a focused field or open details every heartbeat.
294
+ const signature = JSON.stringify([active, filter.value, data.requests?.at(-1)?.id, data.localRequests?.at(-1)?.id, data.builds?.at(-1)?.buildId, data.routes, data.cache,
295
+ data.context?.source, data.context?.screenshot?.data?.length, data.context?.screenshotError, data.context?.sent,
296
+ data.ai?.connected, data.ai?.lastActivity, data.ai?.hostRelay, data.ai?.contexts?.at(-1)?.id]);
297
+ if (!force && signature === lastSignature) return;
298
+ if (!force && content.contains(shadow.activeElement)) return;
299
+ lastSignature = signature; const openDetails=new Map([...content.querySelectorAll('details[data-key]')].map(details=>[details.dataset.key,details.prnextExpand?details.dataset.expanded==='true':details.open]));
300
+ disposeAiView?.();disposeAiView=undefined;syncAiView=undefined;
301
+ content.replaceChildren(({ requests, performance, routes, ai })[active](data));
302
+ for(const details of content.querySelectorAll('details[data-key]'))if(openDetails.has(details.dataset.key)){const expanded=openDetails.get(details.dataset.key);if(details.prnextExpand)details.prnextExpand(expanded,{animate:false});else details.open=expanded;}
303
+ if (data.error && !feedback.textContent) setFeedback(data.error);
304
+ }
305
+ async function load(force=false) {
306
+ if (refreshing) return; refreshing = true; refresh.disabled = true;
307
+ try { await tools.refresh(); if (active === 'ai') connection = await tools.call('dev_connection'); render(force); }
308
+ catch (error) { setFeedback(error.message); }
309
+ finally { refreshing = false; refresh.disabled = false; }
310
+ }
311
+ function open(key = 'requests', nextContext) {
312
+ if (disposed) return;
313
+ if (picker) { picker.dispose(); picker = undefined; }
314
+ if (nextContext) { context = nextContext; tools.setContext(nextContext); }
315
+ const changed = active !== key; active = key; title.textContent = headings[key];
316
+ for (const item of nav.children) { item.setAttribute('aria-current', item.dataset.panel === key ? 'page' : 'false'); }
317
+ filter.hidden = !['requests', 'routes'].includes(key); filter.placeholder = key === 'routes' ? 'Find a route…' : 'Filter requests…'; filter.setAttribute('aria-label', key === 'routes' ? 'Find a route' : 'Filter requests');
318
+ if (changed) { filter.value = ''; setFeedback(''); }
319
+ if (!dialog.open) { dialog.showModal(); stop = tools.watch(); unsubscribe = tools.subscribe(() => render()); }
320
+ render(true);
321
+ const selected=nav.querySelector('[aria-current=page]');
322
+ if(selected&&active!=='ai'){const item=selected.getBoundingClientRect(),bounds=nav.getBoundingClientRect();if(item.right>bounds.right-8)nav.scrollLeft+=item.right-bounds.right+8;else if(item.left<bounds.left+8)nav.scrollLeft-=bounds.left+8-item.left;}
323
+ void load(); if (!filter.hidden) filter.focus(); else if(nextContext)(content.querySelector('.wb-ai-workspace:not([hidden]) textarea')||content.querySelector('.wb-host-copy'))?.focus({preventScroll:true});else close.focus();
324
+ }
325
+ function pick() {
326
+ if(disposed)return;
327
+ const epoch=++pickEpoch;
328
+ if (dialog.open) dialog.close();
329
+ let shown = false;
330
+ picker?.dispose(); picker = createElementPicker({ tools, view, onCancel(){picker=undefined;restoreFocus();}, onSelect(selection) {
331
+ if(disposed||epoch!==pickEpoch)return;if(!shown)instruction='';context = selection; picker = undefined;
332
+ if(shown){if(dialog.open&&active==='ai')render();return;}shown=true;open('ai', selection);
333
+ } });
334
+ }
335
+ filter.addEventListener('input', () => render(true));
336
+ content.addEventListener('focusout',()=>view.requestAnimationFrame(()=>render()));
337
+ dialog.addEventListener('close', () => { stop?.(); stop = undefined; unsubscribe?.(); unsubscribe = undefined; restoreFocus(); });
338
+ return { open, pick, hide() { pickEpoch++;if (dialog.open) dialog.close();picker?.dispose(); picker = undefined; },
339
+ dispose() { disposed = true;pickEpoch++;picker?.dispose(); stop?.(); unsubscribe?.();disposeAiView?.(); dialog.remove(); style.remove(); },
340
+ };
341
+ }
@@ -0,0 +1,37 @@
1
+ // Images are MCP image blocks, never base64 text in the model's text context.
2
+ // Browser HTTP snapshots retain the original image for the local inspector.
3
+ export function devToolResult(value) {
4
+ const copy=structuredClone(value),data=copy&&typeof copy==='object'&&!Array.isArray(copy)?copy:{value:copy??null},images=[],candidates=[];
5
+ // Walk JSON iteratively, so deeply nested CLI replies cannot overflow the
6
+ // formatter stack. All image data is removed from text, even malformed data.
7
+ const stack=[data];
8
+ while(stack.length){
9
+ const parent=stack.pop();if(!parent||typeof parent!=='object')continue;
10
+ const entries=Object.entries(parent);
11
+ for(let index=entries.length-1;index>=0;index--){
12
+ const [key,item]=entries[index];
13
+ if(key==='screenshot'&&(typeof item==='string'||item&&typeof item==='object'&&Object.hasOwn(item,'data'))||
14
+ item&&typeof item==='object'&&typeof item.mimeType==='string'&&item.mimeType.startsWith('image/')&&Object.hasOwn(item,'data')){
15
+ candidates.push({parent,key,image:item});continue;
16
+ }
17
+ if(item&&typeof item==='object')stack.push(item);
18
+ }
19
+ }
20
+ // The traversal above retains chronological context array order. Prefer its
21
+ // most recent valid image; older/invalid images remain explicit omissions.
22
+ let selected;
23
+ for(const candidate of candidates){
24
+ const image=candidate.image;
25
+ const valid=image?.mimeType==='image/png'&&typeof image.data==='string'&&image.data.length<=350000&&/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)&&
26
+ Buffer.from(image.data,'base64').subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]));
27
+ candidate.valid=valid;if(valid)selected=candidate;
28
+ }
29
+ for(const candidate of candidates){
30
+ const {parent,key,image,valid}=candidate;
31
+ if(candidate===selected){
32
+ images.push({type:'image',mimeType:'image/png',data:image.data});
33
+ parent[key]={mimeType:'image/png',width:image.width,height:image.height,attached:true};
34
+ }else parent[key]={omitted:true,reason:valid?'Only the latest screenshot is attached.':'Screenshot is invalid, unsupported or exceeds the image limit.'};
35
+ }
36
+ return {content:[{type:'text',text:JSON.stringify(data)},...images],structuredContent:data,isError:false};
37
+ }