@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.
Files changed (62) hide show
  1. package/README.md +76 -20
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +75 -48
  5. package/src/api/router/runtime.ts +10 -2
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +37 -5
  8. package/src/cli/dev.ts +6 -0
  9. package/src/cli/index.ts +17 -3
  10. package/src/cli/request-pipeline.ts +1340 -0
  11. package/src/cli/server-entry.ts +180 -0
  12. package/src/cli/start.ts +39 -1311
  13. package/src/client/build.ts +59 -20
  14. package/src/client/chunk-fold.ts +40 -0
  15. package/src/client/compat-surface.ts +175 -0
  16. package/src/client/entry.ts +67 -52
  17. package/src/compat/actions/action-client.ts +8 -1
  18. package/src/compat/actions/action-dispatch.ts +11 -1
  19. package/src/compat/actions/discovery.ts +23 -6
  20. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  21. package/src/compat/bundler/worker.ts +2 -1
  22. package/src/compat/client/errors/bare-boundary.ts +32 -0
  23. package/src/compat/client/errors/error-boundary.ts +1 -15
  24. package/src/compat/client/errors/primitive-throw.ts +16 -0
  25. package/src/compat/client/link-status.ts +1 -1
  26. package/src/compat/css/lightningcss.ts +2 -1
  27. package/src/compat/css/modules.ts +4 -3
  28. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  29. package/src/compat/lifecycle/instrumentation.ts +5 -2
  30. package/src/compat/next/config-loader.ts +33 -5
  31. package/src/compat/next/dynamic.tsx +9 -5
  32. package/src/compat/next/link-validation-transform.ts +5 -1
  33. package/src/compat/next/link.tsx +51 -58
  34. package/src/compat/pages/client-plugin.ts +2 -1
  35. package/src/compat/react/action-state.ts +159 -0
  36. package/src/compat/react/client-lite.ts +74 -0
  37. package/src/compat/react/hooks-extra.ts +92 -0
  38. package/src/compat/react/parity.ts +128 -0
  39. package/src/compat/react/preact.ts +33 -420
  40. package/src/compat/react/server-inserted-html.ts +14 -7
  41. package/src/compat/react/use.ts +72 -0
  42. package/src/compat/register/actions.ts +27 -7
  43. package/src/config.ts +15 -1
  44. package/src/css/build.ts +13 -2
  45. package/src/dev/imports.ts +34 -5
  46. package/src/dev/module-cache.ts +19 -0
  47. package/src/dev/module-transform.ts +7 -1
  48. package/src/dev/server.ts +91 -22
  49. package/src/ppr.ts +5 -4
  50. package/src/proxy.ts +5 -1
  51. package/src/render/island-context.ts +21 -3
  52. package/src/render/renderer.ts +102 -26
  53. package/src/resolve/engine.ts +12 -2
  54. package/src/resolve/scan-facts.ts +5 -1
  55. package/src/routing/href.ts +4 -5
  56. package/src/routing/routes.ts +14 -2
  57. package/src/runtime/server.ts +8 -4
  58. package/src/runtime/vendor.ts +1 -1
  59. package/src/typegen.ts +3 -3
  60. package/src/utils/esbuild.ts +58 -0
  61. package/src/utils/fs.ts +14 -2
  62. package/src/utils/native-require.ts +28 -0
@@ -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(() => renderNotFoundPage(notFoundOptions, false, { forceDefault })),
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(() => pageMetadata(mergeMetadataEntries(layoutRender.metadata)))
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: { status?: number; nextFlightText?: boolean; forceDefault?: boolean } = {},
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.', robots: 'noindex' },
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.', robots: 'noindex' },
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
- { robots: 'noindex', ...resolvedMetadata },
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
- const events = new EventSource('/__pnext/events');
7371
- events.addEventListener('reload', () => location.reload());
7372
- events.addEventListener('css-update', event => {
7373
- const links = [...document.querySelectorAll('link[rel=stylesheet]')].filter(link => {
7374
- try { return new URL(link.href, location.href).origin === location.origin; } catch { return false; }
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
- if (links.length === 0) { location.reload(); return; }
7377
- for (const link of links) {
7378
- const url = new URL(link.href, location.href);
7379
- url.searchParams.set('__pnext_css', event.data);
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
  }
@@ -1,6 +1,16 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { ResolverFactory, type NapiResolveOptions } from 'oxc-resolver';
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 ??= new ResolverFactory(baseOptions());
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
- import { parseSync } from 'oxc-parser';
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 {
@@ -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
- let trailingSlashUrls = false;
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
- trailingSlashUrls = enabled;
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 (trailingSlashUrls) {
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}`;
@@ -340,7 +340,11 @@ export function setRouteFactsStore(store: RouteFactsStore | undefined) {
340
340
  factsStore = store;
341
341
  }
342
342
 
343
- function withDeferredFacts(base: RoutePathEntry, compute: () => RouteFacts): RouteManifestEntry {
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
- const key = `${base.kind}:${base.id}`;
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',
@@ -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 { build, transform, type Loader, type Metafile, type OnLoadResult, type OnResolveResult, type Plugin } from 'esbuild';
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
- throw new Error(
2840
- `${path.relative(config.root, resolvedEntry)}\nModule parse failed: Unexpected token`,
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 ??
@@ -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.outPath, 'types', 'pnext.gen.d.ts');
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.outPath, 'types', 'checks');
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.outPath, 'types', 'app');
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
- export async function ensureEmptyDir(dir: string) {
96
- await rm(dir, { recursive: true, force: true });
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
+ }