@tyndall/dev 0.0.1

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/dist/index.js ADDED
@@ -0,0 +1,1424 @@
1
+ import { createServer } from "node:http";
2
+ import { copyFile, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { inspect } from "node:util";
6
+ import { CLIENT_ROUTER_BOOTSTRAP_PATH, createRouteGraph, handleDynamicPageApiRequest, loadConfig, renderClientRouterBootstrap, resolvePageModule, resolveRouteWithPolicyFallback, resolveUIAdapter, runGetServerProps, serializeProps, } from "@tyndall/core";
7
+ import { createLogger, computeCacheRootKey, getBunVersion, getOsArch, hash, normalizePath, readFileSafe, resolveCacheRoot, } from "@tyndall/shared";
8
+ import { createDynamicModuleGraphManager, DEFAULT_IMPORT_EXTENSIONS } from "@tyndall/dynamic-graph";
9
+ import { createFileWatcher } from "./watcher.js";
10
+ import { createDevInspector, INSPECTOR_API_PATH, INSPECTOR_PATH, renderInspectorHtml, } from "./inspector.js";
11
+ import { createHmrServer, getHmrClientScript, HMR_CLIENT_PATH, HMR_WS_PATH, } from "./hmr.js";
12
+ export { createFileWatcher } from "./watcher.js";
13
+ export { createHmrServer, getHmrClientScript, HMR_CLIENT_PATH, HMR_WS_PATH } from "./hmr.js";
14
+ const CONTENT_TYPES = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".mjs": "text/javascript; charset=utf-8",
18
+ ".css": "text/css; charset=utf-8",
19
+ ".json": "application/json; charset=utf-8",
20
+ ".txt": "text/plain; charset=utf-8",
21
+ };
22
+ const NAVIGATION_HEADER = "x-hyper-navigation";
23
+ const NAVIGATION_MODE = "csr";
24
+ const DYNAMIC_FALLBACK_HEADER = "x-hyper-dynamic-fallback";
25
+ const RENDER_WARNINGS_HEADER = "x-hyper-render-warnings";
26
+ const DEV_CLIENT_ENTRY_PREFIX = "/_hyper/dev-client";
27
+ const MAX_DEV_CLIENT_ASSET_VERSIONS_PER_ROUTE = 4;
28
+ const DEV_RUNTIME_BUILD_RETRY_COUNT = 2;
29
+ const DEV_RUNTIME_BUILD_RETRY_DELAY_MS = 20;
30
+ const DEV_RUNTIME_DEPENDENCIES = [
31
+ "react",
32
+ "react-dom",
33
+ "scheduler",
34
+ "react-router",
35
+ "cookie",
36
+ "set-cookie-parser",
37
+ "@tyndall/react",
38
+ ];
39
+ const DEV_RUNTIME_ALIAS_CANDIDATES = new Map([
40
+ ["react", ["react/index.js"]],
41
+ ["react/jsx-runtime", ["react/jsx-runtime.js"]],
42
+ ["react/jsx-runtime.js", ["react/jsx-runtime.js"]],
43
+ ["react/jsx-dev-runtime", ["react/jsx-dev-runtime.js"]],
44
+ ["react/jsx-dev-runtime.js", ["react/jsx-dev-runtime.js"]],
45
+ ["react-dom", ["react-dom/index.js"]],
46
+ ["react-dom/client", ["react-dom/client.js"]],
47
+ ["react-dom/client.js", ["react-dom/client.js"]],
48
+ ["react-dom/server", ["react-dom/server.browser.js", "react-dom/server.js"]],
49
+ ["react-dom/server.js", ["react-dom/server.browser.js", "react-dom/server.js"]],
50
+ ["react-router", ["react-router/dist/development/index.mjs", "react-router/dist/development/index.js"]],
51
+ ["@tyndall/react", ["@tyndall/react/src/index.ts", "@tyndall/react/dist/index.js"]],
52
+ ["scheduler", ["scheduler/index.js"]],
53
+ ["cookie", ["cookie/dist/index.js", "cookie/index.js"]],
54
+ ["set-cookie-parser", ["set-cookie-parser/lib/set-cookie.js"]],
55
+ ]);
56
+ const DEV_RUNTIME_TRANSIENT_BUILD_PATTERNS = [
57
+ /EISDIR reading file:/,
58
+ /Unexpected reading file:/,
59
+ /EBADF reading file:/,
60
+ /react\/jsx-dev-runtime\.js/,
61
+ /ResolveMessage: Could not resolve: "\.\/(?:adapter|components|router|registry|head|head-manager|dynamic-manifest|types|react-router-bridge|islands)\.js"/,
62
+ ];
63
+ const resolveFilePath = (root, urlPath) => {
64
+ const sanitized = urlPath.split("?")[0].split("#")[0];
65
+ const fullPath = resolve(root, `.${sanitized}`);
66
+ // Important: block path traversal outside the public directory.
67
+ if (!fullPath.startsWith(resolve(root))) {
68
+ return null;
69
+ }
70
+ return fullPath;
71
+ };
72
+ const toImportSpecifier = (fromDir, targetPath) => {
73
+ const normalizedRelative = normalizePath(relative(fromDir, targetPath));
74
+ if (normalizedRelative.startsWith(".")) {
75
+ return normalizedRelative;
76
+ }
77
+ return `./${normalizedRelative}`;
78
+ };
79
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ const loadRouteGraph = async (routesDir, routeMeta, nestedLayouts) => {
81
+ try {
82
+ return await createRouteGraph({
83
+ routesDir,
84
+ routeMeta,
85
+ nestedLayouts,
86
+ });
87
+ }
88
+ catch {
89
+ return { routes: [] };
90
+ }
91
+ };
92
+ const HMR_CLIENT_SCRIPT = getHmrClientScript({ wsPath: HMR_WS_PATH });
93
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;");
94
+ const renderHeadDescriptor = (head) => {
95
+ const parts = [];
96
+ if (head.title) {
97
+ parts.push(`<title>${escapeHtml(head.title)}</title>`);
98
+ }
99
+ for (const meta of head.meta ?? []) {
100
+ const attrs = Object.entries(meta)
101
+ .map(([key, value]) => `${key}=\"${escapeHtml(String(value))}\"`)
102
+ .join(" ");
103
+ parts.push(`<meta ${attrs}>`);
104
+ }
105
+ for (const link of head.link ?? []) {
106
+ const attrs = Object.entries(link)
107
+ .map(([key, value]) => `${key}=\"${escapeHtml(String(value))}\"`)
108
+ .join(" ");
109
+ parts.push(`<link ${attrs}>`);
110
+ }
111
+ for (const script of head.script ?? []) {
112
+ const attrs = Object.entries(script)
113
+ .map(([key, value]) => `${key}=\"${escapeHtml(String(value))}\"`)
114
+ .join(" ");
115
+ parts.push(`<script ${attrs}></script>`);
116
+ }
117
+ return parts.join("");
118
+ };
119
+ const renderDevDocument = (input) => {
120
+ const htmlAttrs = input.ssrPreview ? ' data-hyper-ssr="true"' : "";
121
+ const buildVersionMeta = input.buildVersion
122
+ ? `<meta name=\"hyper-build-version\" content=\"${escapeHtml(input.buildVersion)}\">`
123
+ : "";
124
+ const buildVersionAttr = input.buildVersion
125
+ ? ` data-hyper-build-version=\"${escapeHtml(input.buildVersion)}\"`
126
+ : "";
127
+ const appAttrs = input.ssrPreview
128
+ ? ` data-hyper-route=\"${escapeHtml(input.routeId)}\" data-hyper-ssr=\"true\" data-hyper-hydration=\"${input.hydration}\" data-hyper-island-root=\"router\"`
129
+ : ` data-hyper-route=\"${escapeHtml(input.routeId)}\" data-hyper-hydration=\"${input.hydration}\"`;
130
+ return [
131
+ "<!doctype html>",
132
+ `<html${htmlAttrs}${buildVersionAttr}>`,
133
+ "<head>",
134
+ renderHeadDescriptor(input.head),
135
+ buildVersionMeta,
136
+ "</head>",
137
+ "<body>",
138
+ `<div id=\"app\"${appAttrs}>${input.appHtml}</div>`,
139
+ `<script id=\"__HYPER_PROPS__\" type=\"application/json\">${input.propsPayload}</script>`,
140
+ `<script>window.__HYPER_ROUTE_ID__ = ${JSON.stringify(input.routeId)};</script>`,
141
+ `<script type=\"module\" src=\"${input.clientBootstrapScriptPath}\"></script>`,
142
+ ...(input.clientRuntimeScriptPath
143
+ ? [`<script type=\"module\" src=\"${input.clientRuntimeScriptPath}\"></script>`]
144
+ : []),
145
+ `<script type=\"module\" src=\"${HMR_CLIENT_PATH}\"></script>`,
146
+ "</body>",
147
+ "</html>",
148
+ ].join("");
149
+ };
150
+ const buildRoutePayload = (rendered) => ({
151
+ kind: "hyper-route-payload",
152
+ routeId: rendered.routeId,
153
+ appHtml: rendered.appHtml,
154
+ propsPayload: rendered.propsPayload,
155
+ clientRuntimeScriptPath: rendered.clientRuntimeScriptPath,
156
+ head: rendered.head,
157
+ hydration: rendered.hydration,
158
+ });
159
+ const isCsrNavigationRequest = (request) => {
160
+ const raw = request.headers?.[NAVIGATION_HEADER];
161
+ const value = Array.isArray(raw) ? raw[0] : raw;
162
+ return typeof value === "string" && value.toLowerCase() === NAVIGATION_MODE;
163
+ };
164
+ const setRenderWarningsHeader = (response, warnings) => {
165
+ if (!warnings || warnings.length === 0) {
166
+ return;
167
+ }
168
+ response.setHeader(RENDER_WARNINGS_HEADER, warnings.join(" | "));
169
+ };
170
+ const stableParamsKey = (params) => {
171
+ const entries = Object.keys(params)
172
+ .sort()
173
+ .map((key) => [key, params[key]]);
174
+ return JSON.stringify(entries);
175
+ };
176
+ const getRouteCacheKey = (match) => `${match.route.id}:${stableParamsKey(match.params)}`;
177
+ const normalizeCachePath = (value) => normalizePath(resolve(value));
178
+ const isPathWithinRoot = (rootPath, targetPath) => {
179
+ const normalizedRoot = normalizeCachePath(rootPath);
180
+ const normalizedTarget = normalizeCachePath(targetPath);
181
+ return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}/`);
182
+ };
183
+ const resolveCacheDir = (rootDir, cacheDir) => isAbsolute(cacheDir) ? cacheDir : resolve(rootDir, cacheDir);
184
+ const resolveHyperVersion = async (rootDir) => {
185
+ const packagePath = join(rootDir, "packages", "tyndall-core", "package.json");
186
+ const raw = await readFileSafe(packagePath, "utf-8");
187
+ const content = raw ? raw.toString() : null;
188
+ if (!content) {
189
+ return "unknown";
190
+ }
191
+ try {
192
+ const parsed = JSON.parse(content);
193
+ return parsed.version ?? "unknown";
194
+ }
195
+ catch {
196
+ return "unknown";
197
+ }
198
+ };
199
+ const normalizeUrlPath = (value) => value.split("?")[0].split("#")[0];
200
+ const getQueryValue = (url, key) => {
201
+ try {
202
+ if (!url) {
203
+ return null;
204
+ }
205
+ const parsed = new URL(url, "http://localhost");
206
+ return parsed.searchParams.get(key);
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ };
212
+ const isJavaScriptLike = (filePath) => {
213
+ const ext = extname(filePath);
214
+ return ext === ".js" || ext === ".jsx" || ext === ".ts" || ext === ".tsx";
215
+ };
216
+ const isJavaScriptOutputAsset = (filePath) => {
217
+ const ext = extname(filePath);
218
+ return ext === ".js" || ext === ".mjs";
219
+ };
220
+ const isRuntimeOutputAsset = (filePath) => {
221
+ const ext = extname(filePath);
222
+ return ext === ".js" || ext === ".mjs" || ext === ".css";
223
+ };
224
+ const toPublicUrl = (publicDir, filePath) => {
225
+ const relativePath = relative(publicDir, filePath);
226
+ if (!relativePath || relativePath.startsWith("..") || relativePath.includes(`..${sep}`)) {
227
+ return null;
228
+ }
229
+ return `/${relativePath.split(sep).join("/")}`;
230
+ };
231
+ const toErrorInfo = (error) => {
232
+ if (error instanceof Error) {
233
+ return {
234
+ message: error.message,
235
+ stack: error.stack,
236
+ };
237
+ }
238
+ return {
239
+ message: String(error),
240
+ stack: undefined,
241
+ };
242
+ };
243
+ const collectBuildFailureDetails = (error) => {
244
+ const bundleError = error;
245
+ const details = [];
246
+ if (Array.isArray(bundleError.errors) && bundleError.errors.length > 0) {
247
+ details.push(bundleError.errors.map((value) => String(value)).join(" | "));
248
+ }
249
+ if (Array.isArray(bundleError.logs) && bundleError.logs.length > 0) {
250
+ details.push(bundleError.logs
251
+ .map((value) => {
252
+ if (value && typeof value === "object" && "message" in value) {
253
+ const message = value.message;
254
+ if (typeof message === "string" && message.length > 0) {
255
+ return message;
256
+ }
257
+ }
258
+ return inspect(value, { depth: 2 });
259
+ })
260
+ .join(" | "));
261
+ }
262
+ if (bundleError.cause) {
263
+ details.push(String(bundleError.cause));
264
+ }
265
+ if (details.length === 0) {
266
+ details.push(inspect(error, { depth: 3 }));
267
+ }
268
+ return details;
269
+ };
270
+ const isTransientRuntimeBuildFailure = (details) => details.some((detail) => DEV_RUNTIME_TRANSIENT_BUILD_PATTERNS.some((pattern) => pattern.test(detail)));
271
+ const getBunBuildApi = () => {
272
+ const runtime = globalThis;
273
+ if (runtime.Bun && typeof runtime.Bun.build === "function") {
274
+ return { build: runtime.Bun.build };
275
+ }
276
+ return null;
277
+ };
278
+ export const startDevServer = async (options = {}) => {
279
+ const host = options.host ?? "localhost";
280
+ const port = options.port ?? 3000;
281
+ const rootDir = options.rootDir ?? process.cwd();
282
+ const config = await loadConfig(rootDir);
283
+ const buildVersion = config.build.version.trim();
284
+ // SSR preview is allowed only when both --ssr and config.ssr are enabled.
285
+ const ssrPreviewEnabled = Boolean(options.ssr) && config.ssr;
286
+ const ssrClientRoutingEnabled = ssrPreviewEnabled && config.routing.ssrClientRouting;
287
+ const logger = options.logger ??
288
+ createLogger({
289
+ level: config.logging?.level ?? "info",
290
+ prefix: "hyper:dev",
291
+ });
292
+ const clientBootstrapScript = renderClientRouterBootstrap({
293
+ navigationMode: ssrClientRoutingEnabled ? "client" : config.routing.navigation,
294
+ clientRenderMode: config.routing.clientRender,
295
+ linkInterceptionMode: ssrClientRoutingEnabled ? "all" : "marked",
296
+ });
297
+ const routesDir = options.routesDir ?? config.routesDir;
298
+ const publicDir = options.publicDir ?? config.publicDir;
299
+ const resolvedRootDir = resolve(rootDir);
300
+ const resolvedRoutesDir = resolve(rootDir, routesDir);
301
+ const resolvedPublicDir = resolve(rootDir, publicDir);
302
+ const normalizedRoutesDir = normalizeCachePath(resolvedRoutesDir);
303
+ const normalizedPublicDir = normalizeCachePath(resolvedPublicDir);
304
+ const normalizedRootDir = normalizeCachePath(resolvedRootDir);
305
+ const normalizedNodeModulesDir = normalizeCachePath(join(resolvedRootDir, "node_modules"));
306
+ const normalizedGitDir = normalizeCachePath(join(resolvedRootDir, ".git"));
307
+ const cacheDir = resolveCacheDir(rootDir, config.cache.dir);
308
+ const normalizedCacheDir = normalizeCachePath(cacheDir);
309
+ const normalizedInternalDir = normalizeCachePath(resolve(rootDir, ".hyper"));
310
+ const normalizedOutDir = normalizeCachePath(resolve(rootDir, config.outDir));
311
+ const ignoredRouteGraphRoots = [
312
+ normalizedNodeModulesDir,
313
+ normalizedGitDir,
314
+ normalizedInternalDir,
315
+ normalizedOutDir,
316
+ normalizedCacheDir,
317
+ ];
318
+ const isIgnoredRouteGraphPath = (targetPath) => ignoredRouteGraphRoots.some((ignoredRoot) => isPathWithinRoot(ignoredRoot, targetPath));
319
+ const filterRouteGraph = (graph) => ({
320
+ routes: graph.routes.filter((route) => !isIgnoredRouteGraphPath(route.filePath)),
321
+ });
322
+ let routeGraph = filterRouteGraph(await loadRouteGraph(resolvedRoutesDir, config.routing.routeMeta, config.routing.nestedLayouts));
323
+ const inspector = createDevInspector();
324
+ const prefetchPath = options.prefetchPath ?? "/__hyper/prefetch";
325
+ const routeCache = new Map();
326
+ const buildRouteIndex = (graph) => {
327
+ const byId = new Map();
328
+ const byFile = new Map();
329
+ for (const route of graph.routes) {
330
+ byId.set(route.id, route);
331
+ byFile.set(normalizeCachePath(route.filePath), route);
332
+ }
333
+ return { byId, byFile };
334
+ };
335
+ const buildLayoutIndex = (graph) => {
336
+ const byFile = new Map();
337
+ for (const route of graph.routes) {
338
+ for (const layoutFile of route.layoutFiles ?? []) {
339
+ const key = normalizeCachePath(layoutFile);
340
+ const routes = byFile.get(key) ?? new Set();
341
+ routes.add(route.id);
342
+ byFile.set(key, routes);
343
+ }
344
+ }
345
+ return { byFile };
346
+ };
347
+ let routeIndex = buildRouteIndex(routeGraph);
348
+ let layoutIndex = buildLayoutIndex(routeGraph);
349
+ const setRouteGraph = (graph) => {
350
+ routeGraph = graph;
351
+ routeIndex = buildRouteIndex(graph);
352
+ layoutIndex = buildLayoutIndex(graph);
353
+ };
354
+ const uiAdapter = typeof config.ui.adapter === "string"
355
+ ? config.ui.adapter
356
+ : (config.ui.adapter?.name ?? "custom");
357
+ const hyperVersion = config.cache.enabled ? await resolveHyperVersion(rootDir) : "disabled";
358
+ const rootKey = config.cache.enabled
359
+ ? computeCacheRootKey({
360
+ projectId: normalizeCachePath(rootDir),
361
+ hyperVersion,
362
+ bunVersion: getBunVersion(),
363
+ uiAdapter,
364
+ osArch: getOsArch(),
365
+ })
366
+ : "disabled";
367
+ // Important: cacheRoot keeps dev caches separated per project/toolchain.
368
+ const cacheRoot = config.cache.enabled ? resolveCacheRoot(cacheDir, rootKey) : cacheDir;
369
+ // Keep runtime deps out of cacheRoot/node_modules so server renders resolve React from the app tree.
370
+ const runtimeNodeModulesDir = join(cacheRoot, "runtime-node-modules");
371
+ const legacyRuntimeNodeModulesDir = join(cacheRoot, "node_modules");
372
+ await rm(legacyRuntimeNodeModulesDir, { recursive: true, force: true }).catch(() => { });
373
+ let runtimeNodeModulesReady = null;
374
+ let runtimeDependencyAliasMapReady = null;
375
+ const findRuntimeDependency = async (startDir, dependencyName) => {
376
+ let current = resolve(startDir);
377
+ while (true) {
378
+ const candidate = join(current, "node_modules", dependencyName);
379
+ const exists = await stat(candidate)
380
+ .then(() => true)
381
+ .catch(() => false);
382
+ if (exists) {
383
+ return candidate;
384
+ }
385
+ const parent = dirname(current);
386
+ if (parent === current) {
387
+ return undefined;
388
+ }
389
+ current = parent;
390
+ }
391
+ };
392
+ const ensureRuntimeNodeModules = async () => {
393
+ if (runtimeNodeModulesReady) {
394
+ await runtimeNodeModulesReady;
395
+ return;
396
+ }
397
+ runtimeNodeModulesReady = (async () => {
398
+ const legacyExists = await stat(legacyRuntimeNodeModulesDir)
399
+ .then(() => true)
400
+ .catch(() => false);
401
+ if (legacyExists) {
402
+ await rm(legacyRuntimeNodeModulesDir, { recursive: true, force: true });
403
+ }
404
+ await mkdir(runtimeNodeModulesDir, { recursive: true });
405
+ for (const dependencyName of DEV_RUNTIME_DEPENDENCIES) {
406
+ const targetPath = join(runtimeNodeModulesDir, dependencyName);
407
+ const sourceRoots = [resolvedRootDir, process.cwd()].filter((value, index, list) => list.indexOf(value) === index);
408
+ let sourcePath;
409
+ for (const root of sourceRoots) {
410
+ sourcePath = await findRuntimeDependency(root, dependencyName);
411
+ if (sourcePath) {
412
+ break;
413
+ }
414
+ }
415
+ if (!sourcePath) {
416
+ const searched = sourceRoots.map((root) => join(root, "node_modules", dependencyName));
417
+ throw new Error(`Missing dev runtime dependency "${dependencyName}" in ${searched.join(", ")}.`);
418
+ }
419
+ // Bun dev runtime bundling is unstable with symlinked workspace dependencies,
420
+ // so we materialize concrete package trees inside cacheRoot/runtime-node-modules.
421
+ await cp(sourcePath, targetPath, {
422
+ recursive: true,
423
+ dereference: true,
424
+ force: true,
425
+ });
426
+ }
427
+ })();
428
+ await runtimeNodeModulesReady;
429
+ };
430
+ const ensureRuntimeDependencyAliasMap = async () => {
431
+ if (runtimeDependencyAliasMapReady) {
432
+ return runtimeDependencyAliasMapReady;
433
+ }
434
+ runtimeDependencyAliasMapReady = (async () => {
435
+ await ensureRuntimeNodeModules();
436
+ const aliasMap = new Map();
437
+ for (const [specifier, candidates] of DEV_RUNTIME_ALIAS_CANDIDATES.entries()) {
438
+ for (const relativeCandidate of candidates) {
439
+ const candidatePath = join(runtimeNodeModulesDir, relativeCandidate);
440
+ const candidateIsFile = await stat(candidatePath)
441
+ .then((result) => result.isFile())
442
+ .catch(() => false);
443
+ if (!candidateIsFile) {
444
+ continue;
445
+ }
446
+ aliasMap.set(specifier, candidatePath);
447
+ break;
448
+ }
449
+ }
450
+ return aliasMap;
451
+ })();
452
+ return runtimeDependencyAliasMapReady;
453
+ };
454
+ const dynamicGraph = createDynamicModuleGraphManager({
455
+ extensions: DEFAULT_IMPORT_EXTENSIONS,
456
+ });
457
+ for (const route of routeGraph.routes) {
458
+ dynamicGraph.registerEntry({ entryId: route.id, filePath: route.filePath });
459
+ }
460
+ const routeHits = new Set();
461
+ const routeRenderVersion = new Map();
462
+ const routeRenderDirectories = new Map();
463
+ const inflightRouteRenders = new Map();
464
+ const devClientAssets = new Map();
465
+ const routeClientAssetVersions = new Map();
466
+ let runtimeBuildQueue = Promise.resolve();
467
+ const runSerializedRuntimeBuild = async (buildTask) => {
468
+ const pending = runtimeBuildQueue.then(buildTask, buildTask);
469
+ runtimeBuildQueue = pending.then(() => undefined, () => undefined);
470
+ return pending;
471
+ };
472
+ let hyperCoreBrowserShimSource = null;
473
+ const resolveCoreShimModulePath = async (entryDir, moduleName) => {
474
+ const candidates = [
475
+ `${moduleName}.js`,
476
+ `${moduleName}.mjs`,
477
+ `${moduleName}.cjs`,
478
+ `${moduleName}.ts`,
479
+ `${moduleName}.tsx`,
480
+ ];
481
+ for (const candidate of candidates) {
482
+ const candidatePath = join(entryDir, candidate);
483
+ const exists = await stat(candidatePath)
484
+ .then((result) => result.isFile())
485
+ .catch(() => false);
486
+ if (exists) {
487
+ return candidatePath;
488
+ }
489
+ }
490
+ throw new Error(`Missing core shim module "${moduleName}" in ${entryDir}.`);
491
+ };
492
+ const resolveHyperCoreBrowserShimSource = async () => {
493
+ if (hyperCoreBrowserShimSource) {
494
+ return hyperCoreBrowserShimSource;
495
+ }
496
+ const resolver = import.meta;
497
+ if (typeof resolver.resolve !== "function") {
498
+ throw new Error("import.meta.resolve is unavailable while building dev client runtime.");
499
+ }
500
+ const resolvedEntry = await resolver.resolve("@tyndall/core");
501
+ const entryPath = resolvedEntry.startsWith("file://")
502
+ ? fileURLToPath(resolvedEntry)
503
+ : resolvedEntry;
504
+ const entryDir = dirname(entryPath);
505
+ const headPath = await resolveCoreShimModulePath(entryDir, "head");
506
+ const propsPath = await resolveCoreShimModulePath(entryDir, "props");
507
+ const renderPolicyPath = await resolveCoreShimModulePath(entryDir, "render-policy");
508
+ const resolverFallbackPath = await resolveCoreShimModulePath(entryDir, "resolver-fallback");
509
+ hyperCoreBrowserShimSource = [
510
+ `export { mergeHeadDescriptors } from ${JSON.stringify(headPath)};`,
511
+ `export { serializeProps } from ${JSON.stringify(propsPath)};`,
512
+ `export { evaluateRenderPolicy } from ${JSON.stringify(renderPolicyPath)};`,
513
+ `export { shouldForceDynamicFallback } from ${JSON.stringify(resolverFallbackPath)};`,
514
+ ].join("\n");
515
+ return hyperCoreBrowserShimSource;
516
+ };
517
+ const getRouteAssetPrefix = (routeId) => `${DEV_CLIENT_ENTRY_PREFIX}/${hash(routeId).slice(0, 10)}/`;
518
+ const getRouteAssetVersionPrefix = (routeId, version) => `${getRouteAssetPrefix(routeId)}${version}/`;
519
+ const removeDevClientAssetsByRouteVersion = (routeId, version) => {
520
+ const prefix = getRouteAssetVersionPrefix(routeId, version);
521
+ for (const assetPath of Array.from(devClientAssets.keys())) {
522
+ if (assetPath.startsWith(prefix)) {
523
+ devClientAssets.delete(assetPath);
524
+ }
525
+ }
526
+ };
527
+ const removeDevClientAssetsByRoute = (routeId) => {
528
+ const prefix = getRouteAssetPrefix(routeId);
529
+ for (const assetPath of Array.from(devClientAssets.keys())) {
530
+ if (assetPath.startsWith(prefix)) {
531
+ devClientAssets.delete(assetPath);
532
+ }
533
+ }
534
+ routeClientAssetVersions.delete(routeId);
535
+ };
536
+ const trackRouteAssetVersion = (routeId, version) => {
537
+ const nextVersions = [
538
+ ...(routeClientAssetVersions.get(routeId) ?? []).filter((item) => item !== version),
539
+ version,
540
+ ].sort((left, right) => right - left);
541
+ routeClientAssetVersions.set(routeId, nextVersions);
542
+ };
543
+ const pruneStaleRouteAssetVersions = (routeId) => {
544
+ const versions = routeClientAssetVersions.get(routeId);
545
+ if (!versions || versions.length <= MAX_DEV_CLIENT_ASSET_VERSIONS_PER_ROUTE) {
546
+ return;
547
+ }
548
+ const retained = versions.slice(0, MAX_DEV_CLIENT_ASSET_VERSIONS_PER_ROUTE);
549
+ const stale = versions.slice(MAX_DEV_CLIENT_ASSET_VERSIONS_PER_ROUTE);
550
+ routeClientAssetVersions.set(routeId, retained);
551
+ for (const version of stale) {
552
+ removeDevClientAssetsByRouteVersion(routeId, version);
553
+ }
554
+ };
555
+ const registerRouteHit = (route) => {
556
+ routeHits.add(route.id);
557
+ dynamicGraph.registerEntry({ entryId: route.id, filePath: route.filePath });
558
+ };
559
+ const resolveAdapterForRoute = (route, loaded, pageRender, pageHead) => resolveUIAdapter(config.ui.adapter, {
560
+ ...config.ui.options,
561
+ navigationMode: config.routing.navigation,
562
+ clientRenderMode: config.routing.clientRender,
563
+ nestedLayouts: config.routing.nestedLayouts,
564
+ routeGraph,
565
+ rootDir: resolvedRootDir,
566
+ routeRoot: loaded.routeRoot,
567
+ routeRender: pageRender,
568
+ routeHead: pageHead,
569
+ }, options.adapterRegistry);
570
+ const resolveRouteModulesForRender = (route) => {
571
+ const modules = dynamicGraph.ensureEntryGraph({
572
+ entryId: route.id,
573
+ filePath: route.filePath,
574
+ });
575
+ const normalizedModules = new Set(modules.map((modulePath) => normalizeCachePath(modulePath)));
576
+ normalizedModules.add(normalizeCachePath(route.filePath));
577
+ for (const layoutFile of route.layoutFiles ?? []) {
578
+ normalizedModules.add(normalizeCachePath(layoutFile));
579
+ }
580
+ return normalizedModules;
581
+ };
582
+ const shouldCopyRouteSnapshotModule = (modulePath) => {
583
+ if (!isPathWithinRoot(resolvedRootDir, modulePath)) {
584
+ return false;
585
+ }
586
+ if (isPathWithinRoot(normalizedNodeModulesDir, modulePath)) {
587
+ return false;
588
+ }
589
+ if (isPathWithinRoot(normalizedInternalDir, modulePath)) {
590
+ return false;
591
+ }
592
+ if (isPathWithinRoot(normalizedOutDir, modulePath)) {
593
+ return false;
594
+ }
595
+ return true;
596
+ };
597
+ const loadRouteModule = async (route) => {
598
+ const modules = resolveRouteModulesForRender(route);
599
+ const localModules = Array.from(modules).filter((modulePath) => shouldCopyRouteSnapshotModule(modulePath));
600
+ if (localModules.length === 0) {
601
+ return {
602
+ module: await import(`${pathToFileURL(route.filePath).href}?dev=${Date.now()}`),
603
+ };
604
+ }
605
+ const version = (routeRenderVersion.get(route.id) ?? 0) + 1;
606
+ routeRenderVersion.set(route.id, version);
607
+ const routeRoot = join(cacheRoot, "dev-route-modules", hash(route.id).slice(0, 10), String(version));
608
+ await mkdir(routeRoot, { recursive: true });
609
+ for (const modulePath of localModules) {
610
+ const relativePath = relative(resolvedRootDir, modulePath);
611
+ if (!relativePath || relativePath.startsWith(`..${sep}`) || relativePath === "..") {
612
+ continue;
613
+ }
614
+ const targetPath = join(routeRoot, relativePath);
615
+ await mkdir(dirname(targetPath), { recursive: true });
616
+ await copyFile(modulePath, targetPath);
617
+ }
618
+ const routeRelativePath = relative(resolvedRootDir, route.filePath);
619
+ if (!routeRelativePath ||
620
+ routeRelativePath === ".." ||
621
+ routeRelativePath.startsWith(`..${sep}`)) {
622
+ return {
623
+ module: await import(`${pathToFileURL(route.filePath).href}?dev=${Date.now()}`),
624
+ };
625
+ }
626
+ const copiedEntryPath = join(routeRoot, routeRelativePath);
627
+ // Keep versioned snapshots for the active dev session; deleting previous snapshots eagerly
628
+ // can race with in-flight imports/builds from concurrent prefetch requests.
629
+ const routeVersions = routeRenderDirectories.get(route.id) ?? new Map();
630
+ routeVersions.set(version, routeRoot);
631
+ routeRenderDirectories.set(route.id, routeVersions);
632
+ return {
633
+ module: await import(pathToFileURL(copiedEntryPath).href),
634
+ routeRoot,
635
+ routeRelativePath,
636
+ version,
637
+ };
638
+ };
639
+ const buildClientRuntimeScript = async (route, loaded, adapter) => {
640
+ if (!loaded.routeRoot || !loaded.routeRelativePath || loaded.version === undefined) {
641
+ return undefined;
642
+ }
643
+ if (typeof adapter.createClientEntry !== "function") {
644
+ return undefined;
645
+ }
646
+ const routeRoot = loaded.routeRoot;
647
+ const pageModule = `./${loaded.routeRelativePath.split(sep).join("/")}`;
648
+ const routeModuleMap = routeGraph.routes.reduce((acc, routeRecord) => {
649
+ acc[routeRecord.id] = toImportSpecifier(routeRoot, routeRecord.filePath);
650
+ return acc;
651
+ }, {});
652
+ const entrySource = adapter.createClientEntry({
653
+ routeId: route.id,
654
+ routeGraph,
655
+ rootDir: resolvedRootDir,
656
+ entryDir: routeRoot,
657
+ routeRoot,
658
+ uiOptions: {
659
+ ...config.ui.options,
660
+ pageModule,
661
+ navigationMode: config.routing.navigation,
662
+ clientRenderMode: config.routing.clientRender,
663
+ clientRouteModules: routeModuleMap,
664
+ nestedLayouts: config.routing.nestedLayouts,
665
+ },
666
+ adapterOptions: {
667
+ ...config.ui.options,
668
+ pageModule,
669
+ navigationMode: config.routing.navigation,
670
+ clientRenderMode: config.routing.clientRender,
671
+ clientRouteModules: routeModuleMap,
672
+ nestedLayouts: config.routing.nestedLayouts,
673
+ },
674
+ });
675
+ if (typeof entrySource !== "string" || entrySource.trim().length === 0) {
676
+ return undefined;
677
+ }
678
+ const entryFilePath = join(loaded.routeRoot, "__hyper_client_entry.tsx");
679
+ const outDir = join(loaded.routeRoot, "__hyper_client_bundle");
680
+ await mkdir(outDir, { recursive: true });
681
+ await writeFile(entryFilePath, entrySource, "utf-8");
682
+ const bun = getBunBuildApi();
683
+ if (!bun) {
684
+ throw new Error("Bun.build API is unavailable while generating dev client runtime.");
685
+ }
686
+ const hyperCoreShim = await resolveHyperCoreBrowserShimSource();
687
+ const runtimeDependencyAliasMap = await ensureRuntimeDependencyAliasMap();
688
+ const runtimeDependencyAliasSpecifiers = Array.from(runtimeDependencyAliasMap.keys()).sort((left, right) => right.length - left.length);
689
+ const runtimeDependencyAliasPattern = runtimeDependencyAliasSpecifiers.length > 0
690
+ ? new RegExp(`^(?:${runtimeDependencyAliasSpecifiers.map((specifier) => escapeRegExp(specifier)).join("|")})$`)
691
+ : null;
692
+ const plugins = [
693
+ {
694
+ name: "@tyndall/core-browser-shim",
695
+ setup(build) {
696
+ build.onResolve({ filter: /^@tyndall\/core$/ }, () => ({
697
+ path: "@tyndall/core-browser-shim",
698
+ namespace: "@tyndall/core-browser-shim",
699
+ }));
700
+ build.onLoad({ filter: /^@tyndall\/core-browser-shim$/, namespace: "@tyndall/core-browser-shim" }, () => ({
701
+ contents: hyperCoreShim,
702
+ loader: "js",
703
+ }));
704
+ },
705
+ },
706
+ ...(runtimeDependencyAliasPattern
707
+ ? [
708
+ {
709
+ name: "@tyndall/runtime-dependency-alias",
710
+ setup(build) {
711
+ build.onResolve({
712
+ filter: runtimeDependencyAliasPattern,
713
+ }, ({ path }) => {
714
+ const resolvedPath = runtimeDependencyAliasMap.get(path);
715
+ if (!resolvedPath) {
716
+ return;
717
+ }
718
+ return { path: resolvedPath };
719
+ });
720
+ },
721
+ },
722
+ ]
723
+ : []),
724
+ ];
725
+ let buildResult;
726
+ for (let attempt = 0; attempt <= DEV_RUNTIME_BUILD_RETRY_COUNT; attempt += 1) {
727
+ try {
728
+ const candidate = await runSerializedRuntimeBuild(() => bun.build({
729
+ entrypoints: [entryFilePath],
730
+ outdir: outDir,
731
+ root: loaded.routeRoot,
732
+ target: "browser",
733
+ format: "esm",
734
+ splitting: true,
735
+ sourcemap: "inline",
736
+ minify: false,
737
+ plugins,
738
+ }));
739
+ if (!candidate.success) {
740
+ const errors = (candidate.logs ?? [])
741
+ .map((log) => log.message)
742
+ .filter((message) => typeof message === "string" && message.length > 0);
743
+ if (attempt < DEV_RUNTIME_BUILD_RETRY_COUNT &&
744
+ isTransientRuntimeBuildFailure(errors)) {
745
+ await new Promise((resolveRetry) => setTimeout(resolveRetry, DEV_RUNTIME_BUILD_RETRY_DELAY_MS * (attempt + 1)));
746
+ continue;
747
+ }
748
+ throw new Error(`Failed to build dev client runtime for route "${route.id}".${errors.length > 0 ? ` ${errors.join(" | ")}` : ""}`);
749
+ }
750
+ buildResult = candidate;
751
+ break;
752
+ }
753
+ catch (error) {
754
+ const info = toErrorInfo(error);
755
+ if (attempt < DEV_RUNTIME_BUILD_RETRY_COUNT &&
756
+ isTransientRuntimeBuildFailure([info.message])) {
757
+ await new Promise((resolveRetry) => setTimeout(resolveRetry, DEV_RUNTIME_BUILD_RETRY_DELAY_MS * (attempt + 1)));
758
+ continue;
759
+ }
760
+ const details = collectBuildFailureDetails(error);
761
+ throw new Error(`Failed to build dev client runtime for route "${route.id}" (entry: ${entryFilePath}). ${details.join(" || ")}`);
762
+ }
763
+ }
764
+ if (!buildResult?.success) {
765
+ throw new Error(`Failed to build dev client runtime for route "${route.id}" after ${DEV_RUNTIME_BUILD_RETRY_COUNT + 1} attempts.`);
766
+ }
767
+ const outputAssets = (buildResult.outputs ?? [])
768
+ .map((artifact) => artifact.path)
769
+ .filter((path) => typeof path === "string" && isRuntimeOutputAsset(path) && isPathWithinRoot(outDir, path));
770
+ if (outputAssets.length === 0) {
771
+ throw new Error(`Dev client runtime output is missing for route "${route.id}".`);
772
+ }
773
+ const assetPrefix = `${getRouteAssetPrefix(route.id)}${loaded.version}/`;
774
+ let scriptPath;
775
+ for (const outputPath of outputAssets) {
776
+ const relativeOutputPath = normalizePath(relative(outDir, outputPath));
777
+ if (relativeOutputPath.length === 0 ||
778
+ relativeOutputPath.startsWith("..") ||
779
+ relativeOutputPath.includes("/../")) {
780
+ continue;
781
+ }
782
+ const publicPath = `${assetPrefix}${relativeOutputPath}`;
783
+ devClientAssets.set(publicPath, outputPath);
784
+ if (!scriptPath &&
785
+ isJavaScriptOutputAsset(outputPath) &&
786
+ (relativeOutputPath === "__hyper_client_entry.js" ||
787
+ relativeOutputPath === "__hyper_client_entry.mjs")) {
788
+ scriptPath = publicPath;
789
+ }
790
+ }
791
+ if (!scriptPath) {
792
+ const fallbackScript = outputAssets
793
+ .filter((outputPath) => isJavaScriptOutputAsset(outputPath))
794
+ .sort((left, right) => left.localeCompare(right))[0];
795
+ if (!fallbackScript) {
796
+ throw new Error(`Dev client runtime script output is missing for route "${route.id}".`);
797
+ }
798
+ const relativeOutputPath = normalizePath(relative(outDir, fallbackScript));
799
+ scriptPath = `${assetPrefix}${relativeOutputPath}`;
800
+ }
801
+ trackRouteAssetVersion(route.id, loaded.version);
802
+ pruneStaleRouteAssetVersions(route.id);
803
+ return scriptPath;
804
+ };
805
+ const renderRoute = async (match, request, response) => {
806
+ const loaded = await loadRouteModule(match.route);
807
+ const page = resolvePageModule(loaded.module);
808
+ const adapter = resolveAdapterForRoute(match.route, loaded, (props) => page.default(props), page.head ? (props) => page.head?.(props) ?? {} : undefined);
809
+ if (typeof adapter.renderToHtml !== "function") {
810
+ throw new Error(`UI adapter "${adapter.name}" does not support renderToHtml.`);
811
+ }
812
+ // Run server props in dev whenever the route exports it so page contracts stay consistent.
813
+ const serverProps = page.getServerProps
814
+ ? await runGetServerProps(page.getServerProps, {
815
+ routeId: match.route.id,
816
+ params: match.params,
817
+ request,
818
+ response,
819
+ })
820
+ : null;
821
+ const props = serverProps?.props ?? {};
822
+ const rendered = await adapter.renderToHtml({
823
+ routeId: match.route.id,
824
+ params: match.params,
825
+ props,
826
+ });
827
+ const payload = adapter.serializeProps?.(props) ?? serializeProps(props);
828
+ let clientRuntimeScriptPath;
829
+ try {
830
+ clientRuntimeScriptPath = await buildClientRuntimeScript(match.route, loaded, adapter);
831
+ }
832
+ catch (error) {
833
+ const info = toErrorInfo(error);
834
+ logger.warn("Dev client runtime build failed; falling back to bootstrap-only mode", {
835
+ routeId: match.route.id,
836
+ message: info.message,
837
+ });
838
+ }
839
+ const hydration = ssrPreviewEnabled ? "islands" : "full";
840
+ const html = renderDevDocument({
841
+ routeId: match.route.id,
842
+ appHtml: rendered.html,
843
+ head: rendered.head ?? {},
844
+ propsPayload: payload,
845
+ ssrPreview: ssrPreviewEnabled,
846
+ hydration,
847
+ clientBootstrapScriptPath: CLIENT_ROUTER_BOOTSTRAP_PATH,
848
+ clientRuntimeScriptPath,
849
+ buildVersion,
850
+ });
851
+ return {
852
+ html,
853
+ routeId: match.route.id,
854
+ appHtml: rendered.html,
855
+ head: rendered.head ?? {},
856
+ propsPayload: payload,
857
+ clientRuntimeScriptPath,
858
+ hydration,
859
+ status: rendered.status ?? serverProps?.status ?? 200,
860
+ headers: {
861
+ ...(serverProps?.headers ?? {}),
862
+ ...(rendered.headers ?? {}),
863
+ },
864
+ };
865
+ };
866
+ const invalidateRoutesById = (routeIds) => {
867
+ for (const routeId of routeIds) {
868
+ for (const key of routeCache.keys()) {
869
+ if (key.startsWith(`${routeId}:`)) {
870
+ routeCache.delete(key);
871
+ }
872
+ }
873
+ }
874
+ };
875
+ const refreshRouteGraphForChanges = async (changedPaths) => {
876
+ const shouldReload = changedPaths.some((changedPath) => changedPath.startsWith(normalizedRoutesDir) && !isIgnoredRouteGraphPath(changedPath));
877
+ if (!shouldReload) {
878
+ return {
879
+ added: [],
880
+ removed: [],
881
+ changed: [],
882
+ };
883
+ }
884
+ const nextGraph = filterRouteGraph(await loadRouteGraph(resolvedRoutesDir, config.routing.routeMeta, config.routing.nestedLayouts));
885
+ const previousById = routeIndex.byId;
886
+ const nextIndex = buildRouteIndex(nextGraph);
887
+ const added = [];
888
+ const removed = [];
889
+ const changed = [];
890
+ for (const [routeId, nextRoute] of nextIndex.byId.entries()) {
891
+ const previous = previousById.get(routeId);
892
+ if (!previous) {
893
+ added.push(nextRoute);
894
+ continue;
895
+ }
896
+ if (normalizeCachePath(previous.filePath) !== normalizeCachePath(nextRoute.filePath)) {
897
+ changed.push(nextRoute);
898
+ }
899
+ }
900
+ for (const [routeId, previousRoute] of previousById.entries()) {
901
+ if (!nextIndex.byId.has(routeId)) {
902
+ removed.push(previousRoute);
903
+ }
904
+ }
905
+ setRouteGraph(nextGraph);
906
+ return { added, removed, changed };
907
+ };
908
+ const refreshDynamicGraphForChanges = async (changedPaths) => {
909
+ const normalizedChangedPaths = changedPaths.map((path) => normalizeCachePath(path));
910
+ const hasDirectoryScopeChange = normalizedChangedPaths.some((changedPath) => changedPath === normalizedRootDir ||
911
+ changedPath === normalizedRoutesDir ||
912
+ changedPath === normalizedPublicDir);
913
+ const needsFullClear = normalizedChangedPaths.some((changedPath) => {
914
+ if (hasDirectoryScopeChange) {
915
+ return true;
916
+ }
917
+ if (!changedPath.startsWith(normalizedRootDir)) {
918
+ return false;
919
+ }
920
+ if (changedPath.startsWith(normalizedPublicDir) && extname(changedPath) === ".css") {
921
+ return false;
922
+ }
923
+ return !isJavaScriptLike(changedPath);
924
+ });
925
+ const impactedRoutes = new Set();
926
+ const changeResult = dynamicGraph.applyFileChanges(normalizedChangedPaths);
927
+ for (const routeId of changeResult.impactedEntryIds) {
928
+ impactedRoutes.add(routeId);
929
+ }
930
+ for (const changedPath of normalizedChangedPaths) {
931
+ const directRoute = routeIndex.byFile.get(changedPath);
932
+ if (directRoute) {
933
+ impactedRoutes.add(directRoute.id);
934
+ }
935
+ const layoutRoutes = layoutIndex.byFile.get(changedPath);
936
+ if (layoutRoutes) {
937
+ for (const routeId of layoutRoutes) {
938
+ impactedRoutes.add(routeId);
939
+ }
940
+ }
941
+ }
942
+ if (hasDirectoryScopeChange && impactedRoutes.size === 0) {
943
+ for (const routeId of routeHits) {
944
+ impactedRoutes.add(routeId);
945
+ }
946
+ }
947
+ for (const routeId of impactedRoutes) {
948
+ const route = routeIndex.byId.get(routeId);
949
+ if (!route || !routeHits.has(routeId)) {
950
+ continue;
951
+ }
952
+ dynamicGraph.ensureEntryGraph({ entryId: routeId, filePath: route.filePath });
953
+ }
954
+ // Invalidate cache before async route-graph refresh so manual `server.invalidate()` calls apply immediately.
955
+ if (needsFullClear) {
956
+ routeCache.clear();
957
+ devClientAssets.clear();
958
+ routeClientAssetVersions.clear();
959
+ }
960
+ else {
961
+ invalidateRoutesById(impactedRoutes);
962
+ }
963
+ const { added, removed, changed } = await refreshRouteGraphForChanges(normalizedChangedPaths);
964
+ for (const removedRoute of removed) {
965
+ impactedRoutes.add(removedRoute.id);
966
+ dynamicGraph.removeEntry(removedRoute.id);
967
+ routeHits.delete(removedRoute.id);
968
+ removeDevClientAssetsByRoute(removedRoute.id);
969
+ }
970
+ for (const route of [...added, ...changed]) {
971
+ dynamicGraph.registerEntry({ entryId: route.id, filePath: route.filePath });
972
+ impactedRoutes.add(route.id);
973
+ if (routeHits.has(route.id)) {
974
+ dynamicGraph.ensureEntryGraph({ entryId: route.id, filePath: route.filePath });
975
+ }
976
+ }
977
+ if (!needsFullClear) {
978
+ invalidateRoutesById(impactedRoutes);
979
+ }
980
+ const hmrRoutes = Array.from(impactedRoutes)
981
+ .filter((routeId) => routeHits.has(routeId))
982
+ .sort((left, right) => left.localeCompare(right));
983
+ return {
984
+ normalizedChangedPaths,
985
+ hmrRoutes,
986
+ };
987
+ };
988
+ const server = createServer(async (request, response) => {
989
+ const urlPath = normalizeUrlPath(request.url ?? "/");
990
+ const requestUrl = new URL(request.url ?? "/", "http://localhost");
991
+ const startedAt = process.hrtime.bigint();
992
+ let routeId;
993
+ let kind = "unknown";
994
+ // Use `finish` to capture total time including async IO and response flush.
995
+ response.on("finish", () => {
996
+ const durationMs = Number(process.hrtime.bigint() - startedAt) / 1000000;
997
+ inspector.recordRequest({
998
+ method: request.method ?? "GET",
999
+ path: urlPath,
1000
+ status: response.statusCode,
1001
+ durationMs,
1002
+ routeId,
1003
+ kind,
1004
+ });
1005
+ });
1006
+ const resolveRouteForRequest = (pathname) => resolveRouteWithPolicyFallback({
1007
+ pathname,
1008
+ routeGraph,
1009
+ appMode: config.mode,
1010
+ policy: options.fallbackPolicy,
1011
+ dynamicResolver: options.dynamicPageApi
1012
+ ? (path) => options.dynamicPageApi?.resolveByPath({
1013
+ path,
1014
+ locale: requestUrl.searchParams.get("locale") ?? undefined,
1015
+ variant: requestUrl.searchParams.get("variant") ?? undefined,
1016
+ device: requestUrl.searchParams.get("device") ?? undefined,
1017
+ ctxHash: requestUrl.searchParams.get("ctxHash") ?? undefined,
1018
+ }) ?? Promise.resolve(null)
1019
+ : undefined,
1020
+ });
1021
+ const respondWithResolvedRoute = async (resolved, pathname) => {
1022
+ if (resolved.type === "file") {
1023
+ await respondWithRoute(resolved.match);
1024
+ return true;
1025
+ }
1026
+ if (resolved.type === "redirect") {
1027
+ kind = "route";
1028
+ routeId = pathname;
1029
+ response.statusCode = 307;
1030
+ response.setHeader("location", resolved.location);
1031
+ response.end();
1032
+ return true;
1033
+ }
1034
+ if (resolved.type === "dynamic") {
1035
+ kind = "route";
1036
+ routeId = pathname;
1037
+ setRenderWarningsHeader(response, resolved.warnings);
1038
+ if (resolved.mode === "ssr") {
1039
+ response.statusCode = 501;
1040
+ response.setHeader("content-type", "text/plain; charset=utf-8");
1041
+ response.end("Dynamic SSR fallback is not implemented in dev server.");
1042
+ return true;
1043
+ }
1044
+ if (isCsrNavigationRequest(request)) {
1045
+ response.statusCode = 204;
1046
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
1047
+ response.setHeader(DYNAMIC_FALLBACK_HEADER, "csr");
1048
+ response.end();
1049
+ return true;
1050
+ }
1051
+ response.statusCode = 200;
1052
+ response.setHeader("content-type", "text/html; charset=utf-8");
1053
+ response.end(renderDevDocument({
1054
+ routeId: pathname,
1055
+ appHtml: `<main data-hyper-dynamic=\"true\">Dynamic Route: ${escapeHtml(pathname)}</main>`,
1056
+ head: { title: `Dynamic ${pathname}` },
1057
+ propsPayload: serializeProps({}),
1058
+ ssrPreview: ssrPreviewEnabled,
1059
+ hydration: ssrPreviewEnabled ? "islands" : "full",
1060
+ clientBootstrapScriptPath: CLIENT_ROUTER_BOOTSTRAP_PATH,
1061
+ buildVersion,
1062
+ }));
1063
+ return true;
1064
+ }
1065
+ return false;
1066
+ };
1067
+ const respondWithRoute = async (match) => {
1068
+ kind = "route";
1069
+ routeId = match.route.id;
1070
+ const navigationRequest = isCsrNavigationRequest(request);
1071
+ registerRouteHit(match.route);
1072
+ const cacheKey = getRouteCacheKey(match);
1073
+ const cached = routeCache.get(cacheKey);
1074
+ if (cached) {
1075
+ response.statusCode = cached.status;
1076
+ for (const [header, value] of Object.entries(cached.headers)) {
1077
+ response.setHeader(header, value);
1078
+ }
1079
+ if (navigationRequest) {
1080
+ response.setHeader("content-type", "application/json; charset=utf-8");
1081
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
1082
+ response.setHeader("x-hyper-route-cache", "hit");
1083
+ response.end(JSON.stringify(buildRoutePayload(cached)));
1084
+ return;
1085
+ }
1086
+ response.setHeader("content-type", "text/html; charset=utf-8");
1087
+ response.setHeader("x-hyper-route-cache", "hit");
1088
+ response.end(cached.html);
1089
+ return;
1090
+ }
1091
+ try {
1092
+ let renderedPromise = inflightRouteRenders.get(cacheKey);
1093
+ if (!renderedPromise) {
1094
+ // Deduplicate concurrent renders for the same route cache key to avoid duplicate
1095
+ // module snapshot/build work and transient file-system races.
1096
+ const routeRenderPromise = renderRoute(match, request, response);
1097
+ const trackedRouteRenderPromise = routeRenderPromise.finally(() => {
1098
+ if (inflightRouteRenders.get(cacheKey) === trackedRouteRenderPromise) {
1099
+ inflightRouteRenders.delete(cacheKey);
1100
+ }
1101
+ });
1102
+ inflightRouteRenders.set(cacheKey, trackedRouteRenderPromise);
1103
+ renderedPromise = trackedRouteRenderPromise;
1104
+ }
1105
+ const rendered = await renderedPromise;
1106
+ response.statusCode = rendered.status;
1107
+ for (const [header, value] of Object.entries(rendered.headers)) {
1108
+ response.setHeader(header, value);
1109
+ }
1110
+ routeCache.set(cacheKey, { ...rendered, updatedAt: Date.now() });
1111
+ if (navigationRequest) {
1112
+ response.setHeader("content-type", "application/json; charset=utf-8");
1113
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
1114
+ response.setHeader("x-hyper-route-cache", "miss");
1115
+ response.end(JSON.stringify(buildRoutePayload(rendered)));
1116
+ return;
1117
+ }
1118
+ response.setHeader("content-type", "text/html; charset=utf-8");
1119
+ response.setHeader("x-hyper-route-cache", "miss");
1120
+ response.end(rendered.html);
1121
+ }
1122
+ catch (error) {
1123
+ const { message, stack } = toErrorInfo(error);
1124
+ logger.error("Route render failed", {
1125
+ routeId: match.route.id,
1126
+ path: request.url ?? "/",
1127
+ message,
1128
+ });
1129
+ if (stack) {
1130
+ logger.error("Route render stack", {
1131
+ routeId: match.route.id,
1132
+ stack,
1133
+ });
1134
+ }
1135
+ hmr.broadcastError(message, stack);
1136
+ response.statusCode = 500;
1137
+ response.setHeader("x-hyper-route-cache", "miss");
1138
+ response.end(renderDevDocument({
1139
+ routeId: match.route.id,
1140
+ appHtml: `<main><h1>Dev Render Error</h1><pre>${escapeHtml(message)}</pre></main>`,
1141
+ head: { title: `Dev Error ${match.route.id}` },
1142
+ propsPayload: serializeProps({}),
1143
+ ssrPreview: ssrPreviewEnabled,
1144
+ hydration: ssrPreviewEnabled ? "islands" : "full",
1145
+ clientBootstrapScriptPath: CLIENT_ROUTER_BOOTSTRAP_PATH,
1146
+ buildVersion,
1147
+ }));
1148
+ }
1149
+ };
1150
+ if (urlPath === CLIENT_ROUTER_BOOTSTRAP_PATH) {
1151
+ kind = "hmr";
1152
+ response.statusCode = 200;
1153
+ response.setHeader("content-type", "text/javascript; charset=utf-8");
1154
+ response.setHeader("cache-control", "no-store");
1155
+ response.end(clientBootstrapScript);
1156
+ return;
1157
+ }
1158
+ if (urlPath === HMR_CLIENT_PATH) {
1159
+ kind = "hmr";
1160
+ response.statusCode = 200;
1161
+ response.setHeader("content-type", "text/javascript; charset=utf-8");
1162
+ response.setHeader("cache-control", "no-store");
1163
+ response.end(HMR_CLIENT_SCRIPT);
1164
+ return;
1165
+ }
1166
+ if (urlPath.startsWith(`${DEV_CLIENT_ENTRY_PREFIX}/`)) {
1167
+ kind = "asset";
1168
+ const assetFilePath = devClientAssets.get(urlPath);
1169
+ if (!assetFilePath) {
1170
+ response.statusCode = 404;
1171
+ response.setHeader("content-type", "text/plain; charset=utf-8");
1172
+ response.end("Not Found");
1173
+ return;
1174
+ }
1175
+ try {
1176
+ const code = await readFile(assetFilePath);
1177
+ response.statusCode = 200;
1178
+ response.setHeader("content-type", CONTENT_TYPES[extname(assetFilePath)] ?? "application/octet-stream");
1179
+ response.setHeader("cache-control", "no-store");
1180
+ response.end(code);
1181
+ }
1182
+ catch {
1183
+ response.statusCode = 404;
1184
+ response.setHeader("content-type", "text/plain; charset=utf-8");
1185
+ response.end("Not Found");
1186
+ }
1187
+ return;
1188
+ }
1189
+ if (urlPath === INSPECTOR_PATH || urlPath === INSPECTOR_API_PATH) {
1190
+ kind = "inspector";
1191
+ const snapshot = inspector.getSnapshot();
1192
+ if (urlPath === INSPECTOR_API_PATH) {
1193
+ response.statusCode = 200;
1194
+ response.setHeader("content-type", "application/json; charset=utf-8");
1195
+ response.end(JSON.stringify(snapshot, null, 2));
1196
+ return;
1197
+ }
1198
+ response.statusCode = 200;
1199
+ response.setHeader("content-type", "text/html; charset=utf-8");
1200
+ response.end(renderInspectorHtml(snapshot));
1201
+ return;
1202
+ }
1203
+ if (options.dynamicPageApi) {
1204
+ const apiResult = await handleDynamicPageApiRequest({
1205
+ method: request.method ?? "GET",
1206
+ pathname: urlPath,
1207
+ query: requestUrl.searchParams,
1208
+ headers: request.headers,
1209
+ }, options.dynamicPageApi);
1210
+ if (apiResult) {
1211
+ kind = "api";
1212
+ response.statusCode = apiResult.status;
1213
+ for (const [key, value] of Object.entries(apiResult.headers)) {
1214
+ response.setHeader(key, value);
1215
+ }
1216
+ response.end(apiResult.body ?? "");
1217
+ return;
1218
+ }
1219
+ }
1220
+ if (urlPath === prefetchPath && request.method === "POST") {
1221
+ kind = "route";
1222
+ const target = getQueryValue(request.url, "path");
1223
+ if (!target) {
1224
+ response.statusCode = 400;
1225
+ response.setHeader("content-type", "application/json; charset=utf-8");
1226
+ response.end(JSON.stringify({ ok: false, error: "Missing path" }));
1227
+ return;
1228
+ }
1229
+ const resolvedTarget = await resolveRouteForRequest(target);
1230
+ if (resolvedTarget.type === "file") {
1231
+ await respondWithRoute(resolvedTarget.match);
1232
+ return;
1233
+ }
1234
+ if (resolvedTarget.type === "redirect") {
1235
+ response.statusCode = 200;
1236
+ response.setHeader("content-type", "application/json; charset=utf-8");
1237
+ response.end(JSON.stringify({ ok: true, redirect: resolvedTarget.location }));
1238
+ return;
1239
+ }
1240
+ if (resolvedTarget.type === "dynamic") {
1241
+ response.statusCode = 200;
1242
+ response.setHeader("content-type", "application/json; charset=utf-8");
1243
+ response.end(JSON.stringify({
1244
+ ok: true,
1245
+ dynamic: true,
1246
+ mode: resolvedTarget.mode,
1247
+ warnings: resolvedTarget.warnings,
1248
+ }));
1249
+ return;
1250
+ }
1251
+ if (resolvedTarget.type === "notfound") {
1252
+ response.statusCode = 404;
1253
+ response.setHeader("content-type", "application/json; charset=utf-8");
1254
+ response.end(JSON.stringify({ ok: false, error: "Route not found" }));
1255
+ return;
1256
+ }
1257
+ return;
1258
+ }
1259
+ const publicPath = resolveFilePath(resolvedPublicDir, urlPath);
1260
+ if (publicPath) {
1261
+ try {
1262
+ const stats = await stat(publicPath);
1263
+ if (!stats.isFile()) {
1264
+ throw new Error("Not a file");
1265
+ }
1266
+ const data = await readFile(publicPath);
1267
+ kind = "asset";
1268
+ response.statusCode = 200;
1269
+ response.setHeader("content-type", CONTENT_TYPES[extname(publicPath)] ?? "application/octet-stream");
1270
+ response.end(data);
1271
+ }
1272
+ catch {
1273
+ const resolvedRoute = await resolveRouteForRequest(urlPath);
1274
+ if (await respondWithResolvedRoute(resolvedRoute, urlPath)) {
1275
+ return;
1276
+ }
1277
+ response.statusCode = 404;
1278
+ response.setHeader("content-type", "text/plain; charset=utf-8");
1279
+ response.end("Not Found");
1280
+ }
1281
+ return;
1282
+ }
1283
+ const resolvedRoute = await resolveRouteForRequest(urlPath);
1284
+ if (await respondWithResolvedRoute(resolvedRoute, urlPath)) {
1285
+ return;
1286
+ }
1287
+ kind = "notfound";
1288
+ response.statusCode = 404;
1289
+ response.setHeader("content-type", "text/plain; charset=utf-8");
1290
+ response.end("Not Found");
1291
+ });
1292
+ const hmr = createHmrServer(server, { path: HMR_WS_PATH });
1293
+ const watchPaths = Array.from(new Set([resolvedRootDir, resolvedRoutesDir, resolvedPublicDir]));
1294
+ const ignoredWatchRoots = [
1295
+ normalizedNodeModulesDir,
1296
+ normalizedGitDir,
1297
+ normalizedInternalDir,
1298
+ normalizedCacheDir,
1299
+ normalizedOutDir,
1300
+ ];
1301
+ const isIgnoredWatchPath = (changedPath) => ignoredWatchRoots.some((ignoredRoot) => isPathWithinRoot(ignoredRoot, changedPath));
1302
+ const watcher = createFileWatcher({
1303
+ paths: watchPaths,
1304
+ onInvalidate: (changedPaths) => {
1305
+ void (async () => {
1306
+ const watchedChangedPaths = changedPaths
1307
+ .map((path) => normalizeCachePath(path))
1308
+ .filter((path) => !isIgnoredWatchPath(path));
1309
+ if (watchedChangedPaths.length === 0) {
1310
+ return;
1311
+ }
1312
+ const { normalizedChangedPaths, hmrRoutes } = await refreshDynamicGraphForChanges(watchedChangedPaths);
1313
+ const hmrRouteList = hmrRoutes.length > 0 ? hmrRoutes : undefined;
1314
+ const cssUpdates = [];
1315
+ const refreshUpdates = [];
1316
+ let needsReload = false;
1317
+ // Prefer React refresh for JS/TS changes; fallback to full reload for unknown changes.
1318
+ for (const changedPath of normalizedChangedPaths) {
1319
+ if (changedPath.startsWith(normalizedPublicDir) && extname(changedPath) === ".css") {
1320
+ const url = toPublicUrl(resolvedPublicDir, changedPath);
1321
+ if (url) {
1322
+ cssUpdates.push(url);
1323
+ continue;
1324
+ }
1325
+ }
1326
+ if ((changedPath.startsWith(normalizedRoutesDir) ||
1327
+ changedPath.startsWith(normalizedRootDir)) &&
1328
+ isJavaScriptLike(changedPath)) {
1329
+ refreshUpdates.push(changedPath);
1330
+ continue;
1331
+ }
1332
+ needsReload = true;
1333
+ }
1334
+ if (needsReload) {
1335
+ logger.warn("HMR fell back to full reload", {
1336
+ changedPaths: normalizedChangedPaths,
1337
+ });
1338
+ hmr.broadcastFullReload(normalizedChangedPaths, hmrRouteList);
1339
+ return;
1340
+ }
1341
+ if (refreshUpdates.length > 0) {
1342
+ hmr.broadcastReactRefresh(refreshUpdates, hmrRouteList);
1343
+ return;
1344
+ }
1345
+ if (cssUpdates.length > 0) {
1346
+ hmr.broadcastCssUpdate(cssUpdates);
1347
+ }
1348
+ })().catch((error) => {
1349
+ const { message, stack } = toErrorInfo(error);
1350
+ logger.error("Watcher invalidation failed", {
1351
+ changedPaths,
1352
+ message,
1353
+ });
1354
+ if (stack) {
1355
+ logger.error("Watcher invalidation stack", { stack });
1356
+ }
1357
+ hmr.broadcastFullReload(changedPaths);
1358
+ });
1359
+ },
1360
+ });
1361
+ try {
1362
+ await new Promise((resolve, reject) => {
1363
+ server.once("error", reject);
1364
+ server.listen(port, host, () => resolve());
1365
+ });
1366
+ }
1367
+ catch (error) {
1368
+ const { message, stack } = toErrorInfo(error);
1369
+ logger.fatal("Dev server failed to start", {
1370
+ host,
1371
+ port,
1372
+ rootDir: resolvedRootDir,
1373
+ message,
1374
+ });
1375
+ if (stack) {
1376
+ logger.error("Dev server startup stack", { stack });
1377
+ }
1378
+ throw error;
1379
+ }
1380
+ const address = server.address();
1381
+ if (!address || typeof address === "string") {
1382
+ throw new Error("Dev server did not return a TCP address.");
1383
+ }
1384
+ // Ensure we capture the real port when port=0 for ephemeral assignments.
1385
+ const url = `http://${address.address}:${address.port}`;
1386
+ return {
1387
+ host: address.address,
1388
+ port: address.port,
1389
+ url,
1390
+ hmr,
1391
+ invalidate: (paths) => {
1392
+ void refreshDynamicGraphForChanges(paths).catch((error) => {
1393
+ const { message, stack } = toErrorInfo(error);
1394
+ logger.error("Manual invalidation failed", { paths, message });
1395
+ if (stack) {
1396
+ logger.error("Manual invalidation stack", { stack });
1397
+ }
1398
+ });
1399
+ },
1400
+ close: () => new Promise((resolve, reject) => {
1401
+ watcher.close();
1402
+ hmr.close();
1403
+ inflightRouteRenders.clear();
1404
+ for (const routeRenderVersions of routeRenderDirectories.values()) {
1405
+ for (const renderDir of routeRenderVersions.values()) {
1406
+ void rm(renderDir, { recursive: true, force: true }).catch(() => { });
1407
+ }
1408
+ }
1409
+ routeRenderDirectories.clear();
1410
+ server.close((error) => {
1411
+ if (error) {
1412
+ const { message, stack } = toErrorInfo(error);
1413
+ logger.error("Dev server close failed", { message });
1414
+ if (stack) {
1415
+ logger.error("Dev server close stack", { stack });
1416
+ }
1417
+ reject(error);
1418
+ return;
1419
+ }
1420
+ resolve();
1421
+ });
1422
+ }),
1423
+ };
1424
+ };