@thomas.f/prnext 0.1.17-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 (137) hide show
  1. package/README.md +26 -869
  2. package/build/actions.mjs +1 -1
  3. package/build/analyze.mjs +180 -24
  4. package/build/app-config.mjs +13 -0
  5. package/build/app-static.mjs +34 -12
  6. package/build/app-styles.mjs +79 -0
  7. package/build/app.mjs +81 -26
  8. package/build/bundle-runtime.mjs +22 -9
  9. package/build/cache-components.mjs +1 -1
  10. package/build/cache-handlers.mjs +1 -1
  11. package/build/compact-css.mjs +53 -0
  12. package/build/compilation-cache.mjs +269 -0
  13. package/build/compilation-store.mjs +142 -0
  14. package/build/compiler.mjs +31 -8
  15. package/build/config.mjs +30 -1
  16. package/build/create.mjs +239 -0
  17. package/build/css-modules.mjs +56 -12
  18. package/build/deployment-assets.mjs +89 -0
  19. package/build/deployment-config.mjs +18 -0
  20. package/build/dev-css-update.mjs +4 -2
  21. package/build/dev-dependencies.mjs +1 -1
  22. package/build/dev-discovery.mjs +3 -3
  23. package/build/dev.mjs +15 -8
  24. package/build/dynamic.mjs +1 -1
  25. package/build/edge.mjs +3 -3
  26. package/build/fonts.mjs +1 -1
  27. package/build/framework-imports.mjs +15 -5
  28. package/build/html-only-config.mjs +53 -0
  29. package/build/html-only-graph.mjs +67 -0
  30. package/build/index.mjs +59 -26
  31. package/build/instrumentation.mjs +30 -0
  32. package/build/metadata-routes.mjs +1 -1
  33. package/build/migration-audit.mjs +1 -1
  34. package/build/native-type-config.mjs +37 -0
  35. package/build/persistent-cache.mjs +18 -3
  36. package/build/precompress.mjs +1 -1
  37. package/build/prerender-cache.mjs +70 -0
  38. package/build/prerender-config.mjs +29 -0
  39. package/build/prerender-pages.mjs +19 -9
  40. package/build/prerender-pool.mjs +165 -0
  41. package/build/prerender-process-worker.mjs +58 -0
  42. package/build/prerender-worker.mjs +5 -5
  43. package/build/production-builder.mjs +58 -9
  44. package/build/react-compiler-report.mjs +225 -0
  45. package/build/react-compiler.mjs +139 -0
  46. package/build/runtime-contract.mjs +2 -2
  47. package/build/static-export.mjs +4 -4
  48. package/build/styles.mjs +6 -4
  49. package/build/transform.mjs +1 -1
  50. package/build/typed-routes.mjs +4 -0
  51. package/build/typescript-dev.mjs +35 -8
  52. package/build/typescript-native-worker.mjs +55 -0
  53. package/build/typescript-native.mjs +18 -0
  54. package/build/typescript-toolchain.mjs +19 -0
  55. package/build/typescript.mjs +6 -4
  56. package/build/webpack-graph.mjs +6 -3
  57. package/build/worker-memory.mjs +9 -0
  58. package/cli.mjs +60 -4
  59. package/compat/activity-cache.cjs +26 -0
  60. package/compat/after.cjs +6 -4
  61. package/compat/app-layout-context.cjs +52 -2
  62. package/compat/base64.cjs +10 -0
  63. package/compat/cache-handlers.cjs +12 -2
  64. package/compat/cache-wire.cjs +28 -0
  65. package/compat/cache.cjs +2 -1
  66. package/compat/cache.d.cts +4 -0
  67. package/compat/cookies.cjs +21 -5
  68. package/compat/data-cache.cjs +12 -8
  69. package/compat/flight-delta.cjs +150 -0
  70. package/compat/headers.cjs +4 -2
  71. package/compat/incremental-cache.cjs +20 -7
  72. package/compat/index.d.ts +68 -3
  73. package/compat/link.cjs +12 -9
  74. package/compat/navigation-authorization.cjs +9 -0
  75. package/compat/navigation-stages.cjs +19 -0
  76. package/compat/offline-store.cjs +11 -0
  77. package/compat/offline.cjs +5 -0
  78. package/compat/offline.d.cts +2 -0
  79. package/compat/redis-cache.cjs +201 -0
  80. package/compat/redis-cache.d.cts +37 -0
  81. package/compat/server-primitives.cjs +31 -11
  82. package/compat/server.d.cts +15 -2
  83. package/compat/static-generation.cjs +7 -0
  84. package/compat/use-cache.cjs +3 -0
  85. package/docs/ai/compatibility.md +171 -2
  86. package/docs/ai/data.md +165 -5
  87. package/docs/ai/deployment.md +158 -0
  88. package/docs/ai/quickstart.md +29 -4
  89. package/docs/ai/routing.md +82 -0
  90. package/docs/ai/troubleshooting.md +37 -2
  91. package/package.json +26 -7
  92. package/runtime/action-client.mjs +10 -4
  93. package/runtime/action-server.mjs +2 -0
  94. package/runtime/app-client.mjs +212 -65
  95. package/runtime/app-content.mjs +4 -3
  96. package/runtime/app-partial-client.mjs +3 -0
  97. package/runtime/app-partial.mjs +1 -1
  98. package/runtime/app-prefetch-constants.mjs +4 -0
  99. package/runtime/app-prefetch.mjs +2 -3
  100. package/runtime/app-render-key.mjs +5 -0
  101. package/runtime/app-render.mjs +88 -28
  102. package/runtime/app-routing.mjs +22 -9
  103. package/runtime/app-static.mjs +18 -8
  104. package/runtime/app-stream-html.mjs +10 -53
  105. package/runtime/app-style-selection.mjs +34 -0
  106. package/runtime/app-styles.mjs +154 -0
  107. package/runtime/cache-components-rsc.mjs +4 -1
  108. package/runtime/cache-request.mjs +2 -1
  109. package/runtime/deployment-client.mjs +19 -0
  110. package/runtime/dev-client.mjs +7 -2
  111. package/runtime/dev-compiler-broker.mjs +42 -0
  112. package/runtime/dev-indicator.mjs +8 -3
  113. package/runtime/dev-lazy-tool.mjs +16 -0
  114. package/runtime/development.mjs +29 -12
  115. package/runtime/document.mjs +1 -1
  116. package/runtime/edge.mjs +6 -5
  117. package/runtime/fetch-cache.mjs +10 -8
  118. package/runtime/flight-delta.mjs +50 -0
  119. package/runtime/flight-tap.mjs +28 -0
  120. package/runtime/html-only.mjs +27 -0
  121. package/runtime/http.mjs +2 -0
  122. package/runtime/incremental-cache.mjs +25 -4
  123. package/runtime/instrumentation.mjs +49 -0
  124. package/runtime/navigation-recovery.mjs +36 -0
  125. package/runtime/navigation.mjs +7 -0
  126. package/runtime/node-html-stream.mjs +47 -0
  127. package/runtime/pages-client.mjs +14 -7
  128. package/runtime/pages-navigation.mjs +48 -63
  129. package/runtime/prefetch-scheduler.mjs +110 -0
  130. package/runtime/profile.mjs +14 -0
  131. package/runtime/profiles.json +2 -2
  132. package/runtime/render.mjs +3 -0
  133. package/runtime/route-pattern.mjs +44 -0
  134. package/runtime/rsc-worker.mjs +34 -12
  135. package/runtime/trace.mjs +78 -0
  136. package/runtime/watch-project.mjs +19 -15
  137. package/runtime/worker.mjs +70 -26
@@ -0,0 +1,19 @@
1
+ import {readFile,stat} from 'node:fs/promises';
2
+ import {createRequire} from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ // Select from the application's installation, never from the framework's own
6
+ // development dependencies. Native TypeScript deliberately lacks the old API.
7
+ export async function resolveTypeScript(root) {
8
+ const require=createRequire(path.join(root,'package.json'));
9
+ const manifest=require.resolve('typescript/package.json');
10
+ const metadata=JSON.parse(await readFile(manifest,'utf8'));
11
+ const major=Number(/^([0-9]+)\./.exec(metadata.version)?.[1]);
12
+ if(!Number.isInteger(major))throw new Error('The installed TypeScript package has no valid version.');
13
+ if(major<7)return {native:false,compiler:require.resolve('typescript'),version:metadata.version};
14
+ const entry=typeof metadata.bin==='string'?metadata.bin:metadata.bin?.tsc;
15
+ if(typeof entry!=='string'||!entry)throw new Error('The installed TypeScript package has no tsc command. Reinstall TypeScript with its optional native dependencies.');
16
+ const directory=path.dirname(manifest),bin=path.resolve(directory,entry);
17
+ if(!bin.startsWith(directory+path.sep)||!(await stat(bin)).isFile())throw new Error('The installed TypeScript tsc command is invalid.');
18
+ return {native:true,compiler:bin,version:metadata.version};
19
+ }
@@ -1,9 +1,10 @@
1
1
  import { stat } from 'node:fs/promises';
2
- import { createRequire } from 'node:module';
3
2
  import { execFile } from 'node:child_process';
4
3
  import { promisify } from 'node:util';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import path from 'node:path';
6
+ import {resolveTypeScript} from './typescript-toolchain.mjs';
7
+ import {checkNativeTypeScript} from './typescript-native.mjs';
7
8
 
8
9
  const execute = promisify(execFile);
9
10
 
@@ -37,11 +38,12 @@ export async function checkTypeScript(root, config, { dev = false, tsconfig, for
37
38
  if (!force && (dev || !config.typescript || config.typescript.ignoreBuildErrors)) return;
38
39
  tsconfig ??= await projectTsconfig(root, config);
39
40
  if (!tsconfig) throw new Error('TypeScript checking was explicitly enabled, but tsconfig.json does not exist. Create a TypeScript configuration, set typescript.tsconfigPath to an existing configuration, or explicitly set typescript.ignoreBuildErrors: true if types are checked separately.');
40
- let compiler;
41
- try { compiler = createRequire(path.join(root, 'package.json')).resolve('typescript'); }
41
+ let toolchain;
42
+ try { toolchain = await resolveTypeScript(root); }
42
43
  catch (error) { throw new Error('TypeScript checking requires typescript in the project. Install it as a dev dependency, or explicitly set typescript.ignoreBuildErrors: true if a separate process checks types.', { cause: error }); }
43
44
  try {
44
- await execute(process.execPath, [fileURLToPath(new URL('./typescript-check.mjs', import.meta.url)), compiler, tsconfig], {
45
+ if(toolchain.native){await checkNativeTypeScript(toolchain.compiler,tsconfig,root);return;}
46
+ await execute(process.execPath, [fileURLToPath(new URL('./typescript-check.mjs', import.meta.url)), toolchain.compiler, tsconfig], {
45
47
  cwd: root, windowsHide: true, maxBuffer: 4 * 1024 * 1024,
46
48
  });
47
49
  } catch (error) {
@@ -233,7 +233,7 @@ async function createGraphSession(root,{webpack,reusable,stage}) {
233
233
  cssChunkFilename:'style-[contenthash:16].css',module:true,library:{type:'module'},chunkFormat:'module',chunkLoading:'import',
234
234
  publicPath:target==='node'?'./':state.assetBase+'/',environment:{module:true,dynamicImport:true},compareBeforeEmit:false,clean:false},
235
235
  performance:false,
236
- optimization:{minimize:!!options.minify,minimizer:[{apply(compiler){
236
+ optimization:{minimize:!!options.minify,mangleExports:options.minify?'size':false,minimizer:[{apply(compiler){
237
237
  compiler.hooks.compilation.tap('PRNextMinify',compilation=>{
238
238
  compilation.hooks.processAssets.tapPromise({name:'PRNextMinify',stage:webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE},async()=>{
239
239
  // Sequential native esbuild transforms avoid a Terser worker pool.
@@ -247,7 +247,7 @@ async function createGraphSession(root,{webpack,reusable,stage}) {
247
247
  });
248
248
  });
249
249
  }}],runtimeChunk:options.splitting?{name:()=>runtime}:false,
250
- splitChunks:options.splitting?{chunks:'all',minSize:0,cacheGroups:{default:false,defaultVendors:false,shared:{minChunks:2,test:module=>!module.type.startsWith('css'),name:false}}}:false},
250
+ splitChunks:options.splitting?{chunks:'all',minSize:0,cacheGroups:{default:false,defaultVendors:false,shared:{minChunks:2,reuseExistingChunk:true,test:module=>!module.type.startsWith('css'),name:false}}}:false},
251
251
  resolve:{alias:config.resolution.resolveAlias || {},conditionNames:[...(options.conditions || []),'...'],extensions:config.resolution.resolveExtensions || ['.tsx','.ts','.jsx','.js','.mjs','.json'],fullySpecified:false},
252
252
  module:{unsafeCache:false,rules:[{test:/./,type:'javascript/auto',enforce:'post',use:[{loader:loaderPath,options:{bridge:bridgeId}}]},
253
253
  {test:/\.json$/,type:'json'},{test:/\.(?:css|scss|sass)$/,type:'css'},
@@ -379,7 +379,10 @@ async function createGraphSession(root,{webpack,reusable,stage}) {
379
379
  for(const name of previousOutputs)if(!currentOutputs.has(name))await discardPrivateFile(path.join(actualOut,name),actualOut);
380
380
  previousOutputs.clear();for(const name of currentOutputs)previousOutputs.add(name);
381
381
  }
382
- return {errors:[],warnings:[],metafile:{inputs,outputs},...(options.write===false?{outputFiles}:{})};
382
+ // This compatibility metafile does not yet encode webpack's runtime and
383
+ // async chunk-group edges. Consumers must not mistake empty import lists
384
+ // for a complete initial browser graph.
385
+ return {errors:[],warnings:[],metafile:{inputs,outputs,initialGraphComplete:false},...(options.write===false?{outputFiles}:{})};
383
386
  } finally {
384
387
  active.delete(bridgeId);
385
388
  for(const file of virtualFiles)if(!usedVirtualFiles.has(file)){virtualFiles.delete(file);await discardPrivateFile(file,home);}
@@ -0,0 +1,9 @@
1
+ export function validateWorkerMemory(value) {
2
+ if (value === undefined || value === false) return undefined;
3
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
4
+ Object.keys(value).some(key => key !== 'maxRssMb') ||
5
+ !Number.isSafeInteger(value.maxRssMb) || value.maxRssMb < 64 || value.maxRssMb > 65536) {
6
+ throw new TypeError('workerMemory must be false or { maxRssMb: 64..65536 }');
7
+ }
8
+ return { maxRssMb: value.maxRssMb };
9
+ }
package/cli.mjs CHANGED
@@ -11,6 +11,9 @@ const help = `PRNext ${packageManifest.version}
11
11
 
12
12
  Use prnext for portable scripts; prn is a shortcut on compatible shells.
13
13
 
14
+ prnext create <directory> Create a project and install dependencies
15
+ prnext create <directory> --no-install Prepare project files without installing
16
+ prnext create --help TypeScript, JavaScript and creation options
14
17
  prnext build [directory] Build React/TypeScript and static HTML
15
18
  prnext build [directory] --watch Incremental production rebuilds on changes
16
19
  prnext build [directory] --adapter static [--from dist] [--spa]
@@ -40,6 +43,7 @@ Use prnext for portable scripts; prn is a shortcut on compatible shells.
40
43
  prnext typecheck [directory] [--json] Generate types and check TypeScript without building
41
44
  prnext inspect [directory] [--json|--otlp] Explain routes, health and sampled timings
42
45
  prnext analyze [directory] [--json] [--max-js 150] Browser bundles and budget (kB)
46
+ prnext compiler [directory] [--json] React Compiler results from the last successful build
43
47
  prnext host [prnext.host.json] Host built apps by hostname; wake on demand
44
48
  prnext host [config] --check Validate hosting configuration and builds
45
49
  prnext host [config] --status [--json] Show memory, process and idle status
@@ -69,10 +73,12 @@ Analysis options: --metric raw|gzip|brotli (default raw), --baseline analysis.js
69
73
  --max-increase-bytes 1024 --max-increase-percent 5
70
74
  --budgets budgets.json Exact route keys with maxJs (kB),
71
75
  maxIncreaseBytes and/or maxIncreasePercent
72
- Analysis exit codes: 0 passed, 1 budget exceeded, 2 invalid input or missing build.
76
+ Analysis exit codes: 0 passed, 1 budget exceeded or initial total unverifiable,
77
+ 2 invalid input or missing build. Request-dependent totals are lower bounds.
73
78
 
74
79
  Server options: --port 3000 --hostname 127.0.0.1 --workers 1 --inspect
75
80
  Production profiles: --profile balanced|speed|memory|classic
81
+ Render readiness: --prewarm --readiness-file /private/path/ready.json
76
82
  Worker retirement: --worker-idle-seconds 300 --lane-idle-seconds 30 --keep-warm [true|false]
77
83
  Memory favors lower RAM under concurrency, accepting more waiting and CPU per response.
78
84
  Without an option: PRNEXT_PROFILE, legacy PRNEXT_MEMORY_PROFILE=compact, then balanced.
@@ -101,6 +107,30 @@ function stop(child) {
101
107
  async function main() {
102
108
  if (['help', '--help', '-h'].includes(command)) { console.log(help); return; }
103
109
  if (['--version', '-v'].includes(command)) { console.log(packageManifest.version); return; }
110
+ if (command === 'create') {
111
+ const { createHelp, parseCreateArgs, createProject } = await import('./build/create.mjs');
112
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { console.log(createHelp); return; }
113
+ const { directory, options } = parseCreateArgs(argv);
114
+ const controller = new AbortController();
115
+ let interrupted;
116
+ const interrupt = signal => { interrupted = signal; controller.abort(); };
117
+ const onInt = () => interrupt('SIGINT'), onTerm = () => interrupt('SIGTERM');
118
+ process.on('SIGINT', onInt); process.on('SIGTERM', onTerm);
119
+ try {
120
+ const report = await createProject(directory, { ...options, signal: controller.signal,
121
+ onOutput: options.json ? () => {} : (chunk, stream) => process[stream].write(chunk) });
122
+ if (options.json) console.log(JSON.stringify(report, null, 2));
123
+ else {
124
+ console.log(`PRNext project ${report.status}: ${report.root}`);
125
+ if (report.error) console.error(report.error);
126
+ if (!report.ok) console.log('Project files were preserved. Retry installation from this directory.');
127
+ if (path.resolve(process.cwd()) !== directory) console.log(`\nOpen ${report.root} in your terminal, then run:`);
128
+ for (const next of report.next) console.log(` ${next}`);
129
+ }
130
+ if (!report.ok) process.exitCode = interrupted === 'SIGINT' ? 130 : interrupted === 'SIGTERM' ? 143 : 1;
131
+ } finally { process.removeListener('SIGINT', onInt); process.removeListener('SIGTERM', onTerm); }
132
+ return;
133
+ }
104
134
  if (command === 'docs') {
105
135
  const { executeAiDocs } = await import('./runtime/ai-docs.mjs');
106
136
  const result = await executeAiDocs(argv);
@@ -185,10 +215,28 @@ async function main() {
185
215
  if (!report.ok) process.exitCode = 1;
186
216
  return;
187
217
  }
188
- if (!['build', 'start', 'dev', 'routes', 'check', 'typegen', 'typecheck', 'inspect', 'analyze', 'output'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
218
+ if (!['build', 'start', 'dev', 'routes', 'check', 'typegen', 'typecheck', 'inspect', 'analyze', 'compiler', 'output'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
189
219
  const args = [...argv];
190
220
  const root = path.resolve(args[0] && !args[0].startsWith('-') ? args.shift() : '.');
191
- if (['check','typegen','typecheck','analyze'].includes(command) && args.some(arg => ['--help','-h'].includes(arg))) { console.log(help); return; }
221
+ if (['check','typegen','typecheck','analyze','compiler'].includes(command) && args.some(arg => ['--help','-h'].includes(arg))) { console.log(help); return; }
222
+ if(command==='compiler'){
223
+ try {
224
+ if(args.some(arg=>arg!=='--json') || args.filter(arg=>arg==='--json').length>1)throw new Error('compiler accepts only [directory] and --json');
225
+ const {readReactCompilerReport,printReactCompilerReport}=await import('./build/react-compiler-report.mjs');
226
+ const {readBuildOutput}=await import('./build/output-contract.mjs');
227
+ const directory=path.join(root,await readBuildDirectory(root));
228
+ const output=await readBuildOutput(directory);
229
+ if(output.adapter!=='react')throw new Error('React Compiler reports apply to React builds. This application uses the static adapter.');
230
+ const report=await readReactCompilerReport(directory,{buildId:output.buildId});
231
+ if(!report)throw new Error('No React Compiler report is available. Rebuild this application with the installed PRNext version.');
232
+ if(args.includes('--json'))console.log(JSON.stringify(report,null,2));else printReactCompilerReport(report,{details:true});
233
+ } catch(error) {
234
+ if(args.includes('--json'))console.log(JSON.stringify({ok:false,error:error.message}));
235
+ else console.error(`PRNext compiler: ${error.message}`);
236
+ process.exitCode=2;
237
+ }
238
+ return;
239
+ }
192
240
  if(command==='analyze'){
193
241
  try {
194
242
  const {analyzeBuild,printAnalysis,parseAnalysisArgs}=await import('./build/analyze.mjs');
@@ -307,7 +355,15 @@ async function main() {
307
355
  await buildStatic(root, options);
308
356
  } else {
309
357
  const { build } = await import('./build/index.mjs');
310
- await build(root, { ...options, dev, envMode });
358
+ const result=await build(root, { ...options, dev, envMode });
359
+ if(!dev&&result.compilationCache?.hits){
360
+ const {hits,reusedBytes}=result.compilationCache;
361
+ console.log(`Compilation cache: ${hits} ${hits===1?'graph':'graphs'} reused (${Math.ceil(reusedBytes/1024)} KiB).`);
362
+ }
363
+ if(result.reactCompilerReport?.enabled){
364
+ const {printReactCompilerReport}=await import('./build/react-compiler-report.mjs');
365
+ printReactCompilerReport(result.reactCompilerReport,{details:false});
366
+ }
311
367
  }
312
368
  console.log(`PRNext built ${root} in ${((performance.now() - started) / 1000).toFixed(2)}s`);
313
369
  return;
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ // A shared budget across layout slots, rather than a separate page budget at
4
+ // every nesting level. Only inactive branches enter this registry.
5
+ function createActivityCache({ limit = 4, ttl = 120000, now = Date.now, schedule = setTimeout, cancel = clearTimeout } = {}) {
6
+ const entries = new Map(); let timer;
7
+ function arm() {
8
+ cancel(timer); timer = undefined;
9
+ if (entries.size) {
10
+ timer = schedule(prune, Math.max(1, Math.min(...[...entries.values()].map(entry => entry.expires)) - now()));
11
+ timer?.unref?.();
12
+ }
13
+ }
14
+ function prune() {
15
+ for (const [key, entry] of entries) if (entry.expires <= now()) { entries.delete(key); entry.evict(); }
16
+ while (entries.size > limit) { const [key, entry] = entries.entries().next().value; entries.delete(key); entry.evict(); }
17
+ arm();
18
+ }
19
+ return {
20
+ retain(key, evict) { if (!entries.has(key)) entries.set(key, { evict, expires: now() + ttl }); prune(); },
21
+ release(key) { entries.delete(key); arm(); },
22
+ clear() { const previous = [...entries.values()]; entries.clear(); cancel(timer); timer = undefined; for (const entry of previous) entry.evict(); },
23
+ get size() { return entries.size; },
24
+ };
25
+ }
26
+ module.exports = { createActivityCache };
package/compat/after.cjs CHANGED
@@ -4,11 +4,11 @@ const { AsyncLocalStorage } = require('node:async_hooks');
4
4
  function after(task) {
5
5
  const context = require('./headers.cjs').currentRequest();
6
6
  if (require('./data-cache.cjs').inCacheScope()) throw new Error('after() cannot be registered inside a cache scope');
7
- if (!context.afterScope || !['route', 'action', 'after'].includes(context.phase)) throw new Error('after() currently requires a Node Route Handler or Server Action in PRNext. Server Component and Edge after() lifecycles are not implemented.');
7
+ if (!context.afterScope || !['route', 'action', 'render', 'after'].includes(context.phase)) throw new Error('after() requires a Node Server Component, Route Handler or Server Action. Edge after() is not implemented.');
8
8
  context.afterScope.add(task, context, AsyncLocalStorage.snapshot());
9
9
  }
10
10
 
11
- function createAfterScope({ signal, timeoutMs = 25000, onTimeout = () => {}, onComplete, report = error => console.error('[prnext] after() failed:', error?.stack || error) } = {}) {
11
+ function createAfterScope({ signal, timeoutMs = 25000, onTimeout = () => {}, onComplete, onRegistered, report = error => console.error('[prnext] after() failed:', error?.stack || error) } = {}) {
12
12
  const queue = []; let count = 0, closed = false, finishing;
13
13
  const deadline = Date.now() + timeoutMs;
14
14
  const track = promise => signal?.[Symbol.for('prnext.request-work')]?.(promise, { timeoutMs: Math.max(1, deadline - Date.now()) });
@@ -18,6 +18,7 @@ function createAfterScope({ signal, timeoutMs = 25000, onTimeout = () => {}, onC
18
18
  if (closed) throw new Error('after() request has already completed');
19
19
  if (typeof task !== 'function' && (!task || typeof task.then !== 'function')) throw new TypeError('after() expects a callback or a Promise');
20
20
  if (++count > 128) throw new Error('after() exceeds 128 tasks per request');
21
+ if (count === 1) onRegistered?.();
21
22
  const pending = typeof task === 'function' ? null : observe(task);
22
23
  if (pending) track(pending);
23
24
  queue.push({ task: pending ? null : task, pending, context, run });
@@ -27,8 +28,9 @@ function createAfterScope({ signal, timeoutMs = 25000, onTimeout = () => {}, onC
27
28
  try {
28
29
  while (queue.length) {
29
30
  const item = queue.shift(), remaining = deadline - Date.now();
30
- if (remaining <= 0) { const error = new Error('after() exceeded the request maxDuration'); report(error); onTimeout(error); break; }
31
+ if (remaining <= 0) { const error = new Error('after() exceeded the request maxDuration'); await item.run(()=>report(error)); onTimeout(error); break; }
31
32
  const abort = new AbortController();
33
+ item.context.afterRestricted ||= item.context.phase === 'render';
32
34
  item.context.cookies._mutable = false;
33
35
  item.context.phase = 'after';
34
36
  item.context.signal = abort.signal;
@@ -38,7 +40,7 @@ function createAfterScope({ signal, timeoutMs = 25000, onTimeout = () => {}, onC
38
40
  let timer;
39
41
  try {
40
42
  const outcome = await Promise.race([work, new Promise(resolve => { timer = setTimeout(() => { const error = new Error('after() exceeded the request maxDuration'); abort.abort(error); resolve({ error }); }, remaining); })]);
41
- if ('error' in outcome) report(outcome.error);
43
+ if ('error' in outcome) await item.run(()=>report(outcome.error));
42
44
  if (abort.signal.aborted) { onTimeout(outcome.error); break; }
43
45
  } finally { clearTimeout(timer); item.context.cacheState.closed = true; await onComplete?.(item.context); }
44
46
  }
@@ -1,8 +1,53 @@
1
1
  'use client';
2
2
  'use strict';
3
3
  const React = require('react');
4
+ const { AppRouterContext } = require('./app-context.cjs');
4
5
  const LayoutContext = React.createContext({ children: [] });
5
6
  const LayoutCacheContext = React.createContext(null);
7
+ const ActivityCacheContext = React.createContext(null);
8
+
9
+ function ActivitySlot({ identity, children, authorizationKey }) {
10
+ const retention = React.useContext(ActivityCacheContext);
11
+ authorizationKey ??= retention?.authorizationKey;
12
+ const router = React.useContext(AppRouterContext);
13
+ const epoch = retention?.epoch ?? 0;
14
+ const authorizationEpoch = retention?.authorizationEpoch ?? 0;
15
+ const registrations = React.useRef(new Set());
16
+ const serial = React.useRef(0);
17
+ const [saved, setSaved] = React.useState(() => ({ epoch, authorizationEpoch, active: identity, entries: new Map() }));
18
+ let entries = saved.entries;
19
+ const current = entries.get(identity);
20
+ if (saved.epoch !== epoch || saved.authorizationEpoch !== authorizationEpoch || current?.children !== children || current?.router !== router || current?.authorizationKey !== authorizationKey) {
21
+ const sameAuthorization = saved.authorizationEpoch === authorizationEpoch && !(current?.authorizationKey && authorizationKey && current.authorizationKey !== authorizationKey);
22
+ entries = saved.epoch === epoch && sameAuthorization ? new Map(entries) : new Map();
23
+ const preserve = current && saved.authorizationEpoch === authorizationEpoch && (sameAuthorization && (saved.epoch === epoch || saved.active === identity)
24
+ || !retention?.strictAuthorization && saved.active === identity);
25
+ entries.set(identity, { children, router, authorizationKey, token: preserve ? current.token : {}, reactKey: preserve ? current.reactKey : ++serial.current });
26
+ setSaved({ epoch, authorizationEpoch, active: identity, entries });
27
+ }
28
+ React.useLayoutEffect(() => {
29
+ const cache = retention?.cache;
30
+ if (!cache) return;
31
+ const inactive = new Set([...entries].filter(([key]) => key !== identity).map(([, entry]) => entry.token));
32
+ for (const token of registrations.current) if (!inactive.has(token)) cache.release(token);
33
+ for (const [key, entry] of entries) {
34
+ if (key === identity) cache.release(entry.token);
35
+ else cache.retain(entry.token, () => setSaved(previous => {
36
+ if (!previous.entries.has(key)) return previous;
37
+ const next = new Map(previous.entries); next.delete(key); return { ...previous, entries: next };
38
+ }));
39
+ }
40
+ registrations.current = inactive;
41
+ }, [entries, identity, retention?.cache]);
42
+ React.useLayoutEffect(() => () => {
43
+ for (const token of registrations.current) retention?.cache.release(token);
44
+ registrations.current.clear();
45
+ }, [retention?.cache]);
46
+ // Identical boundary structure during SSR and hydration. Hidden branches get
47
+ // their original router context and their effects are disconnected by React.
48
+ return [...entries].map(([key, entry]) => React.createElement(React.Activity, { key: entry.reactKey, mode: key === identity ? 'visible' : 'hidden' },
49
+ React.createElement(AppRouterContext.Provider, { value: key === identity ? router : entry.router }, entry.children)));
50
+ }
6
51
 
7
52
  function LayoutProvider({ id, segments, children }) {
8
53
  const cache = React.useContext(LayoutCacheContext);
@@ -13,15 +58,20 @@ function LayoutProvider({ id, segments, children }) {
13
58
  return React.createElement(LayoutContext.Provider, { value: current }, children);
14
59
  }
15
60
 
16
- function LayoutSlot({ id, preserve, children }) {
61
+ function LayoutSlot({ id, preserve, children, activityKey, activityPreserve, authorizationKey }) {
17
62
  const cache = React.useContext(LayoutCacheContext);
18
63
  const previous = React.useRef(children);
19
64
  const current = preserve && cache && Object.hasOwn(cache.slots, id) ? cache.slots[id] : preserve ? previous.current : children;
20
65
  React.useLayoutEffect(() => { previous.current = current; if (cache && id) cache.slots[id] = current; }, [cache, id, current]);
21
- return current ?? null;
66
+ const previousKey = React.useRef(activityKey);
67
+ const identity = preserve || activityPreserve ? previousKey.current : activityKey;
68
+ React.useLayoutEffect(() => { previousKey.current = identity; }, [identity]);
69
+ return activityKey === undefined ? current ?? null : React.createElement(ActivitySlot, { identity, authorizationKey }, current);
22
70
  }
23
71
 
24
72
  module.exports.LayoutContext = LayoutContext;
25
73
  module.exports.LayoutCacheContext = LayoutCacheContext;
26
74
  module.exports.LayoutProvider = LayoutProvider;
27
75
  module.exports.LayoutSlot = LayoutSlot;
76
+ module.exports.ActivitySlot = ActivitySlot;
77
+ module.exports.ActivityCacheContext = ActivityCacheContext;
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+ // Native decoding avoids a JavaScript callback for each Flight byte. The loop
3
+ // remains available on browsers that do not implement Uint8Array.fromBase64.
4
+ function decodeBase64(value) {
5
+ if(typeof Uint8Array.fromBase64==='function')return Uint8Array.fromBase64(value);
6
+ const binary=atob(value),bytes=new Uint8Array(binary.length);
7
+ for(let n=0;n<binary.length;n++)bytes[n]=binary.charCodeAt(n);
8
+ return bytes;
9
+ }
10
+ module.exports={decodeBase64};
@@ -139,10 +139,19 @@ async function customCachedValue(key, kind, producer, options) {
139
139
  const timestamp = Date.now();
140
140
  const record = { revision, timestamp, work: undefined, metadata: undefined };
141
141
  work = (async () => {
142
+ let lease,canStore=true;
143
+ try {
144
+ if(typeof handler.acquire==='function'){
145
+ try{
146
+ lease=await call(handler,'acquire',[key,{signal:context.signal}],context);
147
+ const shared=await call(handler,'get',[key,tags],context);
148
+ if(shared){const sharedInfo=metadata(shared);if(Date.now()-sharedInfo.timestamp<Math.min(sharedInfo.expire,sharedInfo.revalidate)*1000){record.metadata=sharedInfo;options.onReadMetadata?.(sharedInfo);return await read(shared,context);}void shared.value.cancel().catch(()=>{});}
149
+ }catch{context.signal?.throwIfAborted();canStore=false;}
150
+ }
142
151
  const result = await producer();
143
152
  const info = resolveMetadata();
144
153
  record.metadata = info;
145
- if (result.byteLength <= MAX_BYTES && revision === (revisions.get(handler) || 0)) {
154
+ if (canStore && result.byteLength <= MAX_BYTES && revision === (revisions.get(handler) || 0)) {
146
155
  try {
147
156
  const changed = await expiration(handler, [...new Set([...tags, ...info.tags])], context);
148
157
  // Many backends record Date.now() millisecond timestamps. Treat the
@@ -150,12 +159,13 @@ async function customCachedValue(key, kind, producer, options) {
150
159
  // hide an invalidation which actually happened after the read started.
151
160
  if ((changed === Infinity || changed < Math.floor(timestamp)) && revision === (revisions.get(handler) || 0)) {
152
161
  const value = stream(result);
153
- try { await call(handler, 'set', [key, Promise.resolve({ value, tags: info.tags, stale: info.stale, revalidate: info.revalidate, expire: info.expire, timestamp })], context); }
162
+ try { await call(handler, 'set', [key, Promise.resolve({ value, tags: info.tags, stale: info.stale, revalidate: info.revalidate, expire: info.expire, timestamp }),{lease,softTags:tags}], context); }
154
163
  catch { if (!value.locked) void value.cancel().catch(() => {}); }
155
164
  }
156
165
  } catch { context.signal?.throwIfAborted(); }
157
166
  }
158
167
  return result;
168
+ }finally{try{await lease?.release();}catch{/* A failed cache cleanup does not replay application work. */}}
159
169
  })();
160
170
  record.work = work;
161
171
  if (pendingCount < 64) {
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ // Versioned private framing: bounded JSON metadata followed by uninterpreted bytes.
3
+ const TYPE = 'application/vnd.prnext.cache-v2';
4
+ const MAX_VALUE = 2 * 1024 * 1024;
5
+ const MAX_META = 2 * 1024 * 1024;
6
+ function encode(operation) {
7
+ const { value, ...metadata } = operation;
8
+ if (value !== undefined && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) throw new TypeError('Cache wire values must be bytes');
9
+ const bytes = value === undefined ? undefined : Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
10
+ if (bytes && (metadata.op !== 'commit' || bytes.length > MAX_VALUE)) throw new Error('Invalid cache wire value');
11
+ if (metadata.op === 'commit' && !bytes) throw new Error('Cache commit requires bytes');
12
+ const json = Buffer.from(JSON.stringify(metadata));
13
+ if (json.length > MAX_META) throw new Error('Cache wire metadata exceeds limit');
14
+ const prefix = Buffer.allocUnsafe(4); prefix.writeUInt32BE(json.length);
15
+ return bytes ? [prefix, json, bytes] : [prefix, json];
16
+ }
17
+ function decode(bytes) {
18
+ if (bytes.length < 4) throw new Error('Truncated cache wire frame');
19
+ const length = bytes.readUInt32BE(0);
20
+ if (length > MAX_META || length > bytes.length - 4) throw new Error('Invalid cache wire metadata length');
21
+ const value = JSON.parse(bytes.subarray(4, 4 + length).toString('utf8'));
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid cache wire metadata');
23
+ const raw = bytes.subarray(4 + length);
24
+ if (raw.length > MAX_VALUE || (value.value !== true && raw.length) || (value.value !== undefined && value.value !== true)) throw new Error('Invalid cache wire payload');
25
+ if (value.value === true) value.value = raw;
26
+ return value;
27
+ }
28
+ module.exports = { TYPE, MAX_VALUE, MAX_META, encode, decode };
package/compat/cache.cjs CHANGED
@@ -96,4 +96,5 @@ function unstable_noStore() {
96
96
  }
97
97
  function refresh() { cacheState(mutationContext('refresh', true)).refresh = true; }
98
98
 
99
- module.exports = { unstable_cache, revalidateTag, updateTag, revalidatePath, unstable_noStore, refresh, cacheTag, cacheLife };
99
+ const { navigation, prefetch } = require('./navigation-stages.cjs');
100
+ module.exports = { unstable_cache, revalidateTag, updateTag, revalidatePath, unstable_noStore, refresh, cacheTag, cacheLife, navigation, prefetch };
@@ -34,3 +34,7 @@ export function updateTag(tag: string): void;
34
34
  export function revalidatePath(path: string, type?: 'page' | 'layout'): void;
35
35
  export function unstable_noStore(): void;
36
36
  export function refresh(): void;
37
+ /** Defer runtime work until navigation, while allowing static prerenders. */
38
+ export function navigation(): Promise<void>;
39
+ /** Defer runtime work until a per-link prefetch or navigation. */
40
+ export function prefetch(): Promise<void>;
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
 
3
- function parseCookieHeader(value = '') {
3
+ function parseCookieHeader(value = '', { ignoreMalformed = false } = {}) {
4
4
  const values = new Map();
5
5
  for (const part of String(value).split(';')) {
6
6
  const separator = part.indexOf('=');
7
7
  if (separator < 1) continue;
8
8
  const name = part.slice(0, separator).trim();
9
9
  let value = part.slice(separator + 1).trim();
10
- try { value = decodeURIComponent(value); } catch { /* Preserve malformed user input. */ }
10
+ try { value = decodeURIComponent(value); } catch { if (ignoreMalformed) continue; }
11
11
  values.set(name, { name, value });
12
12
  }
13
13
  return values;
@@ -50,7 +50,7 @@ function serializeCookie(cookie) {
50
50
 
51
51
  function parseResponseCookie(header) {
52
52
  const [pair, ...attributes] = header.split(';');
53
- const cookie = parseCookieHeader(pair).values().next().value;
53
+ const cookie = parseCookieHeader(pair, { ignoreMalformed: true }).values().next().value;
54
54
  if (!cookie) return;
55
55
  for (const attribute of attributes) {
56
56
  const separator = attribute.indexOf('=');
@@ -71,7 +71,7 @@ function parseResponseCookie(header) {
71
71
 
72
72
  class CookieStore {
73
73
  constructor(header, { mutable = false, onChange } = {}) {
74
- this._cookies = parseCookieHeader(header);
74
+ this._cookies = parseCookieHeader(header, { ignoreMalformed: true });
75
75
  this._mutable = mutable;
76
76
  this._onChange = onChange;
77
77
  }
@@ -99,6 +99,22 @@ class CookieStore {
99
99
  *[Symbol.iterator]() { for (const [name, cookie] of this._cookies) yield [name, { ...cookie }]; }
100
100
  }
101
101
 
102
+ class RequestCookieStore extends CookieStore {
103
+ constructor(headers) {
104
+ super(headers.get('cookie') || '', { mutable: true, onChange: () => headers.set('cookie', this.toString()) });
105
+ }
106
+ set(name, value) {
107
+ const cookie = typeof name === 'object' ? { name: name.name, value: name.value } : { name, value };
108
+ return super.set(cookie);
109
+ }
110
+ delete(names) {
111
+ const result = Array.isArray(names) ? names.map(name => this._cookies.delete(name)) : this._cookies.delete(names);
112
+ this._onChange?.();
113
+ return result;
114
+ }
115
+ clear() { this.delete([...this._cookies.keys()]); return this; }
116
+ }
117
+
102
118
  class ResponseCookieStore extends CookieStore {
103
119
  constructor(headers) {
104
120
  super('', { mutable: true });
@@ -126,4 +142,4 @@ class ResponseCookieStore extends CookieStore {
126
142
  toString() { return [...this._cookies.values()].map(serializeCookie).join('; '); }
127
143
  }
128
144
 
129
- module.exports = { CookieStore, ResponseCookieStore, parseCookieHeader, serializeCookie };
145
+ module.exports = { CookieStore, RequestCookieStore, ResponseCookieStore, parseCookieHeader, serializeCookie };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  const { AsyncLocalStorage, AsyncResource } = require('node:async_hooks');
3
3
  const http = require('node:http');
4
+ const cacheWire = require('./cache-wire.cjs');
4
5
  const { setTimeout: delay } = require('node:timers/promises');
5
6
  const { trackStaticDependency } = require('./static-generation.cjs');
6
7
  const diagnostics = require('./diagnostics.cjs');
@@ -21,7 +22,8 @@ const rpcScope = new AsyncResource('prnext-cache-transport');
21
22
  // idle sockets churns ephemeral ports under concurrent PPR traffic. Idle
22
23
  // connections expire after 30 seconds and retain no request context.
23
24
  const rpcAgent = new http.Agent({ keepAlive: true, maxSockets: 32, maxTotalSockets: 32, maxFreeSockets: 32, timeout: 30_000 });
24
- function localRpc(target, body, signal) {
25
+ function localRpc(target, parts, signal, binary) {
26
+ const length = parts.reduce((sum,part)=>sum+Buffer.byteLength(part),0);
25
27
  return rpcScope.runInAsyncScope(() => new Promise((resolve, reject) => {
26
28
  let settled = false;
27
29
  const finish = (error, value) => {
@@ -31,7 +33,7 @@ function localRpc(target, body, signal) {
31
33
  if (error) reject(error); else resolve(value);
32
34
  };
33
35
  const request = http.request(target.url, { method: 'POST', agent: rpcAgent,
34
- headers: { 'content-type': 'application/json', authorization: `Bearer ${target.token}`, 'content-length': Buffer.byteLength(body) },
36
+ headers: { 'content-type': binary ? cacheWire.TYPE : 'application/json', authorization: `Bearer ${target.token}`, 'content-length': length },
35
37
  }, response => {
36
38
  let size = 0;
37
39
  const chunks = [];
@@ -43,14 +45,14 @@ function localRpc(target, body, signal) {
43
45
  });
44
46
  response.on('end', () => {
45
47
  const ok = response.statusCode >= 200 && response.statusCode < 300;
46
- try { finish(null, { ok, status: response.statusCode, value: ok ? JSON.parse(Buffer.concat(chunks, size).toString()) : undefined }); }
48
+ try { finish(null, { ok, status: response.statusCode, value: ok ? (binary ? (response.headers['content-type'] === cacheWire.TYPE ? cacheWire.decode(Buffer.concat(chunks,size)) : (()=>{throw new Error('Cache protocol mismatch')})()) : JSON.parse(Buffer.concat(chunks, size).toString())) : undefined }); }
47
49
  catch (error) { finish(error); }
48
50
  });
49
51
  });
50
52
  const abort = () => request.destroy(signal.reason instanceof Error ? signal.reason : new Error('Cache RPC aborted'));
51
53
  request.on('error', error => finish(error));
52
54
  signal?.addEventListener('abort', abort, { once: true });
53
- if (signal?.aborted) abort(); else request.end(body);
55
+ if (signal?.aborted) abort(); else { request.cork(); for(const part of parts) request.write(part); request.end(); request.uncork(); }
54
56
  }));
55
57
  }
56
58
 
@@ -149,9 +151,10 @@ async function acquireExternalCacheLease(key, signal) {
149
151
  throw new Error('Timed out waiting for a native incremental cache lease');
150
152
  }
151
153
  async function rpc(target, operation, signal) {
152
- const body = JSON.stringify(operation);
153
154
  const url = new URL(target.url);
154
155
  const local = url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname);
156
+ const binary = local && process.env.PRNEXT_CACHE_PROTOCOL === '2';
157
+ const body = binary ? cacheWire.encode(operation) : [JSON.stringify(operation.value === undefined ? operation : { ...operation, value: bufferValue(operation.value).toString('base64') })];
155
158
  const controller = new AbortController();
156
159
  const timer = setTimeout(() => controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError')), 5000);
157
160
  timer.unref();
@@ -159,10 +162,10 @@ async function rpc(target, operation, signal) {
159
162
  const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
160
163
  let retryMs = 10;
161
164
  while (true) {
162
- const response = local ? await localRpc(target, body, operationSignal) : await nativeFetch(target.url, {
165
+ const response = local ? await localRpc(target, body, operationSignal, binary) : await nativeFetch(target.url, {
163
166
  method: 'POST',
164
167
  headers: { 'content-type': 'application/json', authorization: `Bearer ${target.token}` },
165
- body,
168
+ body: body[0],
166
169
  signal: operationSignal,
167
170
  });
168
171
  // The deadline covers the JSON body as well as response headers. A
@@ -198,6 +201,7 @@ function bufferValue(value) {
198
201
  return Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
199
202
  }
200
203
  function decodeValue(value) {
204
+ if (Buffer.isBuffer(value)) { if(value.length > MAX_CACHE_VALUE_BYTES) throw new Error('Invalid PRNext data cache value'); return value; }
201
205
  if (typeof value !== 'string' || value.length > Math.ceil(MAX_CACHE_VALUE_BYTES / 3) * 4) throw new Error('Invalid PRNext data cache value');
202
206
  const bytes = Buffer.from(value, 'base64');
203
207
  if (bytes.length > MAX_CACHE_VALUE_BYTES) throw new Error('Invalid PRNext data cache value');
@@ -247,7 +251,7 @@ async function compute(target, key, lease, producer, options) {
247
251
  try {
248
252
  const metadata = options.resolveMetadata?.();
249
253
  if (metadata?.externalCache) { await release(target, key, lease); return; }
250
- const result = await rpc(target, { op: 'commit', key, lease, value: bytes.toString('base64'), revalidate: options.revalidate,
254
+ const result = await rpc(target, { op: 'commit', key, lease, value: bytes, revalidate: options.revalidate,
251
255
  ...(metadata ? { tags: validateTags(metadata.tags), paths: metadata.paths || [], revalidate: validateRevalidate(metadata.revalidate, { allowZero: true }), expire: metadata.expire } : {}) }, signal);
252
256
  if (!result.stored) await release(target, key, lease);
253
257
  } catch {