@thomas.f/prnext 0.1.12-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 (50) hide show
  1. package/README.md +262 -33
  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/compiler.mjs +3 -1
  7. package/build/config.mjs +9 -2
  8. package/build/font-cache.mjs +50 -0
  9. package/build/font-files.mjs +5 -3
  10. package/build/fonts.mjs +1 -1
  11. package/build/index.mjs +5 -0
  12. package/build/metadata-conflicts.mjs +13 -0
  13. package/build/metadata-images.mjs +3 -3
  14. package/build/metadata-routes.mjs +3 -3
  15. package/build/native-packages.mjs +5 -1
  16. package/build/npm-packages.mjs +2 -1
  17. package/build/optimize-imports.mjs +112 -0
  18. package/build/proxy-body-config.mjs +10 -0
  19. package/build/scan.mjs +9 -7
  20. package/cli.mjs +16 -0
  21. package/compat/after.cjs +51 -0
  22. package/compat/index.d.ts +3 -1
  23. package/compat/server.cjs +2 -1
  24. package/compat/server.d.cts +2 -0
  25. package/native/launch.mjs +19 -0
  26. package/package.json +7 -7
  27. package/runtime/api.mjs +15 -5
  28. package/runtime/app-render.mjs +78 -33
  29. package/runtime/app-route-plan.mjs +45 -0
  30. package/runtime/app-routing-state.mjs +101 -21
  31. package/runtime/app-routing.mjs +37 -18
  32. package/runtime/app-segments.mjs +25 -11
  33. package/runtime/app-static.mjs +1 -1
  34. package/runtime/app-stream-html.mjs +2 -1
  35. package/runtime/cache-request.mjs +10 -2
  36. package/runtime/cloud/archive.mjs +316 -0
  37. package/runtime/cloud/client.mjs +658 -0
  38. package/runtime/cloud/environment.mjs +60 -0
  39. package/runtime/cloud/state.mjs +141 -0
  40. package/runtime/cloud/upload.mjs +48 -0
  41. package/runtime/html-envelope.mjs +70 -7
  42. package/runtime/http.mjs +7 -4
  43. package/runtime/middleware.mjs +3 -3
  44. package/runtime/request-transport.mjs +1 -1
  45. package/runtime/route-static.mjs +1 -1
  46. package/runtime/rsc-worker.mjs +27 -4
  47. package/runtime/stream-utils.mjs +22 -3
  48. package/runtime/transport.mjs +16 -0
  49. package/runtime/worker-metadata.mjs +23 -0
  50. package/runtime/worker.mjs +36 -9
package/build/config.mjs CHANGED
@@ -17,6 +17,8 @@ import { validateI18n } from './i18n.mjs';
17
17
  import { frameworkPackages } from './framework-imports.mjs';
18
18
  import { validateTypeScript } from './typescript.mjs';
19
19
  import { validateWorkerRetirement } from './worker-retirement.mjs';
20
+ import { validateOptimizePackageImports } from './optimize-imports.mjs';
21
+ import { validateProxyBodySize } from './proxy-body-config.mjs';
20
22
 
21
23
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
24
  const configName = /^(?:next|prnext)\.config\.(?:js|mjs|cjs|ts)$/;
@@ -29,6 +31,7 @@ supported.add('turbopack');
29
31
  supported.add('i18n');
30
32
  supported.add('webpack');
31
33
  supported.add('onDemandEntries');
34
+ supported.add('devIndicators');
32
35
  for (const key of ['reactCompiler', 'typedRoutes', 'typescript']) supported.add(key);
33
36
  for (const key of ['output', 'outputFileTracingRoot', 'outputFileTracingIncludes', 'outputFileTracingExcludes']) supported.add(key);
34
37
 
@@ -89,6 +92,7 @@ export function validateProjectConfig(input, filename = 'project config') {
89
92
  }
90
93
  config.typescript = validateTypeScript(config.typescript);
91
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.');
92
96
  if (config.onDemandEntries !== undefined) {
93
97
  const entries = config.onDemandEntries;
94
98
  if (!entries || typeof entries !== 'object' || Array.isArray(entries)) throw new TypeError('onDemandEntries must be an object');
@@ -117,14 +121,17 @@ export function validateProjectConfig(input, filename = 'project config') {
117
121
  config[key] = [...new Set(names)];
118
122
  }
119
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');
120
127
  if (config.sassOptions !== undefined && (!config.sassOptions || typeof config.sassOptions !== 'object' || Array.isArray(config.sassOptions))) throw new Error(`${filename}: sassOptions must be an object.`);
121
128
  if (!config.experimental || typeof config.experimental !== 'object' || Array.isArray(config.experimental)) throw new Error(`${filename}: experimental must be an object.`);
122
129
  for (const key of Object.keys(config.experimental)) {
123
- 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.`);
124
131
  }
125
132
  const nextScriptWorkers = config.experimental.nextScriptWorkers === undefined ? false : config.experimental.nextScriptWorkers;
126
133
  if (typeof nextScriptWorkers !== 'boolean') throw new Error(`${filename}: experimental.nextScriptWorkers must be a boolean.`);
127
- 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) };
128
135
  for (const key of ['basePath', 'assetPrefix']) {
129
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.`);
130
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,5 +1,6 @@
1
1
  import { rename } from '../runtime/fs.mjs';
2
2
  import { writeRuntimeContract } from './runtime-contract.mjs';
3
+ import { bundleRuntime } from './bundle-runtime.mjs';
3
4
  import {frameworkImportName, frameworkRuntimeImportPattern} from './framework-imports.mjs';
4
5
  import { expandLocales, localizedStaticPaths } from './i18n.mjs';
5
6
  import { build as bundle, withCompilation, configureCompiler, compilerSource } from './compiler.mjs';
@@ -294,11 +295,15 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
294
295
  }
295
296
  }
296
297
  await compileMiddleware({ project, stage, manifest, dev, moduleResolution: config.turbopack, defineEnvironment: environment });
298
+ if (manifest.middleware) manifest.middleware.bodySizeLimit = config.experimental.proxyClientMaxBodySize;
297
299
  await prepareMetadataRoutes(project, stage);
298
300
  await compileApp({ project, stage, manifest, moduleResolution: config.turbopack, cssModules, fonts, images, dev, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages });
299
301
  await checkTypeScript(project.root, config, { dev, tsconfig });
300
302
  fonts.attach(manifest);
301
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);
302
307
  await prerender(stage, manifest, dev);
303
308
  await prerenderApp(stage, manifest, dev);
304
309
  const pages = manifest.routes.filter(route => route.kind === 'page' && route.router !== 'app' && !route.internal);
@@ -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) {
@@ -91,7 +91,8 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
91
91
  // same validation as Pages to avoid bypassing compatibility transforms.
92
92
  if (!pages && !external.has(name)) return;
93
93
  const resolved = await resolve(args.path, args.importer || path.join(projectRoot, 'package.json'), args.kind);
94
- 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;
95
96
  // A tsconfig paths/baseUrl import can look like an npm specifier while
96
97
  // resolving to application source. Node cannot resolve that alias from
97
98
  // the emitted server bundle; keep the resolved source in the graph.
@@ -0,0 +1,112 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parse } from '@babel/parser';
4
+ import generateModule from '@babel/generator';
5
+ import * as t from '@babel/types';
6
+ import { generateMapped } from './source-maps.mjs';
7
+
8
+ const generate = generateModule.default || generateModule;
9
+ const name = node => node?.name ?? node?.value;
10
+
11
+ export function validateOptimizePackageImports(value = []) {
12
+ if (!Array.isArray(value) || value.length > 256 || value.some(item => typeof item !== 'string' || !/^(?:@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(item))) {
13
+ throw new TypeError('experimental.optimizePackageImports must be an array of at most 256 npm package names');
14
+ }
15
+ return [...new Set(value)];
16
+ }
17
+
18
+ /** Only bypass pure ESM barrels in packages declaring sideEffects:false.
19
+ * Uncertain graphs retain their original import and normal bundler semantics.
20
+ * Never execute package code or infer private export paths from symbol names.
21
+ */
22
+ export function createImportOptimizer(packages) {
23
+ const selected = new Set(packages), barrels = new Map(), dependencies = new Set();
24
+ async function inspect(file) {
25
+ if (!barrels.has(file)) barrels.set(file, (async () => {
26
+ if (!/\.[cm]?jsx?$/.test(file) || (await stat(file)).size > 2 * 1024 * 1024) return;
27
+ let directory = path.dirname(file), owner;
28
+ for (;;) {
29
+ try { owner = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')); break; }
30
+ catch (error) { if (error.code !== 'ENOENT') return; }
31
+ const parent = path.dirname(directory);
32
+ if (parent === directory) return;
33
+ directory = parent;
34
+ }
35
+ if (owner.sideEffects !== false || !selected.has(owner.name)) return;
36
+ dependencies.add(file);
37
+ dependencies.add(path.join(directory, 'package.json'));
38
+ const ast = parse(await readFile(file, 'utf8'), { sourceType: 'module', plugins: ['jsx'] });
39
+ // Crossing either directive here would change the RSC boundary identity.
40
+ if (ast.program.directives.some(d => d.value.value !== 'use strict')) return;
41
+ const imports = new Map(), exports = new Map();
42
+ for (const node of ast.program.body) {
43
+ if (t.isImportDeclaration(node)) {
44
+ if (!node.specifiers.length || node.attributes?.length) return;
45
+ for (const item of node.specifiers) {
46
+ if (t.isImportNamespaceSpecifier(item)) return;
47
+ imports.set(item.local.name, { source: node.source.value, imported: t.isImportDefaultSpecifier(item) ? 'default' : name(item.imported) });
48
+ }
49
+ } else if (!t.isExportNamedDeclaration(node) || node.declaration || node.attributes?.length) return;
50
+ }
51
+ for (const node of ast.program.body) if (t.isExportNamedDeclaration(node)) {
52
+ for (const item of node.specifiers) {
53
+ if (!t.isExportSpecifier(item)) return;
54
+ const target = node.source ? { source: node.source.value, imported: name(item.local) } : imports.get(name(item.local));
55
+ if (!target || !target.source.startsWith('./') && !target.source.startsWith('../')) return;
56
+ exports.set(name(item.exported), target);
57
+ }
58
+ }
59
+ return { exports, root: directory };
60
+ })());
61
+ return barrels.get(file);
62
+ }
63
+ async function optimize(source, file, resolve) {
64
+ if (![...selected].some(value => source.includes(value))) return source;
65
+ const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: file, plugins: ['jsx', ...(/\.[cm]?tsx?$/.test(file) ? ['typescript'] : [])] });
66
+ let changed = false;
67
+ for (const node of ast.program.body) {
68
+ if (!t.isImportDeclaration(node) || !selected.has(node.source.value) || node.importKind === 'type' || !node.specifiers.length || node.attributes?.length) continue;
69
+ const resolved = await resolve(node.source.value, file);
70
+ if (!resolved) continue;
71
+ const barrel = await inspect(resolved);
72
+ if (!barrel) continue;
73
+ const replacements = [];
74
+ for (const item of node.specifiers) {
75
+ if (!t.isImportSpecifier(item) || item.importKind === 'type') continue;
76
+ const target = barrel.exports.get(name(item.imported));
77
+ if (!target) continue;
78
+ const leaf = await resolve(target.source, resolved);
79
+ if (!leaf) continue;
80
+ const relative = path.relative(barrel.root, leaf);
81
+ if (relative.startsWith('..') || path.isAbsolute(relative) || relative.split(path.sep).includes('node_modules')) continue;
82
+ replacements.push({ item, declaration: t.importDeclaration([target.imported === 'default' ? t.importDefaultSpecifier(item.local) : t.importSpecifier(item.local, t.isValidIdentifier(target.imported) ? t.identifier(target.imported) : t.stringLiteral(target.imported))], t.stringLiteral(leaf)) });
83
+ }
84
+ if (!replacements.length) continue;
85
+ const at = ast.program.body.indexOf(node);
86
+ node.specifiers = node.specifiers.filter(item => !replacements.some(value => value.item === item));
87
+ ast.program.body.splice(at, node.specifiers.length ? 0 : 1, ...replacements.map(value => value.declaration));
88
+ changed = true;
89
+ }
90
+ return changed ? generateMapped(generate, ast, { retainLines: true }, source, file) : source;
91
+ }
92
+ optimize.dependencies = dependencies;
93
+ return optimize;
94
+ }
95
+
96
+ export function optimizeImportsPlugins(plugins, packages) {
97
+ if (!packages?.length) return plugins;
98
+ const optimize = createImportOptimizer(packages);
99
+ return plugins.map(plugin => ({ ...plugin, setup(builder) {
100
+ return plugin.setup({ ...builder, onLoad(settings, callback) {
101
+ builder.onLoad(settings, async args => {
102
+ const result = await callback(args);
103
+ if (!result || typeof result.contents !== 'string' || args.namespace !== 'file' || !/\.[cm]?[jt]sx?$/.test(args.path) || result.errors?.length) return result;
104
+ const contents = await optimize(result.contents, args.path, async (specifier, importer) => {
105
+ const resolved = await builder.resolve(specifier, { importer, resolveDir: path.dirname(importer), kind: 'import-statement', pluginData: { prnextNpmResolving: true } });
106
+ return !resolved.errors.length && !resolved.external && resolved.namespace === 'file' && !resolved.suffix ? resolved.path : undefined;
107
+ });
108
+ return { ...result, contents, watchFiles: [...(result.watchFiles || []), ...optimize.dependencies] };
109
+ });
110
+ } });
111
+ } }));
112
+ }
@@ -0,0 +1,10 @@
1
+ export function validateProxyBodySize(value = 10 * 1024 * 1024) {
2
+ let bytes = value;
3
+ if (typeof value === 'string') {
4
+ const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(value.trim());
5
+ if (!match) throw new TypeError('experimental.proxyClientMaxBodySize expects bytes or a size such as "25mb"');
6
+ bytes = Math.floor(Number(match[1]) * 1024 ** Math.max(0, ['b', 'kb', 'mb', 'gb'].indexOf((match[2] || 'b').toLowerCase())));
7
+ }
8
+ if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > 32 * 1024 * 1024) throw new TypeError('experimental.proxyClientMaxBodySize must be between 1 byte and the PRNext 32 MiB buffered transport limit');
9
+ return bytes;
10
+ }
package/build/scan.mjs CHANGED
@@ -6,7 +6,7 @@ import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
6
6
  import { scanMiddleware } from './middleware.mjs';
7
7
  import { scanMetadataRoutes } from './metadata-routes.mjs';
8
8
  import { scanAdvancedAppRoutes } from './app-routing.mjs';
9
- import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
9
+ import { pageExtensionPattern, validatePageSource, defaultPageExtensions } from './page-extensions.mjs';
10
10
 
11
11
  const sourceExtension = /\.(?:[cm]?js|jsx|tsx?)$/;
12
12
 
@@ -96,7 +96,7 @@ export async function scanProject(projectRoot, { basePath = '', pageExtensions,
96
96
  });
97
97
  }
98
98
  const appRoot = appRoots[0];
99
- const { appNotFound, appGlobalError } = appRoot ? await scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension }) : {};
99
+ const { appNotFound, appGlobalError } = appRoot ? await scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension, pageExtensions }) : {};
100
100
  const metadataManifest = appRoot ? await scanMetadataRoutes({ root, appRoot, files: await walk(appRoot), routes, patterns, validatePattern, basePath, pageExtensions, appNotFound }) : undefined;
101
101
  if (metadataManifest) for (const route of routes) if (route.router === 'app' && route.kind === 'page') route.staticMetadata = { manifest: metadataManifest };
102
102
  if (!routes.length && !appNotFound) throw new Error('No page or API route found in the Pages Router or App Router directories.');
@@ -108,11 +108,13 @@ const appConventions = new Map([
108
108
  ['loading', 'loading'], ['error', 'error'], ['not-found', 'notFound'], ['default', 'default'],
109
109
  ]);
110
110
 
111
- async function scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension }) {
111
+ async function scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension, pageExtensions = defaultPageExtensions }) {
112
112
  const directories = new Map();
113
113
  const configs = new Map();
114
114
  let appGlobalError;
115
- for (const file of await walk(appRoot)) {
115
+ const rank = file => pageExtensions.findIndex(extension => file.endsWith('.' + extension));
116
+ const files = (await walk(appRoot)).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
117
+ for (const file of files) {
116
118
  if (!sourceExtension.test(file) || file.endsWith('.d.ts')) continue;
117
119
  const relative = path.relative(appRoot, file).replaceAll(path.sep, '/');
118
120
  const parts = relative.split('/');
@@ -122,18 +124,18 @@ async function scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension
122
124
  // This convention replaces the complete root document. A similarly named
123
125
  // file inside a route group or segment is not a nested error boundary.
124
126
  if (parts.length === 0) {
125
- if (appGlobalError) throw new Error('Multiple global-error files in the App Router root. Keep only one.');
127
+ if (appGlobalError) { console.warn(`Duplicate global-error file ${relative}; using ${path.relative(appRoot, appGlobalError)} according to pageExtensions.`); continue; }
126
128
  appGlobalError = file;
127
129
  }
128
130
  continue;
129
131
  }
130
132
  if (!appConventions.has(filename)) continue;
131
133
  validatePageSource(file);
132
- if (['page', 'layout', 'route'].includes(filename)) configs.set(file, await validateAppConfig(file));
133
134
  const directory = parts.join('/');
134
135
  const descriptor = directories.get(directory) || { directory, files: {} };
135
136
  const name = appConventions.get(filename);
136
- if (descriptor.files[name]) throw new Error(`Multiple ${filename} files in App Router directory ${directory || '/'}. Keep only one file for each convention.`);
137
+ if (descriptor.files[name]) { console.warn(`Duplicate ${filename} file ${relative}; using ${path.relative(appRoot, descriptor.files[name])} according to pageExtensions.`); continue; }
138
+ if (['page', 'layout', 'route'].includes(filename)) configs.set(file, await validateAppConfig(file));
137
139
  descriptor.files[name] = file;
138
140
  directories.set(directory, descriptor);
139
141
  }
package/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import { readFile, writeFile, rm } from 'node:fs/promises';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import path from 'node:path';
7
7
  import { resolveNativeBinary, packageManifest } from './native/resolve.mjs';
8
+ import { replaceWithNative } from './native/launch.mjs';
8
9
  import { readBuildDirectory } from './runtime/build-directory.mjs';
9
10
  import { shouldWatchProjectFile } from './runtime/env.mjs';
10
11
 
@@ -31,6 +32,11 @@ Use prnext for portable scripts; prn is a shortcut on compatible shells.
31
32
  prnext host [config] --check Validate hosting configuration and builds
32
33
  prnext host [config] --status [--json] Show memory, process and idle status
33
34
  prnext migrate [directory] [options] Migrate a Next project's scripts and dependencies
35
+ prn login [--server URL] Connect PRNext Cloud through your browser
36
+ prn deploy [directory] Upload sources, deploy and link the project
37
+ prn update [directory] Deploy changes to the linked Cloud project
38
+ prn whoami / prn logout Show or disconnect your terminal account
39
+ prn deploy --help Cloud options and local development
34
40
 
35
41
  Migration options: --dry-run (preview), --no-install (prepare only), --json
36
42
  prnext migrate --check Audit source compatibility without migrating
@@ -69,6 +75,11 @@ function stop(child) {
69
75
  async function main() {
70
76
  if (['help', '--help', '-h'].includes(command)) { console.log(help); return; }
71
77
  if (['--version', '-v'].includes(command)) { console.log(packageManifest.version); return; }
78
+ if (['login', 'logout', 'whoami', 'deploy', 'update'].includes(command)) {
79
+ const { cloudCommand } = await import('./runtime/cloud/client.mjs');
80
+ await cloudCommand(command, argv);
81
+ return;
82
+ }
72
83
  if (['pstart', 'prestart', 'pstop', 'pstatus', 'plist', 'plogs', 'pdelete', 'pstartup', 'pdown'].includes(command)) {
73
84
  const { persistentCommand } = await import('./runtime/persistent/client.mjs');
74
85
  await persistentCommand(command, argv);
@@ -184,6 +195,11 @@ async function main() {
184
195
  }
185
196
  const binary = await resolveNativeBinary();
186
197
  let outputDirectory = await readBuildDirectory(root);
198
+ const serverEnvironment = { ...process.env, NODE_ENV: process.env.NODE_ENV || (command === 'dev' ? 'development' : 'production') };
199
+ if (command === 'start' && serverEnvironment.NODE_ENV !== 'development') {
200
+ if (replaceWithNative(binary, ['start', root, '--worker', path.join(root, outputDirectory, 'runtime/worker.mjs'),
201
+ '--node', process.execPath, ...args], serverEnvironment)) return;
202
+ }
187
203
  const launch = () => spawn(binary, [command === 'routes' ? 'routes' : 'start', root,
188
204
  ...(command === 'routes' ? [] : ['--worker', path.join(root, outputDirectory, 'runtime/worker.mjs'), '--node', process.execPath,
189
205
  '--shutdown-on-stdin-eof']), ...args], { stdio: ['pipe', 'inherit', 'inherit'], windowsHide: true, env: { ...process.env, NODE_ENV: process.env.NODE_ENV || (command === 'dev' ? 'development' : 'production') } });
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+ const { AsyncLocalStorage } = require('node:async_hooks');
3
+
4
+ function after(task) {
5
+ const context = require('./headers.cjs').currentRequest();
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.');
8
+ context.afterScope.add(task, context, AsyncLocalStorage.snapshot());
9
+ }
10
+
11
+ function createAfterScope({ signal, timeoutMs = 25000, onTimeout = () => {}, onComplete, report = error => console.error('[prnext] after() failed:', error?.stack || error) } = {}) {
12
+ const queue = []; let count = 0, closed = false, finishing;
13
+ const deadline = Date.now() + timeoutMs;
14
+ const track = promise => signal?.[Symbol.for('prnext.request-work')]?.(promise, { timeoutMs: Math.max(1, deadline - Date.now()) });
15
+ const observe = promise => Promise.resolve(promise).then(value => ({ value }), error => ({ error }));
16
+ const scope = {
17
+ add(task, context, run) {
18
+ if (closed) throw new Error('after() request has already completed');
19
+ if (typeof task !== 'function' && (!task || typeof task.then !== 'function')) throw new TypeError('after() expects a callback or a Promise');
20
+ if (++count > 128) throw new Error('after() exceeds 128 tasks per request');
21
+ const pending = typeof task === 'function' ? null : observe(task);
22
+ if (pending) track(pending);
23
+ queue.push({ task: pending ? null : task, pending, context, run });
24
+ },
25
+ finish() {
26
+ return finishing ||= (async () => {
27
+ try {
28
+ while (queue.length) {
29
+ 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
+ const abort = new AbortController();
32
+ item.context.cookies._mutable = false;
33
+ item.context.phase = 'after';
34
+ item.context.signal = abort.signal;
35
+ item.context.cacheState.closed = false;
36
+ const work = item.pending || observe(Promise.resolve().then(() => item.run(item.task)));
37
+ track(work);
38
+ let timer;
39
+ try {
40
+ 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);
42
+ if (abort.signal.aborted) { onTimeout(outcome.error); break; }
43
+ } finally { clearTimeout(timer); item.context.cacheState.closed = true; await onComplete?.(item.context); }
44
+ }
45
+ } finally { closed = true; queue.length = 0; }
46
+ })();
47
+ },
48
+ };
49
+ return scope;
50
+ }
51
+ module.exports = { after, createAfterScope };
package/compat/index.d.ts CHANGED
@@ -118,6 +118,8 @@ export interface NextConfig {
118
118
  workerRetirement?: { idleSeconds?: number; laneIdleSeconds?: number; keepWarm?: boolean };
119
119
  /** Next dev page-retention hints; PRNext builds all routes without that eviction queue. */
120
120
  onDemandEntries?: { maxInactiveAge?: number; pagesBufferLength?: number };
121
+ /** Disable the Next development indicator. Error overlays and Fast Refresh remain enabled. */
122
+ devIndicators?: false;
121
123
  i18n?: { locales: string[]; defaultLocale: string; localeDetection?: boolean; domains?: Array<{domain: string; defaultLocale: string; locales?: string[]; http?: boolean}> };
122
124
  webpack?: (config: import('webpack').Configuration, context: { buildId: string; dev: boolean; isServer: boolean; nextRuntime?: 'nodejs' | 'edge'; webpack: typeof import('webpack'); defaultLoaders: {babel: {loader: string}} }) => import('webpack').Configuration | Promise<import('webpack').Configuration>;
123
125
  turbopack?: { root?: string; rules?: Record<string, TurbopackRule | TurbopackRule[]>; resolveAlias?: Record<string, string | { browser: string }>; resolveExtensions?: string[] };
@@ -141,7 +143,7 @@ export interface NextConfig {
141
143
  implementation?: 'sass' | 'sass-embedded';
142
144
  [option: string]: unknown;
143
145
  };
144
- experimental?: { nextScriptWorkers?: boolean; serverActions?: true | { allowedOrigins?: string[]; bodySizeLimit?: number | string } };
146
+ experimental?: { nextScriptWorkers?: boolean; optimizePackageImports?: string[]; proxyClientMaxBodySize?: number | string; serverActions?: true | { allowedOrigins?: string[]; bodySizeLimit?: number | string } };
145
147
  trailingSlash?: boolean;
146
148
  skipTrailingSlashRedirect?: boolean;
147
149
  skipMiddlewareUrlNormalize?: boolean;
package/compat/server.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const { NextRequest, NextResponse, NextURL } = require('./server-primitives.cjs');
3
+ const { after } = require('./after.cjs');
3
4
 
4
5
  async function connection() {
5
6
  const context = require('./headers.cjs').currentRequest();
@@ -10,4 +11,4 @@ async function connection() {
10
11
  dynamicUsage('connection()', context);
11
12
  }
12
13
 
13
- module.exports = { NextRequest, NextResponse, NextURL, connection };
14
+ module.exports = { NextRequest, NextResponse, NextURL, connection, after };
@@ -35,3 +35,5 @@ export interface MiddlewareConfig {
35
35
  export interface ProxyConfig { matcher?: MiddlewareConfig['matcher'] }
36
36
  /** Exclude the following render from prerendering until a request is available. */
37
37
  export function connection(): Promise<void>;
38
+ /** Post-response work in Node Route Handlers. Other after() lifecycles fail explicitly. */
39
+ export function after(task: Promise<unknown> | (() => unknown)): void;
@@ -0,0 +1,19 @@
1
+ // A production CLI has nothing to supervise once the native server starts.
2
+ // On supported Linux Node releases, replace it instead of retaining a Node heap.
3
+ // Keep the portable parent/pipe lifecycle for Windows, older Node, IPC callers
4
+ // and instrumentation which may own descriptors or need JS exit handlers.
5
+ export function replaceWithNative(binary, args, env, runtime = process) {
6
+ const options = [...(runtime.execArgv || []), env.NODE_OPTIONS || ''].join(' ');
7
+ // Only heap sizing is known not to require the Node launcher's lifetime.
8
+ // Be conservative for all other Node flags, including future diagnostics,
9
+ // loaders, preloads, permissions and signal-triggered reports/snapshots.
10
+ const customRuntime = options.replace(/(?:^|\s)--max[-_](?:semi|old)[-_]space[-_]size(?:=|\s+)\d+(?=\s|$)/g, '').trim();
11
+ if (runtime.platform !== 'linux' || typeof runtime.execve !== 'function'
12
+ || typeof runtime.send === 'function' || env.NODE_V8_COVERAGE || customRuntime) return false;
13
+ // execve keeps stdin/out/err and the PID. No parent pipe is needed: signals
14
+ // reach Rust directly, and its worker pipes still supervise every Node child.
15
+ // The marker also makes an old native binary fail clearly instead of using
16
+ // this lifecycle without the Linux parent-death guard it requires.
17
+ runtime.execve(binary, [binary, ...args, '--cli-exec'], env);
18
+ return true; // Only reached by injected test runtimes; real execve never returns.
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thomas.f/prnext",
3
- "version": "0.1.12-alpha",
3
+ "version": "0.1.13-alpha",
4
4
  "license": "MIT",
5
5
  "description": "Experimental Rust-powered runtime for Next.js apps, React and npm",
6
6
  "homepage": "https://prnext.dev",
@@ -133,12 +133,12 @@
133
133
  "react-server-dom-webpack": "19.3.0"
134
134
  },
135
135
  "optionalDependencies": {
136
- "prnext-darwin-arm64": "0.1.12-alpha",
137
- "prnext-darwin-x64": "0.1.12-alpha",
138
- "prnext-linux-arm64-gnu": "0.1.12-alpha",
139
- "prnext-linux-x64-gnu": "0.1.12-alpha",
140
- "@thomas.f/prnext-win32-x64-msvc": "0.1.12-alpha",
141
- "@thomas.f/prnext-win32-arm64-msvc": "0.1.12-alpha"
136
+ "prnext-darwin-arm64": "0.1.13-alpha",
137
+ "prnext-darwin-x64": "0.1.13-alpha",
138
+ "prnext-linux-arm64-gnu": "0.1.13-alpha",
139
+ "prnext-linux-x64-gnu": "0.1.13-alpha",
140
+ "@thomas.f/prnext-win32-x64-msvc": "0.1.13-alpha",
141
+ "@thomas.f/prnext-win32-arm64-msvc": "0.1.13-alpha"
142
142
  },
143
143
  "engines": {
144
144
  "node": ">=22"