@wular/pnext 0.0.2 → 0.0.3
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.
- package/README.md +76 -20
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +75 -48
- package/src/api/router/runtime.ts +10 -2
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +37 -5
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +17 -3
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +19 -0
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +5 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +14 -2
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- package/src/utils/native-require.ts +28 -0
package/src/render/renderer.ts
CHANGED
|
@@ -103,7 +103,7 @@ import {
|
|
|
103
103
|
ISLAND_BOUNDARY_ERROR_ELEMENT,
|
|
104
104
|
ISLAND_BOUNDARY_ERROR_MESSAGE_ATTRIBUTE,
|
|
105
105
|
} from '../islands/boundary-error';
|
|
106
|
-
import { runWithCacheScope } from '../cache/context';
|
|
106
|
+
import { captureCacheScope, runWithCacheScope } from '../cache/context';
|
|
107
107
|
import { registerServerRuntime } from '../runtime/server';
|
|
108
108
|
import { readText } from '../utils/fs';
|
|
109
109
|
import {
|
|
@@ -2424,8 +2424,14 @@ export function pprSubShellPath(outPath: string, routeId: string, key: string) {
|
|
|
2424
2424
|
return `${outPath}/ppr/${routeId}.${key}.html`;
|
|
2425
2425
|
}
|
|
2426
2426
|
|
|
2427
|
+
/**
|
|
2428
|
+
* `skipNoindex`: the 404 came out of a server action, where robots the page
|
|
2429
|
+
* declared must stay authoritative — the metadata base stops forcing noindex.
|
|
2430
|
+
* A default noindex is still injected when nothing declares robots at all.
|
|
2431
|
+
*/
|
|
2427
2432
|
export async function renderGlobalNotFoundResponse(
|
|
2428
2433
|
options: Omit<RenderOptions, 'route' | 'params'>,
|
|
2434
|
+
{ skipNoindex = false }: { skipNoindex?: boolean } = {},
|
|
2429
2435
|
) {
|
|
2430
2436
|
const globalFile = ['tsx', 'ts', 'jsx', 'js']
|
|
2431
2437
|
.map(ext => `${options.config.appPath}/global-not-found.${ext}`)
|
|
@@ -2526,7 +2532,9 @@ export async function renderGlobalNotFoundResponse(
|
|
|
2526
2532
|
renderScopeRequest(notFoundOptions),
|
|
2527
2533
|
() =>
|
|
2528
2534
|
runWithFontScope(() =>
|
|
2529
|
-
runWithCacheScope(() =>
|
|
2535
|
+
runWithCacheScope(() =>
|
|
2536
|
+
renderNotFoundPage(notFoundOptions, false, { forceDefault, skipNoindex }),
|
|
2537
|
+
),
|
|
2530
2538
|
),
|
|
2531
2539
|
{},
|
|
2532
2540
|
);
|
|
@@ -2548,6 +2556,44 @@ export async function renderGlobalNotFoundResponse(
|
|
|
2548
2556
|
return response;
|
|
2549
2557
|
}
|
|
2550
2558
|
|
|
2559
|
+
/**
|
|
2560
|
+
* notFound() thrown from a Server Action: the URL DID match a route, so render the not-found
|
|
2561
|
+
* boundary within that route (keeping its layout chain and generateMetadata) instead of anchoring
|
|
2562
|
+
* at the app root like renderGlobalNotFoundResponse.
|
|
2563
|
+
*/
|
|
2564
|
+
export async function renderNotFoundForRoute(
|
|
2565
|
+
options: RenderOptions,
|
|
2566
|
+
{ skipNoindex = false }: { skipNoindex?: boolean } = {},
|
|
2567
|
+
): Promise<Response> {
|
|
2568
|
+
const notFoundRequest = options.request
|
|
2569
|
+
? new Request(options.request.url, { headers: options.request.headers })
|
|
2570
|
+
: undefined;
|
|
2571
|
+
const notFoundOptions: RenderOptions = {
|
|
2572
|
+
...options,
|
|
2573
|
+
...(notFoundRequest ? { request: notFoundRequest } : {}),
|
|
2574
|
+
status: 404,
|
|
2575
|
+
};
|
|
2576
|
+
const page = await runWithRequest(
|
|
2577
|
+
renderScopeRequest(notFoundOptions),
|
|
2578
|
+
() =>
|
|
2579
|
+
runWithFontScope(() =>
|
|
2580
|
+
runWithCacheScope(() => renderNotFoundPage(notFoundOptions, false, { skipNoindex })),
|
|
2581
|
+
),
|
|
2582
|
+
{},
|
|
2583
|
+
);
|
|
2584
|
+
const response = new Response(pageStream(page), {
|
|
2585
|
+
status: 404,
|
|
2586
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
2587
|
+
});
|
|
2588
|
+
const html = await response.text();
|
|
2589
|
+
const withRobots = html.includes('name="robots"')
|
|
2590
|
+
? html
|
|
2591
|
+
: html.replace('</head>', '<meta name="robots" content="noindex"/></head>');
|
|
2592
|
+
const headers = new Headers(response.headers);
|
|
2593
|
+
headers.delete('content-length');
|
|
2594
|
+
return new Response(withRobots, { status: response.status, headers });
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2551
2597
|
// Everything after this marker is late metadata, not document bytes: the
|
|
2552
2598
|
// router's streamed read commits the document at the marker and folds the
|
|
2553
2599
|
// remainder into the live head.
|
|
@@ -3351,9 +3397,14 @@ async function renderTree(
|
|
|
3351
3397
|
// is body-streamed anyway, resolve it as a deferred stream chunk instead.
|
|
3352
3398
|
// Only on an RSC navigation render: a hard document load keeps blocking, so a
|
|
3353
3399
|
// metadata notFound()/redirect() still owns the whole document.
|
|
3400
|
+
// Captured explicitly: the page body already populated the render's cache() scope, and a deferred
|
|
3401
|
+
// generateMetadata must reuse it or its cache()-wrapped values drift from what the body rendered.
|
|
3402
|
+
const runInCacheScope = captureCacheScope();
|
|
3354
3403
|
const pendingPageMetadata =
|
|
3355
3404
|
typeof pageMetadata === 'function' && deferrableNavigationMetadata(options, runtimeMetadataInBody, stream)
|
|
3356
|
-
? Promise.resolve().then(() =>
|
|
3405
|
+
? Promise.resolve().then(() =>
|
|
3406
|
+
runInCacheScope(() => pageMetadata(mergeMetadataEntries(layoutRender.metadata))),
|
|
3407
|
+
)
|
|
3357
3408
|
: undefined;
|
|
3358
3409
|
try {
|
|
3359
3410
|
resolvedPageMetadata = pendingPageMetadata
|
|
@@ -4009,9 +4060,16 @@ export function defaultNotFoundDocument(): string {
|
|
|
4009
4060
|
async function renderNotFoundPage(
|
|
4010
4061
|
options: RenderOptions,
|
|
4011
4062
|
stream: boolean,
|
|
4012
|
-
extra: {
|
|
4063
|
+
extra: {
|
|
4064
|
+
status?: number;
|
|
4065
|
+
nextFlightText?: boolean;
|
|
4066
|
+
forceDefault?: boolean;
|
|
4067
|
+
skipNoindex?: boolean;
|
|
4068
|
+
} = {},
|
|
4013
4069
|
) {
|
|
4014
4070
|
const status = extra.status ?? 404;
|
|
4071
|
+
// Server-action 404s carry no fallback robots (Next's NonIndex skip).
|
|
4072
|
+
const fallbackRobots = extra.skipNoindex ? {} : { robots: 'noindex' };
|
|
4015
4073
|
// forceDefault: globalNotFound is enabled but no global-not-found.* exists —
|
|
4016
4074
|
// skip the app's not-found.* boundary and render the built-in default 404.
|
|
4017
4075
|
const file = extra.forceDefault ? undefined : nearestConventionFile(options, 'not-found.tsx');
|
|
@@ -4021,7 +4079,7 @@ async function renderNotFoundPage(
|
|
|
4021
4079
|
notFoundOptions,
|
|
4022
4080
|
defaultNotFoundTree(),
|
|
4023
4081
|
stream,
|
|
4024
|
-
{ title: '404: This page could not be found.',
|
|
4082
|
+
{ title: '404: This page could not be found.', ...fallbackRobots },
|
|
4025
4083
|
{ status },
|
|
4026
4084
|
);
|
|
4027
4085
|
return extra.nextFlightText ? withNextFlightText(page) : page;
|
|
@@ -4049,7 +4107,7 @@ async function renderNotFoundPage(
|
|
|
4049
4107
|
notFoundOptions,
|
|
4050
4108
|
defaultNotFoundTree(),
|
|
4051
4109
|
stream,
|
|
4052
|
-
{ title: '404: This page could not be found.',
|
|
4110
|
+
{ title: '404: This page could not be found.', ...fallbackRobots },
|
|
4053
4111
|
{ status },
|
|
4054
4112
|
);
|
|
4055
4113
|
return extra.nextFlightText ? withNextFlightText(page) : page;
|
|
@@ -4087,7 +4145,7 @@ async function renderNotFoundPage(
|
|
|
4087
4145
|
stream,
|
|
4088
4146
|
// On the skip path a baked `<title>Not found</title>` would precede the
|
|
4089
4147
|
// streamed metadata chunk in document order and win `document.title`.
|
|
4090
|
-
{
|
|
4148
|
+
{ ...fallbackRobots, ...resolvedMetadata },
|
|
4091
4149
|
{
|
|
4092
4150
|
status,
|
|
4093
4151
|
...(resolvedViewport ? { viewport: resolvedViewport } : {}),
|
|
@@ -7367,25 +7425,43 @@ function devReloadScript() {
|
|
|
7367
7425
|
// unstyled and never loses client state. Anything the server could not prove
|
|
7368
7426
|
// was CSS-only still arrives as `reload`.
|
|
7369
7427
|
return `<script data-pnext-dev>
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7428
|
+
let events;
|
|
7429
|
+
let generation;
|
|
7430
|
+
// The stream is a long-lived connection counted against the browser's per-origin
|
|
7431
|
+
// socket cap (6 on HTTP/1.1). A page the browser keeps alive after navigating away
|
|
7432
|
+
// (bfcache) holds its own open, so every page load would leak one until the pool is
|
|
7433
|
+
// exhausted and further requests stall for minutes. Close on the way out, reopen on
|
|
7434
|
+
// restore.
|
|
7435
|
+
function connectDevEvents() {
|
|
7436
|
+
events = new EventSource('/__pnext/events');
|
|
7437
|
+
// The server stamps its build generation on connect: a page restored onto a
|
|
7438
|
+
// generation it never saw missed a rebuild while it was disconnected.
|
|
7439
|
+
events.addEventListener('ready', event => {
|
|
7440
|
+
if (generation !== undefined && event.data !== generation) return location.reload();
|
|
7441
|
+
generation = event.data;
|
|
7442
|
+
});
|
|
7443
|
+
events.addEventListener('reload', () => location.reload());
|
|
7444
|
+
events.addEventListener('css-update', event => {
|
|
7445
|
+
const links = [...document.querySelectorAll('link[rel=stylesheet]')].filter(link => {
|
|
7446
|
+
try { return new URL(link.href, location.href).origin === location.origin; } catch { return false; }
|
|
7447
|
+
});
|
|
7448
|
+
if (links.length === 0) { location.reload(); return; }
|
|
7449
|
+
for (const link of links) {
|
|
7450
|
+
const url = new URL(link.href, location.href);
|
|
7451
|
+
url.searchParams.set('__pnext_css', event.data);
|
|
7452
|
+
const next = link.cloneNode();
|
|
7453
|
+
// React tracks precedence-carrying links as its own; the swap is ours.
|
|
7454
|
+
next.removeAttribute('data-precedence');
|
|
7455
|
+
next.href = url.href;
|
|
7456
|
+
const drop = () => link.remove();
|
|
7457
|
+
next.addEventListener('load', drop, { once: true });
|
|
7458
|
+
next.addEventListener('error', drop, { once: true });
|
|
7459
|
+
link.after(next);
|
|
7460
|
+
}
|
|
7375
7461
|
});
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
const next = link.cloneNode();
|
|
7381
|
-
// React tracks precedence-carrying links as its own; the swap is ours.
|
|
7382
|
-
next.removeAttribute('data-precedence');
|
|
7383
|
-
next.href = url.href;
|
|
7384
|
-
const drop = () => link.remove();
|
|
7385
|
-
next.addEventListener('load', drop, { once: true });
|
|
7386
|
-
next.addEventListener('error', drop, { once: true });
|
|
7387
|
-
link.after(next);
|
|
7388
|
-
}
|
|
7389
|
-
});
|
|
7462
|
+
}
|
|
7463
|
+
connectDevEvents();
|
|
7464
|
+
addEventListener('pagehide', () => events.close());
|
|
7465
|
+
addEventListener('pageshow', event => { if (event.persisted) connectDevEvents(); });
|
|
7390
7466
|
</script>`;
|
|
7391
7467
|
}
|
package/src/resolve/engine.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { ResolverFactory,
|
|
3
|
+
import type { ResolverFactory, NapiResolveOptions } from 'oxc-resolver';
|
|
4
|
+
import { loadNative } from '../utils/native-require';
|
|
5
|
+
|
|
6
|
+
// Lazy: the native binding costs ~1.3 MB RSS and a prod server may never resolve.
|
|
7
|
+
function createResolverFactory(options: NapiResolveOptions): ResolverFactory {
|
|
8
|
+
const { ResolverFactory: Factory } = loadNative(
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
10
|
+
() => require('oxc-resolver') as typeof import('oxc-resolver'),
|
|
11
|
+
);
|
|
12
|
+
return new Factory(options);
|
|
13
|
+
}
|
|
4
14
|
import { clearNodeModulesFsCache } from '../utils/fs-cache';
|
|
5
15
|
|
|
6
16
|
const sourceExtensions = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.mts', '.cts', '.module.css', '.css'];
|
|
@@ -200,7 +210,7 @@ function baseOptions(): NapiResolveOptions {
|
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
function baseResolver() {
|
|
203
|
-
baseFactory ??=
|
|
213
|
+
baseFactory ??= createResolverFactory(baseOptions());
|
|
204
214
|
return baseFactory;
|
|
205
215
|
}
|
|
206
216
|
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
// The same parse also backs `rewriteFacts`: the record-shaped rewrite passes (specifier aliasing, namespace
|
|
11
11
|
// imports, import.meta.url) splice byte spans off it instead of re-scanning the source with their own
|
|
12
12
|
// regexes.
|
|
13
|
-
|
|
13
|
+
// Lazy: the oxc-parser native binding costs ~12.6 MB RSS; load it only when a parse happens.
|
|
14
|
+
const parseSync: typeof import('oxc-parser').parseSync = (...args) =>
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
|
+
loadNative(() => require('oxc-parser') as typeof import('oxc-parser')).parseSync(...args);
|
|
17
|
+
import { loadNative } from '../utils/native-require';
|
|
14
18
|
import { spliceSource } from '../dev/module-transform';
|
|
15
19
|
|
|
16
20
|
export interface ScanEdge {
|
package/src/routing/href.ts
CHANGED
|
@@ -28,14 +28,13 @@ declare global {
|
|
|
28
28
|
// once per process by the server runtime from the resolved config. On the
|
|
29
29
|
// client the value is read from the injected window global (register-render's
|
|
30
30
|
// trailingSlashScript), mirroring basePath / skipTrailingSlash.
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// Anchored on globalThis, not a module local: this module is dual-copied into
|
|
32
|
+
// the client/prebundled bundles, where setTrailingSlashUrls never runs.
|
|
33
33
|
export function setTrailingSlashUrls(enabled: boolean) {
|
|
34
|
-
|
|
34
|
+
globalThis.__PNEXT_TRAILING_SLASH__ = enabled;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function isTrailingSlashEnabled(): boolean {
|
|
38
|
-
if (trailingSlashUrls) return true;
|
|
39
38
|
if (process.browser || typeof window !== 'undefined') return window.__PNEXT_TRAILING_SLASH__ === true;
|
|
40
39
|
return globalThis.__PNEXT_TRAILING_SLASH__ === true;
|
|
41
40
|
}
|
|
@@ -76,7 +75,7 @@ export function withBasePath(path: string) {
|
|
|
76
75
|
export function applyTrailingSlash(pathname: string) {
|
|
77
76
|
const [, path = '', rest = ''] = /^([^?#]*)([\s\S]*)$/.exec(pathname) ?? [];
|
|
78
77
|
if (!path.startsWith('/')) return pathname;
|
|
79
|
-
if (
|
|
78
|
+
if (isTrailingSlashEnabled()) {
|
|
80
79
|
// Add the canonical trailing slash (skip root, already-slashed, and files).
|
|
81
80
|
if (path === '/' || path.endsWith('/') || /\.[^/]+$/.test(path)) return pathname;
|
|
82
81
|
return `${path}/${rest}`;
|
package/src/routing/routes.ts
CHANGED
|
@@ -340,7 +340,11 @@ export function setRouteFactsStore(store: RouteFactsStore | undefined) {
|
|
|
340
340
|
factsStore = store;
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
function withDeferredFacts(
|
|
343
|
+
function withDeferredFacts(
|
|
344
|
+
appPath: string,
|
|
345
|
+
base: RoutePathEntry,
|
|
346
|
+
compute: () => RouteFacts,
|
|
347
|
+
): RouteManifestEntry {
|
|
344
348
|
const entry = base as RouteManifestEntry;
|
|
345
349
|
let facts: RouteFacts | undefined;
|
|
346
350
|
// Consumers still assign some of these (the build marks needsRouterEntry), so
|
|
@@ -355,7 +359,10 @@ function withDeferredFacts(base: RoutePathEntry, compute: () => RouteFacts): Rou
|
|
|
355
359
|
get() {
|
|
356
360
|
if (overrides.has(field)) return overrides.get(field);
|
|
357
361
|
if (!facts) {
|
|
358
|
-
|
|
362
|
+
// Route ids repeat across apps ('page:/' everywhere), so the app the
|
|
363
|
+
// facts were derived in is part of the key - otherwise one app's
|
|
364
|
+
// record answers another app's lookup in the same process.
|
|
365
|
+
const key = `${appPath}\0${base.kind}:${base.id}`;
|
|
359
366
|
const stored = factsStore?.load(key);
|
|
360
367
|
facts = stored ?? withDirCache(compute);
|
|
361
368
|
if (!stored) factsStore?.save(key, facts);
|
|
@@ -415,6 +422,7 @@ function buildRouteTable(appPath: string, files: string[]): RouteManifestEntry[]
|
|
|
415
422
|
if (!metadataParts) continue;
|
|
416
423
|
routes.push(
|
|
417
424
|
withDeferredFacts(
|
|
425
|
+
appPath,
|
|
418
426
|
{
|
|
419
427
|
id: `metadata-${routeId(metadataParts.route)}`,
|
|
420
428
|
kind: 'handler',
|
|
@@ -462,6 +470,7 @@ function buildRouteTable(appPath: string, files: string[]): RouteManifestEntry[]
|
|
|
462
470
|
const interception = parts.interception;
|
|
463
471
|
routes.push(
|
|
464
472
|
withDeferredFacts(
|
|
473
|
+
appPath,
|
|
465
474
|
{
|
|
466
475
|
id: interception ? `intercept-${sanitizeIdPart(routeDir)}` : routeId(parts.route || '/'),
|
|
467
476
|
kind,
|
|
@@ -844,6 +853,7 @@ function appendSlotDerivedRoutes(
|
|
|
844
853
|
// interceptor during slot rendering. Kept minimal on purpose.
|
|
845
854
|
routes.push(
|
|
846
855
|
withDeferredFacts(
|
|
856
|
+
appPath,
|
|
847
857
|
{
|
|
848
858
|
id: `intercept-${sanitizeIdPart(candidate.relative.replace(pageOrRouteTrailing(), ''))}`,
|
|
849
859
|
kind: 'page',
|
|
@@ -905,6 +915,7 @@ function appendSlotDerivedRoutes(
|
|
|
905
915
|
|
|
906
916
|
routes.push(
|
|
907
917
|
withDeferredFacts(
|
|
918
|
+
appPath,
|
|
908
919
|
{
|
|
909
920
|
id: `slot-${routeId(parts.route || '/')}`,
|
|
910
921
|
kind: 'page',
|
|
@@ -1050,6 +1061,7 @@ function appendDefaultDerivedRoutes(
|
|
|
1050
1061
|
|
|
1051
1062
|
routes.push(
|
|
1052
1063
|
withDeferredFacts(
|
|
1064
|
+
appPath,
|
|
1053
1065
|
{
|
|
1054
1066
|
id: `default-${routeId(parts.route || '/')}`,
|
|
1055
1067
|
kind: 'page',
|
package/src/runtime/server.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
|
4
4
|
import { copyFile, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import {
|
|
7
|
+
import type { Loader, Metafile, OnLoadResult, OnResolveResult, Plugin } from 'esbuild';
|
|
8
|
+
import { build, transform } from '../utils/esbuild';
|
|
8
9
|
import {
|
|
9
10
|
applyBundledSourceTransforms,
|
|
10
11
|
applyServerSourcePreTransforms,
|
|
@@ -2836,9 +2837,12 @@ async function bundleExternalPackageImpl(
|
|
|
2836
2837
|
resolvedEntry?.match(/\.[cm]?tsx?$/) &&
|
|
2837
2838
|
!getExternalPackagePolicy().transpile(packageName)
|
|
2838
2839
|
) {
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2840
|
+
const parseFailure = `${path.relative(config.root, resolvedEntry)}\nModule parse failed: Unexpected token`;
|
|
2841
|
+
// Printed directly as well: the digest log path inspects the error's stack,
|
|
2842
|
+
// which some runtimes emit without the message - the CLI output must always
|
|
2843
|
+
// carry the parse failure (Next's transpilePackages error contract).
|
|
2844
|
+
console.error(`⨯ Error: ${parseFailure}`);
|
|
2845
|
+
throw new Error(parseFailure);
|
|
2842
2846
|
}
|
|
2843
2847
|
const entry =
|
|
2844
2848
|
requiredEntry ??
|
package/src/runtime/vendor.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
37
37
|
import { copyFile, mkdir, readFile } from 'node:fs/promises';
|
|
38
38
|
import path from 'node:path';
|
|
39
39
|
import { fileURLToPath } from 'node:url';
|
|
40
|
-
import { build } from 'esbuild';
|
|
40
|
+
import { build } from '../utils/esbuild';
|
|
41
41
|
import { isCommonJsModuleSource } from '../resolve/imports';
|
|
42
42
|
import { outputSpecifiers } from '../dev/module-transform';
|
|
43
43
|
import { escapeRegex, isIdentifier, uniqueIdentifier } from '../utils/source';
|
package/src/typegen.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function writeTypegen(
|
|
|
25
25
|
allRoutes: RouteManifestEntry[],
|
|
26
26
|
): Promise<TypegenResult> {
|
|
27
27
|
const routes = allRoutes.filter(route => !route.interception && !route.synthetic);
|
|
28
|
-
const file = path.join(config.
|
|
28
|
+
const file = path.join(config.typesPath, 'pnext.gen.d.ts');
|
|
29
29
|
const source = typegenSource(routes);
|
|
30
30
|
await writeIfChanged(file, source);
|
|
31
31
|
const aliases = await writeRouteAliases(config, routes);
|
|
@@ -57,7 +57,7 @@ function routeEntry(route: RouteManifestEntry) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
async function writeChecks(config: ResolvedConfig, routes: RouteManifestEntry[]) {
|
|
60
|
-
const checksDir = path.join(config.
|
|
60
|
+
const checksDir = path.join(config.typesPath, 'checks');
|
|
61
61
|
await rm(checksDir, { recursive: true, force: true });
|
|
62
62
|
await ensureDir(checksDir);
|
|
63
63
|
|
|
@@ -72,7 +72,7 @@ async function writeChecks(config: ResolvedConfig, routes: RouteManifestEntry[])
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
async function writeRouteAliases(config: ResolvedConfig, routes: RouteManifestEntry[]) {
|
|
75
|
-
const aliasesDir = path.join(config.
|
|
75
|
+
const aliasesDir = path.join(config.typesPath, 'app');
|
|
76
76
|
await rm(aliasesDir, { recursive: true, force: true });
|
|
77
77
|
await ensureDir(aliasesDir);
|
|
78
78
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Lazy esbuild facade: importing 'esbuild' eagerly costs ~2.7 MB RSS and its
|
|
2
|
+
// first build()/transform() call spawns a resident ~10-45 MB service child, so
|
|
3
|
+
// the library must not load until something actually compiles.
|
|
4
|
+
import type * as Esbuild from 'esbuild';
|
|
5
|
+
import { loadNative } from './native-require';
|
|
6
|
+
|
|
7
|
+
let esbuild: typeof Esbuild | undefined;
|
|
8
|
+
|
|
9
|
+
function load(): typeof Esbuild {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
|
+
return (esbuild ??= loadNative(() => require('esbuild') as typeof Esbuild));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// In-flight compiles, so a stop() never kills the service child out from under
|
|
15
|
+
// one: esbuild drops the pending call's promise and it never settles.
|
|
16
|
+
let inFlight = 0;
|
|
17
|
+
let stopWhenIdle = false;
|
|
18
|
+
|
|
19
|
+
function track<T>(promise: Promise<T>): Promise<T> {
|
|
20
|
+
inFlight += 1;
|
|
21
|
+
return promise.finally(() => {
|
|
22
|
+
inFlight -= 1;
|
|
23
|
+
if (inFlight === 0 && stopWhenIdle) {
|
|
24
|
+
stopWhenIdle = false;
|
|
25
|
+
void esbuild?.stop();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const build: typeof Esbuild.build = options => track(load().build(options));
|
|
31
|
+
|
|
32
|
+
export const transform: typeof Esbuild.transform = (input, options) =>
|
|
33
|
+
track(load().transform(input, options));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spawn the resident service child ahead of the first real compile. Fire-and-forget: the spawn and
|
|
37
|
+
* its handshake are subprocess work, so a caller holding wall-clock it does not control (a dev boot,
|
|
38
|
+
* which compiles nothing before it listens) can absorb a cost the first compile would otherwise pay.
|
|
39
|
+
*/
|
|
40
|
+
export function warmEsbuildService(): void {
|
|
41
|
+
void transform('', { loader: 'js' }).catch(() => undefined);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Kill the resident esbuild service child (a prod server is done compiling
|
|
46
|
+
* after its boot config build). No-op when esbuild never loaded; the service
|
|
47
|
+
* respawns transparently on the next build()/transform() call. Deferred while a
|
|
48
|
+
* compile is in flight — instrumentation register() bundles concurrently with
|
|
49
|
+
* server boot, and killing the service mid-build hangs it forever.
|
|
50
|
+
*/
|
|
51
|
+
export function stopEsbuildService(): void {
|
|
52
|
+
if (!esbuild) return;
|
|
53
|
+
if (inFlight > 0) {
|
|
54
|
+
stopWhenIdle = true;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
void esbuild.stop();
|
|
58
|
+
}
|
package/src/utils/fs.ts
CHANGED
|
@@ -92,9 +92,21 @@ export function listFilesSync(root: string): string[] {
|
|
|
92
92
|
return out;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/** Empty `dir`, leaving the named top-level entries (and their contents) in place. */
|
|
96
|
+
export async function ensureEmptyDir(dir: string, keep: readonly string[] = []) {
|
|
97
|
+
if (keep.length === 0) {
|
|
98
|
+
await rm(dir, { recursive: true, force: true });
|
|
99
|
+
await mkdir(dir, { recursive: true });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
97
102
|
await mkdir(dir, { recursive: true });
|
|
103
|
+
const preserved = new Set(keep);
|
|
104
|
+
const entries = await readdir(dir).catch(() => [] as string[]);
|
|
105
|
+
await Promise.all(
|
|
106
|
+
entries
|
|
107
|
+
.filter(entry => !preserved.has(entry))
|
|
108
|
+
.map(entry => rm(path.join(dir, entry), { recursive: true, force: true })),
|
|
109
|
+
);
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
export async function ensureDir(dir: string) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Native-binding loader guard.
|
|
2
|
+
//
|
|
3
|
+
// The lazy `require('esbuild' | 'oxc-*' | 'lightningcss')` facades defer a native binding until
|
|
4
|
+
// something actually compiles/parses/resolves - which means the FIRST load can land anywhere, including
|
|
5
|
+
// inside a render the compat edge runtime is wrapping. That runtime swaps a `process` proxy onto
|
|
6
|
+
// globalThis which hides `version`/`versions` (Next parity: user code must not detect Node there), and
|
|
7
|
+
// these loaders read `process.versions.node` / `process.versions.pnp` while initializing - so the
|
|
8
|
+
// require throws. Load them against the real process instead; the module cache then holds a good copy
|
|
9
|
+
// for every later call.
|
|
10
|
+
import realProcess from 'node:process';
|
|
11
|
+
|
|
12
|
+
export function loadNative<T>(load: () => T): T {
|
|
13
|
+
const globalObject = globalThis as typeof globalThis & { process?: NodeJS.Process };
|
|
14
|
+
if (globalObject.process === realProcess) return load();
|
|
15
|
+
const previous = Object.getOwnPropertyDescriptor(globalObject, 'process');
|
|
16
|
+
Object.defineProperty(globalObject, 'process', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
enumerable: false,
|
|
19
|
+
writable: true,
|
|
20
|
+
value: realProcess,
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
return load();
|
|
24
|
+
} finally {
|
|
25
|
+
if (previous) Object.defineProperty(globalObject, 'process', previous);
|
|
26
|
+
else delete (globalObject as { process?: NodeJS.Process }).process;
|
|
27
|
+
}
|
|
28
|
+
}
|