@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,201 @@
1
+ 'use strict';
2
+ const {createHash,randomBytes}=require('node:crypto');
3
+ const MAX_VALUE=2*1024*1024,MAX_META=65536;
4
+ const hash=value=>createHash('sha256').update(value).digest('hex');
5
+ const ACQUIRE=`
6
+ if not redis.call('HGET',KEYS[2],'_incarnation') then redis.call('HSET',KEYS[2],'_incarnation',ARGV[3],'_epoch',ARGV[3]) end
7
+ redis.call('PEXPIRE',KEYS[2],ARGV[4])
8
+ local epoch=redis.call('HGET',KEYS[2],'_epoch');local incarnation=redis.call('HGET',KEYS[2],'_incarnation')
9
+ if redis.call('SET',KEYS[1],ARGV[1],'NX','PX',ARGV[2]) then return {1,epoch,incarnation} end
10
+ return {0}`;
11
+ const RELEASE=`if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0`;
12
+ const READ=`
13
+ local fields=redis.call('HMGET',KEYS[1],'meta','value')
14
+ if not fields[1] or not fields[2] or string.len(fields[1])>65536 or string.len(fields[2])>tonumber(ARGV[2]) then return nil end
15
+ local meta=cjson.decode(fields[1]); local extra=cjson.decode(ARGV[1]); local stale=0
16
+ if redis.call('HGET',KEYS[2],'_incarnation')~=meta.incarnation then return nil end
17
+ local t=redis.call('TIME'); local now=tonumber(t[1])*1000+math.floor(tonumber(t[2])/1000)
18
+ local seen={}
19
+ for _,id in ipairs(meta.tagIds) do seen[id]=true end
20
+ for _,id in ipairs(extra) do seen[id]=true end
21
+ for id,_ in pairs(seen) do
22
+ local value=redis.call('HGET',KEYS[2],id)
23
+ if value then
24
+ local tag=cjson.decode(value)
25
+ if tag.generation~=(meta.versions[id] or '0') then
26
+ if tag.deadline>=0 and tag.deadline<=now then return nil end
27
+ stale=1
28
+ end
29
+ end
30
+ end
31
+ return {fields[1],fields[2],math.max(0,now-meta.storedAt),stale}`;
32
+ const WRITE=`
33
+ if redis.call('GET',KEYS[3])~=ARGV[1] or redis.call('HGET',KEYS[2],'_epoch')~=ARGV[2] then return 0 end
34
+ local meta=cjson.decode(ARGV[3]); meta.versions={};meta.incarnation=ARGV[5];meta.writer=ARGV[1]
35
+ local t=redis.call('TIME'); local now=tonumber(t[1])*1000+math.floor(tonumber(t[2])/1000)
36
+ meta.storedAt=now-tonumber(ARGV[6])
37
+ for _,id in ipairs(meta.tagIds) do
38
+ local value=redis.call('HGET',KEYS[2],id)
39
+ meta.versions[id]=value and cjson.decode(value).generation or '0'
40
+ end
41
+ local encoded=cjson.encode(meta)
42
+ if string.len(encoded)>65536 then return 0 end
43
+ redis.call('HSET',KEYS[1],'meta',encoded,'value',ARGV[4])
44
+ redis.call('PEXPIRE',KEYS[1],ARGV[7]);redis.call('PEXPIRE',KEYS[2],ARGV[8]);return 1`;
45
+ const INVALIDATE=`
46
+ local generation=ARGV[3];local ids=cjson.decode(ARGV[4]);local added=0
47
+ for _,id in ipairs(ids) do if redis.call('HEXISTS',KEYS[1],id)==0 then added=added+1 end end
48
+ if redis.call('HLEN',KEYS[1])+added>4098 then redis.call('DEL',KEYS[1]) end
49
+ if not redis.call('HGET',KEYS[1],'_incarnation') then redis.call('HSET',KEYS[1],'_incarnation',generation) end
50
+ redis.call('HSET',KEYS[1],'_epoch',generation)
51
+ local t=redis.call('TIME');local now=tonumber(t[1])*1000+math.floor(tonumber(t[2])/1000)
52
+ local expiry=tonumber(ARGV[1]);local deadline=expiry<0 and -1 or now+expiry
53
+ for _,id in ipairs(ids) do redis.call('HSET',KEYS[1],id,cjson.encode({generation=generation,deadline=deadline})) end
54
+ redis.call('PEXPIRE',KEYS[1],ARGV[2])
55
+ return 1`;
56
+ const REMOVE=`local meta=redis.call('HGET',KEYS[1],'meta');if meta and cjson.decode(meta).writer==ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0`;
57
+
58
+ function tags(values=[]){
59
+ if(!Array.isArray(values)||values.length>256||values.some(value=>typeof value!=='string'||!value||Buffer.byteLength(value)>8192))throw new TypeError('Redis cache tags must be bounded nonempty strings.');
60
+ return [...new Set(values)];
61
+ }
62
+ function positive(value,fallback,min,max,name){value??=fallback;if(!Number.isSafeInteger(value)||value<min||value>max)throw new TypeError(`Redis cache ${name} must be between ${min} and ${max}.`);return value;}
63
+ function pause(ms,signal){return new Promise((resolve,reject)=>{const done=error=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);error?reject(error):resolve();};const abort=()=>done(signal.reason);const timer=setTimeout(done,ms);signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort();});}
64
+ function boundedRead(reader,ms){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Redis cache value read timed out.')),ms);reader.read().then(value=>{clearTimeout(timer);resolve(value);},error=>{clearTimeout(timer);reject(error);});});}
65
+
66
+ /** First-party Cache Components handler; Redis is loaded only when configured. */
67
+ function createRedisCache(options={},maxValueBytes=MAX_VALUE){
68
+ if(!options||typeof options!=='object'||Array.isArray(options))throw new TypeError('Redis cache options must be an object.');
69
+ const known=new Set(['namespace','urlEnv','client','leaseMs','waitMs','commandTimeoutMs','maxAgeSeconds']);
70
+ for(const key of Object.keys(options))if(!known.has(key))throw new TypeError(`Unknown Redis cache option: ${key}`);
71
+ if(options.client!==undefined&&typeof options.client?.withTypeMapping!=='function')throw new TypeError('Redis cache client must be a node-redis client.');
72
+ if(typeof options.namespace!=='string'||!options.namespace||Buffer.byteLength(options.namespace)>256)throw new TypeError('Redis cache requires an application-specific namespace.');
73
+ const urlEnv=options.urlEnv??'REDIS_URL';if(typeof urlEnv!=='string'||!/^[_A-Za-z][_A-Za-z0-9]*$/.test(urlEnv))throw new TypeError('Redis cache urlEnv must name an environment variable.');
74
+ const leaseMs=positive(options.leaseMs,30000,1000,120000,'leaseMs'),waitMs=positive(options.waitMs,2500,100,3500,'waitMs');
75
+ const commandTimeoutMs=positive(options.commandTimeoutMs,1000,100,2000,'commandTimeoutMs'),maxAgeSeconds=positive(options.maxAgeSeconds,604800,1,2592000,'maxAgeSeconds');
76
+ if(waitMs+2*commandTimeoutMs>4500)throw new TypeError('Redis cache waitMs plus twice commandTimeoutMs must not exceed 4500 ms.');
77
+ const prefix='prnext:v1:{'+hash(options.namespace)+'}:',epochKey=prefix+'tags',retention=String(maxAgeSeconds*1000+leaseMs+60000);
78
+ let owned,connecting,closed=false;
79
+ async function client(){
80
+ if(closed)throw new Error('Redis cache is closed.');
81
+ if(options.client){
82
+ if(options.client.isReady===false)throw new Error('Redis cache client is not connected.');
83
+ return options.client;
84
+ }
85
+ if(owned?.isReady)return owned;
86
+ if(!connecting){
87
+ connecting=(async()=>{
88
+ const url=process.env[urlEnv];
89
+ if(!url||!/^rediss?:\/\//.test(url))throw new Error(`Set ${urlEnv} to a Redis connection URL.`);
90
+ // Use node-redis's base client; Redis Stack command packages are not
91
+ // needed by this provider's bounded EVAL/HASH protocol.
92
+ const {createClient}=require('@redis/client');
93
+ owned?.destroy();owned=createClient({url,disableOfflineQueue:true,commandsQueueMaxLength:128,socket:{connectTimeout:commandTimeoutMs,reconnectStrategy:false}});
94
+ owned.on('error',()=>{});await owned.connect();return owned;
95
+ })().finally(()=>{connecting=undefined;});
96
+ }
97
+ return connecting;
98
+ }
99
+ async function command(args,signal){
100
+ const connection=await client();
101
+ // '$' (36) is RESP's binary-safe bulk-string type, as exposed by node-redis.
102
+ const binary=connection.withTypeMapping({36:Buffer});
103
+ const deadline=AbortSignal.timeout(commandTimeoutMs);
104
+ return binary.sendCommand(args,{abortSignal:signal?AbortSignal.any([signal,deadline]):deadline});
105
+ }
106
+ const keys=key=>{if(typeof key!=='string'||!key||Buffer.byteLength(key)>8192)throw new TypeError('Invalid Redis cache key.');const id=hash(key);return {entry:prefix+'entry:'+id,lock:prefix+'lease:'+id};};
107
+ async function acquire(key,{signal}={}){
108
+ const {lock}=keys(key),token=randomBytes(24).toString('hex'),until=performance.now()+waitMs;
109
+ for(;;){
110
+ signal?.throwIfAborted();
111
+ const started=performance.now();
112
+ const result=await command(['EVAL',ACQUIRE,'2',lock,epochKey,token,String(leaseMs),randomBytes(24).toString('hex'),retention],signal);
113
+ if(Number(result[0])===1){let released=false;return {token,started,epoch:String(result[1]),incarnation:String(result[2]),async release(){if(released)return;released=true;await command(['EVAL',RELEASE,'1',lock,token]);}};}
114
+ if(performance.now()>=until)throw Object.assign(new Error('Redis cache fill wait exceeded its budget.'),{code:'PRNEXT_REDIS_BUSY'});
115
+ await pause(Math.min(50,Math.max(1,until-performance.now())),signal);
116
+ }
117
+ }
118
+ async function get(key,softTags=[]){
119
+ const result=await command(['EVAL',READ,'2',keys(key).entry,epochKey,JSON.stringify(tags(softTags).map(hash)),String(maxValueBytes)]);
120
+ if(!result)return undefined;
121
+ const meta=JSON.parse(result[0].toString('utf8')),bytes=result[1];
122
+ if(meta.version!==1||!Buffer.isBuffer(bytes)||bytes.length>maxValueBytes)throw new Error('Invalid Redis cache entry.');
123
+ const age=Number(result[2]);if(!Number.isFinite(age)||age<0)throw new Error('Invalid Redis cache age.');
124
+ let offset=0;
125
+ return {tags:meta.tags,stale:meta.stale,revalidate:Number(result[3])?0:meta.revalidate,expire:meta.expire,timestamp:Math.max(0,Date.now()-age),
126
+ value:new ReadableStream({pull(controller){if(offset===bytes.length){controller.close();return;}const end=Math.min(bytes.length,offset+65536);controller.enqueue(new Uint8Array(bytes.subarray(offset,end)));offset=end;}},{highWaterMark:0})};
127
+ }
128
+ async function set(key,pending,{lease,softTags=[]}={}){
129
+ if(!lease||!Number.isFinite(lease.started)){
130
+ // Observe and release a caller's value even when its lease is missing.
131
+ // An unfenced producer must never publish after an invalidation.
132
+ void Promise.resolve(pending).then(entry=>entry.value.cancel()).catch(()=>{});
133
+ throw new Error('Redis cache writes require the lease returned by acquire().');
134
+ }
135
+ const entry=await pending,entryTags=tags(entry.tags);
136
+ for(const field of ['timestamp','stale','revalidate','expire'])if(!Number.isFinite(entry[field])||entry[field]<0)throw new TypeError('Invalid Redis cache '+field);
137
+ const reader=entry.value.getReader(),chunks=[];let size=0,complete=false;
138
+ const until=performance.now()+commandTimeoutMs;
139
+ let reads=0;
140
+ try{for(;;){if(performance.now()>=until||++reads>10000)throw new Error('Redis cache value read timed out.');const part=await boundedRead(reader,Math.max(1,until-performance.now()));if(part.done){complete=true;break;}if(!(part.value instanceof Uint8Array)||(size+=part.value.byteLength)>maxValueBytes)throw new Error(`Redis cache value exceeds ${maxValueBytes/1024/1024} MiB.`);chunks.push(Buffer.from(part.value));}}
141
+ finally{if(!complete)void reader.cancel().catch(()=>{});reader.releaseLock();}
142
+ const meta={version:1,tags:entryTags,tagIds:tags([...entryTags,...softTags]).map(hash),stale:entry.stale,revalidate:entry.revalidate,expire:entry.expire};
143
+ const metadata=JSON.stringify(meta);if(Buffer.byteLength(metadata)>MAX_META)throw new Error('Redis cache metadata exceeds 64 KiB.');
144
+ const ttl=Math.ceil(Math.min(maxAgeSeconds,entry.expire)*1000);if(ttl<=0)return;
145
+ const {entry:entryKey,lock}=keys(key);
146
+ return Number(await command(['EVAL',WRITE,'3',entryKey,epochKey,lock,lease.token,lease.epoch,metadata,Buffer.concat(chunks,size),lease.incarnation,String(Math.max(0,performance.now()-lease.started)),String(ttl),retention]))===1;
147
+ }
148
+ async function updateTags(values,durations){
149
+ const list=tags(values);if(!list.length)return;
150
+ const expire=durations?.expire;if(expire!==undefined&&(!Number.isFinite(expire)||expire<0))throw new TypeError('Invalid Redis tag expiration.');
151
+ await command(['EVAL',INVALIDATE,'1',epochKey,String(expire===undefined?-1:Math.ceil(expire*1000)),retention,randomBytes(24).toString('hex'),JSON.stringify(list.map(hash))]);
152
+ }
153
+ return {get,set,acquire,updateTags,
154
+ async release(key,lease){if(lease?.token)await command(['EVAL',RELEASE,'1',keys(key).lock,lease.token]);},
155
+ async remove(key,lease){if(lease?.token)await command(['EVAL',REMOVE,'1',keys(key).entry,lease.token]);},
156
+ async refreshTags(){},async getExpiration(){return Infinity;},async close(){closed=true;await connecting?.catch(()=>{});if(owned?.isOpen)owned.destroy();}};
157
+ }
158
+
159
+ /** Legacy incremental handler for HTML/ISR, fetch and unstable_cache. */
160
+ function createRedisIncrementalCache(options){
161
+ const {serialize,deserialize}=require('node:v8');
162
+ const backend=createRedisCache({leaseMs:60000,...options},16*1024*1024);
163
+ const keyFor=key=>'incremental:v8-'+process.versions.v8.split('.')[0]+':'+key;
164
+ function leaseFor(lease){
165
+ if(!lease||!['token','epoch','incarnation'].every(field=>typeof lease[field]==='string'&&/^[a-f0-9]{48}$/.test(lease[field]))||!Number.isFinite(lease.startedAt))throw new Error('Incremental Redis writes require a valid generation lease.');
166
+ return {...lease,started:performance.now()-Math.max(0,Date.now()-lease.startedAt)};
167
+ }
168
+ return class RedisIncrementalCache {
169
+ get prnextLeaseVersion(){return 1;}
170
+ async acquire(key,{signal}={}){
171
+ const lease=await backend.acquire(keyFor(key),{signal});
172
+ return {token:lease.token,epoch:lease.epoch,incarnation:lease.incarnation,startedAt:Date.now()};
173
+ }
174
+ async release(key,lease){await backend.release(keyFor(key),lease);}
175
+ async get(key,context={}){
176
+ const entry=await backend.get(keyFor(key),tags([...new Set([...(context.tags||[]),...(context.softTags||[])])]));
177
+ if(!entry)return null;
178
+ const bytes=Buffer.from(await new Response(entry.value).arrayBuffer());
179
+ const payload=deserialize(bytes);
180
+ if(payload?.version!==1||!Number.isFinite(payload.lastModified)||!payload.value||typeof payload.value.kind!=='string')throw new Error('Invalid incremental Redis payload.');
181
+ return {value:payload.value,lastModified:payload.lastModified,isStale:entry.revalidate===0};
182
+ }
183
+ async set(key,value,context={}){
184
+ const lease=leaseFor(context.prnextLease);
185
+ if(value===null){await backend.remove(keyFor(key),lease);return true;}
186
+ const raw=context.cacheControl?.revalidate??value.revalidate??value._prnext?.revalidate??false;
187
+ if(raw!==false&&(!Number.isFinite(raw)||raw<0))throw new TypeError('Invalid incremental Redis revalidate value.');
188
+ if(raw===0)return true;
189
+ const revalidate=raw===false?31536000:raw;
190
+ const entryTags=tags([...new Set([...(context.tags||[]),...(context.softTags||[])])]);
191
+ const bytes=serialize({version:1,lastModified:Date.now(),value});
192
+ if(bytes.length>16*1024*1024)throw new Error('Incremental Redis entry exceeds 16 MiB.');
193
+ return backend.set(keyFor(key),Promise.resolve({timestamp:Date.now(),stale:0,revalidate,expire:Math.max(revalidate,options?.maxAgeSeconds??604800),tags:entryTags,
194
+ value:new ReadableStream({start(controller){controller.enqueue(bytes);controller.close();}})}),{lease});
195
+ }
196
+ async revalidateTag(values,durations){await backend.updateTags(Array.isArray(values)?values:[values],durations);}
197
+ resetRequestCache(){}
198
+ async close(){await backend.close();}
199
+ };
200
+ }
201
+ module.exports={createRedisCache,createRedisIncrementalCache};
@@ -0,0 +1,37 @@
1
+ /// <reference types="node" />
2
+ export interface RedisCacheOptions {
3
+ /** Unique application namespace, shared by its replicas. */
4
+ namespace: string;
5
+ /** Runtime environment variable; the URL is never embedded in a build. Default REDIS_URL. */
6
+ urlEnv?: string;
7
+ /** An already-connected node-redis client. Its lifecycle remains yours. */
8
+ client?: {readonly isReady?: boolean; withTypeMapping(mapping: {36: BufferConstructor}): {sendCommand(args: (string | Buffer)[], options?: {abortSignal?: AbortSignal}): Promise<unknown>}};
9
+ leaseMs?: number;
10
+ waitMs?: number;
11
+ commandTimeoutMs?: number;
12
+ maxAgeSeconds?: number;
13
+ }
14
+ interface CacheEntry {value: ReadableStream<Uint8Array>; tags: string[]; timestamp: number; stale: number; revalidate: number; expire: number}
15
+ interface Lease {token: string; started: number; epoch: string; incarnation: string; release(): Promise<void>}
16
+ export declare function createRedisCache(options: RedisCacheOptions): {
17
+ get(key: string, softTags?: string[]): Promise<CacheEntry | undefined>;
18
+ set(key: string, entry: Promise<CacheEntry>, options?: {lease?: Lease; softTags?: string[]}): Promise<void>;
19
+ acquire(key: string, options?: {signal?: AbortSignal}): Promise<Lease>;
20
+ refreshTags(): Promise<void>;
21
+ getExpiration(): Promise<number>;
22
+ updateTags(tags: string[], durations?: {expire: number}): Promise<void>;
23
+ close(): Promise<void>;
24
+ };
25
+
26
+ export interface IncrementalRedisLease {token: string; epoch: string; incarnation: string; startedAt: number}
27
+ /** Configure its returned constructor as cacheHandler; Cache Components use createRedisCache separately. */
28
+ export declare function createRedisIncrementalCache(options: RedisCacheOptions): new (context?: unknown) => {
29
+ readonly prnextLeaseVersion: 1;
30
+ acquire(key: string, options?: {signal?: AbortSignal}): Promise<IncrementalRedisLease>;
31
+ release(key: string, lease: IncrementalRedisLease): Promise<void>;
32
+ get(key: string, context?: {tags?: string[]; softTags?: string[]}): Promise<{value: unknown; lastModified: number; isStale?: boolean} | null>;
33
+ set(key: string, value: unknown, context?: {prnextLease?: IncrementalRedisLease; tags?: string[]; softTags?: string[]; cacheControl?: {revalidate: number | false}}): Promise<boolean>;
34
+ revalidateTag(tags: string | string[], durations?: {expire?: number}): Promise<void>;
35
+ resetRequestCache(): void;
36
+ close(): Promise<void>;
37
+ };
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- const { CookieStore, ResponseCookieStore } = require('./cookies.cjs');
2
+ const { RequestCookieStore, ResponseCookieStore } = require('./cookies.cjs');
3
3
  const { hasBasePath, addBasePath, removeBasePath } = require('./paths.cjs');
4
4
  const { localePath, withLocale } = require('./locale.cjs');
5
5
 
@@ -14,21 +14,37 @@ function middlewareHeaders(init, headers) {
14
14
  headers.set('x-middleware-override-headers', names.join(','));
15
15
  }
16
16
 
17
+ function nextDataPath(pathname) {
18
+ const match = /^\/_next\/data\/([^/]+)\/(.*)\.json$/.exec(pathname);
19
+ if (!match) return { pathname };
20
+ const parts = match[2].split('/');
21
+ return { buildId: match[1], pathname: parts[0] === 'index' ? '/' : '/' + parts.join('/') };
22
+ }
23
+
17
24
  class NextURL extends URL {
18
25
  constructor(input, baseOrOptions, options) {
19
26
  const base = typeof baseOrOptions === 'string' || baseOrOptions instanceof URL ? baseOrOptions : undefined;
20
27
  const config = options || (base ? {} : baseOrOptions) || {};
21
28
  super(input, base || config.base);
22
29
  this._nextConfig = config.nextConfig || {};
30
+ this._forceLocale = config.forceLocale === true;
31
+ this._parseData = !(this._nextConfig.skipProxyUrlNormalize ?? this._nextConfig.skipMiddlewareUrlNormalize);
23
32
  this._trailingSlash = super.pathname === '/' ? !!this._nextConfig.trailingSlash : super.pathname.endsWith('/');
24
33
  this._basePath = hasBasePath(super.pathname, this._nextConfig.basePath) ? this._nextConfig.basePath || '' : '';
25
- this._locale = this._nextConfig.i18n ? localePath(removeBasePath(super.pathname, this._basePath), {...this._nextConfig.i18n,defaultLocale:this.defaultLocale}).locale : '';
34
+ const logical = this._parseData ? nextDataPath(removeBasePath(super.pathname, this._basePath)) : { pathname: removeBasePath(super.pathname, this._basePath) };
35
+ this._buildId = logical.buildId;
36
+ this._locale = this._nextConfig.i18n ? localePath(logical.pathname, {...this._nextConfig.i18n,defaultLocale:this.defaultLocale}).locale : '';
37
+ }
38
+ get pathname() {
39
+ const raw = removeBasePath(super.pathname, this._basePath);
40
+ return localePath(this._parseData ? nextDataPath(raw).pathname : raw, this._nextConfig.i18n).pathname;
26
41
  }
27
- get pathname() { return localePath(removeBasePath(super.pathname, this._basePath), this._nextConfig.i18n).pathname; }
28
42
  set pathname(value) { super.pathname = addBasePath(withLocale(String(value).startsWith('/') ? value : `/${value}`, this._locale, this.defaultLocale), this._basePath); }
29
43
  get defaultLocale() { return this.domainLocale?.defaultLocale || this._nextConfig.i18n?.defaultLocale; }
30
44
  get domainLocale() { return this._nextConfig.i18n?.domains?.find(value=>value.domain.toLowerCase()===this.host.toLowerCase()); }
31
45
  get locale() { return this._locale; }
46
+ get buildId() { return this._buildId; }
47
+ set buildId(value) { this._buildId = value; }
32
48
  set locale(value) {
33
49
  if (!this._nextConfig.i18n?.locales.includes(value)) throw new TypeError(`Unknown locale: ${value}`);
34
50
  const pathname=this.pathname;this._locale=value;this.pathname=pathname;
@@ -43,8 +59,13 @@ class NextURL extends URL {
43
59
  set trailingSlash(value) { this._trailingSlash = value; }
44
60
  get href() {
45
61
  const url = new URL(super.href);
46
- if (this._nextConfig.i18n) url.pathname=addBasePath(withLocale(this.pathname,this._locale,this.defaultLocale),this._basePath);
47
- if (this._trailingSlash) { if (!url.pathname.endsWith('/')) url.pathname += '/'; }
62
+ let pathname = withLocale(this.pathname, this._locale, this._buildId || this._forceLocale ? undefined : this.defaultLocale);
63
+ if (this._buildId) {
64
+ pathname = pathname.replace(/\/$/, '');
65
+ pathname = '/_next/data/' + this._buildId + pathname + (this.pathname === '/' ? '/index.json' : '.json');
66
+ }
67
+ url.pathname = addBasePath(pathname, this._basePath);
68
+ if (this._trailingSlash && !this._buildId) { if (!url.pathname.endsWith('/')) url.pathname += '/'; }
48
69
  else if (url.pathname !== '/') url.pathname = url.pathname.replace(/\/$/, '');
49
70
  return url.href;
50
71
  }
@@ -54,19 +75,18 @@ class NextURL extends URL {
54
75
  super.href = value;
55
76
  this._trailingSlash = super.pathname === '/' ? !!this._nextConfig.trailingSlash : super.pathname.endsWith('/');
56
77
  this._basePath = hasBasePath(super.pathname, this._nextConfig.basePath) ? this._nextConfig.basePath || '' : '';
57
- this._locale = this._nextConfig.i18n ? localePath(removeBasePath(super.pathname, this._basePath), {...this._nextConfig.i18n,defaultLocale:this.defaultLocale}).locale : '';
78
+ const logical = this._parseData ? nextDataPath(removeBasePath(super.pathname, this._basePath)) : { pathname: removeBasePath(super.pathname, this._basePath) };
79
+ this._buildId = logical.buildId;
80
+ this._locale = this._nextConfig.i18n ? localePath(logical.pathname, {...this._nextConfig.i18n,defaultLocale:this.defaultLocale}).locale : '';
58
81
  }
59
- clone() { return new NextURL(this.href, { nextConfig: this._nextConfig }); }
82
+ clone() { return new NextURL(this.href, { nextConfig: this._nextConfig, forceLocale: this._forceLocale }); }
60
83
  }
61
84
 
62
85
  class NextRequest extends Request {
63
86
  constructor(input, init) {
64
87
  super(input, init);
65
88
  this.nextUrl = new NextURL(this.url, { nextConfig: init?.nextConfig || input?.nextUrl?._nextConfig });
66
- this.cookies = new CookieStore(this.headers.get('cookie') || '', {
67
- mutable: true,
68
- onChange: () => this.headers.set('cookie', this.cookies.toString()),
69
- });
89
+ this.cookies = new RequestCookieStore(this.headers);
70
90
  }
71
91
  }
72
92
 
@@ -1,7 +1,20 @@
1
1
  import type { CookieStore } from './headers.cjs';
2
2
  export interface NextURLConfig { i18n?: import('./index.js').NextConfig['i18n']; basePath?: string; trailingSlash?: boolean; skipMiddlewareUrlNormalize?: boolean; skipProxyUrlNormalize?: boolean }
3
- export class NextURL extends URL { constructor(input: string | URL, options?: { base?: string | URL; nextConfig?: NextURLConfig }); constructor(input: string | URL, base: string | URL, options?: { nextConfig?: NextURLConfig }); clone(): NextURL; locale: string; readonly defaultLocale?: string; readonly domainLocale?: NonNullable<NonNullable<import('./index.js').NextConfig['i18n']>['domains']>[number]; basePath: string; trailingSlash: boolean }
4
- export class NextRequest extends Request { constructor(input: RequestInfo | URL, init?: RequestInit & { nextConfig?: NextURLConfig }); nextUrl: NextURL; cookies: CookieStore }
3
+ export class NextURL extends URL { constructor(input: string | URL, options?: { base?: string | URL; nextConfig?: NextURLConfig; forceLocale?: boolean }); constructor(input: string | URL, base: string | URL, options?: { nextConfig?: NextURLConfig; forceLocale?: boolean }); clone(): NextURL; buildId: string | undefined; locale: string; readonly defaultLocale?: string; readonly domainLocale?: NonNullable<NonNullable<import('./index.js').NextConfig['i18n']>['domains']>[number]; basePath: string; trailingSlash: boolean }
4
+ export interface RequestCookie { name: string; value: string }
5
+ export interface RequestCookieStore extends Iterable<[string, RequestCookie]> {
6
+ readonly size: number;
7
+ get(name: string | {name: string}): RequestCookie | undefined;
8
+ getAll(name?: string | {name: string}): RequestCookie[];
9
+ has(name: string): boolean;
10
+ set(name: string, value: string): this;
11
+ set(cookie: RequestCookie): this;
12
+ delete(name: string): boolean;
13
+ delete(names: string[]): boolean[];
14
+ clear(): this;
15
+ toString(): string;
16
+ }
17
+ export class NextRequest extends Request { constructor(input: RequestInfo | URL, init?: RequestInit & { nextConfig?: NextURLConfig }); nextUrl: NextURL; cookies: RequestCookieStore }
5
18
  export interface MiddlewareResponseInit extends ResponseInit { request?: { headers?: Headers } }
6
19
  export class NextResponse<Body = unknown> extends Response {
7
20
  cookies: CookieStore;
@@ -27,6 +27,13 @@ function dynamicUsage(reason, request = context()) {
27
27
  function staticBailout(reason, request = context()) {
28
28
  if (diagnostics.enabled) diagnostics.record('dynamic', { route: diagnostics.route(request), reason });
29
29
  const state = request?.staticState;
30
+ if (!state && request?.navigationStage && ['an uncached fetch', 'connection()', 'unstable_noStore()'].includes(reason)
31
+ && !require('./data-cache.cjs').inCacheScope()) require('./navigation-stages.cjs').postponeStage(reason);
32
+ const requirement = request?.cacheConfig?.ensureStatic;
33
+ if (!state && (request?.navigationStage === 'shell' && ['shell', 'prefetch', 'navigation'].includes(requirement)
34
+ || request?.navigationStage === 'prefetch' && ['prefetch', 'navigation'].includes(requirement))) {
35
+ require('./navigation-stages.cjs').postponeStage(reason);
36
+ }
30
37
  if (!state) return true;
31
38
  if (state.partial && state.mode === 'auto') {
32
39
  state.dynamicReasons.add(reason);
@@ -35,6 +35,9 @@ function recordCacheDependency({ tags = [], revalidate } = {}) {
35
35
  }
36
36
  function metadata(scope, context) { return { ...scope.life, revalidate: Math.min(scope.life.revalidate, scope.life.expire), tags: [...scope.tags], paths: getCachePaths(context), ...(scope.externalCache ? { externalCache: true } : {}) }; }
37
37
  function propagate(info, parent, context) {
38
+ if (context?.staticState && context.cacheConfig?.ensureStatic === 'navigation' && (info.expire < 300 || info.stale < 30 || info.revalidate === 0)) {
39
+ dynamicUsage('a cache lifetime with expire below 300 seconds, stale below 30 seconds, or revalidate=0', context);
40
+ }
38
41
  trackStaticDependency(info, context);
39
42
  if (!parent || parent === true) return;
40
43
  if (info.externalCache) parent.externalCache = true;
@@ -18,12 +18,181 @@ transpilation, image/font tooling and caching deserve targeted build and browser
18
18
  tests. Support for one configuration shape does not imply support for all
19
19
  upstream plugin behavior.
20
20
 
21
+ ## Instrumentation and tracing
22
+
23
+ Use a single `instrumentation.ts`, `.js`, `.mjs` or `.cjs` at the project root
24
+ or in `src`. Export an optional async `register()` for runtime initialization
25
+ and an optional async `onRequestError(error, request, context)` for reporting.
26
+ The `Instrumentation.onRequestError` type is exported from PRNext's root API.
27
+ `process.env.NEXT_RUNTIME` distinguishes `nodejs` and `edge`; conditional imports
28
+ keep Node SDKs out of an Edge bundle. Registration runs once per server realm,
29
+ including the separate RSC realm, before application requests. A production
30
+ prerender does not run the runtime startup hook. Request-time ISR and PPR
31
+ regeneration initialize their server realm and report failures through the same
32
+ hooks. Repeated propagation of an RSC error to the HTML worker does not report
33
+ the same error again.
34
+
35
+ ```ts
36
+ import type { Instrumentation } from '@thomas.f/prnext'
37
+
38
+ export async function register() {
39
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
40
+ await import('./observability.node')
41
+ }
42
+ }
43
+
44
+ export const onRequestError: Instrumentation.onRequestError = async (
45
+ error, request, context
46
+ ) => {
47
+ await reportError(error, request, context)
48
+ }
49
+ ```
50
+
51
+ `reportError` above is an application function, not a PRNext API. Initialization
52
+ has a 30-second budget. Each error hook has a five-second budget; reporting
53
+ failure does not replace the application failure. Redirect/not-found control
54
+ signals are excluded. Hooks receive request information: the application owns
55
+ redaction before sending that information to a third party.
56
+
57
+ For built-in correlated traces, explicitly set
58
+ `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` to an OTLP HTTP collector's complete traces
59
+ URL and `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/json`. Alternatively,
60
+ `OTEL_EXPORTER_OTLP_ENDPOINT` supplies a base URL with `/v1/traces` appended.
61
+ `OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_TRACES_HEADERS` (or general
62
+ `OTEL_EXPORTER_OTLP_HEADERS`) and `OTEL_SDK_DISABLED=true` are supported. Keep
63
+ exporter credentials in the server environment. This built-in path supports
64
+ HTTP JSON, not gRPC or HTTP protobuf; use your own SDK for other protocols.
65
+
66
+ Native HTTP spans cover static and dynamic responses through body completion
67
+ or cancellation. Node, RSC and fetch spans share W3C trace context. Export queues
68
+ are bounded and telemetry is best effort; an unavailable collector does not
69
+ block a site response. Built-in span attributes exclude bodies, cookie values,
70
+ query strings and authorization headers. An application-installed
71
+ `@opentelemetry/api` can use the same parent context when its SDK installs a
72
+ context manager in `register`. No SDK is installed automatically, and this does
73
+ not certify every vendor integration.
74
+
75
+ ## Production bytes and navigation
76
+
77
+ Production CSS Modules use deterministic compact class and animation names.
78
+ Global classes and declared composition exports retain their meaning; development
79
+ keeps readable names. Internal JavaScript exports are shortened by the selected
80
+ compiler, while public exports and dynamic imports keep their interfaces. Asset
81
+ hashes and source maps remain compiler-owned.
82
+
83
+ App navigation can negotiate binary differential Flight responses. Every request
84
+ still runs fresh rendering and access checks. The server retains at most 64
85
+ completed bases, 32 MiB in total, with a two-MiB per-base limit and 30-second
86
+ expiry. Bases are keyed by deployment and credentials; an unknown, expired or
87
+ incompatible base produces ordinary Flight. The browser retains one completed
88
+ base, streams reconstructed bytes to React and can retry one failed differential
89
+ navigation as a full GET. Server Actions are never replayed through this path.
90
+ Per worker, at most eight captures and two encoders run together. Additional
91
+ requests use ordinary Flight, keeping transient transport work bounded too.
92
+
21
93
  Configuration uses one `next.config.*` or `prnext.config.*` file and rejects
22
- unknown keys rather than ignoring them. `reactCompiler: false` is accepted;
23
- enabling React Compiler is explicitly unsupported. `typedRoutes: true` is
94
+ unknown keys rather than ignoring them. React Compiler is available through an
95
+ explicit `reactCompiler` option described below. `typedRoutes: true` is
24
96
  implemented through PRNext's generated route registry. Do not copy a full
25
97
  Next.js configuration template into a new application.
26
98
 
99
+ The repository's reference tests build and run matching applications against
100
+ Next.js 16.4.3, including mounted App navigation, form Actions and cookies,
101
+ metadata, Pages locale routing, redirects, APIs and selected webpack loader
102
+ contracts. These executable examples cover specific behavior; they do not
103
+ certify every Next.js application or plugin. `NextRequest.nextUrl` understands
104
+ data-route build IDs and locales, and request-cookie mutations synchronize the
105
+ request header. Request cookies have different mutation results from response
106
+ cookies; do not use them interchangeably.
107
+
108
+ A locked real-application fixture additionally exercises Better Auth 1.7.14
109
+ (email/password, session cookies and logout), Drizzle ORM 0.45.4 over SQLite
110
+ through its proxy driver, next-intl 4.14.9 (server and client translations),
111
+ and the MDX 3.1.1 webpack loader against Next.js 16.4.3. It covers authenticated
112
+ Server Actions, user-owned rows, cookie/locale isolation, client navigation and
113
+ Server Component `after()` callbacks. This is evidence for those workflows,
114
+ not a guarantee for every plugin, provider or deployment configuration.
115
+
116
+ PRNext-specific options such as `prerender` and `htmlOnly` are described in
117
+ [Build and deployment](deployment.md). Do not pass them to an upstream Next.js
118
+ build when comparing the two implementations.
119
+
120
+ ## TypeScript 7 and earlier compilers
121
+
122
+ PRNext uses the application's installed TypeScript. TypeScript 7 runs through its
123
+ official native `tsc` command for production checks, `prnext typecheck` and live
124
+ development diagnostics. Install TypeScript normally, including its platform
125
+ optional dependencies. PRNext does not download a compiler during a build.
126
+ TypeScript 5 and 6 retain their compiler API integration.
127
+
128
+ Checks use `--noEmit` and keep incremental compiler state in an owned temporary
129
+ directory. Development reports updated errors and clears fixed diagnostics;
130
+ new source files and configuration changes restart the native watch generation.
131
+ `typedRoutes` also supports native TypeScript and preserves JSONC comments when
132
+ adding its generated declaration to the config. Compiler support does not imply
133
+ that every application is ready for TypeScript 7's own language/config changes.
134
+
135
+ ## React Compiler
136
+
137
+ Enable the bundled official React Compiler in the project's existing config:
138
+
139
+ ```js
140
+ export default {
141
+ reactCompiler: true,
142
+ };
143
+ ```
144
+
145
+ No separate compiler installation is required. The default is disabled;
146
+ `reactCompiler: false` preserves the uncompiled component path. Enabled builds
147
+ optimize project-owned browser components in both Pages and App applications,
148
+ in development and production. App Client Components also use the transform in
149
+ their isolated server-rendering graph. The Pages server graph stays uncompiled,
150
+ including APIs, custom Document code and data-loader utilities, so ordinary
151
+ server functions never acquire client-only memoization hooks. React Server
152
+ Components, Server Action modules, PRNext internals, generated entrypoints and
153
+ npm packages are not compiled. Keep the matching React 19 peer versions required
154
+ by the installed framework.
155
+
156
+ Ordinary `.mts`/`.cts` hook helpers can be optimized; server/client directives
157
+ in those extensions remain unsupported. Decorated `.mts`/`.cts` utility modules
158
+ keep their native compilation path while importing React components can still
159
+ be optimized.
160
+
161
+ For gradual adoption, use `reactCompiler: { compilationMode: 'annotation' }`, then
162
+ put `"use memo";` at the beginning of each component or hook to optimize. In
163
+ either mode, `"use no memo";` excludes an individual function. Supported object
164
+ options are `compilationMode: 'infer' | 'annotation'`,
165
+ `panicThreshold: 'none' | 'critical_errors' | 'all_errors'` and `target: '19'`.
166
+ Defaults are `'infer'`, `'none'` and `'19'`; other fields are rejected.
167
+
168
+ After a successful build, inspect the compiler's actual results:
169
+
170
+ ```sh
171
+ prnext compiler
172
+ prnext compiler --json
173
+ ```
174
+
175
+ The report names observed functions, their project-relative files, optimization
176
+ or skip status, and the compiler's reasons. These are transform decisions;
177
+ server-code removal and tree shaking can later omit a function from the browser
178
+ bundle. Cached transforms retain their
179
+ diagnostics and are identified as reused. Browser and server graphs do not
180
+ inflate the function count. An incremental build can reuse graph inputs without
181
+ observing every transform; the report states that scope rather than claiming
182
+ complete application coverage. Disabled compilation is reported explicitly.
183
+ The report describes the last successful build, stays outside public assets and
184
+ does not include source code. A failed build preserves the previous report.
185
+
186
+ The compiler can skip code that cannot be safely optimized. Follow the Rules of
187
+ React and check actual behavior rather than assuming every component was
188
+ compiled. It reduces repeated component work; it does not guarantee faster
189
+ server responses, smaller JavaScript or a specific speed multiplier. Validate
190
+ hydration, interactions and Fast Refresh in the application, then compare a
191
+ production build with the option enabled and disabled. Compilation adds build
192
+ work; PRNext caches pure transforms with their compiler version and settings.
193
+
194
+ See the [official React Compiler documentation](https://react.dev/learn/react-compiler).
195
+
27
196
  Only explicit private Next.js import adapters are supported. Do not add arbitrary
28
197
  `next/dist/...` imports or install another Next runtime to satisfy one: isolate
29
198
  or replace the incompatible dependency, retain the original framework where