almostnode 0.2.5 → 0.2.7

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.
@@ -7,6 +7,14 @@ import { DevServer, DevServerOptions, ResponseData, HMRUpdate } from '../dev-ser
7
7
  import { VirtualFS } from '../virtual-fs';
8
8
  import { Buffer } from '../shims/stream';
9
9
  import { simpleHash } from '../utils/hash';
10
+ import { loadTailwindConfig } from './tailwind-config-loader';
11
+ import {
12
+ redirectNpmImports as _redirectNpmImports,
13
+ stripCssImports as _stripCssImports,
14
+ addReactRefresh as _addReactRefresh,
15
+ transformEsmToCjsSimple,
16
+ type CssModuleContext,
17
+ } from './code-transforms';
10
18
 
11
19
  // Check if we're in a real browser environment (not jsdom or Node.js)
12
20
  const isBrowser = typeof window !== 'undefined' &&
@@ -77,6 +85,20 @@ export interface NextDevServerOptions extends DevServerOptions {
77
85
  preferAppRouter?: boolean;
78
86
  /** Environment variables (NEXT_PUBLIC_* are available in browser code via process.env) */
79
87
  env?: Record<string, string>;
88
+ /** Asset prefix for static files (e.g., '/marketing'). Auto-detected from next.config if not specified. */
89
+ assetPrefix?: string;
90
+ /** Base path for the app (e.g., '/docs'). Auto-detected from next.config if not specified. */
91
+ basePath?: string;
92
+ }
93
+
94
+ /** Resolved App Router route with page, layouts, and UI convention files */
95
+ interface AppRoute {
96
+ page: string;
97
+ layouts: string[];
98
+ params: Record<string, string | string[]>;
99
+ loading?: string;
100
+ error?: string;
101
+ notFound?: string;
80
102
  }
81
103
 
82
104
  /**
@@ -269,11 +291,19 @@ const getVirtualBasePath = () => {
269
291
  return match[0].endsWith('/') ? match[0] : match[0] + '/';
270
292
  };
271
293
 
294
+ const getBasePath = () => window.__NEXT_BASE_PATH__ || '';
295
+
272
296
  const applyVirtualBase = (url) => {
273
297
  if (typeof url !== 'string') return url;
274
298
  if (!url || url.startsWith('#') || url.startsWith('?')) return url;
275
299
  if (/^(https?:)?\\/\\//.test(url)) return url;
276
300
 
301
+ // Apply basePath first
302
+ const bp = getBasePath();
303
+ if (bp && url.startsWith('/') && !url.startsWith(bp + '/') && url !== bp) {
304
+ url = bp + url;
305
+ }
306
+
277
307
  const base = getVirtualBasePath();
278
308
  if (!base) return url;
279
309
  if (url.startsWith(base)) return url;
@@ -283,25 +313,31 @@ const applyVirtualBase = (url) => {
283
313
 
284
314
  export default function Link({ href, children, ...props }) {
285
315
  const handleClick = (e) => {
316
+ console.log('[Link] Click handler called, href:', href);
317
+
286
318
  if (props.onClick) {
287
319
  props.onClick(e);
288
320
  }
289
321
 
290
322
  // Allow cmd/ctrl click to open in new tab
291
323
  if (e.metaKey || e.ctrlKey) {
324
+ console.log('[Link] Meta/Ctrl key pressed, allowing default behavior');
292
325
  return;
293
326
  }
294
327
 
295
328
  if (typeof href !== 'string' || !href || href.startsWith('#') || href.startsWith('?')) {
329
+ console.log('[Link] Skipping navigation for href:', href);
296
330
  return;
297
331
  }
298
332
 
299
333
  if (/^(https?:)?\\/\\//.test(href)) {
334
+ console.log('[Link] External URL, allowing default behavior:', href);
300
335
  return;
301
336
  }
302
337
 
303
338
  e.preventDefault();
304
339
  const resolvedHref = applyVirtualBase(href);
340
+ console.log('[Link] Navigating to:', resolvedHref);
305
341
  window.history.pushState({}, '', resolvedHref);
306
342
  window.dispatchEvent(new PopStateEvent('popstate'));
307
343
  };
@@ -566,14 +602,47 @@ export function useSearchParams() {
566
602
  * For route /users/[id]/page.jsx with URL /users/123:
567
603
  * @example const { id } = useParams(); // { id: '123' }
568
604
  *
569
- * NOTE: This simplified implementation returns empty object.
570
- * Full implementation would need route pattern matching.
605
+ * Fetches params from the server's route-info endpoint for dynamic routes.
571
606
  */
572
607
  export function useParams() {
573
- // In a real implementation, this would parse the current route
574
- // against the route pattern to extract params
575
- // For now, return empty object - works for basic cases
576
- return {};
608
+ const [params, setParams] = useState(() => {
609
+ // Check if initial params were embedded by the server
610
+ if (typeof window !== 'undefined' && window.__NEXT_ROUTE_PARAMS__) {
611
+ return window.__NEXT_ROUTE_PARAMS__;
612
+ }
613
+ return {};
614
+ });
615
+
616
+ useEffect(() => {
617
+ let cancelled = false;
618
+
619
+ const fetchParams = async () => {
620
+ const pathname = stripVirtualBase(window.location.pathname);
621
+ const base = getVirtualBasePath();
622
+ const baseUrl = base ? base.replace(/\\/$/, '') : '';
623
+
624
+ try {
625
+ const response = await fetch(baseUrl + '/_next/route-info?pathname=' + encodeURIComponent(pathname));
626
+ const info = await response.json();
627
+ if (!cancelled && info.params) {
628
+ setParams(info.params);
629
+ }
630
+ } catch (e) {
631
+ // Silently fail - static routes won't have params
632
+ }
633
+ };
634
+
635
+ fetchParams();
636
+
637
+ const handler = () => fetchParams();
638
+ window.addEventListener('popstate', handler);
639
+ return () => {
640
+ cancelled = true;
641
+ window.removeEventListener('popstate', handler);
642
+ };
643
+ }, []);
644
+
645
+ return params;
577
646
  }
578
647
 
579
648
  /**
@@ -670,6 +739,413 @@ export default function Head({ children }) {
670
739
  }
671
740
  `;
672
741
 
742
+ /**
743
+ * Next.js Image shim code
744
+ * Provides a simple img-based implementation of next/image
745
+ */
746
+ const NEXT_IMAGE_SHIM = `
747
+ import React from 'react';
748
+
749
+ function Image({
750
+ src,
751
+ alt = '',
752
+ width,
753
+ height,
754
+ fill,
755
+ loader,
756
+ quality = 75,
757
+ priority,
758
+ loading,
759
+ placeholder,
760
+ blurDataURL,
761
+ unoptimized,
762
+ onLoad,
763
+ onError,
764
+ style,
765
+ className,
766
+ sizes,
767
+ ...rest
768
+ }) {
769
+ // Handle src - could be string or StaticImageData object
770
+ const imageSrc = typeof src === 'object' ? src.src : src;
771
+
772
+ // Build style object
773
+ const imgStyle = { ...style };
774
+ if (fill) {
775
+ imgStyle.position = 'absolute';
776
+ imgStyle.width = '100%';
777
+ imgStyle.height = '100%';
778
+ imgStyle.objectFit = imgStyle.objectFit || 'cover';
779
+ imgStyle.inset = '0';
780
+ }
781
+
782
+ return React.createElement('img', {
783
+ src: imageSrc,
784
+ alt,
785
+ width: fill ? undefined : width,
786
+ height: fill ? undefined : height,
787
+ loading: priority ? 'eager' : (loading || 'lazy'),
788
+ decoding: 'async',
789
+ style: imgStyle,
790
+ className,
791
+ onLoad,
792
+ onError,
793
+ ...rest
794
+ });
795
+ }
796
+
797
+ export default Image;
798
+ export { Image };
799
+ `;
800
+
801
+ /**
802
+ * next/dynamic shim - Dynamic imports with loading states
803
+ */
804
+ const NEXT_DYNAMIC_SHIM = `
805
+ import React from 'react';
806
+
807
+ function dynamic(importFn, options = {}) {
808
+ const {
809
+ loading: LoadingComponent,
810
+ ssr = true,
811
+ } = options;
812
+
813
+ // Create a lazy component
814
+ const LazyComponent = React.lazy(importFn);
815
+
816
+ // Wrapper component that handles loading state
817
+ function DynamicComponent(props) {
818
+ const fallback = LoadingComponent
819
+ ? React.createElement(LoadingComponent, { isLoading: true })
820
+ : null;
821
+
822
+ return React.createElement(
823
+ React.Suspense,
824
+ { fallback },
825
+ React.createElement(LazyComponent, props)
826
+ );
827
+ }
828
+
829
+ return DynamicComponent;
830
+ }
831
+
832
+ export default dynamic;
833
+ export { dynamic };
834
+ `;
835
+
836
+ /**
837
+ * next/script shim - Loads external scripts
838
+ */
839
+ const NEXT_SCRIPT_SHIM = `
840
+ import React from 'react';
841
+
842
+ function Script({
843
+ src,
844
+ strategy = 'afterInteractive',
845
+ onLoad,
846
+ onReady,
847
+ onError,
848
+ children,
849
+ dangerouslySetInnerHTML,
850
+ ...rest
851
+ }) {
852
+ React.useEffect(function() {
853
+ if (!src && !children && !dangerouslySetInnerHTML) return;
854
+
855
+ var script = document.createElement('script');
856
+
857
+ if (src) {
858
+ script.src = src;
859
+ script.async = strategy !== 'beforeInteractive';
860
+ }
861
+
862
+ Object.keys(rest).forEach(function(key) {
863
+ script.setAttribute(key, rest[key]);
864
+ });
865
+
866
+ if (children) {
867
+ script.textContent = children;
868
+ } else if (dangerouslySetInnerHTML && dangerouslySetInnerHTML.__html) {
869
+ script.textContent = dangerouslySetInnerHTML.__html;
870
+ }
871
+
872
+ script.onload = function() {
873
+ if (onLoad) onLoad();
874
+ if (onReady) onReady();
875
+ };
876
+ script.onerror = onError;
877
+
878
+ document.head.appendChild(script);
879
+
880
+ return function() {
881
+ if (script.parentNode) {
882
+ script.parentNode.removeChild(script);
883
+ }
884
+ };
885
+ }, [src]);
886
+
887
+ return null;
888
+ }
889
+
890
+ export default Script;
891
+ export { Script };
892
+ `;
893
+
894
+ /**
895
+ * next/font/google shim - Loads Google Fonts via CDN
896
+ * Uses a Proxy to dynamically handle ANY Google Font without hardcoding
897
+ */
898
+ const NEXT_FONT_GOOGLE_SHIM = `
899
+ // Track loaded fonts to avoid duplicate style injections
900
+ const loadedFonts = new Set();
901
+
902
+ /**
903
+ * Convert font function name to Google Fonts family name
904
+ * Examples:
905
+ * DM_Sans -> DM Sans
906
+ * Open_Sans -> Open Sans
907
+ * Fraunces -> Fraunces
908
+ */
909
+ function toFontFamily(fontName) {
910
+ return fontName.replace(/_/g, ' ');
911
+ }
912
+
913
+ /**
914
+ * Inject font CSS into document
915
+ * - Adds preconnect links for faster font loading
916
+ * - Loads the font from Google Fonts CDN
917
+ * - Creates a CSS class that sets the CSS variable
918
+ */
919
+ function injectFontCSS(fontFamily, variableName, weight, style) {
920
+ const fontKey = fontFamily + '-' + (variableName || 'default');
921
+ if (loadedFonts.has(fontKey)) {
922
+ return;
923
+ }
924
+ loadedFonts.add(fontKey);
925
+
926
+ if (typeof document === 'undefined') {
927
+ return;
928
+ }
929
+
930
+ // Add preconnect links for faster loading (only once)
931
+ if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) {
932
+ const preconnect1 = document.createElement('link');
933
+ preconnect1.rel = 'preconnect';
934
+ preconnect1.href = 'https://fonts.googleapis.com';
935
+ document.head.appendChild(preconnect1);
936
+
937
+ const preconnect2 = document.createElement('link');
938
+ preconnect2.rel = 'preconnect';
939
+ preconnect2.href = 'https://fonts.gstatic.com';
940
+ preconnect2.crossOrigin = 'anonymous';
941
+ document.head.appendChild(preconnect2);
942
+ }
943
+
944
+ // Build Google Fonts URL
945
+ const escapedFamily = fontFamily.replace(/ /g, '+');
946
+
947
+ // Build axis list based on options
948
+ let axisList = '';
949
+ const axes = [];
950
+
951
+ // Handle italic style
952
+ if (style === 'italic') {
953
+ axes.push('ital');
954
+ }
955
+
956
+ // Handle weight - use specific weight or variable range
957
+ if (weight && weight !== '400' && !Array.isArray(weight)) {
958
+ // Specific weight requested
959
+ axes.push('wght');
960
+ if (style === 'italic') {
961
+ axisList = ':ital,wght@1,' + weight;
962
+ } else {
963
+ axisList = ':wght@' + weight;
964
+ }
965
+ } else if (Array.isArray(weight)) {
966
+ // Multiple weights
967
+ axes.push('wght');
968
+ axisList = ':wght@' + weight.join(';');
969
+ } else {
970
+ // Default: request common weights for flexibility
971
+ axisList = ':wght@400;500;600;700';
972
+ }
973
+
974
+ const fontUrl = 'https://fonts.googleapis.com/css2?family=' +
975
+ escapedFamily + axisList + '&display=swap';
976
+
977
+ // Add link element for Google Fonts (if not already present)
978
+ if (!document.querySelector('link[href*="family=' + escapedFamily + '"]')) {
979
+ const link = document.createElement('link');
980
+ link.rel = 'stylesheet';
981
+ link.href = fontUrl;
982
+ document.head.appendChild(link);
983
+ }
984
+
985
+ // Create style element for CSS variable at :root level (globally available)
986
+ // This makes the variable work without needing to apply the class to body
987
+ if (variableName) {
988
+ const styleEl = document.createElement('style');
989
+ styleEl.setAttribute('data-font-var', variableName);
990
+ styleEl.textContent = ':root { ' + variableName + ': "' + fontFamily + '", ' + (fontFamily.includes('Serif') ? 'serif' : 'sans-serif') + '; }';
991
+ document.head.appendChild(styleEl);
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Create a font loader function for a specific font
997
+ */
998
+ function createFontLoader(fontName) {
999
+ const fontFamily = toFontFamily(fontName);
1000
+
1001
+ return function(options = {}) {
1002
+ const {
1003
+ weight,
1004
+ style = 'normal',
1005
+ subsets = ['latin'],
1006
+ variable,
1007
+ display = 'swap',
1008
+ preload = true,
1009
+ fallback = ['sans-serif'],
1010
+ adjustFontFallback = true
1011
+ } = options;
1012
+
1013
+ // Inject the font CSS
1014
+ injectFontCSS(fontFamily, variable, weight, style);
1015
+
1016
+ // Generate class name from variable (--font-inter -> __font-inter)
1017
+ const className = variable
1018
+ ? variable.replace('--', '__')
1019
+ : '__font-' + fontName.toLowerCase().replace(/_/g, '-');
1020
+
1021
+ return {
1022
+ className,
1023
+ variable: className,
1024
+ style: {
1025
+ fontFamily: '"' + fontFamily + '", ' + fallback.join(', ')
1026
+ }
1027
+ };
1028
+ };
1029
+ }
1030
+
1031
+ /**
1032
+ * Use a Proxy to dynamically create font loaders for ANY font name
1033
+ * This allows: import { AnyGoogleFont } from "next/font/google"
1034
+ */
1035
+ const fontProxy = new Proxy({}, {
1036
+ get(target, prop) {
1037
+ // Handle special properties
1038
+ if (prop === '__esModule') return true;
1039
+ if (prop === 'default') return fontProxy;
1040
+ if (typeof prop !== 'string') return undefined;
1041
+
1042
+ // Create a font loader for this font name
1043
+ return createFontLoader(prop);
1044
+ }
1045
+ });
1046
+
1047
+ // Export the proxy as both default and named exports
1048
+ export default fontProxy;
1049
+
1050
+ // Re-export through proxy for named imports
1051
+ export const {
1052
+ Fraunces, Inter, DM_Sans, DM_Serif_Text, Roboto, Open_Sans, Lato,
1053
+ Montserrat, Poppins, Playfair_Display, Merriweather, Raleway, Nunito,
1054
+ Ubuntu, Oswald, Quicksand, Work_Sans, Fira_Sans, Barlow, Mulish, Rubik,
1055
+ Noto_Sans, Manrope, Space_Grotesk, Geist, Geist_Mono
1056
+ } = fontProxy;
1057
+ `;
1058
+
1059
+ /**
1060
+ * next/font/local shim - Loads local font files
1061
+ * Accepts font source path and creates @font-face declaration + CSS variable
1062
+ */
1063
+ const NEXT_FONT_LOCAL_SHIM = `
1064
+ const loadedLocalFonts = new Set();
1065
+
1066
+ function localFont(options = {}) {
1067
+ const {
1068
+ src,
1069
+ weight,
1070
+ style = 'normal',
1071
+ variable,
1072
+ display = 'swap',
1073
+ fallback = ['sans-serif'],
1074
+ declarations = [],
1075
+ adjustFontFallback = true
1076
+ } = options;
1077
+
1078
+ // Determine font family name from variable or src
1079
+ const familyName = variable
1080
+ ? variable.replace('--', '').replace(/-/g, ' ')
1081
+ : 'local-font-' + Math.random().toString(36).slice(2, 8);
1082
+
1083
+ const fontKey = familyName + '-' + (variable || 'default');
1084
+ if (typeof document !== 'undefined' && !loadedLocalFonts.has(fontKey)) {
1085
+ loadedLocalFonts.add(fontKey);
1086
+
1087
+ // Build @font-face declarations
1088
+ let fontFaces = '';
1089
+
1090
+ if (typeof src === 'string') {
1091
+ // Single source
1092
+ fontFaces = '@font-face {\\n' +
1093
+ ' font-family: "' + familyName + '";\\n' +
1094
+ ' src: url("' + src + '");\\n' +
1095
+ ' font-weight: ' + (weight || '400') + ';\\n' +
1096
+ ' font-style: ' + style + ';\\n' +
1097
+ ' font-display: ' + display + ';\\n' +
1098
+ '}';
1099
+ } else if (Array.isArray(src)) {
1100
+ // Multiple sources (different weights/styles)
1101
+ fontFaces = src.map(function(s) {
1102
+ const path = typeof s === 'string' ? s : s.path;
1103
+ const w = (typeof s === 'object' && s.weight) || weight || '400';
1104
+ const st = (typeof s === 'object' && s.style) || style;
1105
+ return '@font-face {\\n' +
1106
+ ' font-family: "' + familyName + '";\\n' +
1107
+ ' src: url("' + path + '");\\n' +
1108
+ ' font-weight: ' + w + ';\\n' +
1109
+ ' font-style: ' + st + ';\\n' +
1110
+ ' font-display: ' + display + ';\\n' +
1111
+ '}';
1112
+ }).join('\\n');
1113
+ }
1114
+
1115
+ // Inject font-face CSS
1116
+ if (fontFaces) {
1117
+ var styleEl = document.createElement('style');
1118
+ styleEl.setAttribute('data-local-font', fontKey);
1119
+ styleEl.textContent = fontFaces;
1120
+ document.head.appendChild(styleEl);
1121
+ }
1122
+
1123
+ // Inject CSS variable at :root level
1124
+ if (variable) {
1125
+ var varStyle = document.createElement('style');
1126
+ varStyle.setAttribute('data-font-var', variable);
1127
+ varStyle.textContent = ':root { ' + variable + ': "' + familyName + '", ' + fallback.join(', ') + '; }';
1128
+ document.head.appendChild(varStyle);
1129
+ }
1130
+ }
1131
+
1132
+ const className = variable
1133
+ ? variable.replace('--', '__')
1134
+ : '__font-' + familyName.toLowerCase().replace(/\\s+/g, '-');
1135
+
1136
+ return {
1137
+ className,
1138
+ variable: className,
1139
+ style: {
1140
+ fontFamily: '"' + familyName + '", ' + fallback.join(', ')
1141
+ }
1142
+ };
1143
+ }
1144
+
1145
+ export default localFont;
1146
+ export { localFont };
1147
+ `;
1148
+
673
1149
  /**
674
1150
  * NextDevServer - A lightweight Next.js-compatible development server
675
1151
  *
@@ -718,6 +1194,21 @@ export class NextDevServer extends DevServer {
718
1194
  /** Transform result cache for performance */
719
1195
  private transformCache: Map<string, { code: string; hash: string }> = new Map();
720
1196
 
1197
+ /** Path aliases from tsconfig.json (e.g., @/* -> ./*) */
1198
+ private pathAliases: Map<string, string> = new Map();
1199
+
1200
+ /** Cached Tailwind config script (injected before CDN) */
1201
+ private tailwindConfigScript: string = '';
1202
+
1203
+ /** Whether Tailwind config has been loaded */
1204
+ private tailwindConfigLoaded: boolean = false;
1205
+
1206
+ /** Asset prefix for static files (e.g., '/marketing') */
1207
+ private assetPrefix: string = '';
1208
+
1209
+ /** Base path for the app (e.g., '/docs') */
1210
+ private basePath: string = '';
1211
+
721
1212
  constructor(vfs: VirtualFS, options: NextDevServerOptions) {
722
1213
  super(vfs, options);
723
1214
  this.options = options;
@@ -733,6 +1224,166 @@ export class NextDevServer extends DevServer {
733
1224
  // Prefer App Router if /app directory exists with a page.jsx file
734
1225
  this.useAppRouter = this.hasAppRouter();
735
1226
  }
1227
+
1228
+ // Load path aliases from tsconfig.json
1229
+ this.loadPathAliases();
1230
+
1231
+ // Load assetPrefix from options or auto-detect from next.config
1232
+ this.loadAssetPrefix(options.assetPrefix);
1233
+
1234
+ // Load basePath from options or auto-detect from next.config
1235
+ this.loadBasePath(options.basePath);
1236
+ }
1237
+
1238
+ /**
1239
+ * Load path aliases from tsconfig.json
1240
+ * Supports common patterns like @/* -> ./*
1241
+ */
1242
+ private loadPathAliases(): void {
1243
+ try {
1244
+ const tsconfigPath = '/tsconfig.json';
1245
+ if (!this.vfs.existsSync(tsconfigPath)) {
1246
+ return;
1247
+ }
1248
+
1249
+ const content = this.vfs.readFileSync(tsconfigPath, 'utf-8');
1250
+ const tsconfig = JSON.parse(content);
1251
+ const paths = tsconfig?.compilerOptions?.paths;
1252
+
1253
+ if (!paths) {
1254
+ return;
1255
+ }
1256
+
1257
+ // Convert tsconfig paths to a simple alias map
1258
+ // e.g., "@/*": ["./*"] becomes "@/" -> "/"
1259
+ for (const [alias, targets] of Object.entries(paths)) {
1260
+ if (Array.isArray(targets) && targets.length > 0) {
1261
+ // Remove trailing * from alias and target
1262
+ const aliasPrefix = alias.replace(/\*$/, '');
1263
+ const targetPrefix = (targets[0] as string).replace(/\*$/, '').replace(/^\./, '');
1264
+ this.pathAliases.set(aliasPrefix, targetPrefix);
1265
+ }
1266
+ }
1267
+ } catch (e) {
1268
+ // Silently ignore tsconfig parse errors
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * Load assetPrefix from options or auto-detect from next.config.ts/js
1274
+ * The assetPrefix is used to prefix static asset URLs (e.g., '/marketing')
1275
+ */
1276
+ private loadAssetPrefix(optionValue?: string): void {
1277
+ // If explicitly provided in options, use it
1278
+ if (optionValue !== undefined) {
1279
+ // Normalize: ensure it starts with / and doesn't end with /
1280
+ this.assetPrefix = optionValue.startsWith('/') ? optionValue : `/${optionValue}`;
1281
+ if (this.assetPrefix.endsWith('/')) {
1282
+ this.assetPrefix = this.assetPrefix.slice(0, -1);
1283
+ }
1284
+ return;
1285
+ }
1286
+
1287
+ // Try to auto-detect from next.config.ts or next.config.js
1288
+ try {
1289
+ const configFiles = ['/next.config.ts', '/next.config.js', '/next.config.mjs'];
1290
+
1291
+ for (const configPath of configFiles) {
1292
+ if (!this.vfs.existsSync(configPath)) {
1293
+ continue;
1294
+ }
1295
+
1296
+ const content = this.vfs.readFileSync(configPath, 'utf-8');
1297
+
1298
+ // Extract assetPrefix from config using regex
1299
+ // Matches: assetPrefix: "/marketing" or assetPrefix: '/marketing'
1300
+ const match = content.match(/assetPrefix\s*:\s*["']([^"']+)["']/);
1301
+ if (match) {
1302
+ let prefix = match[1];
1303
+ // Normalize: ensure it starts with / and doesn't end with /
1304
+ if (!prefix.startsWith('/')) {
1305
+ prefix = `/${prefix}`;
1306
+ }
1307
+ if (prefix.endsWith('/')) {
1308
+ prefix = prefix.slice(0, -1);
1309
+ }
1310
+ this.assetPrefix = prefix;
1311
+ return;
1312
+ }
1313
+ }
1314
+ } catch (e) {
1315
+ // Silently ignore config parse errors
1316
+ }
1317
+ }
1318
+
1319
+ /**
1320
+ * Load basePath from options or auto-detect from next.config.ts/js
1321
+ * The basePath is used to prefix all routes (e.g., '/docs' means / -> /docs)
1322
+ */
1323
+ private loadBasePath(optionValue?: string): void {
1324
+ if (optionValue !== undefined) {
1325
+ this.basePath = optionValue.startsWith('/') ? optionValue : `/${optionValue}`;
1326
+ if (this.basePath.endsWith('/')) {
1327
+ this.basePath = this.basePath.slice(0, -1);
1328
+ }
1329
+ return;
1330
+ }
1331
+
1332
+ try {
1333
+ const configFiles = ['/next.config.ts', '/next.config.js', '/next.config.mjs'];
1334
+ for (const configPath of configFiles) {
1335
+ if (!this.vfs.existsSync(configPath)) continue;
1336
+ const content = this.vfs.readFileSync(configPath, 'utf-8');
1337
+ const match = content.match(/basePath\s*:\s*["']([^"']+)["']/);
1338
+ if (match) {
1339
+ let bp = match[1];
1340
+ if (!bp.startsWith('/')) bp = `/${bp}`;
1341
+ if (bp.endsWith('/')) bp = bp.slice(0, -1);
1342
+ this.basePath = bp;
1343
+ return;
1344
+ }
1345
+ }
1346
+ } catch {
1347
+ // Silently ignore config parse errors
1348
+ }
1349
+ }
1350
+
1351
+ /**
1352
+ * Resolve path aliases in transformed code
1353
+ * Converts imports like "@/components/foo" to "/__virtual__/PORT/components/foo"
1354
+ * This ensures imports go through the virtual server instead of the main server
1355
+ */
1356
+ private resolvePathAliases(code: string, currentFile: string): string {
1357
+ if (this.pathAliases.size === 0) {
1358
+ return code;
1359
+ }
1360
+
1361
+ // Get the virtual server base path
1362
+ const virtualBase = `/__virtual__/${this.port}`;
1363
+
1364
+ let result = code;
1365
+
1366
+ for (const [alias, target] of this.pathAliases) {
1367
+ // Match import/export statements with the alias
1368
+ // Handles: import ... from "@/...", export ... from "@/...", import("@/...")
1369
+ const aliasEscaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1370
+
1371
+ // Pattern to match the alias in import/export statements
1372
+ // This matches: from "@/...", from '@/...', import("@/..."), import('@/...')
1373
+ const pattern = new RegExp(
1374
+ `(from\\s*['"]|import\\s*\\(\\s*['"])${aliasEscaped}([^'"]+)(['"])`,
1375
+ 'g'
1376
+ );
1377
+
1378
+ result = result.replace(pattern, (match, prefix, path, quote) => {
1379
+ // Convert alias to virtual server path
1380
+ // e.g., @/components/faq -> /__virtual__/3001/components/faq
1381
+ const resolvedPath = `${virtualBase}${target}${path}`;
1382
+ return `${prefix}${resolvedPath}${quote}`;
1383
+ });
1384
+ }
1385
+
1386
+ return result;
736
1387
  }
737
1388
 
738
1389
  /**
@@ -762,28 +1413,60 @@ export class NextDevServer extends DevServer {
762
1413
  /**
763
1414
  * Generate a script tag that defines process.env with NEXT_PUBLIC_* variables
764
1415
  * This makes environment variables available to browser code via process.env.NEXT_PUBLIC_*
1416
+ * Also includes all env variables for Server Component compatibility
765
1417
  */
766
1418
  private generateEnvScript(): string {
767
1419
  const env = this.options.env || {};
768
1420
 
769
- // Filter for NEXT_PUBLIC_* variables only (Next.js convention)
770
- const publicEnvVars = Object.entries(env)
771
- .filter(([key]) => key.startsWith('NEXT_PUBLIC_'))
772
- .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {} as Record<string, string>);
773
-
774
- // Return empty string if no public env vars
775
- if (Object.keys(publicEnvVars).length === 0) {
776
- return '';
1421
+ // Only include NEXT_PUBLIC_* vars in the HTML (client-side accessible)
1422
+ // Non-public vars should never be exposed in HTML for security
1423
+ const publicEnvVars: Record<string, string> = {};
1424
+ for (const [key, value] of Object.entries(env)) {
1425
+ if (key.startsWith('NEXT_PUBLIC_')) {
1426
+ publicEnvVars[key] = value;
1427
+ }
777
1428
  }
778
1429
 
1430
+ // Always create process.env even if empty (some code checks for process.env existence)
1431
+ // This prevents "process is not defined" errors
779
1432
  return `<script>
780
- // NEXT_PUBLIC_* environment variables (injected by NextDevServer)
1433
+ // Environment variables (injected by NextDevServer)
781
1434
  window.process = window.process || {};
782
1435
  window.process.env = window.process.env || {};
783
1436
  Object.assign(window.process.env, ${JSON.stringify(publicEnvVars)});
1437
+ // Next.js config values
1438
+ window.__NEXT_BASE_PATH__ = ${JSON.stringify(this.basePath)};
784
1439
  </script>`;
785
1440
  }
786
1441
 
1442
+ /**
1443
+ * Load Tailwind config from tailwind.config.ts and generate a script
1444
+ * that configures the Tailwind CDN at runtime
1445
+ */
1446
+ private async loadTailwindConfigIfNeeded(): Promise<string> {
1447
+ // Return cached script if already loaded
1448
+ if (this.tailwindConfigLoaded) {
1449
+ return this.tailwindConfigScript;
1450
+ }
1451
+
1452
+ try {
1453
+ const result = await loadTailwindConfig(this.vfs, this.root);
1454
+
1455
+ if (result.success) {
1456
+ this.tailwindConfigScript = result.configScript;
1457
+ } else if (result.error) {
1458
+ console.warn('[NextDevServer] Tailwind config warning:', result.error);
1459
+ this.tailwindConfigScript = '';
1460
+ }
1461
+ } catch (error) {
1462
+ console.warn('[NextDevServer] Failed to load tailwind.config:', error);
1463
+ this.tailwindConfigScript = '';
1464
+ }
1465
+
1466
+ this.tailwindConfigLoaded = true;
1467
+ return this.tailwindConfigScript;
1468
+ }
1469
+
787
1470
  /**
788
1471
  * Check if App Router is available
789
1472
  */
@@ -792,11 +1475,30 @@ export class NextDevServer extends DevServer {
792
1475
  // Check if /app directory exists and has a page file
793
1476
  if (!this.exists(this.appDir)) return false;
794
1477
 
795
- // Check for root page
796
1478
  const extensions = ['.jsx', '.tsx', '.js', '.ts'];
1479
+
1480
+ // Check for root page directly
797
1481
  for (const ext of extensions) {
798
1482
  if (this.exists(`${this.appDir}/page${ext}`)) return true;
799
1483
  }
1484
+
1485
+ // Check for root page inside route groups (e.g., /app/(main)/page.tsx)
1486
+ try {
1487
+ const entries = this.vfs.readdirSync(this.appDir);
1488
+ for (const entry of entries) {
1489
+ if (/^\([^)]+\)$/.test(entry) && this.isDirectory(`${this.appDir}/${entry}`)) {
1490
+ for (const ext of extensions) {
1491
+ if (this.exists(`${this.appDir}/${entry}/page${ext}`)) return true;
1492
+ }
1493
+ }
1494
+ }
1495
+ } catch { /* ignore */ }
1496
+
1497
+ // Also check for any layout.tsx which indicates App Router usage
1498
+ for (const ext of extensions) {
1499
+ if (this.exists(`${this.appDir}/layout${ext}`)) return true;
1500
+ }
1501
+
800
1502
  return false;
801
1503
  } catch {
802
1504
  return false;
@@ -813,13 +1515,47 @@ export class NextDevServer extends DevServer {
813
1515
  body?: Buffer
814
1516
  ): Promise<ResponseData> {
815
1517
  const urlObj = new URL(url, 'http://localhost');
816
- const pathname = urlObj.pathname;
1518
+ let pathname = urlObj.pathname;
1519
+
1520
+ // Strip virtual prefix if present (e.g., /__virtual__/3001/foo -> /foo)
1521
+ const virtualPrefixMatch = pathname.match(/^\/__virtual__\/\d+/);
1522
+ if (virtualPrefixMatch) {
1523
+ pathname = pathname.slice(virtualPrefixMatch[0].length) || '/';
1524
+ }
1525
+
1526
+ // Strip assetPrefix if present (e.g., /marketing/images/foo.png -> /images/foo.png)
1527
+ // This allows static assets to be served from /public when using assetPrefix in next.config
1528
+ // Also handles double-slash case: /marketing//images/foo.png (when assetPrefix ends with /)
1529
+ if (this.assetPrefix && pathname.startsWith(this.assetPrefix)) {
1530
+ const rest = pathname.slice(this.assetPrefix.length);
1531
+ // Handle both /marketing/images and /marketing//images cases
1532
+ if (rest === '' || rest.startsWith('/')) {
1533
+ pathname = rest || '/';
1534
+ // Normalize double slashes that may occur from assetPrefix concatenation
1535
+ if (pathname.startsWith('//')) {
1536
+ pathname = pathname.slice(1);
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ // Strip basePath if present (e.g., /docs/about -> /about)
1542
+ if (this.basePath && pathname.startsWith(this.basePath)) {
1543
+ const rest = pathname.slice(this.basePath.length);
1544
+ if (rest === '' || rest.startsWith('/')) {
1545
+ pathname = rest || '/';
1546
+ }
1547
+ }
817
1548
 
818
1549
  // Serve Next.js shims
819
1550
  if (pathname.startsWith('/_next/shims/')) {
820
1551
  return this.serveNextShim(pathname);
821
1552
  }
822
1553
 
1554
+ // Route info endpoint for client-side navigation params extraction
1555
+ if (pathname === '/_next/route-info') {
1556
+ return this.serveRouteInfo(urlObj.searchParams.get('pathname') || '/');
1557
+ }
1558
+
823
1559
  // Serve page components for client-side navigation (Pages Router)
824
1560
  if (pathname.startsWith('/_next/pages/')) {
825
1561
  return this.servePageComponent(pathname);
@@ -835,7 +1571,15 @@ export class NextDevServer extends DevServer {
835
1571
  return this.serveStaticAsset(pathname);
836
1572
  }
837
1573
 
838
- // API routes: /api/*
1574
+ // App Router API routes (route.ts/route.js) - check before Pages Router API routes
1575
+ if (this.useAppRouter) {
1576
+ const appRouteFile = this.resolveAppRouteHandler(pathname);
1577
+ if (appRouteFile) {
1578
+ return this.handleAppRouteHandler(method, pathname, headers, body, appRouteFile, urlObj.search);
1579
+ }
1580
+ }
1581
+
1582
+ // Pages Router API routes: /api/*
839
1583
  if (pathname.startsWith('/api/')) {
840
1584
  return this.handleApiRoute(method, pathname, headers, body);
841
1585
  }
@@ -851,6 +1595,16 @@ export class NextDevServer extends DevServer {
851
1595
  return this.transformAndServe(pathname, pathname);
852
1596
  }
853
1597
 
1598
+ // Try to resolve file with different extensions (for imports without extensions)
1599
+ // e.g., /components/faq -> /components/faq.tsx
1600
+ const resolvedFile = this.resolveFileWithExtension(pathname);
1601
+ if (resolvedFile) {
1602
+ if (this.needsTransform(resolvedFile)) {
1603
+ return this.transformAndServe(resolvedFile, pathname);
1604
+ }
1605
+ return this.serveFile(resolvedFile);
1606
+ }
1607
+
854
1608
  // Serve regular files directly if they exist
855
1609
  if (this.exists(pathname) && !this.isDirectory(pathname)) {
856
1610
  return this.serveFile(pathname);
@@ -880,6 +1634,21 @@ export class NextDevServer extends DevServer {
880
1634
  case 'navigation':
881
1635
  code = NEXT_NAVIGATION_SHIM;
882
1636
  break;
1637
+ case 'image':
1638
+ code = NEXT_IMAGE_SHIM;
1639
+ break;
1640
+ case 'dynamic':
1641
+ code = NEXT_DYNAMIC_SHIM;
1642
+ break;
1643
+ case 'script':
1644
+ code = NEXT_SCRIPT_SHIM;
1645
+ break;
1646
+ case 'font/google':
1647
+ code = NEXT_FONT_GOOGLE_SHIM;
1648
+ break;
1649
+ case 'font/local':
1650
+ code = NEXT_FONT_LOCAL_SHIM;
1651
+ break;
883
1652
  default:
884
1653
  return this.notFound(pathname);
885
1654
  }
@@ -897,6 +1666,32 @@ export class NextDevServer extends DevServer {
897
1666
  };
898
1667
  }
899
1668
 
1669
+ /**
1670
+ * Serve route info for client-side navigation
1671
+ * Returns params extracted from dynamic route segments
1672
+ */
1673
+ private serveRouteInfo(pathname: string): ResponseData {
1674
+ const route = this.resolveAppRoute(pathname);
1675
+
1676
+ const info = route
1677
+ ? { params: route.params, found: true }
1678
+ : { params: {}, found: false };
1679
+
1680
+ const json = JSON.stringify(info);
1681
+ const buffer = Buffer.from(json);
1682
+
1683
+ return {
1684
+ statusCode: 200,
1685
+ statusMessage: 'OK',
1686
+ headers: {
1687
+ 'Content-Type': 'application/json; charset=utf-8',
1688
+ 'Content-Length': String(buffer.length),
1689
+ 'Cache-Control': 'no-cache',
1690
+ },
1691
+ body: buffer,
1692
+ };
1693
+ }
1694
+
900
1695
  /**
901
1696
  * Serve static assets from /_next/static/
902
1697
  */
@@ -992,12 +1787,244 @@ export class NextDevServer extends DevServer {
992
1787
  const timeout = new Promise<void>((_, reject) => {
993
1788
  setTimeout(() => reject(new Error('API handler timeout')), 30000);
994
1789
  });
995
- await Promise.race([res.waitForEnd(), timeout]);
1790
+ await Promise.race([res.waitForEnd(), timeout]);
1791
+ }
1792
+
1793
+ return res.toResponse();
1794
+ } catch (error) {
1795
+ console.error('[NextDevServer] API error:', error);
1796
+ return {
1797
+ statusCode: 500,
1798
+ statusMessage: 'Internal Server Error',
1799
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1800
+ body: Buffer.from(JSON.stringify({
1801
+ error: error instanceof Error ? error.message : 'Internal Server Error'
1802
+ })),
1803
+ };
1804
+ }
1805
+ }
1806
+
1807
+ /**
1808
+ * Resolve an App Router route handler (route.ts/route.js)
1809
+ * Returns the file path if found, null otherwise
1810
+ */
1811
+ private resolveAppRouteHandler(pathname: string): string | null {
1812
+ const extensions = ['.ts', '.js', '.tsx', '.jsx'];
1813
+
1814
+ // Build the directory path in the app dir
1815
+ const segments = pathname === '/' ? [] : pathname.split('/').filter(Boolean);
1816
+ let dirPath = this.appDir;
1817
+
1818
+ for (const segment of segments) {
1819
+ dirPath = `${dirPath}/${segment}`;
1820
+ }
1821
+
1822
+ // Check for route file
1823
+ for (const ext of extensions) {
1824
+ const routePath = `${dirPath}/route${ext}`;
1825
+ if (this.exists(routePath)) {
1826
+ return routePath;
1827
+ }
1828
+ }
1829
+
1830
+ // Try dynamic route resolution with route groups
1831
+ return this.resolveAppRouteHandlerDynamic(segments);
1832
+ }
1833
+
1834
+ /**
1835
+ * Resolve dynamic App Router route handlers with route group support
1836
+ */
1837
+ private resolveAppRouteHandlerDynamic(segments: string[]): string | null {
1838
+ const extensions = ['.ts', '.js', '.tsx', '.jsx'];
1839
+
1840
+ const tryPath = (dirPath: string, remainingSegments: string[]): string | null => {
1841
+ if (remainingSegments.length === 0) {
1842
+ for (const ext of extensions) {
1843
+ const routePath = `${dirPath}/route${ext}`;
1844
+ if (this.exists(routePath)) {
1845
+ return routePath;
1846
+ }
1847
+ }
1848
+
1849
+ // Check route groups
1850
+ try {
1851
+ const entries = this.vfs.readdirSync(dirPath);
1852
+ for (const entry of entries) {
1853
+ if (/^\([^)]+\)$/.test(entry) && this.isDirectory(`${dirPath}/${entry}`)) {
1854
+ for (const ext of extensions) {
1855
+ const routePath = `${dirPath}/${entry}/route${ext}`;
1856
+ if (this.exists(routePath)) {
1857
+ return routePath;
1858
+ }
1859
+ }
1860
+ }
1861
+ }
1862
+ } catch { /* ignore */ }
1863
+
1864
+ return null;
1865
+ }
1866
+
1867
+ const [current, ...rest] = remainingSegments;
1868
+
1869
+ // Try exact match
1870
+ const exactPath = `${dirPath}/${current}`;
1871
+ if (this.isDirectory(exactPath)) {
1872
+ const result = tryPath(exactPath, rest);
1873
+ if (result) return result;
1874
+ }
1875
+
1876
+ // Try route groups and dynamic segments
1877
+ try {
1878
+ const entries = this.vfs.readdirSync(dirPath);
1879
+ for (const entry of entries) {
1880
+ // Route groups
1881
+ if (/^\([^)]+\)$/.test(entry) && this.isDirectory(`${dirPath}/${entry}`)) {
1882
+ const groupExact = `${dirPath}/${entry}/${current}`;
1883
+ if (this.isDirectory(groupExact)) {
1884
+ const result = tryPath(groupExact, rest);
1885
+ if (result) return result;
1886
+ }
1887
+ }
1888
+ // Dynamic segments
1889
+ if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
1890
+ const dynamicPath = `${dirPath}/${entry}`;
1891
+ if (this.isDirectory(dynamicPath)) {
1892
+ const result = tryPath(dynamicPath, rest);
1893
+ if (result) return result;
1894
+ }
1895
+ }
1896
+ // Catch-all
1897
+ if (entry.startsWith('[...') && entry.endsWith(']')) {
1898
+ const dynamicPath = `${dirPath}/${entry}`;
1899
+ if (this.isDirectory(dynamicPath)) {
1900
+ const result = tryPath(dynamicPath, []);
1901
+ if (result) return result;
1902
+ }
1903
+ }
1904
+ }
1905
+ } catch { /* ignore */ }
1906
+
1907
+ return null;
1908
+ };
1909
+
1910
+ return tryPath(this.appDir, segments);
1911
+ }
1912
+
1913
+ /**
1914
+ * Handle App Router route handler (route.ts) requests
1915
+ * These use the Web Request/Response API pattern
1916
+ */
1917
+ private async handleAppRouteHandler(
1918
+ method: string,
1919
+ pathname: string,
1920
+ headers: Record<string, string>,
1921
+ body: Buffer | undefined,
1922
+ routeFile: string,
1923
+ search?: string
1924
+ ): Promise<ResponseData> {
1925
+ try {
1926
+ const code = this.vfs.readFileSync(routeFile, 'utf8');
1927
+ const transformed = await this.transformApiHandler(code, routeFile);
1928
+
1929
+ // Create module context
1930
+ const builtinModules: Record<string, unknown> = {
1931
+ https: await import('../shims/https'),
1932
+ http: await import('../shims/http'),
1933
+ path: await import('../shims/path'),
1934
+ url: await import('../shims/url'),
1935
+ querystring: await import('../shims/querystring'),
1936
+ util: await import('../shims/util'),
1937
+ events: await import('../shims/events'),
1938
+ stream: await import('../shims/stream'),
1939
+ buffer: await import('../shims/buffer'),
1940
+ crypto: await import('../shims/crypto'),
1941
+ };
1942
+
1943
+ const require = (id: string): unknown => {
1944
+ const modId = id.startsWith('node:') ? id.slice(5) : id;
1945
+ if (builtinModules[modId]) return builtinModules[modId];
1946
+ throw new Error(`Module not found: ${id}`);
1947
+ };
1948
+
1949
+ const module = { exports: {} as Record<string, unknown> };
1950
+ const exports = module.exports;
1951
+ const process = {
1952
+ env: { ...this.options.env },
1953
+ cwd: () => '/',
1954
+ platform: 'browser',
1955
+ version: 'v18.0.0',
1956
+ versions: { node: '18.0.0' },
1957
+ };
1958
+
1959
+ const fn = new Function('exports', 'require', 'module', 'process', transformed);
1960
+ fn(exports, require, module, process);
1961
+
1962
+ // Get the handler for the HTTP method
1963
+ const methodUpper = method.toUpperCase();
1964
+ const handler = module.exports[methodUpper] || module.exports[methodUpper.toLowerCase()];
1965
+
1966
+ if (typeof handler !== 'function') {
1967
+ return {
1968
+ statusCode: 405,
1969
+ statusMessage: 'Method Not Allowed',
1970
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1971
+ body: Buffer.from(JSON.stringify({ error: `Method ${method} not allowed` })),
1972
+ };
1973
+ }
1974
+
1975
+ // Create a Web API Request object
1976
+ const requestUrl = new URL(pathname + (search || ''), 'http://localhost');
1977
+ const requestInit: RequestInit = {
1978
+ method: methodUpper,
1979
+ headers: new Headers(headers),
1980
+ };
1981
+ if (body && methodUpper !== 'GET' && methodUpper !== 'HEAD') {
1982
+ requestInit.body = body;
1983
+ }
1984
+ const request = new Request(requestUrl.toString(), requestInit);
1985
+
1986
+ // Extract route params
1987
+ const route = this.resolveAppRoute(pathname);
1988
+ const params = route?.params || {};
1989
+
1990
+ // Call the handler
1991
+ const response = await handler(request, { params: Promise.resolve(params) });
1992
+
1993
+ // Convert Response to our format
1994
+ if (response instanceof Response) {
1995
+ const respHeaders: Record<string, string> = {};
1996
+ response.headers.forEach((value: string, key: string) => {
1997
+ respHeaders[key] = value;
1998
+ });
1999
+
2000
+ const respBody = await response.text();
2001
+ return {
2002
+ statusCode: response.status,
2003
+ statusMessage: response.statusText || 'OK',
2004
+ headers: respHeaders,
2005
+ body: Buffer.from(respBody),
2006
+ };
996
2007
  }
997
2008
 
998
- return res.toResponse();
2009
+ // If the handler returned a plain object, serialize as JSON
2010
+ if (response && typeof response === 'object') {
2011
+ const json = JSON.stringify(response);
2012
+ return {
2013
+ statusCode: 200,
2014
+ statusMessage: 'OK',
2015
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
2016
+ body: Buffer.from(json),
2017
+ };
2018
+ }
2019
+
2020
+ return {
2021
+ statusCode: 200,
2022
+ statusMessage: 'OK',
2023
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
2024
+ body: Buffer.from(String(response || '')),
2025
+ };
999
2026
  } catch (error) {
1000
- console.error('[NextDevServer] API error:', error);
2027
+ console.error('[NextDevServer] App Route handler error:', error);
1001
2028
  return {
1002
2029
  statusCode: 500,
1003
2030
  statusMessage: 'Internal Server Error',
@@ -1510,79 +2537,128 @@ export class NextDevServer extends DevServer {
1510
2537
  /**
1511
2538
  * Resolve App Router route to page and layout files
1512
2539
  */
1513
- private resolveAppRoute(pathname: string): { page: string; layouts: string[] } | null {
1514
- const extensions = ['.jsx', '.tsx', '.js', '.ts'];
2540
+ private resolveAppRoute(pathname: string): AppRoute | null {
1515
2541
  const segments = pathname === '/' ? [] : pathname.split('/').filter(Boolean);
2542
+ // Use the unified dynamic resolver which handles static, dynamic, and route groups
2543
+ return this.resolveAppDynamicRoute(pathname, segments);
2544
+ }
1516
2545
 
1517
- // Build the directory path
1518
- let dirPath = this.appDir;
1519
- const layouts: string[] = [];
2546
+ /**
2547
+ * Resolve App Router routes including static, dynamic, and route groups.
2548
+ * Route groups are folders wrapped in parentheses like (marketing) that
2549
+ * don't affect the URL path but can have their own layouts.
2550
+ */
2551
+ private resolveAppDynamicRoute(
2552
+ _pathname: string,
2553
+ segments: string[]
2554
+ ): AppRoute | null {
2555
+ const extensions = ['.jsx', '.tsx', '.js', '.ts'];
1520
2556
 
1521
- // Collect layouts from root to current directory
1522
- for (const ext of extensions) {
1523
- const rootLayout = `${this.appDir}/layout${ext}`;
1524
- if (this.exists(rootLayout)) {
1525
- layouts.push(rootLayout);
1526
- break;
2557
+ /**
2558
+ * Collect layout from a directory if it exists
2559
+ */
2560
+ const collectLayout = (dirPath: string, layouts: string[]): string[] => {
2561
+ for (const ext of extensions) {
2562
+ const layoutPath = `${dirPath}/layout${ext}`;
2563
+ if (this.exists(layoutPath) && !layouts.includes(layoutPath)) {
2564
+ return [...layouts, layoutPath];
2565
+ }
1527
2566
  }
1528
- }
1529
-
1530
- // Walk through segments to find page and collect layouts
1531
- for (const segment of segments) {
1532
- dirPath = `${dirPath}/${segment}`;
2567
+ return layouts;
2568
+ };
1533
2569
 
1534
- // Check for layout in this segment
2570
+ /**
2571
+ * Find page file in a directory
2572
+ */
2573
+ const findPage = (dirPath: string): string | null => {
1535
2574
  for (const ext of extensions) {
1536
- const layoutPath = `${dirPath}/layout${ext}`;
1537
- if (this.exists(layoutPath)) {
1538
- layouts.push(layoutPath);
1539
- break;
2575
+ const pagePath = `${dirPath}/page${ext}`;
2576
+ if (this.exists(pagePath)) {
2577
+ return pagePath;
1540
2578
  }
1541
2579
  }
1542
- }
2580
+ return null;
2581
+ };
1543
2582
 
1544
- // Find the page file
1545
- for (const ext of extensions) {
1546
- const pagePath = `${dirPath}/page${ext}`;
1547
- if (this.exists(pagePath)) {
1548
- return { page: pagePath, layouts };
2583
+ /**
2584
+ * Find a UI convention file (loading, error, not-found) in a directory
2585
+ */
2586
+ const findConventionFile = (dirPath: string, name: string): string | null => {
2587
+ for (const ext of extensions) {
2588
+ const filePath = `${dirPath}/${name}${ext}`;
2589
+ if (this.exists(filePath)) {
2590
+ return filePath;
2591
+ }
1549
2592
  }
1550
- }
2593
+ return null;
2594
+ };
1551
2595
 
1552
- // Try dynamic segments
1553
- return this.resolveAppDynamicRoute(pathname, segments);
1554
- }
2596
+ /**
2597
+ * Find the nearest convention file by walking up from the page directory
2598
+ */
2599
+ const findNearestConventionFile = (dirPath: string, name: string): string | null => {
2600
+ let current = dirPath;
2601
+ while (current.startsWith(this.appDir)) {
2602
+ const file = findConventionFile(current, name);
2603
+ if (file) return file;
2604
+ // Move up one directory
2605
+ const parent = current.replace(/\/[^/]+$/, '');
2606
+ if (parent === current) break;
2607
+ current = parent;
2608
+ }
2609
+ return null;
2610
+ };
1555
2611
 
1556
- /**
1557
- * Resolve dynamic App Router routes like /app/[id]/page.jsx
1558
- */
1559
- private resolveAppDynamicRoute(
1560
- pathname: string,
1561
- segments: string[]
1562
- ): { page: string; layouts: string[] } | null {
1563
- const extensions = ['.jsx', '.tsx', '.js', '.ts'];
2612
+ /**
2613
+ * Get route group directories (folders matching (name) pattern)
2614
+ */
2615
+ const getRouteGroups = (dirPath: string): string[] => {
2616
+ try {
2617
+ const entries = this.vfs.readdirSync(dirPath);
2618
+ return entries.filter(e => /^\([^)]+\)$/.test(e) && this.isDirectory(`${dirPath}/${e}`));
2619
+ } catch {
2620
+ return [];
2621
+ }
2622
+ };
1564
2623
 
1565
2624
  const tryPath = (
1566
2625
  dirPath: string,
1567
2626
  remainingSegments: string[],
1568
- layouts: string[]
1569
- ): { page: string; layouts: string[] } | null => {
2627
+ layouts: string[],
2628
+ params: Record<string, string | string[]>
2629
+ ): AppRoute | null => {
1570
2630
  // Check for layout at current level
1571
- for (const ext of extensions) {
1572
- const layoutPath = `${dirPath}/layout${ext}`;
1573
- if (this.exists(layoutPath) && !layouts.includes(layoutPath)) {
1574
- layouts = [...layouts, layoutPath];
1575
- }
1576
- }
2631
+ layouts = collectLayout(dirPath, layouts);
1577
2632
 
1578
2633
  if (remainingSegments.length === 0) {
1579
- // Look for page file
1580
- for (const ext of extensions) {
1581
- const pagePath = `${dirPath}/page${ext}`;
1582
- if (this.exists(pagePath)) {
1583
- return { page: pagePath, layouts };
2634
+ // Look for page file directly
2635
+ const page = findPage(dirPath);
2636
+ if (page) {
2637
+ return {
2638
+ page, layouts, params,
2639
+ loading: findNearestConventionFile(dirPath, 'loading') || undefined,
2640
+ error: findNearestConventionFile(dirPath, 'error') || undefined,
2641
+ notFound: findNearestConventionFile(dirPath, 'not-found') || undefined,
2642
+ };
2643
+ }
2644
+
2645
+ // Look for page inside route groups at this level
2646
+ // e.g., /app/(marketing)/page.tsx resolves to /
2647
+ const groups = getRouteGroups(dirPath);
2648
+ for (const group of groups) {
2649
+ const groupPath = `${dirPath}/${group}`;
2650
+ const groupLayouts = collectLayout(groupPath, layouts);
2651
+ const page = findPage(groupPath);
2652
+ if (page) {
2653
+ return {
2654
+ page, layouts: groupLayouts, params,
2655
+ loading: findNearestConventionFile(groupPath, 'loading') || undefined,
2656
+ error: findNearestConventionFile(groupPath, 'error') || undefined,
2657
+ notFound: findNearestConventionFile(groupPath, 'not-found') || undefined,
2658
+ };
1584
2659
  }
1585
2660
  }
2661
+
1586
2662
  return null;
1587
2663
  }
1588
2664
 
@@ -1591,18 +2667,90 @@ export class NextDevServer extends DevServer {
1591
2667
  // Try exact match first
1592
2668
  const exactPath = `${dirPath}/${current}`;
1593
2669
  if (this.isDirectory(exactPath)) {
1594
- const result = tryPath(exactPath, rest, layouts);
2670
+ const result = tryPath(exactPath, rest, layouts, params);
1595
2671
  if (result) return result;
1596
2672
  }
1597
2673
 
1598
- // Try dynamic segment [param]
2674
+ // Try inside route groups - route groups are transparent in URL
2675
+ // e.g., /about might match /app/(marketing)/about/page.tsx
2676
+ const groups = getRouteGroups(dirPath);
2677
+ for (const group of groups) {
2678
+ const groupPath = `${dirPath}/${group}`;
2679
+ const groupLayouts = collectLayout(groupPath, layouts);
2680
+
2681
+ // Try exact match inside group
2682
+ const groupExactPath = `${groupPath}/${current}`;
2683
+ if (this.isDirectory(groupExactPath)) {
2684
+ const result = tryPath(groupExactPath, rest, groupLayouts, params);
2685
+ if (result) return result;
2686
+ }
2687
+
2688
+ // Try dynamic segments inside group
2689
+ try {
2690
+ const groupEntries = this.vfs.readdirSync(groupPath);
2691
+ for (const entry of groupEntries) {
2692
+ if (entry.startsWith('[...') && entry.endsWith(']')) {
2693
+ const dynamicPath = `${groupPath}/${entry}`;
2694
+ if (this.isDirectory(dynamicPath)) {
2695
+ const paramName = entry.slice(4, -1);
2696
+ const newParams = { ...params, [paramName]: [current, ...rest] };
2697
+ const result = tryPath(dynamicPath, [], groupLayouts, newParams);
2698
+ if (result) return result;
2699
+ }
2700
+ } else if (entry.startsWith('[[...') && entry.endsWith(']]')) {
2701
+ const dynamicPath = `${groupPath}/${entry}`;
2702
+ if (this.isDirectory(dynamicPath)) {
2703
+ const paramName = entry.slice(5, -2);
2704
+ const newParams = { ...params, [paramName]: [current, ...rest] };
2705
+ const result = tryPath(dynamicPath, [], groupLayouts, newParams);
2706
+ if (result) return result;
2707
+ }
2708
+ } else if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
2709
+ const dynamicPath = `${groupPath}/${entry}`;
2710
+ if (this.isDirectory(dynamicPath)) {
2711
+ const paramName = entry.slice(1, -1);
2712
+ const newParams = { ...params, [paramName]: current };
2713
+ const result = tryPath(dynamicPath, rest, groupLayouts, newParams);
2714
+ if (result) return result;
2715
+ }
2716
+ }
2717
+ }
2718
+ } catch {
2719
+ // Group directory read failed
2720
+ }
2721
+ }
2722
+
2723
+ // Try dynamic segments at current level
1599
2724
  try {
1600
2725
  const entries = this.vfs.readdirSync(dirPath);
1601
2726
  for (const entry of entries) {
1602
- if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
2727
+ // Handle catch-all routes [...slug]
2728
+ if (entry.startsWith('[...') && entry.endsWith(']')) {
2729
+ const dynamicPath = `${dirPath}/${entry}`;
2730
+ if (this.isDirectory(dynamicPath)) {
2731
+ const paramName = entry.slice(4, -1);
2732
+ const newParams = { ...params, [paramName]: [current, ...rest] };
2733
+ const result = tryPath(dynamicPath, [], layouts, newParams);
2734
+ if (result) return result;
2735
+ }
2736
+ }
2737
+ // Handle optional catch-all routes [[...slug]]
2738
+ else if (entry.startsWith('[[...') && entry.endsWith(']]')) {
1603
2739
  const dynamicPath = `${dirPath}/${entry}`;
1604
2740
  if (this.isDirectory(dynamicPath)) {
1605
- const result = tryPath(dynamicPath, rest, layouts);
2741
+ const paramName = entry.slice(5, -2);
2742
+ const newParams = { ...params, [paramName]: [current, ...rest] };
2743
+ const result = tryPath(dynamicPath, [], layouts, newParams);
2744
+ if (result) return result;
2745
+ }
2746
+ }
2747
+ // Handle single dynamic segment [param]
2748
+ else if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
2749
+ const dynamicPath = `${dirPath}/${entry}`;
2750
+ if (this.isDirectory(dynamicPath)) {
2751
+ const paramName = entry.slice(1, -1);
2752
+ const newParams = { ...params, [paramName]: current };
2753
+ const result = tryPath(dynamicPath, rest, layouts, newParams);
1606
2754
  if (result) return result;
1607
2755
  }
1608
2756
  }
@@ -1624,14 +2772,14 @@ export class NextDevServer extends DevServer {
1624
2772
  }
1625
2773
  }
1626
2774
 
1627
- return tryPath(this.appDir, segments, layouts);
2775
+ return tryPath(this.appDir, segments, layouts, {});
1628
2776
  }
1629
2777
 
1630
2778
  /**
1631
2779
  * Generate HTML for App Router with nested layouts
1632
2780
  */
1633
2781
  private async generateAppRouterHtml(
1634
- route: { page: string; layouts: string[] },
2782
+ route: AppRoute,
1635
2783
  pathname: string
1636
2784
  ): Promise<string> {
1637
2785
  // Use virtual server prefix for all file imports so the service worker can intercept them
@@ -1653,6 +2801,11 @@ export class NextDevServer extends DevServer {
1653
2801
  .map((layout, i) => `import Layout${i} from '${virtualPrefix}${layout}';`)
1654
2802
  .join('\n ');
1655
2803
 
2804
+ // Build convention file paths for the inline script
2805
+ const loadingModulePath = route.loading ? `${virtualPrefix}${route.loading}` : '';
2806
+ const errorModulePath = route.error ? `${virtualPrefix}${route.error}` : '';
2807
+ const notFoundModulePath = route.notFound ? `${virtualPrefix}${route.notFound}` : '';
2808
+
1656
2809
  // Build nested JSX: Layout0 > Layout1 > ... > Page
1657
2810
  let nestedJsx = 'React.createElement(Page)';
1658
2811
  for (let i = route.layouts.length - 1; i >= 0; i--) {
@@ -1662,6 +2815,9 @@ export class NextDevServer extends DevServer {
1662
2815
  // Generate env script for NEXT_PUBLIC_* variables
1663
2816
  const envScript = this.generateEnvScript();
1664
2817
 
2818
+ // Load Tailwind config if available (must be injected BEFORE CDN script)
2819
+ const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
2820
+
1665
2821
  return `<!DOCTYPE html>
1666
2822
  <html lang="en">
1667
2823
  <head>
@@ -1671,6 +2827,7 @@ export class NextDevServer extends DevServer {
1671
2827
  <title>Next.js App</title>
1672
2828
  ${envScript}
1673
2829
  ${TAILWIND_CDN_SCRIPT}
2830
+ ${tailwindConfigScript}
1674
2831
  ${CORS_PROXY_SCRIPT}
1675
2832
  ${globalCssLinks.join('\n ')}
1676
2833
  ${REACT_REFRESH_PREAMBLE}
@@ -1692,7 +2849,12 @@ export class NextDevServer extends DevServer {
1692
2849
  "next/link": "${virtualPrefix}/_next/shims/link.js",
1693
2850
  "next/router": "${virtualPrefix}/_next/shims/router.js",
1694
2851
  "next/head": "${virtualPrefix}/_next/shims/head.js",
1695
- "next/navigation": "${virtualPrefix}/_next/shims/navigation.js"
2852
+ "next/navigation": "${virtualPrefix}/_next/shims/navigation.js",
2853
+ "next/image": "${virtualPrefix}/_next/shims/image.js",
2854
+ "next/dynamic": "${virtualPrefix}/_next/shims/dynamic.js",
2855
+ "next/script": "${virtualPrefix}/_next/shims/script.js",
2856
+ "next/font/google": "${virtualPrefix}/_next/shims/font/google.js",
2857
+ "next/font/local": "${virtualPrefix}/_next/shims/font/local.js"
1696
2858
  }
1697
2859
  }
1698
2860
  </script>
@@ -1706,6 +2868,47 @@ export class NextDevServer extends DevServer {
1706
2868
 
1707
2869
  const virtualBase = '${virtualPrefix}';
1708
2870
 
2871
+ // Initial route params (embedded by server for initial page load)
2872
+ const initialRouteParams = ${JSON.stringify(route.params)};
2873
+ const initialPathname = '${pathname}';
2874
+
2875
+ // Expose initial params for useParams() hook
2876
+ window.__NEXT_ROUTE_PARAMS__ = initialRouteParams;
2877
+
2878
+ // Convention file paths (loading.tsx, error.tsx, not-found.tsx)
2879
+ const loadingModulePath = '${loadingModulePath}';
2880
+ const errorModulePath = '${errorModulePath}';
2881
+ const notFoundModulePath = '${notFoundModulePath}';
2882
+
2883
+ // Route params cache for client-side navigation
2884
+ const routeParamsCache = new Map();
2885
+ routeParamsCache.set(initialPathname, initialRouteParams);
2886
+
2887
+ // Extract route params from server for client-side navigation
2888
+ async function extractRouteParams(pathname) {
2889
+ // Strip virtual base if present
2890
+ let route = pathname;
2891
+ if (route.startsWith(virtualBase)) {
2892
+ route = route.slice(virtualBase.length);
2893
+ }
2894
+ route = route.replace(/^\\/+/, '/') || '/';
2895
+
2896
+ // Check cache first
2897
+ if (routeParamsCache.has(route)) {
2898
+ return routeParamsCache.get(route);
2899
+ }
2900
+
2901
+ try {
2902
+ const response = await fetch(virtualBase + '/_next/route-info?pathname=' + encodeURIComponent(route));
2903
+ const info = await response.json();
2904
+ routeParamsCache.set(route, info.params || {});
2905
+ return info.params || {};
2906
+ } catch (e) {
2907
+ console.error('[Router] Failed to extract route params:', e);
2908
+ return {};
2909
+ }
2910
+ }
2911
+
1709
2912
  // Convert URL path to app router page module path
1710
2913
  function getAppPageModulePath(pathname) {
1711
2914
  let route = pathname;
@@ -1772,11 +2975,138 @@ export class NextDevServer extends DevServer {
1772
2975
  return layouts;
1773
2976
  }
1774
2977
 
2978
+ // Load convention components (loading.tsx, error.tsx)
2979
+ let LoadingComponent = null;
2980
+ let ErrorComponent = null;
2981
+ let NotFoundComponent = null;
2982
+
2983
+ async function loadConventionComponents() {
2984
+ if (loadingModulePath) {
2985
+ try {
2986
+ const mod = await import(/* @vite-ignore */ loadingModulePath);
2987
+ LoadingComponent = mod.default;
2988
+ } catch (e) { /* loading.tsx not available */ }
2989
+ }
2990
+ if (errorModulePath) {
2991
+ try {
2992
+ const mod = await import(/* @vite-ignore */ errorModulePath);
2993
+ ErrorComponent = mod.default;
2994
+ } catch (e) { /* error.tsx not available */ }
2995
+ }
2996
+ if (notFoundModulePath) {
2997
+ try {
2998
+ const mod = await import(/* @vite-ignore */ notFoundModulePath);
2999
+ NotFoundComponent = mod.default;
3000
+ } catch (e) { /* not-found.tsx not available */ }
3001
+ }
3002
+ }
3003
+ await loadConventionComponents();
3004
+
3005
+ // Error boundary class component
3006
+ class ErrorBoundary extends React.Component {
3007
+ constructor(props) {
3008
+ super(props);
3009
+ this.state = { error: null };
3010
+ }
3011
+ static getDerivedStateFromError(error) {
3012
+ return { error };
3013
+ }
3014
+ componentDidCatch(error, info) {
3015
+ console.error('[ErrorBoundary]', error, info);
3016
+ }
3017
+ render() {
3018
+ if (this.state.error) {
3019
+ if (this.props.fallback) {
3020
+ return React.createElement(this.props.fallback, {
3021
+ error: this.state.error,
3022
+ reset: () => this.setState({ error: null })
3023
+ });
3024
+ }
3025
+ return React.createElement('div', { style: { color: 'red', padding: '20px' } },
3026
+ 'Error: ' + this.state.error.message
3027
+ );
3028
+ }
3029
+ return this.props.children;
3030
+ }
3031
+ }
3032
+
3033
+ // Wrapper for async Server Components
3034
+ function AsyncComponent({ component: Component, pathname, search }) {
3035
+ const [content, setContent] = React.useState(null);
3036
+ const [error, setError] = React.useState(null);
3037
+ const [isNotFound, setIsNotFound] = React.useState(false);
3038
+
3039
+ React.useEffect(() => {
3040
+ let cancelled = false;
3041
+ async function render() {
3042
+ try {
3043
+ // Create searchParams as a Promise (Next.js 15 pattern)
3044
+ const url = new URL(window.location.href);
3045
+ const searchParamsObj = Object.fromEntries(url.searchParams);
3046
+ const searchParams = Promise.resolve(searchParamsObj);
3047
+
3048
+ // Extract route params from pathname (fetches from server for dynamic routes)
3049
+ const routeParams = await extractRouteParams(pathname);
3050
+ const params = Promise.resolve(routeParams);
3051
+
3052
+ // Call component with props like Next.js does for page components
3053
+ const result = Component({ searchParams, params });
3054
+ if (result && typeof result.then === 'function') {
3055
+ // It's a Promise (async component)
3056
+ const resolved = await result;
3057
+ if (!cancelled) setContent(resolved);
3058
+ } else {
3059
+ // Synchronous component - result is already JSX
3060
+ if (!cancelled) setContent(result);
3061
+ }
3062
+ } catch (e) {
3063
+ if (e && e.message === 'NEXT_NOT_FOUND') {
3064
+ if (!cancelled) setIsNotFound(true);
3065
+ } else {
3066
+ console.error('[AsyncComponent] Error rendering:', e);
3067
+ if (!cancelled) setError(e);
3068
+ }
3069
+ }
3070
+ }
3071
+ render();
3072
+ return () => { cancelled = true; };
3073
+ }, [Component, pathname, search]);
3074
+
3075
+ if (isNotFound && NotFoundComponent) {
3076
+ return React.createElement(NotFoundComponent);
3077
+ }
3078
+ if (isNotFound) {
3079
+ return React.createElement('div', { style: { padding: '20px', textAlign: 'center' } },
3080
+ React.createElement('h2', null, '404'),
3081
+ React.createElement('p', null, 'This page could not be found.')
3082
+ );
3083
+ }
3084
+ if (error) {
3085
+ if (ErrorComponent) {
3086
+ return React.createElement(ErrorComponent, {
3087
+ error: error,
3088
+ reset: () => setError(null)
3089
+ });
3090
+ }
3091
+ return React.createElement('div', { style: { color: 'red', padding: '20px' } },
3092
+ 'Error: ' + error.message
3093
+ );
3094
+ }
3095
+ if (!content) {
3096
+ if (LoadingComponent) {
3097
+ return React.createElement(LoadingComponent);
3098
+ }
3099
+ return React.createElement('div', { style: { padding: '20px' } }, 'Loading...');
3100
+ }
3101
+ return content;
3102
+ }
3103
+
1775
3104
  // Router component
1776
3105
  function Router() {
1777
3106
  const [Page, setPage] = React.useState(null);
1778
3107
  const [layouts, setLayouts] = React.useState([]);
1779
3108
  const [path, setPath] = React.useState(window.location.pathname);
3109
+ const [search, setSearch] = React.useState(window.location.search);
1780
3110
 
1781
3111
  React.useEffect(() => {
1782
3112
  Promise.all([loadPage(path), loadLayouts(path)]).then(([P, L]) => {
@@ -1788,21 +3118,42 @@ export class NextDevServer extends DevServer {
1788
3118
  React.useEffect(() => {
1789
3119
  const handleNavigation = async () => {
1790
3120
  const newPath = window.location.pathname;
3121
+ const newSearch = window.location.search;
3122
+ console.log('[Router] handleNavigation called, newPath:', newPath, 'current path:', path);
3123
+
3124
+ // Always update search params
3125
+ if (newSearch !== search) {
3126
+ setSearch(newSearch);
3127
+ }
3128
+
1791
3129
  if (newPath !== path) {
3130
+ console.log('[Router] Path changed, loading new page...');
1792
3131
  setPath(newPath);
1793
- const [P, L] = await Promise.all([loadPage(newPath), loadLayouts(newPath)]);
3132
+ const [P, L, routeParams] = await Promise.all([loadPage(newPath), loadLayouts(newPath), extractRouteParams(newPath)]);
3133
+ window.__NEXT_ROUTE_PARAMS__ = routeParams;
3134
+ console.log('[Router] Page loaded:', !!P, 'Layouts:', L.length);
1794
3135
  if (P) setPage(() => P);
1795
3136
  setLayouts(L);
3137
+ } else {
3138
+ console.log('[Router] Path unchanged, skipping navigation');
1796
3139
  }
1797
3140
  };
1798
3141
  window.addEventListener('popstate', handleNavigation);
3142
+ console.log('[Router] Added popstate listener for path:', path);
1799
3143
  return () => window.removeEventListener('popstate', handleNavigation);
1800
- }, [path]);
3144
+ }, [path, search]);
1801
3145
 
1802
3146
  if (!Page) return null;
1803
3147
 
1804
- // Build nested layout structure
1805
- let content = React.createElement(Page);
3148
+ // Use AsyncComponent wrapper to handle async Server Components
3149
+ // Pass search to force re-render when query params change
3150
+ let content = React.createElement(AsyncComponent, { component: Page, pathname: path, search: search });
3151
+
3152
+ // Wrap with error boundary if error.tsx exists
3153
+ if (ErrorComponent) {
3154
+ content = React.createElement(ErrorBoundary, { fallback: ErrorComponent }, content);
3155
+ }
3156
+
1806
3157
  for (let i = layouts.length - 1; i >= 0; i--) {
1807
3158
  content = React.createElement(layouts[i], null, content);
1808
3159
  }
@@ -1962,6 +3313,9 @@ export class NextDevServer extends DevServer {
1962
3313
  // Generate env script for NEXT_PUBLIC_* variables
1963
3314
  const envScript = this.generateEnvScript();
1964
3315
 
3316
+ // Load Tailwind config if available (must be injected BEFORE CDN script)
3317
+ const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
3318
+
1965
3319
  return `<!DOCTYPE html>
1966
3320
  <html lang="en">
1967
3321
  <head>
@@ -1971,6 +3325,7 @@ export class NextDevServer extends DevServer {
1971
3325
  <title>Next.js App</title>
1972
3326
  ${envScript}
1973
3327
  ${TAILWIND_CDN_SCRIPT}
3328
+ ${tailwindConfigScript}
1974
3329
  ${CORS_PROXY_SCRIPT}
1975
3330
  ${globalCssLinks.join('\n ')}
1976
3331
  ${REACT_REFRESH_PREAMBLE}
@@ -1984,7 +3339,13 @@ export class NextDevServer extends DevServer {
1984
3339
  "react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
1985
3340
  "next/link": "${virtualPrefix}/_next/shims/link.js",
1986
3341
  "next/router": "${virtualPrefix}/_next/shims/router.js",
1987
- "next/head": "${virtualPrefix}/_next/shims/head.js"
3342
+ "next/head": "${virtualPrefix}/_next/shims/head.js",
3343
+ "next/navigation": "${virtualPrefix}/_next/shims/navigation.js",
3344
+ "next/image": "${virtualPrefix}/_next/shims/image.js",
3345
+ "next/dynamic": "${virtualPrefix}/_next/shims/dynamic.js",
3346
+ "next/script": "${virtualPrefix}/_next/shims/script.js",
3347
+ "next/font/google": "${virtualPrefix}/_next/shims/font/google.js",
3348
+ "next/font/local": "${virtualPrefix}/_next/shims/font/local.js"
1988
3349
  }
1989
3350
  }
1990
3351
  </script>
@@ -2106,6 +3467,39 @@ export class NextDevServer extends DevServer {
2106
3467
  };
2107
3468
  }
2108
3469
 
3470
+ /**
3471
+ * Try to resolve a file path by adding common extensions
3472
+ * e.g., /components/faq -> /components/faq.tsx
3473
+ * Also handles index files in directories
3474
+ */
3475
+ private resolveFileWithExtension(pathname: string): string | null {
3476
+ // If the file already has an extension and exists, return it
3477
+ if (/\.\w+$/.test(pathname) && this.exists(pathname)) {
3478
+ return pathname;
3479
+ }
3480
+
3481
+ // Common extensions to try, in order of preference
3482
+ const extensions = ['.tsx', '.ts', '.jsx', '.js'];
3483
+
3484
+ // Try adding extensions directly
3485
+ for (const ext of extensions) {
3486
+ const withExt = pathname + ext;
3487
+ if (this.exists(withExt)) {
3488
+ return withExt;
3489
+ }
3490
+ }
3491
+
3492
+ // Try as a directory with index file
3493
+ for (const ext of extensions) {
3494
+ const indexPath = pathname + '/index' + ext;
3495
+ if (this.exists(indexPath)) {
3496
+ return indexPath;
3497
+ }
3498
+ }
3499
+
3500
+ return null;
3501
+ }
3502
+
2109
3503
  /**
2110
3504
  * Check if a file needs transformation
2111
3505
  */
@@ -2139,7 +3533,8 @@ export class NextDevServer extends DevServer {
2139
3533
  };
2140
3534
  }
2141
3535
 
2142
- const transformed = await this.transformCode(content, urlPath);
3536
+ // Use filePath (with extension) for transform so loader is correctly determined
3537
+ const transformed = await this.transformCode(content, filePath);
2143
3538
 
2144
3539
  // Cache the transform result
2145
3540
  this.transformCache.set(filePath, { code: transformed, hash });
@@ -2177,7 +3572,9 @@ export class NextDevServer extends DevServer {
2177
3572
  */
2178
3573
  private async transformCode(code: string, filename: string): Promise<string> {
2179
3574
  if (!isBrowser) {
2180
- return code;
3575
+ // Even in non-browser mode, strip/transform CSS imports
3576
+ // so CSS module imports get replaced with class name objects
3577
+ return this.stripCssImports(code, filename);
2181
3578
  }
2182
3579
 
2183
3580
  await initEsbuild();
@@ -2189,14 +3586,17 @@ export class NextDevServer extends DevServer {
2189
3586
 
2190
3587
  // Remove CSS imports before transformation - they are handled via <link> tags
2191
3588
  // CSS imports in ESM would fail with MIME type errors
2192
- const codeWithoutCssImports = this.stripCssImports(code);
3589
+ const codeWithoutCssImports = this.stripCssImports(code, filename);
3590
+
3591
+ // Resolve path aliases (e.g., @/ -> /) before transformation
3592
+ const codeWithResolvedAliases = this.resolvePathAliases(codeWithoutCssImports, filename);
2193
3593
 
2194
3594
  let loader: 'js' | 'jsx' | 'ts' | 'tsx' = 'js';
2195
3595
  if (filename.endsWith('.jsx')) loader = 'jsx';
2196
3596
  else if (filename.endsWith('.tsx')) loader = 'tsx';
2197
3597
  else if (filename.endsWith('.ts')) loader = 'ts';
2198
3598
 
2199
- const result = await esbuild.transform(codeWithoutCssImports, {
3599
+ const result = await esbuild.transform(codeWithResolvedAliases, {
2200
3600
  loader,
2201
3601
  format: 'esm',
2202
3602
  target: 'esnext',
@@ -2206,28 +3606,39 @@ export class NextDevServer extends DevServer {
2206
3606
  sourcefile: filename,
2207
3607
  });
2208
3608
 
3609
+ // Redirect bare npm imports to esm.sh CDN
3610
+ const codeWithCdnImports = this.redirectNpmImports(result.code);
3611
+
2209
3612
  // Add React Refresh registration for JSX/TSX files
2210
3613
  if (/\.(jsx|tsx)$/.test(filename)) {
2211
- return this.addReactRefresh(result.code, filename);
3614
+ return this.addReactRefresh(codeWithCdnImports, filename);
2212
3615
  }
2213
3616
 
2214
- return result.code;
3617
+ return codeWithCdnImports;
2215
3618
  }
2216
3619
 
2217
- /**
2218
- * Strip CSS imports from code (they are loaded via <link> tags instead)
2219
- * Handles: import './styles.css', import '../globals.css', etc.
2220
- */
2221
- private stripCssImports(code: string): string {
2222
- // Match import statements for CSS files (with or without semicolon)
2223
- // Handles: import './styles.css'; import "./globals.css" import '../path/file.css'
2224
- return code.replace(/import\s+['"][^'"]+\.css['"]\s*;?/g, '// CSS import removed (loaded via <link>)');
3620
+ private redirectNpmImports(code: string): string {
3621
+ return _redirectNpmImports(code);
3622
+ }
3623
+
3624
+ private stripCssImports(code: string, currentFile?: string): string {
3625
+ return _stripCssImports(code, currentFile, this.getCssModuleContext());
3626
+ }
3627
+
3628
+ private getCssModuleContext(): CssModuleContext {
3629
+ return {
3630
+ readFile: (path: string) => this.vfs.readFileSync(path, 'utf-8'),
3631
+ exists: (path: string) => this.exists(path),
3632
+ };
2225
3633
  }
2226
3634
 
2227
3635
  /**
2228
3636
  * Transform API handler code to CommonJS for eval execution
2229
3637
  */
2230
3638
  private async transformApiHandler(code: string, filename: string): Promise<string> {
3639
+ // Resolve path aliases first
3640
+ const codeWithResolvedAliases = this.resolvePathAliases(code, filename);
3641
+
2231
3642
  if (isBrowser) {
2232
3643
  // Use esbuild in browser
2233
3644
  await initEsbuild();
@@ -2242,7 +3653,7 @@ export class NextDevServer extends DevServer {
2242
3653
  else if (filename.endsWith('.tsx')) loader = 'tsx';
2243
3654
  else if (filename.endsWith('.ts')) loader = 'ts';
2244
3655
 
2245
- const result = await esbuild.transform(code, {
3656
+ const result = await esbuild.transform(codeWithResolvedAliases, {
2246
3657
  loader,
2247
3658
  format: 'cjs', // CommonJS for eval execution
2248
3659
  target: 'esnext',
@@ -2253,94 +3664,11 @@ export class NextDevServer extends DevServer {
2253
3664
  return result.code;
2254
3665
  }
2255
3666
 
2256
- // Simple ESM to CJS transform for Node.js/test environment
2257
- let transformed = code;
2258
-
2259
- // Convert: import X from 'Y' -> const X = require('Y')
2260
- transformed = transformed.replace(
2261
- /import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g,
2262
- 'const $1 = require("$2")'
2263
- );
2264
-
2265
- // Convert: import { X } from 'Y' -> const { X } = require('Y')
2266
- transformed = transformed.replace(
2267
- /import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g,
2268
- 'const {$1} = require("$2")'
2269
- );
2270
-
2271
- // Convert: export default function X -> module.exports = function X
2272
- transformed = transformed.replace(
2273
- /export\s+default\s+function\s+(\w+)/g,
2274
- 'module.exports = function $1'
2275
- );
2276
-
2277
- // Convert: export default function -> module.exports = function
2278
- transformed = transformed.replace(
2279
- /export\s+default\s+function\s*\(/g,
2280
- 'module.exports = function('
2281
- );
2282
-
2283
- // Convert: export default X -> module.exports = X
2284
- transformed = transformed.replace(
2285
- /export\s+default\s+/g,
2286
- 'module.exports = '
2287
- );
2288
-
2289
- return transformed;
3667
+ return transformEsmToCjsSimple(codeWithResolvedAliases);
2290
3668
  }
2291
3669
 
2292
- /**
2293
- * Add React Refresh registration to transformed code
2294
- */
2295
3670
  private addReactRefresh(code: string, filename: string): string {
2296
- const components: string[] = [];
2297
-
2298
- const funcDeclRegex = /(?:^|\n)(?:export\s+)?function\s+([A-Z][a-zA-Z0-9]*)\s*\(/g;
2299
- let match;
2300
- while ((match = funcDeclRegex.exec(code)) !== null) {
2301
- if (!components.includes(match[1])) {
2302
- components.push(match[1]);
2303
- }
2304
- }
2305
-
2306
- const arrowRegex = /(?:^|\n)(?:export\s+)?(?:const|let|var)\s+([A-Z][a-zA-Z0-9]*)\s*=/g;
2307
- while ((match = arrowRegex.exec(code)) !== null) {
2308
- if (!components.includes(match[1])) {
2309
- components.push(match[1]);
2310
- }
2311
- }
2312
-
2313
- if (components.length === 0) {
2314
- return `// HMR Setup
2315
- import.meta.hot = window.__vite_hot_context__("${filename}");
2316
-
2317
- ${code}
2318
-
2319
- if (import.meta.hot) {
2320
- import.meta.hot.accept();
2321
- }
2322
- `;
2323
- }
2324
-
2325
- const registrations = components
2326
- .map(name => ` $RefreshReg$(${name}, "${filename} ${name}");`)
2327
- .join('\n');
2328
-
2329
- return `// HMR Setup
2330
- import.meta.hot = window.__vite_hot_context__("${filename}");
2331
-
2332
- ${code}
2333
-
2334
- // React Refresh Registration
2335
- if (import.meta.hot) {
2336
- ${registrations}
2337
- import.meta.hot.accept(() => {
2338
- if (window.$RefreshRuntime$) {
2339
- window.$RefreshRuntime$.performReactRefresh();
2340
- }
2341
- });
2342
- }
2343
- `;
3671
+ return _addReactRefresh(code, filename);
2344
3672
  }
2345
3673
 
2346
3674
  /**
@@ -2420,6 +3748,76 @@ ${registrations}
2420
3748
  }
2421
3749
  }
2422
3750
 
3751
+ /**
3752
+ * Override serveFile to wrap JSON files as ES modules
3753
+ * This is needed because browsers can't dynamically import raw JSON files
3754
+ */
3755
+ protected serveFile(filePath: string): ResponseData {
3756
+ // For JSON files, wrap as ES module so they can be dynamically imported
3757
+ if (filePath.endsWith('.json')) {
3758
+ try {
3759
+ const normalizedPath = this.resolvePath(filePath);
3760
+ const content = this.vfs.readFileSync(normalizedPath);
3761
+
3762
+ // Properly convert content to string
3763
+ // VirtualFS may return string, Buffer, or Uint8Array
3764
+ let jsonContent: string;
3765
+ if (typeof content === 'string') {
3766
+ jsonContent = content;
3767
+ } else if (content instanceof Uint8Array) {
3768
+ // Use TextDecoder for Uint8Array (includes Buffer in browser)
3769
+ jsonContent = new TextDecoder('utf-8').decode(content);
3770
+ } else {
3771
+ // Fallback for other buffer-like objects
3772
+ jsonContent = Buffer.from(content).toString('utf-8');
3773
+ }
3774
+
3775
+ // Wrap JSON as ES module
3776
+ const esModuleContent = `export default ${jsonContent};`;
3777
+ const buffer = Buffer.from(esModuleContent);
3778
+
3779
+ return {
3780
+ statusCode: 200,
3781
+ statusMessage: 'OK',
3782
+ headers: {
3783
+ 'Content-Type': 'application/javascript; charset=utf-8',
3784
+ 'Content-Length': String(buffer.length),
3785
+ 'Cache-Control': 'no-cache',
3786
+ },
3787
+ body: buffer,
3788
+ };
3789
+ } catch (error) {
3790
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
3791
+ return this.notFound(filePath);
3792
+ }
3793
+ return this.serverError(error);
3794
+ }
3795
+ }
3796
+
3797
+ // For all other files, use the parent implementation
3798
+ return super.serveFile(filePath);
3799
+ }
3800
+
3801
+ /**
3802
+ * Resolve a path (helper to access protected method from parent)
3803
+ */
3804
+ protected resolvePath(urlPath: string): string {
3805
+ // Remove query string and hash
3806
+ let path = urlPath.split('?')[0].split('#')[0];
3807
+
3808
+ // Normalize path
3809
+ if (!path.startsWith('/')) {
3810
+ path = '/' + path;
3811
+ }
3812
+
3813
+ // Join with root
3814
+ if (this.root !== '/') {
3815
+ path = this.root + path;
3816
+ }
3817
+
3818
+ return path;
3819
+ }
3820
+
2423
3821
  /**
2424
3822
  * Stop the server
2425
3823
  */