@thomas.f/prnext 0.1.19-alpha → 0.1.21-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 (113) hide show
  1. package/build/app.mjs +46 -78
  2. package/build/build-lock.mjs +2 -71
  3. package/build/build-retention.mjs +119 -0
  4. package/build/build-timings.mjs +38 -0
  5. package/build/bundle-runtime.mjs +17 -16
  6. package/build/cache-command.mjs +31 -0
  7. package/build/check.mjs +41 -3
  8. package/build/compilation-cache.mjs +32 -12
  9. package/build/compilation-storage.mjs +190 -0
  10. package/build/compiler.mjs +5 -4
  11. package/build/config.mjs +23 -2
  12. package/build/create.mjs +1 -1
  13. package/build/css-modules.mjs +4 -0
  14. package/build/dev-action-graph.mjs +21 -0
  15. package/build/flight-summary.mjs +64 -0
  16. package/build/font-cache.mjs +9 -6
  17. package/build/font-files.mjs +1 -1
  18. package/build/index.mjs +57 -36
  19. package/build/inspect.mjs +3 -0
  20. package/build/inspection-summary.mjs +7 -2
  21. package/build/native-discovery.mjs +18 -6
  22. package/build/native-ppr.mjs +32 -0
  23. package/build/npm-packages.mjs +2 -2
  24. package/build/og.mjs +121 -1
  25. package/build/parallel-plan.mjs +55 -0
  26. package/build/persistent-cache.mjs +29 -24
  27. package/build/production-check.mjs +34 -0
  28. package/build/react-compiler-report.mjs +31 -8
  29. package/build/react-compiler.mjs +101 -10
  30. package/build/server-config.mjs +35 -0
  31. package/build/source-analysis.mjs +108 -5
  32. package/build/standalone-copy.mjs +48 -0
  33. package/build/standalone.mjs +2 -22
  34. package/build/static-adapter.mjs +7 -3
  35. package/build/transform-cache.mjs +17 -3
  36. package/build/transform.mjs +38 -6
  37. package/build/typescript-dev.mjs +3 -1
  38. package/cli.mjs +27 -5
  39. package/compat/app-layout-context.cjs +7 -3
  40. package/compat/diagnostics.cjs +5 -4
  41. package/compat/flight-delta.cjs +47 -12
  42. package/compat/index.d.ts +24 -1
  43. package/compat/navigation-authorization.cjs +8 -1
  44. package/compat/runtime-sample.cjs +56 -0
  45. package/compat/use-cache.cjs +5 -2
  46. package/docs/ai/compatibility.md +113 -10
  47. package/docs/ai/deployment.md +144 -0
  48. package/docs/ai/quickstart.md +5 -0
  49. package/docs/ai/troubleshooting.md +22 -0
  50. package/package.json +25 -22
  51. package/release/fontkit.mjs +31 -0
  52. package/release/verify.mjs +3 -0
  53. package/runtime/api.mjs +5 -2
  54. package/runtime/app-client.mjs +246 -54
  55. package/runtime/app-partial.mjs +2 -10
  56. package/runtime/app-prefetch.mjs +2 -2
  57. package/runtime/app-render.mjs +42 -13
  58. package/runtime/app-stream-channel.mjs +20 -6
  59. package/runtime/app-stream-html.mjs +30 -11
  60. package/runtime/app-styles.mjs +22 -4
  61. package/runtime/cache-components-rsc.mjs +26 -6
  62. package/runtime/cache-result.mjs +58 -0
  63. package/runtime/compile-cache-retention.mjs +53 -0
  64. package/runtime/compile-cache.mjs +28 -1
  65. package/runtime/dev-client.mjs +32 -8
  66. package/runtime/dev-compiler-broker.mjs +20 -23
  67. package/runtime/dev-gateway.mjs +23 -6
  68. package/runtime/dev-route-selection.mjs +64 -0
  69. package/runtime/dev-runtime-pool.mjs +3 -2
  70. package/runtime/dev-runtime-spare.mjs +1 -1
  71. package/runtime/dev-session-retention.mjs +154 -0
  72. package/runtime/development-flight.mjs +59 -0
  73. package/runtime/development-queue.mjs +33 -0
  74. package/runtime/development.mjs +37 -16
  75. package/runtime/env.mjs +9 -0
  76. package/runtime/flight-delta.mjs +31 -5
  77. package/runtime/flight-diagnostics.mjs +48 -0
  78. package/runtime/flight-tap.mjs +10 -5
  79. package/runtime/html-envelope.mjs +34 -0
  80. package/runtime/loopback-rpc.mjs +92 -0
  81. package/runtime/native-ppr.mjs +88 -0
  82. package/runtime/og/NOTICE.txt +1 -1
  83. package/runtime/og/index.node.mjs +76 -31216
  84. package/runtime/og/upstream.json +115 -0
  85. package/runtime/persistent/client.mjs +5 -3
  86. package/runtime/persistent/common.mjs +7 -18
  87. package/runtime/persistent/daemon.mjs +4 -12
  88. package/runtime/persistent/health.mjs +58 -0
  89. package/runtime/persistent/native-proxy.mjs +1 -1
  90. package/runtime/persistent/watchdog.mjs +12 -27
  91. package/runtime/prewarm.mjs +35 -0
  92. package/runtime/project-lock.mjs +71 -0
  93. package/runtime/request-transport.mjs +112 -32
  94. package/runtime/rsc-worker.mjs +58 -8
  95. package/runtime/storage-path.mjs +36 -0
  96. package/runtime/trace.mjs +44 -4
  97. package/runtime/worker.mjs +37 -17
  98. package/vendor/fontkit/NOTICE.txt +5 -0
  99. package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
  100. package/vendor/fontkit/index.mjs +3 -0
  101. package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
  102. package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
  103. package/vendor/fontkit/licenses/brotli/readme.md +61 -0
  104. package/vendor/fontkit/licenses/clone/LICENSE +18 -0
  105. package/vendor/fontkit/licenses/dfa/README.md +83 -0
  106. package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
  107. package/vendor/fontkit/licenses/fontkit/README.md +266 -0
  108. package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
  109. package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
  110. package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
  111. package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
  112. package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
  113. package/vendor/fontkit/upstream.json +215 -0
@@ -0,0 +1,55 @@
1
+ import {availableParallelism, freemem} from 'node:os';
2
+ import {mapConcurrent} from './concurrent.mjs';
3
+ import {mkdir, writeFile} from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {createHash} from 'node:crypto';
6
+
7
+ // Budget admission, not a claim to cap esbuild/PostCSS process RSS. A single
8
+ // heavy custom transform can exceed its estimate, so unknown hooks stay serial.
9
+ export function buildPlanConcurrency(options, {cpus = availableParallelism(), freeBytes = freemem()} = {}) {
10
+ if (!options) return 1;
11
+ return Math.max(1, Math.min(options.maxWorkers, cpus, Math.floor(options.maxMemoryMb / 256), Math.floor(freeBytes / (512 * 1024 * 1024))));
12
+ }
13
+
14
+ export function immutableStylePlan(routes, getPlan) {
15
+ const jobs = new Map();
16
+ const plannedRoutes = routes.map(route => Object.freeze({id: route.id, parallel: !!route.parallel,
17
+ scopes: Object.freeze(getPlan(route).map(scope => {
18
+ const styles = Object.freeze([...scope.styles]);
19
+ const signature = JSON.stringify(styles);
20
+ if (!jobs.has(signature)) jobs.set(signature, Object.freeze({signature, styles}));
21
+ return Object.freeze({signature, path: scope.path, kind: scope.kind});
22
+ })),
23
+ }));
24
+ return Object.freeze({routes: Object.freeze(plannedRoutes), jobs: Object.freeze([...jobs.values()])});
25
+ }
26
+
27
+ export async function executeStylePlan(plan, run, options) {
28
+ const concurrency = buildPlanConcurrency(options);
29
+ const outputs = await mapConcurrent(plan.jobs, run, concurrency);
30
+ return {outputs: new Map(plan.jobs.map((job, index) => [job.signature, outputs[index]])), concurrency};
31
+ }
32
+
33
+ // Independent CSS graphs can emit the same url() asset. Compile in parallel,
34
+ // but publish each exact output once. Keep only hashes and settled promises,
35
+ // not all completed bundles, so memory stays bounded by admitted jobs.
36
+ export function createStyleOutputWriter(directory) {
37
+ const root = path.resolve(directory), outputs = new Map();
38
+ return async files => {
39
+ for (const file of files) {
40
+ const target = path.resolve(file.path), relative = path.relative(root, target);
41
+ if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('Style output escaped its build directory');
42
+ const key = process.platform === 'win32' ? target.toLowerCase() : target;
43
+ const hash = createHash('sha256').update(file.contents).digest('hex');
44
+ const previous = outputs.get(key);
45
+ if (previous) {
46
+ if (previous.hash !== hash) throw new Error(`Conflicting style output: ${relative}`);
47
+ await previous.written;
48
+ } else {
49
+ const written = (async () => {await mkdir(path.dirname(target), {recursive: true}); await writeFile(target, file.contents);})();
50
+ outputs.set(key, {hash, written});
51
+ await written;
52
+ }
53
+ }
54
+ };
55
+ }
@@ -6,10 +6,15 @@ import {open,lstat,mkdir,realpath,readFile,readdir,rename,unlink} from 'node:fs/
6
6
  import path from 'node:path';
7
7
  import {fileURLToPath} from 'node:url';
8
8
  import {createRequire} from 'node:module';
9
+ import {deflateRaw,inflateRaw} from 'node:zlib';
10
+ import {promisify} from 'node:util';
11
+ export {pruneCompilerCache} from '../runtime/compile-cache-retention.mjs';
9
12
 
10
13
  export const cacheHash=value=>createHash('sha256').update(value).digest('hex');
11
14
  const packageRoot=fileURLToPath(new URL('../',import.meta.url)),require=createRequire(import.meta.url);
12
15
  const MAX_DISK=64*1024*1024;
16
+ const compress=promisify(deflateRaw),decompress=promisify(inflateRaw);
17
+ const MAGIC=Buffer.from('PRNC2\0'),HEADER=MAGIC.length+4+32+32;
13
18
  let frameworkIdentity;
14
19
  async function frameworkHash(){
15
20
  frameworkIdentity??=(async()=>{
@@ -59,45 +64,45 @@ export async function privateCacheDirectory(root,kind){
59
64
  return directory;
60
65
  }catch{return null;}
61
66
  }
62
- /** Best-effort V8 cache retention; never follow links or traverse app sources. */
63
- export async function pruneCompilerCache(directory,{maxBytes=64*1024*1024,maxFiles=2048}={}){
64
- if(!directory)return;
65
- try{
66
- const files=[];let scanned=0;
67
- async function collect(folder,depth){
68
- if(depth>3||scanned>=8192)return;
69
- for(const entry of await readdir(folder,{withFileTypes:true})){
70
- if(++scanned>8192)break;
71
- const name=path.join(folder,entry.name),info=await lstat(name).catch(()=>null);
72
- if(!info||info.isSymbolicLink())continue;
73
- if(info.isDirectory())await collect(name,depth+1);
74
- else if(info.isFile())files.push({name,size:info.size,time:info.mtimeMs});
75
- }
76
- }
77
- const info=await lstat(directory);if(!info.isDirectory()||info.isSymbolicLink())return;
78
- await collect(directory,0);files.sort((a,b)=>b.time-a.time);let bytes=0;
79
- for(let i=0;i<files.length;i++){bytes+=files[i].size;if(i>=maxFiles||bytes>maxBytes)await unlink(files[i].name).catch(()=>{});}
80
- }catch{/* Optional caches must never prevent compilation. */}
81
- }
82
67
  export async function readCacheRecord(directory,key,limit=16*1024*1024){
83
- if(!directory||!/^[a-f0-9]{64}$/.test(key))return null;
68
+ if(!directory||!/^[a-f0-9]{64}$/.test(key)||!Number.isSafeInteger(limit)||limit<1||limit>64*1024*1024)return null;
84
69
  let file;
85
70
  try{
86
71
  const name=path.join(directory,key+'.json'),info=await lstat(name);
87
72
  if(!info.isFile()||info.isSymbolicLink()||info.size>limit)return null;
88
73
  file=await open(name,fsConstants.O_RDONLY|(fsConstants.O_NOFOLLOW||0));
89
74
  const stat=await file.stat();if(!stat.isFile()||stat.size>limit)return null;
90
- const record=JSON.parse(await file.readFile('utf8'));
75
+ const body=await file.readFile();
76
+ if(body.subarray(0,MAGIC.length).equals(MAGIC)){
77
+ if(body.length<HEADER)return null;
78
+ const size=body.readUInt32BE(MAGIC.length);
79
+ if(size>limit||body.subarray(MAGIC.length+4,MAGIC.length+36).toString('hex')!==key)return null;
80
+ const payload=await decompress(body.subarray(HEADER),{maxOutputLength:Math.max(1,size)});
81
+ if(payload.length!==size||cacheHash(payload)!==body.subarray(MAGIC.length+36,HEADER).toString('hex'))return null;
82
+ return JSON.parse(payload.toString('utf8'));
83
+ }
84
+ const record=JSON.parse(body.toString('utf8'));
91
85
  if(record.version!==1||record.key!==key||typeof record.payload!=='string'||cacheHash(record.payload)!==record.checksum)return null;
92
86
  return JSON.parse(record.payload);
93
87
  }catch{return null;}finally{await file?.close().catch(()=>{});}
94
88
  }
95
89
  export async function writeCacheRecord(directory,key,value,limit=16*1024*1024,{maxFiles=64}={}){
96
- if(!directory||!/^[a-f0-9]{64}$/.test(key))return false;
90
+ if(!directory||!/^[a-f0-9]{64}$/.test(key)||!Number.isSafeInteger(limit)||limit<1||limit>64*1024*1024)return false;
97
91
  if(!Number.isInteger(maxFiles)||maxFiles<1||maxFiles>4096)return false;
98
92
  const filename=path.join(directory,key+'.json'),temporary=filename+'.'+randomUUID()+'.tmp';
99
93
  try{
100
- const payload=JSON.stringify(value),body=JSON.stringify({version:1,key,checksum:cacheHash(payload),payload});
94
+ const payload=JSON.stringify(value);if(payload===undefined||Buffer.byteLength(payload)>limit)return false;
95
+ let body=JSON.stringify({version:1,key,checksum:cacheHash(payload),payload});
96
+ // Large, repetitive JSON benefits from one encoding instead of embedding a
97
+ // second escaped JSON string. Keep small records compatible with v1.
98
+ if(Buffer.byteLength(payload)>=16384){
99
+ const bytes=Buffer.from(payload),packed=await compress(bytes,{level:1});
100
+ if(packed.length+HEADER<bytes.length*0.9){
101
+ const header=Buffer.alloc(HEADER);MAGIC.copy(header);header.writeUInt32BE(bytes.length,MAGIC.length);
102
+ Buffer.from(key,'hex').copy(header,MAGIC.length+4);Buffer.from(cacheHash(bytes),'hex').copy(header,MAGIC.length+36);
103
+ body=Buffer.concat([header,packed]);
104
+ }
105
+ }
101
106
  if(Buffer.byteLength(body)>limit)return false;
102
107
  const file=await open(temporary,'wx',0o600);try{await file.writeFile(body);}finally{await file.close();}
103
108
  await rename(temporary,filename);
@@ -0,0 +1,34 @@
1
+ import {isIP} from 'node:net';
2
+
3
+ /** Configuration checks only: external proxies, databases and sessions need live tests. */
4
+ export function productionDiagnostics(config,project,{env=process.env}={}){
5
+ const diagnostics=[];
6
+ const locations={'production-static':'output','production-origin':'server.publicOrigin','production-origin-https':'server.publicOrigin','production-proxy':'server.trustedProxies','production-proxy-range':'server.trustedProxies','production-auth-cookies':'navigationCache.authCookies','production-action-origins':'experimental.serverActions.allowedOrigins','production-source-maps':'productionBrowserSourceMaps'};
7
+ const add=(id,severity,message,action,location)=>diagnostics.push({id,code:id,path:location||locations[id]||'production',severity,message,...(action?{action}:{})});
8
+ if(config.output==='export'){
9
+ add('production-static','info','Static export does not run the PRNext HTTP server.','Check HTTPS, headers and health checks at the static host.');
10
+ }else{
11
+ const origin=config.server?.publicOrigin;
12
+ if(!origin)add('production-origin','warning','The public server origin is not configured.','Set server.publicOrigin to the public HTTPS origin used by redirects, authentication callbacks and request URLs.');
13
+ else{
14
+ const url=new URL(origin),hostname=url.hostname,loopback=hostname==='localhost'||hostname==='[::1]'||(isIP(hostname)===4&&hostname.startsWith('127.'));
15
+ if(url.protocol!=='https:'&&!loopback)add('production-origin-https','warning','The public server origin uses HTTP.','Use an HTTPS public origin and test secure cookies and authentication callbacks through the real proxy.');
16
+ else add('production-origin','info','The public server origin is configured.');
17
+ }
18
+ if(config.server?.trustedProxies?.length)add('production-proxy','info','Forwarded host and protocol are accepted only from the configured proxy addresses.','Bind the application to the private interface and configure the proxy to replace forwarded headers.');
19
+ if(config.server?.trustedProxies?.some(value=>/\/0$/.test(value)))add('production-proxy-range','error','A trusted proxy range includes every remote address.','Replace /0 ranges with the exact proxy IP addresses or private CIDRs.');
20
+ }
21
+ if(project?.routes.some(route=>route.router==='app')&&config.output!=='export'){
22
+ if(config.navigationCache?.maxEntries>0&&!config.navigationCache?.authCookies?.length)add('production-auth-cookies','warning','Retained App pages have no explicit authentication cookie names.','For authenticated applications, set navigationCache.authCookies to the exact session cookie names, including secure variants. Test account A → sign out → account B without retaining private drafts.');
23
+ else if(config.navigationCache?.authCookies?.length)add('production-auth-cookies','info','Authentication cookie changes enable strict retained-page state resets.','Test account A → sign out → account B in the browser; this check does not verify application session behavior.');
24
+ if(config.experimental?.serverActions?.allowedOrigins?.some(value=>value.includes('*')))add('production-action-origins','warning','Server Actions allow wildcard origin patterns.','Use the narrowest trusted host patterns and test rejected cross-origin mutations.');
25
+ }
26
+ const experiments=['nextScriptWorkers','lazyDynamicImports','streamingRequestBodies','nativePprShell','parallelBuilds'].filter(key=>config.experimental?.[key]);
27
+ if(config.reactCompiler?.backend==='oxc')experiments.push('reactCompiler.backend=oxc');
28
+ if(config.partialPrefetching)experiments.push('partialPrefetching');
29
+ if(env.PRNEXT_SSR_STREAM==='node')experiments.push('PRNEXT_SSR_STREAM=node');
30
+ for(const option of experiments)add('production-experimental','warning',`Experimental option enabled: ${option}.`,'Qualify this exact option on the deployment workload and keep its documented fallback available.',option.includes('=')?option.split('=')[0]:option==='partialPrefetching'?option:'experimental.'+option);
31
+ if(config.productionBrowserSourceMaps)add('production-source-maps','warning','Production browser source maps are enabled.','Confirm that exposing original browser sources is intentional.');
32
+ add('production-validation','info','Configuration preflight does not prove production readiness.','Build the final artifact, test public HTTPS, account changes, health checks, rollback and application dependencies before switching traffic.');
33
+ return diagnostics;
34
+ }
@@ -30,9 +30,10 @@ export const withReactCompilerReporting = run => reports.run({ files: new Map(),
30
30
  export function configureReactCompilerReporting({ root, config, buildId, dev, compilerVersion }) {
31
31
  const state = reports.getStore();
32
32
  if (state) Object.assign(state, { root: path.resolve(root), enabled: !!config?.reactCompiler,
33
- options: config?.reactCompiler || null, buildId, dev: !!dev, compilerVersion });
33
+ options: config?.reactCompiler || null, buildId, dev: !!dev, compilerVersion, backend: config?.reactCompiler?.backend || 'babel' });
34
34
  }
35
35
  export const withReactCompilerGraph = (target, run) => graphs.run(target, run);
36
+ export const reactCompilerGraphTarget = () => graphs.getStore();
36
37
 
37
38
  /** Capture a narrow, source-free subset of the pinned compiler's public events. */
38
39
  export function compilerEvent(event, functions, { moduleOptOut = false, root = '' } = {}) {
@@ -70,7 +71,7 @@ export function recordReactCompilerTransform(file, payload, { cacheHit = false }
70
71
  if (!state.files.has(relative) && state.files.size >= FILE_LIMIT) { state.truncated = true; return; }
71
72
  let record = state.files.get(relative);
72
73
  if (!record) state.files.set(relative, record = { file: relative, transforms: 0, cacheReuses: 0,
73
- targets: new Set(), notes: new Set(), functions: new Map() });
74
+ targets: new Set(), notes: new Set(), functions: new Map(), diagnostics: new Map() });
74
75
  record.transforms++; record.cacheReuses += Number(cacheHit);
75
76
  const target = graphs.getStore() || 'unspecified';
76
77
  record.targets.add(target);
@@ -93,6 +94,16 @@ export function recordReactCompilerTransform(file, payload, { cacheHit = false }
93
94
  if (fn.events.size < EVENT_LIMIT && state.eventCount < TOTAL_EVENT_LIMIT) { fn.events.set(detailKey, { ...detail, targets: new Set([target]) }); state.eventCount++; }
94
95
  else state.truncated = true;
95
96
  }
97
+ // Oxc reports native diagnostics, not function-success events. Keep these at
98
+ // file scope rather than inventing function identities or optimized counts.
99
+ for (const event of payload.diagnostics || []) {
100
+ const detail = { kind: event.kind, status: event.status, reason: event.reason, category: event.category };
101
+ const key = JSON.stringify(detail);
102
+ if (record.diagnostics.has(key)) { record.diagnostics.get(key).targets.add(target); continue; }
103
+ if (record.diagnostics.size < EVENT_LIMIT && state.eventCount < TOTAL_EVENT_LIMIT) {
104
+ record.diagnostics.set(key, { ...detail, targets: new Set([target]) }); state.eventCount++;
105
+ } else state.truncated = true;
106
+ }
96
107
  if (payload.truncated) state.truncated = true;
97
108
  }
98
109
  export function recordReactCompilerGraph(result) {
@@ -115,13 +126,14 @@ export function reactCompilerReport() {
115
126
  if (!state) return null;
116
127
  const files = [...state.files.values()].sort((a, b) => a.file.localeCompare(b.file)).map(file => ({
117
128
  file: file.file, transforms: file.transforms, cacheReuses: file.cacheReuses, targets: [...file.targets].sort(), notes: [...file.notes].sort(),
129
+ diagnostics: [...file.diagnostics.values()].map(({ targets, ...event }) => ({ ...event, targets: [...targets].sort() })),
118
130
  functions: [...file.functions.values()].map(fn => ({ name: fn.name, start: fn.start, end: fn.end,
119
131
  status: fn.statuses.size === 1 ? [...fn.statuses][0] : 'mixed', targets: [...fn.targets].sort(),
120
132
  events: [...fn.events.values()].map(({ targets, ...event }) => ({ ...event, targets: [...targets].sort() })) }))
121
133
  .sort((a, b) => (a.start?.line || 0) - (b.start?.line || 0) || (a.start?.column || 0) - (b.start?.column || 0)),
122
134
  }));
123
135
  const summary = summarize(files);
124
- return { version: 1, compilerVersion: state.compilerVersion || null, enabled: !!state.enabled,
136
+ return { version: 1, backend: state.backend || 'babel', compilerVersion: state.compilerVersion || null, enabled: !!state.enabled,
125
137
  buildId: state.buildId || null, mode: state.dev ? 'development' : 'production', scope: 'observed-transforms',
126
138
  options: state.enabled ? state.options : null, truncated: state.truncated, summary, files,
127
139
  graphs: [...state.graphs.values()].map(graph => ({ target: graph.target, metadataAvailable: graph.metadataAvailable,
@@ -178,9 +190,15 @@ function validReport(report) {
178
190
  const targets = value => Array.isArray(value) && value.length <= 4 && value.every(target => ['browser', 'node', 'edge', 'unspecified'].includes(target));
179
191
  const validPosition = value => value === null || keys(value, ['line', 'column']) && !!position(value);
180
192
  const options = report?.options;
181
- const validOptions = options === null || options === true || keys(options, ['compilationMode', 'panicThreshold', 'target'])
193
+ const validOptions = options === null || options === true || keys(options, ['backend', 'compilationMode', 'panicThreshold', 'target'])
194
+ && (options.backend === undefined || ['babel', 'oxc'].includes(options.backend))
182
195
  && ['infer', 'annotation'].includes(options.compilationMode) && ['none', 'critical_errors', 'all_errors'].includes(options.panicThreshold) && options.target === '19';
183
- return keys(report, ['version', 'compilerVersion', 'enabled', 'buildId', 'mode', 'scope', 'options', 'truncated', 'summary', 'files', 'graphs'])
196
+ const validDiagnostic = event => keys(event, ['kind', 'status', 'reason', 'category', 'targets'])
197
+ && ['CompileError', 'CompileDiagnostic'].includes(event.kind) && ['error', 'diagnostic'].includes(event.status)
198
+ && boundedText(event.reason) && boundedText(event.category) && targets(event.targets);
199
+ return keys(report, ['version', 'backend', 'compilerVersion', 'enabled', 'buildId', 'mode', 'scope', 'options', 'truncated', 'summary', 'files', 'graphs'])
200
+ && (report.backend === undefined || ['babel', 'oxc'].includes(report.backend))
201
+ && (report.backend || 'babel') === (options?.backend || 'babel')
184
202
  && report.version === 1 && report.scope === 'observed-transforms' && typeof report.enabled === 'boolean' && validOptions
185
203
  && typeof report.truncated === 'boolean' && ['development', 'production'].includes(report.mode)
186
204
  && boundedText(report.buildId) && boundedText(report.compilerVersion)
@@ -189,8 +207,9 @@ function validReport(report) {
189
207
  && Array.isArray(report.files) && report.files.length <= FILE_LIMIT && report.summary.files === report.files.length
190
208
  && report.files.every(file => file && typeof file === 'object')
191
209
  && new Set(report.files.map(file => file.file)).size === report.files.length
192
- && report.files.every(file => keys(file, ['file', 'transforms', 'cacheReuses', 'targets', 'notes', 'functions'])
210
+ && report.files.every(file => keys(file, ['file', 'transforms', 'cacheReuses', 'targets', 'notes', 'functions', 'diagnostics'])
193
211
  && safePath(file.file) && count(file.transforms) && count(file.cacheReuses) && file.cacheReuses <= file.transforms && targets(file.targets)
212
+ && (file.diagnostics === undefined || Array.isArray(file.diagnostics) && file.diagnostics.length <= EVENT_LIMIT && file.diagnostics.every(validDiagnostic))
194
213
  && Array.isArray(file.notes) && file.notes.length <= 16 && file.notes.every(boundedText)
195
214
  && Array.isArray(file.functions) && file.functions.length <= FUNCTION_LIMIT && file.functions.every(fn => keys(fn, ['name', 'start', 'end', 'status', 'targets', 'events'])
196
215
  && boundedText(fn.name) && statuses.has(fn.status) && validPosition(fn.start) && validPosition(fn.end) && targets(fn.targets)
@@ -208,18 +227,22 @@ function validReport(report) {
208
227
  function summarize(files) {
209
228
  const summary = { files: files.length, functions: 0, optimized: 0, skipped: 0, errors: 0, diagnostics: 0, mixed: 0, transforms: 0, cacheReuses: 0 };
210
229
  for (const file of files) { summary.transforms += file.transforms; summary.cacheReuses += file.cacheReuses;
211
- for (const fn of file.functions) { summary.functions++; summary[({ error: 'errors', diagnostic: 'diagnostics' })[fn.status] || fn.status]++; } }
230
+ for (const fn of file.functions) { summary.functions++; summary[({ error: 'errors', diagnostic: 'diagnostics' })[fn.status] || fn.status]++; }
231
+ for (const diagnostic of file.diagnostics || []) summary[diagnostic.status === 'error' ? 'errors' : 'diagnostics']++;
232
+ }
212
233
  return summary;
213
234
  }
214
235
  export function printReactCompilerReport(report, { details = false, log = console.log } = {}) {
215
236
  if (!report.enabled) { log('React Compiler: disabled. Enable reactCompiler in the application configuration.'); return; }
216
237
  const summary = report.summary;
217
- log(`React Compiler ${report.compilerVersion}: ${summary.optimized} optimized, ${summary.skipped} skipped, ${summary.errors} errors, ${summary.diagnostics} diagnostics, ${summary.mixed} mixed.`);
238
+ if (report.backend === 'oxc') log(`React Compiler oxc ${report.compilerVersion}: function optimization totals unavailable; ${summary.errors} errors, ${summary.diagnostics} diagnostics.`);
239
+ else log(`React Compiler ${report.compilerVersion}: ${summary.optimized} optimized, ${summary.skipped} skipped, ${summary.errors} errors, ${summary.diagnostics} diagnostics, ${summary.mixed} mixed.`);
218
240
  log(`Observed transforms only: ${summary.files} files; ${summary.cacheReuses}/${summary.transforms} transforms reused cached compiler results.${report.truncated ? ' Report truncated at its safety limit.' : ''}`);
219
241
  if (report.graphs.some(graph => graph.unobservedInputs || !graph.metadataAvailable)) log('Some graph inputs were not observed by React Compiler; totals do not describe every application component.');
220
242
  if (details) for (const file of report.files) {
221
243
  log(file.file);
222
244
  for (const fn of file.functions) log(` ${fn.name || '(anonymous)'}${fn.start ? ':' + fn.start.line : ''}: ${fn.status}${fn.events.some(event => event.reason) ? ' — ' + [...new Set(fn.events.map(event => event.reason).filter(Boolean))].join('; ') : ''}`);
245
+ for (const diagnostic of file.diagnostics || []) log(` ${diagnostic.status}: ${diagnostic.reason || diagnostic.category || 'Oxc diagnostic'}`);
223
246
  for (const note of file.notes) log(' ' + note);
224
247
  }
225
248
  }
@@ -1,19 +1,21 @@
1
1
  import { realpath } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { cachedAsyncTransform } from './transform-cache.mjs';
6
6
  import { extractSourceMap, inlineSourceMap, sourceMapsEnabled } from './source-maps.mjs';
7
- import { compilerEvent, recordReactCompilerTransform } from './react-compiler-report.mjs';
7
+ import { compilerEvent, recordReactCompilerTransform, reactCompilerGraphTarget } from './react-compiler-report.mjs';
8
8
 
9
9
  const require = createRequire(import.meta.url);
10
- // Only metadata is loaded here. Disabled builds do not load Babel or the compiler.
10
+ // Oxc is an optional peer. Disabled/Babel builds must not resolve it at all.
11
11
  export const REACT_COMPILER_VERSION = require('babel-plugin-react-compiler/package.json').version;
12
+ export const OXC_REACT_COMPILER_VERSION = require('../package.json').peerDependencies['oxc-transform-react'];
12
13
  const BABEL_VERSION = require('@babel/core/package.json').version;
13
14
  const frameworkRoot = fileURLToPath(new URL('../', import.meta.url));
14
- const options = new Set(['compilationMode', 'panicThreshold', 'target']);
15
+ const options = new Set(['backend', 'compilationMode', 'panicThreshold', 'target']);
15
16
  let implementation;
16
- const REPORT_CACHE_VERSION = 1;
17
+ const oxcImplementations = new Map();
18
+ const REPORT_CACHE_VERSION = 2;
17
19
  const optOutDirective = directives => directives?.map(item => item.value.value).find(value => value === 'use no memo' || value === 'use no forget') || false;
18
20
 
19
21
  function functionLocations(ast) {
@@ -48,14 +50,37 @@ export function normalizeReactCompilerConfig(value) {
48
50
  if (!options.has(key)) throw new TypeError(`reactCompiler.${String(key)} is not supported by PRNext.`);
49
51
  }
50
52
  const compilationMode = input.compilationMode === undefined ? 'infer' : input.compilationMode;
53
+ const backend = input.backend === undefined ? 'babel' : input.backend;
51
54
  const panicThreshold = input.panicThreshold === undefined ? 'none' : input.panicThreshold;
52
55
  const target = input.target === undefined ? '19' : input.target;
53
56
  if (!['infer', 'annotation'].includes(compilationMode)) throw new TypeError('reactCompiler.compilationMode must be infer or annotation.');
54
57
  if (!['none', 'critical_errors', 'all_errors'].includes(panicThreshold)) throw new TypeError('reactCompiler.panicThreshold must be none, critical_errors or all_errors.');
55
58
  if (target !== '19') throw new TypeError("reactCompiler.target must be '19'; PRNext requires React 19.");
56
- return Object.freeze({ compilationMode, panicThreshold, target });
59
+ if (!['babel', 'oxc'].includes(backend)) throw new TypeError('reactCompiler.backend must be babel or oxc.');
60
+ return Object.freeze({ ...(backend === 'oxc' ? { backend } : {}), compilationMode, panicThreshold, target });
57
61
  }
58
62
 
63
+ function oxcDependency(projectRoot) {
64
+ const candidates = projectRoot ? [createRequire(path.join(path.resolve(projectRoot), 'package.json')), require] : [require];
65
+ let metadata, owner, missing;
66
+ for (const candidate of candidates) {
67
+ try { metadata = candidate('oxc-transform-react/package.json'); owner = candidate; break; }
68
+ catch (cause) {
69
+ if (cause.code !== 'MODULE_NOT_FOUND' && cause.code !== 'ERR_MODULE_NOT_FOUND') throw cause;
70
+ missing = cause;
71
+ }
72
+ }
73
+ if (!owner) throw Object.assign(new Error(`reactCompiler.backend="oxc" requires oxc-transform-react@${OXC_REACT_COMPILER_VERSION}. Install it with npm install --save-dev oxc-transform-react@${OXC_REACT_COMPILER_VERSION}, or select backend:"babel".`, {cause: missing}), {code: 'PRNEXT_OXC_MISSING'});
74
+ if (metadata.name !== 'oxc-transform-react' || metadata.version !== OXC_REACT_COMPILER_VERSION) {
75
+ throw Object.assign(new Error(`reactCompiler.backend="oxc" requires oxc-transform-react@${OXC_REACT_COMPILER_VERSION}; found ${metadata.version || 'an unknown version'}. Install the supported version with npm install --save-dev oxc-transform-react@${OXC_REACT_COMPILER_VERSION}.`), {code: 'PRNEXT_OXC_VERSION'});
76
+ }
77
+ // Resolve and import from the same dependency owner. A global CLI must use
78
+ // its application's peer, while nested framework installations still work.
79
+ return {version: metadata.version, entry: owner.resolve('oxc-transform-react')};
80
+ }
81
+
82
+ export const reactCompilerVersion = (config, projectRoot) => config?.backend === 'oxc' ? oxcDependency(projectRoot).version : REACT_COMPILER_VERSION;
83
+
59
84
  function inside(root, filename) {
60
85
  const relative = path.relative(root, filename);
61
86
  return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
@@ -75,14 +100,74 @@ function loadImplementation() {
75
100
  return implementation;
76
101
  }
77
102
 
103
+ function loadOxcImplementation(dependency) {
104
+ if (!oxcImplementations.has(dependency.entry)) oxcImplementations.set(dependency.entry, Promise.all([import(pathToFileURL(dependency.entry).href), import('oxc-parser')]).then(([compiler, parser]) => {
105
+ if (compiler.__napiBindingTarget !== 'native') throw new Error('reactCompiler.backend="oxc" requires the native platform binding. Install optional dependencies or select backend:"babel".');
106
+ return { transform: compiler.transform, parse: parser.parseSync };
107
+ }).catch(cause => {
108
+ throw new Error(`Cannot load the native oxc-transform-react@${OXC_REACT_COMPILER_VERSION} compiler. Reinstall it with optional dependencies enabled on this platform, or select backend:"babel". ${cause.message}`, {cause});
109
+ }));
110
+ return oxcImplementations.get(dependency.entry);
111
+ }
112
+
113
+ /** Compose only source-map coordinates; application strings never enter this path. */
114
+ async function composeOxcMap(output, input) {
115
+ if (!input || !output) return output;
116
+ const { TraceMap, decodedMappings, originalPositionFor, sourceContentFor, encodedMap } = await import('@jridgewell/trace-mapping');
117
+ const previous = new TraceMap(input), current = new TraceMap(output);
118
+ const sources = [], contents = [], names = [];
119
+ const mappings = decodedMappings(current).map(line => line.map(segment => {
120
+ if (segment.length < 4) return [segment[0]];
121
+ const position = originalPositionFor(previous, { line: segment[2] + 1, column: segment[3] });
122
+ if (position.source === null || position.line === null || position.column === null) return [segment[0]];
123
+ let source = sources.indexOf(position.source);
124
+ if (source < 0) { source = sources.length; sources.push(position.source); contents.push(sourceContentFor(previous, position.source)); }
125
+ const mapped = [segment[0], source, position.line - 1, position.column];
126
+ const name = position.name || (segment.length > 4 ? current.names[segment[4]] : null);
127
+ if (name) { let index = names.indexOf(name); if (index < 0) { index = names.length; names.push(name); } mapped.push(index); }
128
+ return mapped;
129
+ }));
130
+ return encodedMap(new TraceMap({ version: 3, sources, sourcesContent: contents, names, mappings }));
131
+ }
132
+
133
+ async function transformOxc(source, filename, normalized, { mapped, outputMode, loaderOutput, root, dependency }) {
134
+ const { transform, parse } = await loadOxcImplementation(dependency);
135
+ const { code, map } = extractSourceMap(source);
136
+ const lang = /\.tsx$/i.test(filename) ? 'tsx' : /\.[cm]?ts$/i.test(filename) ? 'ts' : 'jsx';
137
+ // The native transform does not expose directives. Inspect its original input
138
+ // with the existing Rust parser, never Babel, before entering executable modules.
139
+ const parsed = parse(filename, code, { lang, sourceType: 'unambiguous' });
140
+ const directives = [];
141
+ if (!parsed.errors.length) for (const statement of parsed.program.body) {
142
+ if (!statement.directive) break;
143
+ directives.push(statement.directive);
144
+ }
145
+ if (directives.includes('use server')) return { code: source, events: [], notes: ['Server Action module excluded.'] };
146
+ const result = await transform(filename, code, { lang, sourceType: 'unambiguous', jsx: 'preserve', sourcemap: mapped,
147
+ reactCompiler: { compilationMode: normalized.compilationMode, panicThreshold: normalized.panicThreshold,
148
+ target: normalized.target, outputMode, reportDiagnostics: true, sources: [''] } });
149
+ const diagnostics = result.errors.slice(0, 16).map(error => compilerEvent({
150
+ kind: result.fatal ? 'CompileError' : 'CompileDiagnostic', reason: error.message, detail: { category: 'Oxc' },
151
+ }, [], { root })).filter(Boolean);
152
+ const notes = ['Experimental Oxc backend; upstream does not expose per-function optimization events. Function optimization totals are unavailable.',
153
+ `Oxc output mode: ${outputMode}. TypeScript syntax is removed; JSX is preserved for the graph compiler.`];
154
+ if (normalized.compilationMode === 'annotation') notes.push('Annotation mode only selects functions with "use memo".');
155
+ if (loaderOutput) notes.push('Locations refer to JavaScript produced by the configured loader.');
156
+ return { code: result.fatal ? source : mapped ? inlineSourceMap(result.code, await composeOxcMap(result.map, map)) : result.code,
157
+ events: [], diagnostics, notes, fatal: result.fatal, truncated: result.errors.length > 16 };
158
+ }
159
+
78
160
  /**
79
- * Run the official compiler on application source before JSX lowering, React
80
- * Refresh and framework instrumentation. JSX/TypeScript/module format remain
81
- * intact for the existing graph compiler. No app Babel configuration is loaded.
161
+ * Compile application source before JSX lowering and React Refresh. The Oxc
162
+ * development path instruments original JSX first, before native hoisting.
163
+ * JSX remains available to the graph compiler; Oxc also removes TypeScript.
164
+ * No application Babel configuration is loaded.
82
165
  */
83
166
  export function createReactCompilerTransform(projectRoot, config, { dev = false } = {}) {
84
167
  const normalized = normalizeReactCompilerConfig(config);
85
168
  if (!normalized) return async source => source;
169
+ const dependency = normalized.backend === 'oxc' ? oxcDependency(projectRoot) : null;
170
+ const compilerVersion = dependency?.version || REACT_COMPILER_VERSION;
86
171
  const root = path.resolve(projectRoot);
87
172
  const canonicalRoot = realpath(root).catch(error => {
88
173
  if (error.code === 'ENOENT') return root;
@@ -102,10 +187,15 @@ export function createReactCompilerTransform(projectRoot, config, { dev = false
102
187
  throw error;
103
188
  });
104
189
  if (excluded(actualRoot, actualFile) || inside(await canonicalFramework, actualFile)) return source;
190
+ const backend = normalized.backend || 'babel';
191
+ const graph = reactCompilerGraphTarget();
192
+ const outputMode = graph === 'node' || graph === 'edge' ? 'ssr' : 'client';
105
193
  let cacheHit = true;
106
194
  const serialized = await cachedAsyncTransform('react-compiler-report', source,
107
- [REPORT_CACHE_VERSION, REACT_COMPILER_VERSION, BABEL_VERSION, normalized, root, actualRoot, filename, actualFile, !!dev, !!loaderOutput], async () => {
195
+ [REPORT_CACHE_VERSION, backend, compilerVersion, dependency?.entry, backend === 'babel' ? BABEL_VERSION : null,
196
+ normalized, root, actualRoot, filename, actualFile, !!dev, !!loaderOutput, backend === 'oxc' ? outputMode : null], async () => {
108
197
  cacheHit = false;
198
+ if (backend === 'oxc') return JSON.stringify(await transformOxc(source, filename, normalized, { mapped: sourceMapsEnabled(), outputMode, loaderOutput, root, dependency }));
109
199
  const { babel, compiler } = await loadImplementation();
110
200
  const mapped = sourceMapsEnabled();
111
201
  const { code, map } = extractSourceMap(source);
@@ -134,6 +224,7 @@ export function createReactCompilerTransform(projectRoot, config, { dev = false
134
224
  });
135
225
  const payload = JSON.parse(serialized);
136
226
  recordReactCompilerTransform(inside(root, filename) ? filename : path.resolve(root, path.relative(actualRoot, actualFile)), payload, { cacheHit });
227
+ if (payload.fatal) throw new Error(`React Compiler (oxc ${OXC_REACT_COMPILER_VERSION}) failed for ${filename}: ${payload.diagnostics.map(value => value.reason).join('; ') || 'Native compilation failed.'}`);
137
228
  return payload.code;
138
229
  };
139
230
  }
@@ -0,0 +1,35 @@
1
+ import { isIP } from 'node:net';
2
+
3
+ export function validateServerConfig(value) {
4
+ if (value === undefined) return { trustedProxies: [] };
5
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['publicOrigin', 'trustedProxies'].includes(key))) {
6
+ throw new TypeError('server must be an object with publicOrigin and trustedProxies.');
7
+ }
8
+ let publicOrigin;
9
+ if (value.publicOrigin !== undefined) {
10
+ const source = value.publicOrigin;
11
+ let url;
12
+ try { url = new URL(source); } catch { /* validated below */ }
13
+ if (typeof source !== 'string' || /[\s\\\u0000-\u001f\u007f]/.test(source) || !url || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash || source.includes('?') || source.includes('#')) {
14
+ throw new TypeError('server.publicOrigin must be an HTTP(S) origin without credentials, path, query or fragment, for example https://example.com.');
15
+ }
16
+ publicOrigin = url.origin;
17
+ }
18
+ const ranges = value.trustedProxies ?? [];
19
+ if (!Array.isArray(ranges) || ranges.length > 32 || ranges.some(range => {
20
+ if (typeof range !== 'string' || range.length > 64) return true;
21
+ const [address, bits, ...extra] = range.split('/');
22
+ const family = isIP(address);
23
+ return !family || extra.length || bits !== undefined && (!/^(0|[1-9]\d*)$/.test(bits) || Number(bits) > (family === 4 ? 32 : 128));
24
+ })) throw new TypeError('server.trustedProxies must contain at most 32 explicit IP addresses or CIDR ranges.');
25
+ const normalized = ranges.map(range => {
26
+ const [address, bits] = range.split('/');
27
+ if (isIP(address) !== 6) return range;
28
+ const mapped = /^\[::ffff:([\da-f]+):([\da-f]+)\]$/i.exec(new URL(`http://[${address}]`).hostname);
29
+ if (!mapped) return range;
30
+ if (bits !== undefined && Number(bits) < 96) throw new TypeError('Mapped IPv4 proxy CIDR ranges need a prefix of at least 96 bits.');
31
+ const high = parseInt(mapped[1], 16), low = parseInt(mapped[2], 16);
32
+ return `${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}${bits === undefined ? '' : '/' + (Number(bits) - 96)}`;
33
+ });
34
+ return { ...(publicOrigin ? { publicOrigin } : {}), trustedProxies: [...new Set(normalized)] };
35
+ }