@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.
- package/config/ts/react.json +2 -1
- package/package.json +1 -1
- package/reference/navigation.md +3 -1
- package/src/api/client-navigation.ts +53 -4
- package/src/api/link.tsx +6 -7
- package/src/cli/adapters/vercel-warm.ts +6 -1
- package/src/cli/adapters/vercel.ts +1 -1
- package/src/cli/build/cache.ts +8 -3
- package/src/cli/build.ts +5 -12
- package/src/cli/index.ts +1 -8
- package/src/cli/serve/entry.ts +16 -161
- package/src/cli/serve/pipeline.ts +11 -3
- package/src/cli/start.ts +1 -1
- package/src/cli/typegen.ts +65 -3
- package/src/client/build.ts +10 -7
- package/src/client/entry.ts +50 -29
- package/src/client/reference-stub.ts +1 -1
- package/src/client/router/hub.ts +13 -11
- package/src/client/router/page-slot.ts +58 -0
- package/src/client/router/runtime.ts +377 -388
- package/src/compat/bundler/symlink-imports.ts +1 -1
- package/src/compat/client/errors/bare-boundary.ts +1 -1
- package/src/compat/client/errors/error-boundary.ts +1 -1
- package/src/compat/client/errors/global-error.ts +40 -79
- package/src/compat/client/errors/install.ts +1 -1
- package/src/compat/client/errors/lazy.ts +7 -0
- package/src/compat/client/errors/primitive-throw.ts +36 -53
- package/src/compat/client/segment-cache-policy.ts +5 -22
- package/src/compat/client/segment-cache.ts +7 -26
- package/src/compat/css/modules.ts +1 -7
- package/src/compat/css/sass.ts +1 -7
- package/src/compat/react/parity.ts +1 -1
- package/src/compat/register/bundler.ts +9 -1
- package/src/compat/tsconfig-defaults.ts +4 -7
- package/src/compat-bootstrap.ts +15 -0
- package/src/css/build.ts +18 -15
- package/src/dev/server.ts +2 -2
- package/src/index.ts +2 -0
- package/src/render/renderer.ts +209 -24
- package/src/render/resource-hints.ts +4 -1
- package/src/resolve/imports.ts +13 -8
- package/src/routing/routes.ts +32 -18
- package/src/runtime/loader.ts +29 -10
- package/src/runtime/modules.ts +28 -7
- package/src/runtime/vendor-build.ts +2 -2
- package/src/types.ts +2 -0
- package/src/utils/fs.ts +122 -0
package/config/ts/react.json
CHANGED
package/package.json
CHANGED
package/reference/navigation.md
CHANGED
|
@@ -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()` |
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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 =
|
|
11
|
+
type LinkMouseEvent = JSX.TargetedMouseEvent<HTMLAnchorElement>
|
|
13
12
|
|
|
14
|
-
type BaseLinkProps = Omit<
|
|
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?:
|
|
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>):
|
|
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
|
|
76
|
-
)
|
|
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 =
|
|
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) &&
|
package/src/cli/build/cache.ts
CHANGED
|
@@ -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 =
|
|
77
|
-
|
|
78
|
-
|
|
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', () =>
|
|
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,
|
|
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
|
-
|
|
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'),
|
package/src/cli/serve/entry.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
5
|
+
* ready banner waits on parsing it.
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from 'node:fs/promises'
|
|
8
8
|
import path from 'node:path'
|
package/src/cli/typegen.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
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
|
+
}
|