@thomas.f/prnext 0.1.11-alpha → 0.1.13-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 (60) hide show
  1. package/README.md +395 -29
  2. package/build/actions.mjs +4 -0
  3. package/build/app-config.mjs +2 -0
  4. package/build/app.mjs +20 -5
  5. package/build/bundle-runtime.mjs +82 -0
  6. package/build/cache-handlers.mjs +4 -1
  7. package/build/compiler.mjs +4 -1
  8. package/build/config.mjs +20 -2
  9. package/build/font-cache.mjs +50 -0
  10. package/build/font-files.mjs +5 -3
  11. package/build/fonts.mjs +1 -1
  12. package/build/index.mjs +12 -1
  13. package/build/metadata-conflicts.mjs +13 -0
  14. package/build/metadata-images.mjs +3 -3
  15. package/build/metadata-routes.mjs +3 -3
  16. package/build/native-packages.mjs +5 -1
  17. package/build/npm-packages.mjs +14 -2
  18. package/build/optimize-imports.mjs +112 -0
  19. package/build/proxy-body-config.mjs +10 -0
  20. package/build/runtime-contract.mjs +16 -0
  21. package/build/scan.mjs +9 -7
  22. package/build/typescript-check.mjs +20 -0
  23. package/build/typescript.mjs +49 -0
  24. package/build/worker-retirement.mjs +14 -0
  25. package/cli.mjs +17 -0
  26. package/compat/after.cjs +51 -0
  27. package/compat/index.d.ts +13 -1
  28. package/compat/server.cjs +2 -1
  29. package/compat/server.d.cts +2 -0
  30. package/native/launch.mjs +19 -0
  31. package/package.json +25 -9
  32. package/release/verify.mjs +9 -0
  33. package/runtime/api.mjs +15 -5
  34. package/runtime/app-render.mjs +164 -53
  35. package/runtime/app-route-plan.mjs +45 -0
  36. package/runtime/app-routing-state.mjs +101 -21
  37. package/runtime/app-routing.mjs +37 -18
  38. package/runtime/app-segments.mjs +25 -11
  39. package/runtime/app-static.mjs +1 -1
  40. package/runtime/app-stream-html.mjs +3 -53
  41. package/runtime/cache-request.mjs +10 -2
  42. package/runtime/cloud/archive.mjs +316 -0
  43. package/runtime/cloud/client.mjs +658 -0
  44. package/runtime/cloud/environment.mjs +60 -0
  45. package/runtime/cloud/state.mjs +141 -0
  46. package/runtime/cloud/upload.mjs +48 -0
  47. package/runtime/html-envelope.mjs +136 -0
  48. package/runtime/http.mjs +9 -6
  49. package/runtime/middleware.mjs +3 -3
  50. package/runtime/profiles.json +13 -8
  51. package/runtime/request-transport.mjs +1 -1
  52. package/runtime/request-work.mjs +2 -2
  53. package/runtime/route-static.mjs +1 -1
  54. package/runtime/rsc-lifecycle.mjs +119 -0
  55. package/runtime/rsc-work-watchdog.mjs +75 -0
  56. package/runtime/rsc-worker.mjs +36 -9
  57. package/runtime/stream-utils.mjs +22 -3
  58. package/runtime/transport.mjs +89 -53
  59. package/runtime/worker-metadata.mjs +23 -0
  60. package/runtime/worker.mjs +119 -27
package/build/app.mjs CHANGED
@@ -118,7 +118,7 @@ function boundaryEntry(boundary) {
118
118
  return `import * as client from ${JSON.stringify(boundary.file)};\n` + boundary.exports.map((name, index) => `const ref${index}=client[${JSON.stringify(name)}];export {ref${index} as ${JSON.stringify(name)}};`).join('\n');
119
119
  }
120
120
 
121
- function routeEntry(route, globalError) {
121
+ function routeEntry(route, globalError, dev) {
122
122
  if (route.kind === 'api') return `export * from ${JSON.stringify(route.file)};`;
123
123
  const imports = [`import * as page from ${JSON.stringify(route.file)};`, 'export {page};', `export const pageConfig=${JSON.stringify(route.pageConfig || {})};`];
124
124
  const rootLayout = route.segments.find(segment => segment.layout)?.layout;
@@ -144,6 +144,9 @@ function routeEntry(route, globalError) {
144
144
  if (route.routing || route.segments.some(segment => segment.error)) imports.push(`export {default as ErrorBoundary} from ${JSON.stringify(path.join(packageRoot, 'compat/app-error-boundary.cjs'))};`);
145
145
  if (route.routing) {
146
146
  let index = 0;
147
+ // Only scaffold objects owned by this compiler are immutable. Imported
148
+ // modules and config values retain their original live bindings/behavior.
149
+ const structural = source => dev ? source : `Object.freeze(${source})`;
147
150
  function serialize(value) {
148
151
  const files = [];
149
152
  for (const [name, file] of Object.entries(value.files)) {
@@ -151,7 +154,7 @@ function routeEntry(route, globalError) {
151
154
  imports.push(`import * as ${binding} from ${JSON.stringify(file)};`);
152
155
  files.push(`${JSON.stringify(name)}:${binding}`);
153
156
  }
154
- return `{path:${JSON.stringify(value.path)},segment:${JSON.stringify(value.segment)},pattern:${JSON.stringify(value.pattern)},layoutId:${JSON.stringify(value.layoutId)},config:${JSON.stringify(value.config || {})},interception:${JSON.stringify(value.interception || null)},files:{${files.join(',')}},children:[${value.children.map(serialize).join(',')}],slots:{${Object.entries(value.slots).map(([name, child]) => `${JSON.stringify(name)}:${serialize(child)}`).join(',')}}}`;
157
+ return structural(`{path:${JSON.stringify(value.path)},segment:${JSON.stringify(value.segment)},pattern:${JSON.stringify(value.pattern)},layoutId:${JSON.stringify(value.layoutId)},config:${JSON.stringify(value.config || {})},interception:${JSON.stringify(value.interception || null)},files:${structural(`{${files.join(',')}}`)},children:${structural(`[${value.children.map(serialize).join(',')}]`)},slots:${structural(`{${Object.entries(value.slots).map(([name, child]) => `${JSON.stringify(name)}:${serialize(child)}`).join(',')}}`)}}`);
155
158
  }
156
159
  const routing = serialize(route.routing);
157
160
  imports.push(`export const routing=${routing};export const interceptionOnly=${!!route.interceptionOnly};`);
@@ -263,8 +266,17 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
263
266
  return { path: args.path, namespace: 'prnext-app-empty' };
264
267
  });
265
268
  esbuild.onLoad({ filter: /.*/, namespace: 'prnext-app-empty' }, () => ({ contents: '', loader: 'js' }));
269
+ if (rsc) esbuild.onLoad({ filter: /^react$/, namespace: 'prnext-rsc-react' }, () => ({
270
+ // Keep the real singleton while preserving bundler-style CJS access.
271
+ // Mixed npm modules may export both server-safe components and unused
272
+ // hook-based components. Native ESM named imports would reject the
273
+ // entire module because React's server build has no client hooks.
274
+ contents: `import React from 'react';export default React;export const {${Object.keys(resolveFromProject('react')).filter(name => t.isValidIdentifier(name) && name !== 'default').join(',')}}=React;`,
275
+ loader: 'js',
276
+ }));
266
277
  esbuild.onResolve({ filter: /^(?:react|react-dom|react-server-dom-webpack)(?:\/|$)/ }, args => {
267
278
  if (args.pluginData?.prnextExportScan) return;
279
+ if (rsc && args.path === 'react' && args.namespace !== 'prnext-rsc-react') return { path: 'react', namespace: 'prnext-rsc-react' };
268
280
  return browser ? { path: resolveFromProject.resolve(args.path) } : { path: args.path, external: true };
269
281
  });
270
282
  if (!browser) {
@@ -446,14 +458,14 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
446
458
  }
447
459
 
448
460
  await mkdir(path.join(stage, 'assets'), { recursive: true });
449
- const entries = {};
461
+ const entries = {}, handlerEntries = {};
450
462
  for (const route of routes) {
451
463
  const entry = path.join(stage, '.entries', route.id + '.server.mjs');
452
464
  if ((route.handlerConfig?.runtime === 'edge' || route.cacheConfig?.runtime === 'edge') && manifest.config?.cacheComponents) throw new Error(`Cache Components cannot run in the Edge Runtime (${route.file}).`);
453
- await writeFile(entry, routeEntry(route, globalError));
465
+ await writeFile(entry, routeEntry(route, globalError, dev));
454
466
  if (route.cacheConfig?.runtime === 'edge' && route.kind === 'page') await edgeAppBuild(entry, path.join(stage, 'server', route.id + '.mjs'), 'rsc');
455
467
  else if (route.handlerConfig?.runtime === 'edge') await compileEdge({ file: route.file, outfile: path.join(stage, 'server', route.id + '.mjs'), projectRoot: project.root, dev, defineEnvironment });
456
- else entries[route.id] = entry;
468
+ else (route.kind === 'api' ? handlerEntries : entries)[route.id] = entry;
457
469
  const instantConfigs = [route.pageConfig, ...(route.segments || []).map(segment=>segment.staticConfig)];
458
470
  const collectInstant = node => {if(!node)return;instantConfigs.push(...Object.values(node.config || {}));for(const child of [...(node.children || []),...Object.values(node.slots || {})])collectInstant(child);};
459
471
  collectInstant(route.routing);
@@ -466,6 +478,9 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
466
478
  }
467
479
  manifest.app = { clientModules: {}, actions: {}, actionKey: actions.actionKey };
468
480
  const actionRevision = () => [...actions.actions.values()].map(action=>action.id+':'+[...(action.runtimes || [])].sort().join(',')).join(';');
481
+ // Route Handlers have a Node React environment and must not share RSC chunks
482
+ // that import registerServerReference or client-reference registration.
483
+ await serverBuild(handlerEntries, 'server', 'route');
469
484
  for (;;) {
470
485
  const revision = `${actionRevision()}:${boundaries.size}:${cssFiles.size}:${globalErrorCssFiles.size}`;
471
486
  for (const action of actions.actions.values()) {
@@ -0,0 +1,82 @@
1
+ import { build } from 'esbuild';
2
+ import { lstat, realpath, readdir, writeFile, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ // Paths used by the native worker, generated application modules, prerender,
6
+ // standalone tracing, compatibility modules and the runtime lifecycle contract.
7
+ // Other relative imports are resolved into shared chunks by this single build.
8
+ export const runtimeEntryNames = Object.freeze([
9
+ 'worker', 'rsc-worker', 'app-render', 'app-stream-html', 'transport', 'request-work',
10
+ 'http', 'app-static', 'route-static', 'render', 'api', 'middleware', 'incremental-cache', 'app-instant',
11
+ 'action-crypto', 'action-client', 'action-ssr', 'edge', 'metadata-route', 'og-loader',
12
+ ].sort());
13
+
14
+ /** Run once, after compiling application/Edge graphs and before any prerender. */
15
+ export async function bundleRuntime(stage) {
16
+ const resolvedStage = path.resolve(stage);
17
+ if (!path.basename(resolvedStage).startsWith('.prnext-build-')) {
18
+ throw new Error('Runtime bundling requires an unpublished PRNext build stage.');
19
+ }
20
+ const runtime = path.join(resolvedStage, 'runtime');
21
+ const [stageInfo, runtimeInfo] = await Promise.all([lstat(resolvedStage), lstat(runtime)]);
22
+ if (!stageInfo.isDirectory() || stageInfo.isSymbolicLink() || !runtimeInfo.isDirectory() || runtimeInfo.isSymbolicLink()) {
23
+ throw new Error('Runtime bundling requires ordinary stage and runtime directories.');
24
+ }
25
+ const [canonicalStage, canonicalRuntime] = await Promise.all([realpath(resolvedStage), realpath(runtime)]);
26
+ if (path.relative(canonicalStage, canonicalRuntime) !== 'runtime') {
27
+ throw new Error('Runtime bundling escaped the build stage.');
28
+ }
29
+ const entries = await readdir(runtime, { withFileTypes: true });
30
+ const originals = entries.filter(entry => entry.name.endsWith('.mjs'));
31
+ if (originals.some(entry => !entry.isFile() || entry.isSymbolicLink())) {
32
+ throw new Error('Runtime source modules must be ordinary files.');
33
+ }
34
+ for (const name of runtimeEntryNames) {
35
+ if (!originals.some(entry => entry.name === `${name}.mjs`)) throw new Error(`Missing runtime entry ${name}.mjs`);
36
+ }
37
+ // write:false makes compilation atomic with respect to the source runtime:
38
+ // no files change if parsing or resolution fails. The virtual output is a
39
+ // sibling so all relative paths have the same depth as the final runtime.
40
+ const virtualOutput = path.join(resolvedStage, '.runtime-bundle');
41
+ const result = await build({
42
+ absWorkingDir: runtime,
43
+ entryPoints: runtimeEntryNames.map(name => path.join(runtime, `${name}.mjs`)),
44
+ outdir: virtualOutput,
45
+ outbase: runtime,
46
+ entryNames: '[name]',
47
+ chunkNames: '_shared-[hash]',
48
+ outExtension: { '.js': '.mjs' },
49
+ bundle: true,
50
+ splitting: true,
51
+ format: 'esm',
52
+ platform: 'node',
53
+ target: 'node22',
54
+ packages: 'external',
55
+ // Edge's prepared VM is already a self-contained CJS artifact. Keeping it
56
+ // external preserves its delayed import and original execution boundary.
57
+ external: ['../compat/*', './profiles.json', './*.cjs'],
58
+ minify: false,
59
+ sourcemap: false,
60
+ metafile: true,
61
+ write: false,
62
+ logLevel: 'silent',
63
+ });
64
+ const emitted = new Map();
65
+ for (const file of result.outputFiles) {
66
+ if (path.dirname(file.path) !== virtualOutput || !file.path.endsWith('.mjs')) {
67
+ throw new Error('Runtime bundler emitted a file outside its flat output directory.');
68
+ }
69
+ emitted.set(path.basename(file.path), file.contents);
70
+ }
71
+ if (runtimeEntryNames.some(name => !emitted.has(`${name}.mjs`))) throw new Error('Runtime bundle omitted a required entry.');
72
+ // This is an unpublished, disposable stage. On any write failure buildProject
73
+ // discards the stage and preserves the previous published build unchanged.
74
+ for (const [name, contents] of emitted) await writeFile(path.join(runtime, name), contents);
75
+ for (const entry of originals) {
76
+ if (!emitted.has(entry.name)) await unlink(path.join(runtime, entry.name));
77
+ }
78
+ // JSON, CJS, license files and the OG assets directory are never traversed or
79
+ // removed. The caller writes the normal runtime contract after prerender.
80
+ return { entryCount: runtimeEntryNames.length, moduleCount: emitted.size,
81
+ bytes: [...emitted.values()].reduce((sum, contents) => sum + contents.length, 0) };
82
+ }
@@ -2,6 +2,7 @@ import { build } from 'esbuild';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
4
  import {frameworkImportName} from './framework-imports.mjs';
5
+ import { projectTsconfig } from './typescript.mjs';
5
6
 
6
7
  export function validateCacheHandler(input) {
7
8
  if (input === undefined) return undefined;
@@ -11,7 +12,7 @@ export function validateCacheHandler(input) {
11
12
 
12
13
  export async function prepareCacheHandler(options) {
13
14
  if (!options.config.cacheHandler) return undefined;
14
- const handlers = await prepareCacheHandlers({ ...options, config: { cacheHandlers: { __legacy: options.config.cacheHandler } } });
15
+ const handlers = await prepareCacheHandlers({ ...options, config: { ...options.config, cacheHandlers: { __legacy: options.config.cacheHandler } } });
15
16
  return handlers.__legacy;
16
17
  }
17
18
 
@@ -33,6 +34,7 @@ export function validateCacheHandlers(input) {
33
34
  export async function prepareCacheHandlers({ config, projectRoot, stage, environment = {} }) {
34
35
  const result = {};
35
36
  const compiled = new Map();
37
+ const tsconfig = config.typescript ? await projectTsconfig(projectRoot, config) : undefined;
36
38
  const require = createRequire(path.join(projectRoot, 'package.json'));
37
39
  for (const [name, source] of Object.entries(config.cacheHandlers || {})) {
38
40
  let entry;
@@ -41,6 +43,7 @@ export async function prepareCacheHandlers({ config, projectRoot, stage, environ
41
43
  if (compiled.has(entry)) { result[name] = compiled.get(entry); continue; }
42
44
  const output = `server/cache-handler-${name}.mjs`;
43
45
  await build({ absWorkingDir: projectRoot, entryPoints: [entry], outfile: path.join(stage, output), bundle: true,
46
+ ...(tsconfig ? { tsconfig } : {}),
44
47
  platform: 'node', target: 'node22', format: 'esm', packages: 'external', logLevel: 'silent', define: environment,
45
48
  banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" },
46
49
  plugins: [{ name: 'cache-handler-compatibility', setup(builder) {
@@ -12,6 +12,7 @@ import { incrementalBuild } from './incremental.mjs';
12
12
  import { webpackGraph } from './webpack-graph.mjs';
13
13
  import { turbopackRules, sourceReader } from './turbopack-conditions.mjs';
14
14
  import {withSourceMaps,configureSourceMaps,inlineSourceMap} from './source-maps.mjs';
15
+ import { optimizeImportsPlugins } from './optimize-imports.mjs';
15
16
 
16
17
  let validateOptions;
17
18
  const compilation = new AsyncLocalStorage();
@@ -20,7 +21,7 @@ export function configureCompiler(options) { Object.assign(compilation.getStore(
20
21
  export async function compilerSource(file, fallback) {
21
22
  return compilation.getStore()?.loaded.get(file)?.source ?? fallback ?? await readFile(file, 'utf8');
22
23
  }
23
- const framework = /^(?:prnext-pages-compatibility|prnext-app-(?:rsc|ssr|browser)|prnext-edge-app|prnext-edge|prnext-middleware-compatibility)$/;
24
+ const framework = /^(?:prnext-pages-compatibility|prnext-app-(?:rsc|ssr|browser|route)|prnext-edge-app|prnext-edge|prnext-middleware-compatibility)$/;
24
25
  function condition(value, input) {
25
26
  if (value === undefined) return true;
26
27
  if (value instanceof RegExp) { value.lastIndex = 0; return value.test(input); }
@@ -120,6 +121,7 @@ export async function validateCompilerConfig(config, root, {dev=false,edge=false
120
121
  }
121
122
  export async function build(options) {
122
123
  const state = compilation.getStore();
124
+ if ((options.plugins || []).some(plugin => framework.test(plugin.name))) options = { ...options, plugins: optimizeImportsPlugins(options.plugins, state?.config?.experimental?.optimizePackageImports) };
123
125
  // esbuild's shared service dispatches callbacks from its own async context.
124
126
  // Re-enter this build so transforms never inherit another project's map mode,
125
127
  // action registry or configuration from the first service invocation.
@@ -134,6 +136,7 @@ export async function build(options) {
134
136
  }}))};
135
137
  // Runtime vendor bundles are framework-owned, not project webpack targets.
136
138
  const projectGraph = (options.plugins || []).some(plugin => framework.test(plugin.name));
139
+ if (projectGraph && state?.tsconfig) options = { ...options, tsconfig: state.tsconfig };
137
140
  const compile = input => state?.incremental && state.dev && (projectGraph || !input.plugins?.length) ? incrementalBuild(input,{root:state.root,stage:state.stage,group:projectGraph?'project':'auxiliary',run:callback=>compilation.run(state,callback)}) : esbuild(input);
138
141
  if (!(state?.config?.webpack || Object.keys(state?.config?.turbopack?.rules || {}).length) || !projectGraph) return compile(options);
139
142
  const target = options.define?.['process.env.NEXT_RUNTIME'] === '"edge"' ? 'edge' : options.platform === 'browser' ? 'browser' : 'node';
package/build/config.mjs CHANGED
@@ -15,17 +15,24 @@ import { validateStandaloneConfig } from './standalone-config.mjs';
15
15
  import { validateTurbopack } from './module-resolution.mjs';
16
16
  import { validateI18n } from './i18n.mjs';
17
17
  import { frameworkPackages } from './framework-imports.mjs';
18
+ import { validateTypeScript } from './typescript.mjs';
19
+ import { validateWorkerRetirement } from './worker-retirement.mjs';
20
+ import { validateOptimizePackageImports } from './optimize-imports.mjs';
21
+ import { validateProxyBodySize } from './proxy-body-config.mjs';
18
22
 
19
23
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
24
  const configName = /^(?:next|prnext)\.config\.(?:js|mjs|cjs|ts)$/;
21
25
  const supported = new Set(['env', 'redirects', 'headers', 'rewrites', 'generateBuildId', 'compress', 'poweredByHeader', 'productionBrowserSourceMaps', 'basePath', 'assetPrefix', 'experimental', 'sassOptions', 'transpilePackages', 'serverExternalPackages', 'cacheComponents', 'cacheLife', 'images', 'reactStrictMode', 'pageExtensions', 'trailingSlash', 'skipTrailingSlashRedirect', 'skipMiddlewareUrlNormalize', 'skipProxyUrlNormalize', 'cacheHandlers', 'distDir']);
22
26
  const defaults = { distDir: '.prnext', env: {}, compress: true, poweredByHeader: true, productionBrowserSourceMaps: false, basePath: '', assetPrefix: '', trailingSlash: false, skipTrailingSlashRedirect: false, skipMiddlewareUrlNormalize: false, experimental: { nextScriptWorkers: false } };
23
27
  supported.add('cacheHandler');
28
+ supported.add('workerRetirement');
24
29
  supported.add('cacheMaxMemorySize');
25
30
  supported.add('turbopack');
26
31
  supported.add('i18n');
27
32
  supported.add('webpack');
28
33
  supported.add('onDemandEntries');
34
+ supported.add('devIndicators');
35
+ for (const key of ['reactCompiler', 'typedRoutes', 'typescript']) supported.add(key);
29
36
  for (const key of ['output', 'outputFileTracingRoot', 'outputFileTracingIncludes', 'outputFileTracingExcludes']) supported.add(key);
30
37
 
31
38
  function validPrefixPath(value, where, { root = false } = {}) {
@@ -78,6 +85,14 @@ export function validateProjectConfig(input, filename = 'project config') {
78
85
  // Optional properties explicitly set to undefined still use their defaults,
79
86
  // as in common process.env-based Next configuration wrappers.
80
87
  const config = { ...defaults, ...Object.fromEntries(Object.entries(input).filter(([,value]) => value !== undefined)) };
88
+ for (const key of ['reactCompiler', 'typedRoutes']) {
89
+ if (config[key] === undefined || config[key] === false) continue;
90
+ if (key === 'reactCompiler') throw new Error('reactCompiler currently supports only false. PRNext does not run the React Compiler transform; keep Next.js for builds that require it.');
91
+ throw new Error('typedRoutes currently supports only false. PRNext does not generate or validate typed route declarations; keep Next.js for builds that require them.');
92
+ }
93
+ config.typescript = validateTypeScript(config.typescript);
94
+ config.workerRetirement = validateWorkerRetirement(config.workerRetirement);
95
+ if (config.devIndicators !== undefined && config.devIndicators !== false) throw new TypeError('devIndicators currently supports only false. PRNext provides development errors and Fast Refresh, but no Next.js development indicator menu.');
81
96
  if (config.onDemandEntries !== undefined) {
82
97
  const entries = config.onDemandEntries;
83
98
  if (!entries || typeof entries !== 'object' || Array.isArray(entries)) throw new TypeError('onDemandEntries must be an object');
@@ -106,14 +121,17 @@ export function validateProjectConfig(input, filename = 'project config') {
106
121
  config[key] = [...new Set(names)];
107
122
  }
108
123
  for (const name of config.transpilePackages) if (config.serverExternalPackages.includes(name)) throw new Error(`${filename}: ${name} cannot be in both transpilePackages and serverExternalPackages.`);
124
+ // Like Next, retain pg's Node module boundary: its optional pg-native getter
125
+ // must resolve lazily, not be traversed by the RSC/server bundler at build.
126
+ if (!config.transpilePackages.includes('pg') && !config.serverExternalPackages.includes('pg')) config.serverExternalPackages.push('pg');
109
127
  if (config.sassOptions !== undefined && (!config.sassOptions || typeof config.sassOptions !== 'object' || Array.isArray(config.sassOptions))) throw new Error(`${filename}: sassOptions must be an object.`);
110
128
  if (!config.experimental || typeof config.experimental !== 'object' || Array.isArray(config.experimental)) throw new Error(`${filename}: experimental must be an object.`);
111
129
  for (const key of Object.keys(config.experimental)) {
112
- if (!['nextScriptWorkers', 'serverActions'].includes(key)) throw new Error(`${filename}: experimental.${key} is not supported by PRNext. It cannot be silently ignored.`);
130
+ if (!['nextScriptWorkers', 'serverActions', 'optimizePackageImports', 'proxyClientMaxBodySize'].includes(key)) throw new Error(`${filename}: experimental.${key} is not supported by PRNext. It cannot be silently ignored.`);
113
131
  }
114
132
  const nextScriptWorkers = config.experimental.nextScriptWorkers === undefined ? false : config.experimental.nextScriptWorkers;
115
133
  if (typeof nextScriptWorkers !== 'boolean') throw new Error(`${filename}: experimental.nextScriptWorkers must be a boolean.`);
116
- config.experimental = { nextScriptWorkers, serverActions: validateServerActions(config.experimental.serverActions) };
134
+ config.experimental = { nextScriptWorkers, serverActions: validateServerActions(config.experimental.serverActions), optimizePackageImports: validateOptimizePackageImports(config.experimental.optimizePackageImports), proxyClientMaxBodySize: validateProxyBodySize(config.experimental.proxyClientMaxBodySize) };
117
135
  for (const key of ['basePath', 'assetPrefix']) {
118
136
  if (typeof config[key] !== 'string' || Buffer.byteLength(config[key]) > 4096) throw new Error(`${filename}: ${key} must be a string of at most 4096 bytes.`);
119
137
  }
@@ -0,0 +1,50 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile, rename, rm, readdir, lstat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const hash = value => createHash('sha256').update(value).digest('hex');
6
+ const MAX_TOTAL = 64 * 1024 * 1024;
7
+ let writes = Promise.resolve();
8
+
9
+ /** Project-local build inputs, never responses/user data. Clearing this directory
10
+ * refreshes Google assets. A warm cache also permits an offline rebuild.
11
+ */
12
+ export async function cachedFontResource(root, url, maximum, accept, download) {
13
+ if (!root) return download();
14
+ const directory = path.join(root, '.prnext-cache', 'fonts-v1');
15
+ const file = path.join(directory, hash(JSON.stringify([url, accept])) + '.bin');
16
+ try {
17
+ const metadata = await lstat(file);
18
+ if (metadata.isFile() && metadata.size > 65 && metadata.size <= maximum + 65) {
19
+ const stored = await readFile(file);
20
+ const bytes = stored.subarray(65);
21
+ if (stored[64] === 10 && hash(bytes) === stored.toString('ascii', 0, 64)) return bytes;
22
+ }
23
+ } catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error; }
24
+ const bytes = await download();
25
+ if (!bytes.length || bytes.length > maximum) throw new Error('Font resource exceeds its cache input limit');
26
+ const task = writes.catch(() => {}).then(async () => {
27
+ await mkdir(directory, { recursive: true });
28
+ const temporary = file + '.' + randomUUID() + '.tmp';
29
+ try {
30
+ await writeFile(temporary, Buffer.concat([Buffer.from(hash(bytes) + '\n'), bytes]), { flag: 'wx' });
31
+ await rename(temporary, file);
32
+ } finally { await rm(temporary, { force: true }); }
33
+ const entries = [];
34
+ for (const item of await readdir(directory, { withFileTypes: true })) {
35
+ if (!item.isFile() || !/^[a-f0-9]{64}\.bin$/.test(item.name)) continue;
36
+ try { entries.push({ file: path.join(directory, item.name), ...await lstat(path.join(directory, item.name)) }); }
37
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
38
+ }
39
+ entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
40
+ let total = 0;
41
+ for (let index = 0; index < entries.length; index++) {
42
+ total += entries[index].size;
43
+ if (index >= 256 || total > MAX_TOTAL) await rm(entries[index].file, { force: true });
44
+ }
45
+ });
46
+ writes = task;
47
+ try { await task; }
48
+ catch (error) { if (!['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'ENOENT'].includes(error.code)) throw error; }
49
+ return bytes;
50
+ }
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import googleFonts from './font-data.json' with { type: 'json' };
5
+ import { cachedFontResource } from './font-cache.mjs';
5
6
 
6
7
  const formats = { woff: 'woff', woff2: 'woff2', ttf: 'truetype', otf: 'opentype', eot: 'embedded-opentype' };
7
8
  const types = { woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject' };
@@ -36,7 +37,7 @@ function validate(kind, options) {
36
37
  }
37
38
 
38
39
  async function responseBytes(url, maximum, accept) {
39
- const response = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { accept,
40
+ const response = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: 'error', headers: { accept,
40
41
  'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' } });
41
42
  if (!response.ok) { await response.body?.cancel(); fail(`Unable to download ${url}: HTTP ${response.status}.`); }
42
43
  if (Number(response.headers.get('content-length')) > maximum) { await response.body?.cancel(); fail(`Font resource exceeds ${maximum} bytes.`); }
@@ -145,7 +146,8 @@ function googleOptions(name, options) {
145
146
 
146
147
  async function google(name, options, context) {
147
148
  const normalized = googleOptions(name, options);
148
- const css = (await responseBytes(normalized.url, 1024 * 1024, 'text/css')).toString('utf8');
149
+ const resourceBytes = (url, maximum, accept) => cachedFontResource(context.projectRoot, url, maximum, accept, () => responseBytes(url, maximum, accept).catch(error => { throw new Error(`next/font: download failed for ${url}: ${error.message}. An initial build needs access to Google Fonts; subsequent builds can reuse .prnext-cache/fonts-v1.`, { cause: error }); }));
150
+ const css = (await resourceBytes(normalized.url, 1024 * 1024, 'text/css')).toString('utf8');
149
151
  const faces = [], downloaded = new Map();
150
152
  let total = 0;
151
153
  const facePattern = /(?:\/\*\s*([^*]+?)\s*\*\/\s*)?@font-face\s*\{([^}]+)\}/g;
@@ -161,7 +163,7 @@ async function google(name, options, context) {
161
163
  if (url.protocol !== 'https:' || url.hostname !== 'fonts.gstatic.com' || url.username || url.password) fail('Google font CSS referenced an unexpected font origin.');
162
164
  if (!downloaded.has(url.href)) {
163
165
  if (downloaded.size >= 64) fail('Google font call exceeds 64 files.');
164
- const bytes = await responseBytes(url.href, MAX_FONT, 'font/woff2,font/woff,*/*');
166
+ const bytes = await resourceBytes(url.href, MAX_FONT, 'font/woff2,font/woff,*/*');
165
167
  total += bytes.length;
166
168
  if (total > 64 * 1024 * 1024) fail('Google font call exceeds 64 MiB.');
167
169
  const ext = path.extname(url.pathname).slice(1).toLowerCase();
package/build/fonts.mjs CHANGED
@@ -37,7 +37,7 @@ export function createFonts({ projectRoot, stage, assetBase }) {
37
37
  async function prepare(kind, name, options, file, position) {
38
38
  const id = hash(JSON.stringify([path.relative(projectRoot, file), position, kind, name, options]));
39
39
  if (!compilations.has(id)) compilations.set(id, (async () => {
40
- const compiled = await compileFont({ kind, name, options, file, id, stage, assetBase });
40
+ const compiled = await compileFont({ kind, name, options, file, id, stage, assetBase, projectRoot });
41
41
  const moduleFile = path.join(stage, '.entries', `font-${id}.mjs`);
42
42
  const cssFile = path.join(stage, '.entries', `font-${id}.css`);
43
43
  await mkdir(path.dirname(moduleFile), { recursive: true });
package/build/index.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { rename } from '../runtime/fs.mjs';
2
+ import { writeRuntimeContract } from './runtime-contract.mjs';
3
+ import { bundleRuntime } from './bundle-runtime.mjs';
2
4
  import {frameworkImportName, frameworkRuntimeImportPattern} from './framework-imports.mjs';
3
5
  import { expandLocales, localizedStaticPaths } from './i18n.mjs';
4
6
  import { build as bundle, withCompilation, configureCompiler, compilerSource } from './compiler.mjs';
@@ -29,6 +31,7 @@ import { npmPackagesPlugin } from './npm-packages.mjs';
29
31
  import { createRefreshTransform, devSingletonsPlugin, devClientFile } from './dev.mjs';
30
32
  import { snapshotEnvConfig } from '../runtime/env.mjs';
31
33
  import { validateStaticPaths } from '../runtime/pages-paths.mjs';
34
+ import { projectTsconfig, checkTypeScript } from './typescript.mjs';
32
35
  export { staticPath } from '../runtime/pages-paths.mjs';
33
36
 
34
37
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -197,6 +200,7 @@ export function build(projectRoot, options = {}) {
197
200
  async function buildProject(projectRoot, { dev = false, envMode, incremental = false, devInputs } = {}) {
198
201
  const started = performance.now();
199
202
  const config = await loadProjectConfig(projectRoot, { dev, envMode });
203
+ const tsconfig = config.typescript ? await projectTsconfig(projectRoot, config) : undefined;
200
204
  const environment = defineEnvironment(config);
201
205
  const publicPaths = { trailingSlash: config.trailingSlash, skipTrailingSlashRedirect: config.skipTrailingSlashRedirect, basePath: config.basePath, assetPrefix: config.assetPrefix, assetBase: publicAssetBase(config) };
202
206
  const customRoutes = await compileCustomRoutes(config);
@@ -219,7 +223,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
219
223
  const refresh = dev ? createRefreshTransform(project.root) : undefined;
220
224
  const fonts = createFonts({ projectRoot: project.root, stage, assetBase: publicPaths.assetBase });
221
225
  const cssModules = createCssModules({ projectRoot: project.root, stage, assetBase: publicPaths.assetBase, sassOptions: config.sassOptions });
222
- configureCompiler({config,root:project.root,stage,buildId,dev,incremental,devInputs,assetBase:publicPaths.assetBase});
226
+ configureCompiler({config,root:project.root,stage,buildId,dev,incremental,devInputs,tsconfig,assetBase:publicPaths.assetBase});
223
227
  try {
224
228
  await mkdir(path.join(stage, 'runtime'), { recursive: true });
225
229
  await mkdir(path.join(stage, 'compat'), { recursive: true });
@@ -235,6 +239,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
235
239
  }
236
240
  await prepareImageResponse(stage);
237
241
  manifest.config.serverActions = config.experimental.serverActions;
242
+ if (config.workerRetirement) manifest.config.workerRetirement = config.workerRetirement;
238
243
  const cacheHandlers = await prepareCacheHandlers({ config, projectRoot: project.root, stage, environment });
239
244
  if (Object.keys(cacheHandlers).length) manifest.config.cacheHandlers = cacheHandlers;
240
245
  const cacheHandler = await prepareCacheHandler({ config, projectRoot: project.root, stage, environment });
@@ -290,10 +295,15 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
290
295
  }
291
296
  }
292
297
  await compileMiddleware({ project, stage, manifest, dev, moduleResolution: config.turbopack, defineEnvironment: environment });
298
+ if (manifest.middleware) manifest.middleware.bodySizeLimit = config.experimental.proxyClientMaxBodySize;
293
299
  await prepareMetadataRoutes(project, stage);
294
300
  await compileApp({ project, stage, manifest, moduleResolution: config.turbopack, cssModules, fonts, images, dev, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages });
301
+ await checkTypeScript(project.root, config, { dev, tsconfig });
295
302
  fonts.attach(manifest);
296
303
  expandLocales(manifest);
304
+ // All runtime preparation must finish before its shared modules are built;
305
+ // no staged renderer has been imported before this production-only point.
306
+ if (!dev) await bundleRuntime(stage);
297
307
  await prerender(stage, manifest, dev);
298
308
  await prerenderApp(stage, manifest, dev);
299
309
  const pages = manifest.routes.filter(route => route.kind === 'page' && route.router !== 'app' && !route.internal);
@@ -323,6 +333,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
323
333
  ...(appRoute ? { app: { client: appRoute.client, css: appRoute.css } } : {}),
324
334
  }));
325
335
  }
336
+ await writeRuntimeContract(stage);
326
337
  await writeFile(path.join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
327
338
  if (config.compress) await precompressBuild(stage, manifest, {cacheDirectory:path.join(project.root,'.prnext-cache/precompress-v1')});
328
339
  if (config.output === 'standalone' && !dev) await (await import('./standalone-runner.mjs')).prepareStandalone({ projectRoot: project.root, stage, manifest, config });
@@ -0,0 +1,13 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function validatePublicMetadata(root, pattern, file, dynamic) {
5
+ const publicFile = path.join(root, 'public', pattern.slice(1));
6
+ try {
7
+ const info = await stat(publicFile);
8
+ // Next production serves public files before App metadata handlers. An
9
+ // identical static duplicate is therefore unambiguous and safe to retain.
10
+ if (!dynamic && info.isFile() && info.size <= 16 * 1024 * 1024 && info.size === (await stat(file)).size && (await readFile(publicFile)).equals(await readFile(file))) return;
11
+ throw new Error(`Metadata route ${pattern} conflicts with a different file in public/. Keep a single source for this URL.`);
12
+ } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
13
+ }
@@ -1,4 +1,4 @@
1
- import { readFile, access } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { execFile } from 'node:child_process';
@@ -7,6 +7,7 @@ import { resolveNativeBinary } from '../native/resolve.mjs';
7
7
  import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
8
8
  import { metadataExportNames } from './metadata-routes.mjs';
9
9
  import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
10
+ import { validatePublicMetadata } from './metadata-conflicts.mjs';
10
11
  const execute = promisify(execFile);
11
12
  const contentTypes = { ico: 'image/x-icon', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', svg: 'image/svg+xml', gif: 'image/gif' };
12
13
  const imageTypes = { favicon: ['ico'], icon: ['ico', 'jpg', 'jpeg', 'png', 'svg'], 'apple-icon': ['jpg', 'jpeg', 'png'], 'opengraph-image': ['jpg', 'jpeg', 'png', 'gif'], 'twitter-image': ['jpg', 'jpeg', 'png', 'gif'] };
@@ -57,8 +58,7 @@ export async function scanMetadataImages({ root, appRoot, files, routes, pattern
57
58
  const pattern = validatePattern([...urlParts, filename, ...(multiple ? ['[__metadata_id__]'] : [])], relative);
58
59
  const signature = pattern.replace(/\[[^\]]+\]/g, '[]');
59
60
  if (patterns.has(signature)) throw new Error(`Conflicting metadata route ${relative} and ${patterns.get(signature)} at ${pattern}.`);
60
- try { await access(path.join(root, 'public', pattern.slice(1))); throw new Error(`Metadata route ${pattern} conflicts with a file in public/.`); }
61
- catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
61
+ await validatePublicMetadata(root, pattern, file, dynamic);
62
62
  patterns.set(signature, relative);
63
63
  const handlerConfig = dynamic ? await validateAppConfig(file) : {};
64
64
  handlerConfig.revalidate ??= false;
@@ -1,10 +1,11 @@
1
- import { readFile, writeFile, access } from 'node:fs/promises';
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { parse } from '@babel/parser';
5
5
  import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
6
6
  import { scanMetadataImages } from './metadata-images.mjs';
7
7
  import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
8
+ import { validatePublicMetadata } from './metadata-conflicts.mjs';
8
9
 
9
10
  const contentTypes = { robots: 'text/plain', sitemap: 'application/xml', manifest: 'application/manifest+json' };
10
11
 
@@ -31,8 +32,7 @@ export async function scanMetadataRoutes({ root, appRoot, files, routes, pattern
31
32
  const signature = pattern.replace(/\[[^\]]+\]/g, '[]');
32
33
  if (patterns.has(signature)) throw new Error(`Conflicting metadata route ${relative} and ${patterns.get(signature)} at ${pattern}.`);
33
34
  if (kind === 'manifest' && manifest) throw new Error('Multiple manifest metadata files found in the App Router root.');
34
- try { await access(path.join(root, 'public', pattern.slice(1))); throw new Error(`Metadata route ${pattern} conflicts with a file in public/.`); }
35
- catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
35
+ await validatePublicMetadata(root, pattern, file, dynamic);
36
36
  patterns.set(signature, relative);
37
37
  const handlerConfig = dynamic ? await validateAppConfig(file) : {};
38
38
  if (dynamic) {
@@ -89,7 +89,11 @@ export function createNativePackages(projectRoot, { isClientSource }) {
89
89
  }
90
90
  if (args.path.startsWith('.') || args.path.startsWith('#')) return;
91
91
  const resolved = await esbuild.resolve(args.path, { importer: args.importer, resolveDir: args.resolveDir, kind: args.kind, pluginData: { prnextNativeResolving: true } });
92
- if (resolved.errors.length || resolved.external || resolved.namespace !== 'file') return resolved;
92
+ // esbuild knows whether the original require() is inside try/catch.
93
+ // A plugin error would erase that optional-dependency semantics.
94
+ // Let normal resolution report genuine missing imports itself.
95
+ if (resolved.errors.length) return;
96
+ if (resolved.external || resolved.namespace !== 'file') return resolved;
93
97
  references.set(resolved.path, { specifier: args.path, importer: args.importer });
94
98
  const owner = ownerFor(resolved.path) || await discoverOwner(resolved.path, mode);
95
99
  if (owner) {
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { builtinModules } from 'node:module';
3
+ import { builtinModules, createRequire } from 'node:module';
4
4
  import { parse } from '@babel/parser';
5
5
  import { VISITOR_KEYS } from '@babel/types';
6
6
  import { createNativeDiscovery } from './native-discovery.mjs';
@@ -15,6 +15,7 @@ const bare = name => !name.startsWith('.') && !name.startsWith('#') && !path.isA
15
15
  /** Inspect reached imports once per compiler graph; never execute npm modules. */
16
16
  export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackages = [], serverExternalPackages = [] }) {
17
17
  const transpile = new Set(transpilePackages), external = new Set(serverExternalPackages);
18
+ const nodeResolve = createRequire(path.join(projectRoot, 'package.json')).resolve;
18
19
  const sources = new Map(), decisions = new Map();
19
20
  let sourceBytes = 0;
20
21
  const native = createNativeDiscovery({ projectRoot, isClientSource: source => /['"]use (?:client|server)['"]/.test(source) });
@@ -90,7 +91,18 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
90
91
  // same validation as Pages to avoid bypassing compatibility transforms.
91
92
  if (!pages && !external.has(name)) return;
92
93
  const resolved = await resolve(args.path, args.importer || path.join(projectRoot, 'package.json'), args.kind);
93
- if (resolved.errors.length || resolved.external || resolved.namespace !== 'file') return resolved;
94
+ if (resolved.errors.length) return;
95
+ if (resolved.external || resolved.namespace !== 'file') return resolved;
96
+ // A tsconfig paths/baseUrl import can look like an npm specifier while
97
+ // resolving to application source. Node cannot resolve that alias from
98
+ // the emitted server bundle; keep the resolved source in the graph.
99
+ if (!resolved.path.split(path.sep).includes('node_modules')) {
100
+ let nodePath;
101
+ try { nodePath = nodeResolve(args.path, { paths: [path.dirname(args.importer || path.join(projectRoot, 'package.json'))] }); } catch {}
102
+ // Preserve externalization of real workspace packages that Node can
103
+ // resolve through a node_modules link, even after esbuild realpaths it.
104
+ if (nodePath !== resolved.path) return resolved;
105
+ }
94
106
  const decision = await classify(resolved.path);
95
107
  const fromRoot = await resolve(args.path, path.join(projectRoot, 'package.json'), args.kind);
96
108
  const nestedResolution = fromRoot.errors.length || fromRoot.path !== resolved.path;