@wular/pnext 0.1.4 → 0.1.6

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 (47) hide show
  1. package/config/ts/react.json +2 -1
  2. package/package.json +1 -1
  3. package/reference/navigation.md +3 -1
  4. package/src/api/client-navigation.ts +53 -4
  5. package/src/api/link.tsx +6 -7
  6. package/src/cli/adapters/vercel-warm.ts +6 -1
  7. package/src/cli/adapters/vercel.ts +1 -1
  8. package/src/cli/build/cache.ts +8 -3
  9. package/src/cli/build.ts +5 -12
  10. package/src/cli/index.ts +1 -8
  11. package/src/cli/serve/entry.ts +16 -161
  12. package/src/cli/serve/pipeline.ts +11 -3
  13. package/src/cli/start.ts +1 -1
  14. package/src/cli/typegen.ts +65 -3
  15. package/src/client/build.ts +10 -7
  16. package/src/client/entry.ts +50 -29
  17. package/src/client/reference-stub.ts +1 -1
  18. package/src/client/router/hub.ts +13 -11
  19. package/src/client/router/page-slot.ts +58 -0
  20. package/src/client/router/runtime.ts +377 -388
  21. package/src/compat/bundler/symlink-imports.ts +1 -1
  22. package/src/compat/client/errors/bare-boundary.ts +1 -1
  23. package/src/compat/client/errors/error-boundary.ts +1 -1
  24. package/src/compat/client/errors/global-error.ts +40 -79
  25. package/src/compat/client/errors/install.ts +1 -1
  26. package/src/compat/client/errors/lazy.ts +7 -0
  27. package/src/compat/client/errors/primitive-throw.ts +36 -53
  28. package/src/compat/client/segment-cache-policy.ts +5 -22
  29. package/src/compat/client/segment-cache.ts +7 -26
  30. package/src/compat/css/modules.ts +1 -7
  31. package/src/compat/css/sass.ts +1 -7
  32. package/src/compat/react/parity.ts +1 -1
  33. package/src/compat/register/bundler.ts +9 -1
  34. package/src/compat/tsconfig-defaults.ts +4 -7
  35. package/src/compat-bootstrap.ts +15 -0
  36. package/src/css/build.ts +18 -15
  37. package/src/dev/server.ts +2 -2
  38. package/src/index.ts +2 -0
  39. package/src/render/renderer.ts +209 -24
  40. package/src/render/resource-hints.ts +4 -1
  41. package/src/resolve/imports.ts +13 -8
  42. package/src/routing/routes.ts +32 -18
  43. package/src/runtime/loader.ts +29 -10
  44. package/src/runtime/modules.ts +28 -7
  45. package/src/runtime/vendor-build.ts +2 -2
  46. package/src/types.ts +2 -0
  47. package/src/utils/fs.ts +122 -0
@@ -4,6 +4,7 @@
4
4
  "extends": "./base.json",
5
5
  "compilerOptions": {
6
6
  "lib": ["DOM", "DOM.Iterable", "ESNext"],
7
- "jsx": "react-jsx"
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "preact"
8
9
  }
9
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -92,7 +92,9 @@ Exported from `@wular/pnext/navigation/client`, for use in components marked `"u
92
92
  | `useSearchParams()` | Read-only URL search params. |
93
93
  | `useParams<Route>()` | Current route params from the initial route state. |
94
94
  | `useRoute<Route>()` | Pathname, route template, params, and search params together. |
95
- | `useLinkStatus()` | Currently always `{ pending: false }`. |
95
+ | `useLinkStatus()` | `{ pending }` for the enclosing `<Link>`. |
96
+
97
+ `useLinkStatus()` tracks the pending state of a `<Link>`, so call it in a component rendered inside one. `pending` turns true when the link is clicked and false once its navigation paints a loading state or the page, fails, or another navigation replaces it.
96
98
 
97
99
  Router pushes and replaces are typed like links. They soft-navigate when the path or query changes, and fall back to the History API for same-URL hash updates. Cross-origin targets become a full page load. Refreshing re-fetches the current route in place.
98
100
 
@@ -1,6 +1,8 @@
1
+ import { options } from 'preact'
1
2
  import { useEffect, useState } from 'preact/hooks'
2
3
  import { ReadonlyURLSearchParams, type HrefOptions } from './navigation'
3
4
  import { blockJavascriptUrl, prefetchRoute, softNavigate } from '../client/router'
5
+ import { routerState } from '../client/router/hub'
4
6
  import { seedHistoryEntry } from '../client/router/history'
5
7
  import {
6
8
  emitLocationChange,
@@ -158,8 +160,31 @@ export function useRoute<Route extends RoutePath = RoutePath>(): CurrentRoute<Ro
158
160
  }
159
161
  }
160
162
 
163
+ interface HookOptions {
164
+ __h?: (component: { __P?: Element }, ...rest: unknown[]) => void
165
+ }
166
+
161
167
  export function useLinkStatus() {
162
- return { pending: false }
168
+ // The router marks a link pending through its anchor, so find the Link this component renders in.
169
+ const hookOptions = options as HookOptions
170
+ const hook = hookOptions.__h
171
+ let parent: Element | undefined
172
+ hookOptions.__h = (component, ...rest) => {
173
+ parent = component.__P
174
+ hook?.(component, ...rest)
175
+ }
176
+ const [pending, setPending] = useState(false)
177
+ hookOptions.__h = hook
178
+ useEffect(() => {
179
+ const link = parent?.closest<HTMLAnchorElement & { __pnextHints?: Set<typeof setPending> }>(
180
+ 'a[data-pnext-link]',
181
+ )
182
+ if (!link) return
183
+ const hints = (link.__pnextHints ??= new Set())
184
+ hints.add(setPending)
185
+ return () => hints.delete(setPending)
186
+ }, [])
187
+ return { pending }
163
188
  }
164
189
 
165
190
  function useLocation() {
@@ -196,11 +221,33 @@ function locationSignature(location: { pathname: string; search: string; hash: s
196
221
  return `${location.pathname}${location.search}${location.hash}`
197
222
  }
198
223
 
224
+ // Server only: a request/context store, anchored on globalThis.
225
+ interface ServerStore {
226
+ request?: { url: string }
227
+ params?: Record<string, RouteParamValue>
228
+ routeMode?: string
229
+ }
230
+
231
+ function serverStore(key: string) {
232
+ const storage = (globalThis as Record<symbol, { getStore(): ServerStore | undefined }>)[
233
+ Symbol.for(key)
234
+ ]
235
+ return storage?.getStore()
236
+ }
237
+
199
238
  function currentLocation() {
200
239
  if (!process.browser && typeof window === 'undefined') {
240
+ const href = serverStore('pnext.requestStorage')?.request?.url
241
+ const url = href ? new URL(href) : undefined
201
242
  return {
202
- pathname: '/',
203
- search: '',
243
+ pathname: url?.pathname ?? '/',
244
+ // Like Next: dev and dynamic renders see the query; static and cached HTML must not bake it in.
245
+ search:
246
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
247
+ process.env.NODE_ENV === 'development' ||
248
+ serverStore('pnext.workUnitStorage')?.routeMode === 'dynamic'
249
+ ? (url?.search ?? '')
250
+ : '',
204
251
  hash: '',
205
252
  }
206
253
  }
@@ -212,7 +259,8 @@ function currentLocation() {
212
259
  }
213
260
 
214
261
  function routeState() {
215
- if (!process.browser && typeof window === 'undefined') return {}
262
+ if (!process.browser && typeof window === 'undefined')
263
+ return { params: serverStore('pnext.requestStorage')?.params } as RouteState
216
264
  const state = (history.state as Record<string, unknown> | null) ?? {}
217
265
  const routeState = window.__PNEXT_ROUTE__ ?? {}
218
266
  const bfcacheId = typeof state.__pnextBfcacheId === 'string' ? state.__pnextBfcacheId : undefined
@@ -242,6 +290,7 @@ function navigate(url: string, mode: 'push' | 'replace', scroll = true) {
242
290
  if (mode === 'replace') window.history.replaceState(history.state, '', next)
243
291
  else window.history.pushState(history.state, '', next)
244
292
  scheduleNavigationScroll(next, { scroll })
293
+ routerState.settleLink?.()
245
294
  }
246
295
 
247
296
  // Exported so compat's link-status can guarantee raw history.pushState /
package/src/api/link.tsx CHANGED
@@ -1,7 +1,6 @@
1
1
  /** @jsxImportSource preact */
2
2
  import { h, type ComponentChildren, type JSX } from 'preact'
3
3
  import { useEffect, useRef } from 'preact/hooks'
4
- import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from 'react'
5
4
  import { getDefaultPrefetchMode, routeHref, type SearchInput } from '../routing/href'
6
5
  import type { PrefetchMode, RouteParams, RoutePath } from '../types'
7
6
 
@@ -9,9 +8,9 @@ export interface NavigateEvent {
9
8
  preventDefault(): void
10
9
  }
11
10
 
12
- type LinkMouseEvent = MouseEvent<HTMLAnchorElement>
11
+ type LinkMouseEvent = JSX.TargetedMouseEvent<HTMLAnchorElement>
13
12
 
14
- type BaseLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'onClick'> & {
13
+ type BaseLinkProps = Omit<JSX.IntrinsicElements['a'], 'href' | 'onClick' | 'ref'> & {
15
14
  prefetch?: PrefetchMode
16
15
  replace?: boolean
17
16
  scroll?: boolean
@@ -21,7 +20,7 @@ type BaseLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'onC
21
20
  onNavigate?: (event: NavigateEvent) => void
22
21
  onPrefetchStart?: () => void
23
22
  onPrefetchFinish?: () => void
24
- children?: ReactNode
23
+ children?: ComponentChildren
25
24
  }
26
25
 
27
26
  type ParamsProp<Route extends RoutePath> =
@@ -48,7 +47,7 @@ export function Link<Route extends RoutePath>({
48
47
  onPrefetchFinish,
49
48
  children,
50
49
  ...anchorProps
51
- }: LinkProps<Route>): ReactNode {
50
+ }: LinkProps<Route>): ComponentChildren {
52
51
  const resolvedHref = routeHref(href, { params: params ?? {}, search, hash })
53
52
  // A link's own prefetch prop wins; otherwise the app-wide config default.
54
53
  const prefetchMode = prefetch ?? getDefaultPrefetchMode()
@@ -72,8 +71,8 @@ export function Link<Route extends RoutePath>({
72
71
  if (navEvent.defaultPrevented) clickEvent.preventDefault()
73
72
  },
74
73
  } as JSX.HTMLAttributes<HTMLAnchorElement>,
75
- children as ComponentChildren,
76
- ) as unknown as ReactNode
74
+ children,
75
+ )
77
76
  }
78
77
 
79
78
  // The prefetch runtime dispatches lifecycle events on the anchor that
@@ -1,3 +1,4 @@
1
+ import { resolveManifest } from '../../utils/fs'
1
2
  /**
2
3
  * Module warm-up for a production build - run in its own process.
3
4
  *
@@ -549,7 +550,11 @@ if (import.meta.main) {
549
550
  // only thing on stderr.
550
551
  const manifestFile = path.join(config.outPath, 'manifest.json')
551
552
  if (!request.skip || !existsSync(manifestFile)) process.exit(0)
552
- const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) as BuildManifest
553
+ const manifest = resolveManifest(
554
+ JSON.parse(await readFile(manifestFile, 'utf8')) as BuildManifest,
555
+ config.outPath,
556
+ config.root,
557
+ )
553
558
  await warmRouteModules(config, manifest, new Set(request.skip ?? []), mode)
554
559
  // App code reached through a route handler can leave the loop alive (a db
555
560
  // pool, a stray interval); the warm pass is done either way.
@@ -1012,7 +1012,7 @@ async function traceNodeModulesClosure(
1012
1012
  // packages - workspace source gets traced further. Bare package specifiers are also recorded
1013
1013
  // so the closure ships (or workspace-links) their node_modules entries; workspace packages
1014
1014
  // need the link even when their source is traced.
1015
- const resolved = resolveImport(config.root, file, specifier)
1015
+ const resolved = resolveImport(config.root, file, specifier, config.workspaceRoot)
1016
1016
  if (
1017
1017
  resolved &&
1018
1018
  isInsideDir(workspaceRoot, resolved) &&
@@ -1,3 +1,4 @@
1
+ import { resolveManifest } from '../../utils/fs'
1
2
  import { createHash } from 'node:crypto'
2
3
  import { existsSync } from 'node:fs'
3
4
  import { lstat, mkdir, readFile, readdir, readlink } from 'node:fs/promises'
@@ -73,9 +74,13 @@ export async function lookupBuildCache(
73
74
  return { enabled: true, context, inputs, reason: `output (${changed || 'unknown'})` }
74
75
  }
75
76
  try {
76
- const manifest = JSON.parse(
77
- await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
78
- ) as BuildManifest
77
+ const manifest = resolveManifest(
78
+ JSON.parse(
79
+ await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
80
+ ) as BuildManifest,
81
+ config.outPath,
82
+ config.root,
83
+ )
79
84
  return { enabled: true, context, inputs, manifest, reason: 'hit' }
80
85
  } catch {
81
86
  return { enabled: true, context, inputs, reason: 'manifest' }
package/src/cli/build.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { serializeBuildManifest } from '../utils/fs'
1
2
  import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises'
2
3
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
3
4
  import { createRequire } from 'node:module'
@@ -90,6 +91,7 @@ import {
90
91
  import { interceptionMarkerLevels } from '../routing/slots'
91
92
  import { writeTypegen } from './typegen'
92
93
  import { lookupBuildCache, writeBuildCache } from './build/cache'
94
+ import { emitServerEntry } from './serve/entry'
93
95
  import { createVerboseLogger, type VerboseLogger } from '../utils/verbose'
94
96
  import { bold, cyan, dim, green } from '../utils/ansi'
95
97
  import type {
@@ -229,15 +231,6 @@ async function runBuild(
229
231
  await ensureEmptyDir(config.outPath, [devOutSegment])
230
232
  await copyPublicDir(config.publicPath, path.join(config.outPath, 'public'))
231
233
  })
232
- // Prebundled server entry for `pnext start`: framework-only, independent of the
233
- // app build, so it runs in a child process for the whole build — its bundling
234
- // heap never stacks on the build's peak RSS and its wall hides under the build.
235
- // Best-effort — a failure only costs start time. Awaited before the summary.
236
- const serverEntryDone = import('./serve/entry')
237
- .then(entry => entry.emitServerEntryChild(config.outPath))
238
- .catch((error: Error) => {
239
- console.warn(`pnext build: server entry bundling skipped — ${error.message}`)
240
- })
241
234
  // The document-level stylesheets run their postcss/Tailwind pass on the CSS
242
235
  // worker, so they overlap with the route scan below instead of serializing
243
236
  // ahead of it. Awaited before prepareRouteCssChunks — route CSS still builds
@@ -257,7 +250,7 @@ async function runBuild(
257
250
  const partytownLib = log.step('partytown lib', () => copyPartytownLib(config))
258
251
  partytownLib.catch(() => undefined)
259
252
 
260
- let routes = await log.step('scan routes', () => scanRoutes(config.appPath))
253
+ let routes = await log.step('scan routes', () => scanRoutes(config.appPath, config.workspaceRoot))
261
254
  log.log(`found ${routes.length} route${routes.length === 1 ? '' : 's'}`)
262
255
  if (options.debugBuildPaths) {
263
256
  routes = filterDebugBuildRoutes(config.root, routes, options.debugBuildPaths)
@@ -937,7 +930,7 @@ async function runBuild(
937
930
  }
938
931
  await writeBuildManifest(config.outPath, manifest)
939
932
  log.log(`wrote manifest.json (${routes.length} route${routes.length === 1 ? '' : 's'})`)
940
- await log.step('server entry', () => serverEntryDone)
933
+ await log.step('server entry', () => emitServerEntry(config.outPath))
941
934
  for (const hook of getBuildExtensions().completeHooks) {
942
935
  await hook({ config, manifest, log })
943
936
  }
@@ -1055,7 +1048,7 @@ async function writeBuildManifest(outPath: string, manifest: BuildManifest) {
1055
1048
  assertManifestServerArtifacts(outPath, manifest)
1056
1049
  const file = path.join(outPath, 'manifest.json')
1057
1050
  const temporary = path.join(outPath, `.manifest-${process.pid}.tmp`)
1058
- await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`)
1051
+ await writeFile(temporary, serializeBuildManifest(manifest))
1059
1052
  await rename(temporary, file)
1060
1053
  }
1061
1054
 
package/src/cli/index.ts CHANGED
@@ -90,14 +90,7 @@ try {
90
90
  } else if (command === 'start') {
91
91
  warnUnknownFlags(command, args)
92
92
  const root = positionalRoot(args)
93
- // The build emits src/cli/start.ts prebundled into one file; parsing that
94
- // instead of walking the framework's source graph is most of `start`'s
95
- // spawn→first-200. Absent (no build yet, custom outDir) → source path.
96
- const { prebuiltServerEntry } = await import('./serve/entry')
97
- const prebuilt = prebuiltServerEntry(root)
98
- const { start } = prebuilt
99
- ? ((await import(prebuilt)) as typeof import('./start'))
100
- : await import('./start')
93
+ const { start } = await import('./start')
101
94
  await start({
102
95
  root,
103
96
  port: optionNumber(args, '--port'),
@@ -1,12 +1,11 @@
1
1
  /**
2
- * Prebundled production server entry: `pnext build` bundles the framework's server graph into one JS
3
- * file (entry point IS src/cli/start.ts, so the identical `start()` runs) and `pnext start` parses
4
- * that instead of walking hundreds of source modules - the dominant share of spawn->first-200.
2
+ * Server entry for hosts that serve a build through `createRequestHandler`: a thin loader that
3
+ * resolves `@wular/pnext` from the output's own location (as `.next/server` loads `next`), so the
4
+ * output carries no framework code and no build-machine paths.
5
5
  */
6
- import { existsSync, readFileSync } from 'node:fs'
6
+ import { readFileSync } from 'node:fs'
7
+ import { mkdir, writeFile } from 'node:fs/promises'
7
8
  import path from 'node:path'
8
- import { pathToFileURL } from 'node:url'
9
- import type { Plugin as EsbuildPlugin } from 'esbuild'
10
9
  import { frameworkFingerprint } from '../../runtime/fingerprint'
11
10
 
12
11
  const frameworkRoot = path.resolve(import.meta.dirname, '..', '..', '..')
@@ -28,163 +27,19 @@ function frameworkVersion(): string {
28
27
  * cache-root record so a restart pays one stat per file rather than re-reading the tree.
29
28
  */
30
29
  export function serverEntryDir(outPath: string): string {
31
- // Cache root spelled inline, not imported: this module is parsed before anything else on the
32
- // start path, and pulling in the module cache for one path.join would drag its whole graph along.
33
30
  const generation = frameworkFingerprint(path.join(outPath, 'cache', 'server'))
34
31
  return path.join(outPath, 'server', `bundle-${frameworkVersion()}-${generation}`)
35
32
  }
36
33
 
37
- function serverEntryFile(outPath: string): string {
38
- return path.join(serverEntryDir(outPath), 'entry.js')
39
- }
40
-
41
- /**
42
- * The prebuilt entry for a project root, or undefined when there is none to
43
- * use (no build, a custom `outDir`, a version or source-generation mismatch, or the opt-out). The
44
- * caller then falls back to importing src/cli/start.ts directly, so a miss only
45
- * costs speed. Deliberately cheap: one existsSync and one package.json read,
46
- * because it runs before anything else on the start path.
47
- */
48
- export function prebuiltServerEntry(root = process.cwd()): string | undefined {
49
- // eslint-disable-next-line turbo/no-undeclared-env-vars
50
- if (process.env.PNEXT_NO_SERVER_BUNDLE === '1') return undefined
51
- const file = serverEntryFile(path.resolve(root, '.pnext'))
52
- return existsSync(file) ? pathToFileURL(file).href : undefined
53
- }
54
-
55
- /**
56
- * Bundle src/cli/start.ts under `<outPath>/server/bundle-<version>-<fingerprint>/`. Bare
57
- * specifiers stay external (the framework's deps are already plain JS), so only
58
- * the framework's own source is inlined. Splitting is on so the graph behind a
59
- * dynamic import (compat, dev, build) stays in its own chunk — folding it into
60
- * the entry would evaluate eagerly what the source graph never even reads.
61
- * Returns the emitted entry, or undefined when bundling failed — the build must
62
- * not fail over a start-time optimization.
63
- */
64
- export async function emitServerEntry(outPath: string): Promise<string | undefined> {
65
- const { build } = await import('../../utils/esbuild')
66
- try {
67
- await build({
68
- entryPoints: [path.join(import.meta.dirname, '..', 'start.ts')],
69
- outdir: serverEntryDir(outPath),
70
- entryNames: 'entry',
71
- chunkNames: '[name]-[hash]',
72
- bundle: true,
73
- splitting: true,
74
- format: 'esm',
75
- platform: 'node',
76
- target: 'esnext',
77
- sourcemap: false,
78
- tsconfig: path.join(frameworkRoot, 'tsconfig.json'),
79
- logLevel: 'silent',
80
- // esbuild's ESM `require` shim throws for anything it did not bundle; the
81
- // framework's lazy `require('esbuild' | 'oxc-*')` facades need a real one,
82
- // anchored at the framework so they load ITS copy, not the app's.
83
- banner: {
84
- js:
85
- 'import { createRequire as __pnextCreateRequire } from "node:module";\n' +
86
- `const require = __pnextCreateRequire(${JSON.stringify(
87
- pathToFileURL(path.join(frameworkRoot, 'package.json')).href,
88
- )});`,
89
- },
90
- plugins: [await serverEntryPlugin(outPath)],
91
- })
92
- return serverEntryFile(outPath)
93
- } catch (error) {
94
- console.warn(`pnext build: server entry bundling skipped — ${(error as Error).message}`)
95
- return undefined
96
- }
97
- }
98
-
99
- /**
100
- * Emit in a short-lived child process: the bundle's source strings and parse
101
- * allocations land in the child's heap (well under the build's own peak), not
102
- * on top of it. Falls back to in-process emission if the spawn fails.
103
- */
104
- export async function emitServerEntryChild(outPath: string): Promise<void> {
105
- try {
106
- const child = Bun.spawn([process.execPath, import.meta.filename, outPath], {
107
- stdout: 'ignore',
108
- stderr: 'inherit',
109
- })
110
- if ((await child.exited) === 0) return
111
- } catch {
112
- // fall through to in-process emission
113
- }
114
- await emitServerEntry(outPath)
115
- }
116
-
117
- async function serverEntryPlugin(outPath: string): Promise<EsbuildPlugin> {
118
- const { rewriteFacts } = await import('../../resolve/scan-facts')
119
- const { spliceSource } = await import('../../runtime/module-transform')
120
- return {
121
- name: 'pnext-server-entry',
122
- setup(build) {
123
- // Bare specifiers (and node builtins) stay out of the bundle. They must
124
- // still resolve to the SAME file the framework's own source graph would
125
- // reach: keep the specifier bare when the output directory resolves it
126
- // identically (portable), otherwise pin the absolute path.
127
- build.onResolve({ filter: /^[^./]/ }, args => {
128
- if (args.path.startsWith('node:')) return { path: args.path, external: true }
129
- const target = resolveFrom(args.path, frameworkRoot)
130
- if (!target) return { path: args.path, external: true }
131
- const fromOutput = resolveFrom(args.path, serverEntryDir(outPath))
132
- return { path: fromOutput === target ? args.path : target, external: true }
133
- })
134
- // `import.meta` in a bundled module still means the ORIGINAL source file
135
- // (the framework locates its own runtime shims that way). oxc's exact
136
- // spans keep occurrences in strings and comments untouched.
137
- build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async args => {
138
- const source = await Bun.file(args.path).text()
139
- if (!source.includes('import.meta')) return undefined
140
- const facts = rewriteFacts(args.path, source)
141
- if (facts.unreliable) return undefined
142
- const edits = facts.importMetas.flatMap(meta => {
143
- const value = importMetaValue(source.slice(meta.end, meta.end + 16), args.path)
144
- return value
145
- ? [{ start: meta.start, end: meta.end + value.length, value: value.code }]
146
- : []
147
- })
148
- if (edits.length === 0) return undefined
149
- return { contents: spliceSource(source, edits), loader: loaderFor(args.path) }
150
- })
151
- },
152
- }
153
- }
154
-
155
- const importMetaProperties = ['dirname', 'filename', 'resolve', 'url', 'dir'] as const
156
-
157
- /** The literal a bundled `import.meta.<prop>` must become, given the tail after `import.meta`. */
158
- function importMetaValue(tail: string, file: string): { code: string; length: number } | undefined {
159
- const property = importMetaProperties.find(name => new RegExp(`^\\.${name}\\b`).test(tail))
160
- if (!property) return undefined
161
- const length = property.length + 1
162
- const dir = path.dirname(file)
163
- if (property === 'dirname' || property === 'dir') return { code: JSON.stringify(dir), length }
164
- if (property === 'filename') return { code: JSON.stringify(file), length }
165
- if (property === 'url') return { code: JSON.stringify(pathToFileURL(file).href), length }
166
- return {
167
- code: `(specifier => Bun.pathToFileURL(Bun.resolveSync(specifier, ${JSON.stringify(dir)})).href)`,
168
- length,
169
- }
170
- }
171
-
172
- function loaderFor(file: string): 'ts' | 'tsx' | 'js' | 'jsx' {
173
- if (file.endsWith('.tsx')) return 'tsx'
174
- if (file.endsWith('.jsx')) return 'jsx'
175
- return /\.[cm]?ts$/.test(file) ? 'ts' : 'js'
176
- }
177
-
178
- function resolveFrom(specifier: string, dir: string): string | undefined {
179
- try {
180
- return Bun.resolveSync(specifier, dir)
181
- } catch {
182
- return undefined
183
- }
184
- }
185
-
186
- // Child-process entry for emitServerEntryChild; last so every binding above is initialized.
187
- if (import.meta.main && process.argv[2]) {
188
- const emitted = await emitServerEntry(process.argv[2])
189
- process.exit(emitted ? 0 : 1)
34
+ /** Write `<outPath>/server/bundle-<version>-<fingerprint>/entry.js`, re-exporting src/cli/start.ts. */
35
+ export async function emitServerEntry(outPath: string): Promise<void> {
36
+ const start = await Bun.file(path.join(import.meta.dirname, '..', 'start.ts')).text()
37
+ const names = new Bun.Transpiler({ loader: 'ts' }).scan(start).exports
38
+ const dir = serverEntryDir(outPath)
39
+ await mkdir(dir, { recursive: true })
40
+ await writeFile(
41
+ path.join(dir, 'entry.js'),
42
+ `const pnext = await import(new URL('./cli/start.ts', import.meta.resolve('@wular/pnext')).href)\n` +
43
+ `export const { ${names.join(', ')} } = pnext\n`,
44
+ )
190
45
  }
@@ -1,3 +1,4 @@
1
+ import { resolveManifest } from '../../utils/fs'
1
2
  /**
2
3
  * The production request pipeline. Split out of `start.ts` so the prebundled
3
4
  * server entry parses only what listening needs: this module (renderer,
@@ -99,9 +100,16 @@ export async function createRequestHandler(
99
100
  const manifestPath = path.join(config.outPath, 'manifest.json')
100
101
  // `start` reads the manifest before binding the port (a missing build must
101
102
  // still fail there, not on the first request) and hands it over.
102
- const manifest =
103
- options.manifest ?? (JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest)
104
- const persistManifest = () => writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
103
+ const manifest = resolveManifest(
104
+ options.manifest ?? (JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest),
105
+ config.outPath,
106
+ config.root,
107
+ )
108
+ const persistManifest = async () => {
109
+ const stored = JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest
110
+ stored.staticFiles = manifest.staticFiles
111
+ await writeFile(manifestPath, `${JSON.stringify(stored, null, 2)}\n`)
112
+ }
105
113
  // Compat action-registry arming + serverActions.bodySizeLimit resolution +
106
114
  // fetch-cache install now run through the extension registry: the action
107
115
  // dispatch interceptor rebuilds the registry from manifest.actions on the
package/src/cli/start.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * `pnext start`: the listen path only. Everything a request needs — renderer,
3
3
  * routing, server runtime — lives in `./serve/pipeline` and is imported
4
4
  * dynamically once the port is bound, so neither the listen syscall nor the
5
- * ready banner waits on parsing it (it is ~700 KB of the prebundled entry).
5
+ * ready banner waits on parsing it.
6
6
  */
7
7
  import { readFile } from 'node:fs/promises'
8
8
  import path from 'node:path'
@@ -3,7 +3,8 @@ import { readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import type { ResolvedConfig } from '../config'
5
5
  import { loadConfig } from '../config'
6
- import { ensureDir, toPosixPath } from '../utils/fs'
6
+ import { bootstrapCompatBoot, generateCompatRouteTypes } from '../compat-bootstrap'
7
+ import { ensureDir, includeTypes, toPosixPath, writeFileAtomic } from '../utils/fs'
7
8
  import { scanRoutes } from '../routing/routes'
8
9
  import type { RouteManifestEntry } from '../types'
9
10
 
@@ -16,8 +17,11 @@ export interface TypegenResult {
16
17
 
17
18
  export async function typegenProject(root?: string): Promise<TypegenResult> {
18
19
  const config = await loadConfig(root)
19
- const routes = await scanRoutes(config.appPath)
20
- return writeTypegen(config, routes)
20
+ await bootstrapCompatBoot(config)
21
+ const routes = await scanRoutes(config.appPath, config.workspaceRoot)
22
+ const result = await writeTypegen(config, routes)
23
+ await generateCompatRouteTypes(config, routes)
24
+ return result
21
25
  }
22
26
 
23
27
  export async function writeTypegen(
@@ -28,6 +32,7 @@ export async function writeTypegen(
28
32
  const file = path.join(config.typesPath, 'pnext.gen.d.ts')
29
33
  const source = typegenSource(routes)
30
34
  await writeIfChanged(file, source)
35
+ await writeCoreEnv(config, file, allRoutes)
31
36
  const aliases = await writeRouteAliases(config, routes)
32
37
  const checks = await writeChecks(config, routes)
33
38
  return { file, routes: routes.length, checks, aliases }
@@ -162,3 +167,60 @@ async function writeIfChanged(file: string, source: string) {
162
167
  await ensureDir(path.dirname(file))
163
168
  await writeFile(file, source)
164
169
  }
170
+
171
+ async function writeCoreEnv(
172
+ config: ResolvedConfig,
173
+ typesFile: string,
174
+ routes: RouteManifestEntry[],
175
+ ) {
176
+ if (config.compat?.next) return
177
+ const file = path.join(config.root, 'tsconfig.json')
178
+ const exists = existsSync(file)
179
+ if (!exists && !routes.some(route => route.sourceFiles.some(file => /\.[cm]?tsx?$/.test(file))))
180
+ return
181
+ const reference = toPosixPath(path.relative(config.root, typesFile))
182
+ await writeIfChanged(
183
+ path.join(config.root, 'pnext-env.d.ts'),
184
+ `/// <reference types="@wular/pnext" />\n/// <reference path="${reference}" />\n\n// This file is generated by PNext. Do not edit.\n`,
185
+ )
186
+ if (!exists) return
187
+ let source: string
188
+ let tsconfig: { include?: unknown }
189
+ try {
190
+ source = await readFile(file, 'utf8')
191
+ tsconfig = Bun.JSONC.parse(source) as typeof tsconfig
192
+ } catch {
193
+ return
194
+ }
195
+ if (!tsconfig || !Array.isArray(tsconfig.include)) return
196
+ const typesGlob = `${toPosixPath(path.relative(config.root, config.typesPath))}/**/*.ts`
197
+ const added = includeTypes(tsconfig, ['pnext-env.d.ts', typesGlob], [])
198
+ if (added.length) await writeFileAtomic(file, updateTypeSelection(source, added))
199
+ }
200
+
201
+ function updateTypeSelection(source: string, added: string[]) {
202
+ // Token offsets preserve comments, whitespace and escaped strings outside the edited selection.
203
+ const tokens = [
204
+ ...source.matchAll(/"(?:\\[\s\S]|[^"\\])*"|\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|[^\s]/g),
205
+ ].filter(token => !token[0].startsWith('//') && !token[0].startsWith('/*'))
206
+ let depth = 0
207
+ for (let index = 0; index < tokens.length; index++) {
208
+ const token = tokens[index]!
209
+ if (
210
+ depth === 1 &&
211
+ token[0].startsWith('"') &&
212
+ JSON.parse(token[0]) === 'include' &&
213
+ tokens[index + 1]?.[0] === ':'
214
+ ) {
215
+ let end = index + 3
216
+ while (tokens[end]?.[0] !== ']') end++
217
+ const close = tokens[end]!
218
+ const previous = tokens[end - 1]![0]
219
+ const insertion = `${previous === '[' || previous === ',' ? '' : ','} ${added.map(value => JSON.stringify(value)).join(', ')}`
220
+ return source.slice(0, close.index) + insertion + source.slice(close.index)
221
+ }
222
+ if (token[0] === '[' || token[0] === '{') depth++
223
+ if (token[0] === ']' || token[0] === '}') depth--
224
+ }
225
+ return source
226
+ }