almostnode 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@
6
6
  import { DevServer, DevServerOptions, ResponseData, HMRUpdate } from '../dev-server';
7
7
  import { VirtualFS } from '../virtual-fs';
8
8
  import { Buffer } from '../shims/stream';
9
+ import { simpleHash } from '../utils/hash';
10
+ import { loadTailwindConfig } from './tailwind-config-loader';
9
11
 
10
12
  // Check if we're in a real browser environment (not jsdom or Node.js)
11
13
  const isBrowser = typeof window !== 'undefined' &&
@@ -76,6 +78,8 @@ export interface NextDevServerOptions extends DevServerOptions {
76
78
  preferAppRouter?: boolean;
77
79
  /** Environment variables (NEXT_PUBLIC_* are available in browser code via process.env) */
78
80
  env?: Record<string, string>;
81
+ /** Asset prefix for static files (e.g., '/marketing'). Auto-detected from next.config if not specified. */
82
+ assetPrefix?: string;
79
83
  }
80
84
 
81
85
  /**
@@ -282,25 +286,31 @@ const applyVirtualBase = (url) => {
282
286
 
283
287
  export default function Link({ href, children, ...props }) {
284
288
  const handleClick = (e) => {
289
+ console.log('[Link] Click handler called, href:', href);
290
+
285
291
  if (props.onClick) {
286
292
  props.onClick(e);
287
293
  }
288
294
 
289
295
  // Allow cmd/ctrl click to open in new tab
290
296
  if (e.metaKey || e.ctrlKey) {
297
+ console.log('[Link] Meta/Ctrl key pressed, allowing default behavior');
291
298
  return;
292
299
  }
293
300
 
294
301
  if (typeof href !== 'string' || !href || href.startsWith('#') || href.startsWith('?')) {
302
+ console.log('[Link] Skipping navigation for href:', href);
295
303
  return;
296
304
  }
297
305
 
298
306
  if (/^(https?:)?\\/\\//.test(href)) {
307
+ console.log('[Link] External URL, allowing default behavior:', href);
299
308
  return;
300
309
  }
301
310
 
302
311
  e.preventDefault();
303
312
  const resolvedHref = applyVirtualBase(href);
313
+ console.log('[Link] Navigating to:', resolvedHref);
304
314
  window.history.pushState({}, '', resolvedHref);
305
315
  window.dispatchEvent(new PopStateEvent('popstate'));
306
316
  };
@@ -669,6 +679,323 @@ export default function Head({ children }) {
669
679
  }
670
680
  `;
671
681
 
682
+ /**
683
+ * Next.js Image shim code
684
+ * Provides a simple img-based implementation of next/image
685
+ */
686
+ const NEXT_IMAGE_SHIM = `
687
+ import React from 'react';
688
+
689
+ function Image({
690
+ src,
691
+ alt = '',
692
+ width,
693
+ height,
694
+ fill,
695
+ loader,
696
+ quality = 75,
697
+ priority,
698
+ loading,
699
+ placeholder,
700
+ blurDataURL,
701
+ unoptimized,
702
+ onLoad,
703
+ onError,
704
+ style,
705
+ className,
706
+ sizes,
707
+ ...rest
708
+ }) {
709
+ // Handle src - could be string or StaticImageData object
710
+ const imageSrc = typeof src === 'object' ? src.src : src;
711
+
712
+ // Build style object
713
+ const imgStyle = { ...style };
714
+ if (fill) {
715
+ imgStyle.position = 'absolute';
716
+ imgStyle.width = '100%';
717
+ imgStyle.height = '100%';
718
+ imgStyle.objectFit = imgStyle.objectFit || 'cover';
719
+ imgStyle.inset = '0';
720
+ }
721
+
722
+ return React.createElement('img', {
723
+ src: imageSrc,
724
+ alt,
725
+ width: fill ? undefined : width,
726
+ height: fill ? undefined : height,
727
+ loading: priority ? 'eager' : (loading || 'lazy'),
728
+ decoding: 'async',
729
+ style: imgStyle,
730
+ className,
731
+ onLoad,
732
+ onError,
733
+ ...rest
734
+ });
735
+ }
736
+
737
+ export default Image;
738
+ export { Image };
739
+ `;
740
+
741
+ /**
742
+ * next/dynamic shim - Dynamic imports with loading states
743
+ */
744
+ const NEXT_DYNAMIC_SHIM = `
745
+ import React from 'react';
746
+
747
+ function dynamic(importFn, options = {}) {
748
+ const {
749
+ loading: LoadingComponent,
750
+ ssr = true,
751
+ } = options;
752
+
753
+ // Create a lazy component
754
+ const LazyComponent = React.lazy(importFn);
755
+
756
+ // Wrapper component that handles loading state
757
+ function DynamicComponent(props) {
758
+ const fallback = LoadingComponent
759
+ ? React.createElement(LoadingComponent, { isLoading: true })
760
+ : null;
761
+
762
+ return React.createElement(
763
+ React.Suspense,
764
+ { fallback },
765
+ React.createElement(LazyComponent, props)
766
+ );
767
+ }
768
+
769
+ return DynamicComponent;
770
+ }
771
+
772
+ export default dynamic;
773
+ export { dynamic };
774
+ `;
775
+
776
+ /**
777
+ * next/script shim - Loads external scripts
778
+ */
779
+ const NEXT_SCRIPT_SHIM = `
780
+ import React from 'react';
781
+
782
+ function Script({
783
+ src,
784
+ strategy = 'afterInteractive',
785
+ onLoad,
786
+ onReady,
787
+ onError,
788
+ children,
789
+ dangerouslySetInnerHTML,
790
+ ...rest
791
+ }) {
792
+ React.useEffect(function() {
793
+ if (!src && !children && !dangerouslySetInnerHTML) return;
794
+
795
+ var script = document.createElement('script');
796
+
797
+ if (src) {
798
+ script.src = src;
799
+ script.async = strategy !== 'beforeInteractive';
800
+ }
801
+
802
+ Object.keys(rest).forEach(function(key) {
803
+ script.setAttribute(key, rest[key]);
804
+ });
805
+
806
+ if (children) {
807
+ script.textContent = children;
808
+ } else if (dangerouslySetInnerHTML && dangerouslySetInnerHTML.__html) {
809
+ script.textContent = dangerouslySetInnerHTML.__html;
810
+ }
811
+
812
+ script.onload = function() {
813
+ if (onLoad) onLoad();
814
+ if (onReady) onReady();
815
+ };
816
+ script.onerror = onError;
817
+
818
+ document.head.appendChild(script);
819
+
820
+ return function() {
821
+ if (script.parentNode) {
822
+ script.parentNode.removeChild(script);
823
+ }
824
+ };
825
+ }, [src]);
826
+
827
+ return null;
828
+ }
829
+
830
+ export default Script;
831
+ export { Script };
832
+ `;
833
+
834
+ /**
835
+ * next/font/google shim - Loads Google Fonts via CDN
836
+ * Uses a Proxy to dynamically handle ANY Google Font without hardcoding
837
+ */
838
+ const NEXT_FONT_GOOGLE_SHIM = `
839
+ // Track loaded fonts to avoid duplicate style injections
840
+ const loadedFonts = new Set();
841
+
842
+ /**
843
+ * Convert font function name to Google Fonts family name
844
+ * Examples:
845
+ * DM_Sans -> DM Sans
846
+ * Open_Sans -> Open Sans
847
+ * Fraunces -> Fraunces
848
+ */
849
+ function toFontFamily(fontName) {
850
+ return fontName.replace(/_/g, ' ');
851
+ }
852
+
853
+ /**
854
+ * Inject font CSS into document
855
+ * - Adds preconnect links for faster font loading
856
+ * - Loads the font from Google Fonts CDN
857
+ * - Creates a CSS class that sets the CSS variable
858
+ */
859
+ function injectFontCSS(fontFamily, variableName, weight, style) {
860
+ const fontKey = fontFamily + '-' + (variableName || 'default');
861
+ if (loadedFonts.has(fontKey)) {
862
+ return;
863
+ }
864
+ loadedFonts.add(fontKey);
865
+
866
+ if (typeof document === 'undefined') {
867
+ return;
868
+ }
869
+
870
+ // Add preconnect links for faster loading (only once)
871
+ if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) {
872
+ const preconnect1 = document.createElement('link');
873
+ preconnect1.rel = 'preconnect';
874
+ preconnect1.href = 'https://fonts.googleapis.com';
875
+ document.head.appendChild(preconnect1);
876
+
877
+ const preconnect2 = document.createElement('link');
878
+ preconnect2.rel = 'preconnect';
879
+ preconnect2.href = 'https://fonts.gstatic.com';
880
+ preconnect2.crossOrigin = 'anonymous';
881
+ document.head.appendChild(preconnect2);
882
+ }
883
+
884
+ // Build Google Fonts URL
885
+ const escapedFamily = fontFamily.replace(/ /g, '+');
886
+
887
+ // Build axis list based on options
888
+ let axisList = '';
889
+ const axes = [];
890
+
891
+ // Handle italic style
892
+ if (style === 'italic') {
893
+ axes.push('ital');
894
+ }
895
+
896
+ // Handle weight - use specific weight or variable range
897
+ if (weight && weight !== '400' && !Array.isArray(weight)) {
898
+ // Specific weight requested
899
+ axes.push('wght');
900
+ if (style === 'italic') {
901
+ axisList = ':ital,wght@1,' + weight;
902
+ } else {
903
+ axisList = ':wght@' + weight;
904
+ }
905
+ } else if (Array.isArray(weight)) {
906
+ // Multiple weights
907
+ axes.push('wght');
908
+ axisList = ':wght@' + weight.join(';');
909
+ } else {
910
+ // Default: request common weights for flexibility
911
+ axisList = ':wght@400;500;600;700';
912
+ }
913
+
914
+ const fontUrl = 'https://fonts.googleapis.com/css2?family=' +
915
+ escapedFamily + axisList + '&display=swap';
916
+
917
+ // Add link element for Google Fonts (if not already present)
918
+ if (!document.querySelector('link[href*="family=' + escapedFamily + '"]')) {
919
+ const link = document.createElement('link');
920
+ link.rel = 'stylesheet';
921
+ link.href = fontUrl;
922
+ document.head.appendChild(link);
923
+ }
924
+
925
+ // Create style element for CSS variable at :root level (globally available)
926
+ // This makes the variable work without needing to apply the class to body
927
+ if (variableName) {
928
+ const styleEl = document.createElement('style');
929
+ styleEl.setAttribute('data-font-var', variableName);
930
+ styleEl.textContent = ':root { ' + variableName + ': "' + fontFamily + '", ' + (fontFamily.includes('Serif') ? 'serif' : 'sans-serif') + '; }';
931
+ document.head.appendChild(styleEl);
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Create a font loader function for a specific font
937
+ */
938
+ function createFontLoader(fontName) {
939
+ const fontFamily = toFontFamily(fontName);
940
+
941
+ return function(options = {}) {
942
+ const {
943
+ weight,
944
+ style = 'normal',
945
+ subsets = ['latin'],
946
+ variable,
947
+ display = 'swap',
948
+ preload = true,
949
+ fallback = ['sans-serif'],
950
+ adjustFontFallback = true
951
+ } = options;
952
+
953
+ // Inject the font CSS
954
+ injectFontCSS(fontFamily, variable, weight, style);
955
+
956
+ // Generate class name from variable (--font-inter -> __font-inter)
957
+ const className = variable
958
+ ? variable.replace('--', '__')
959
+ : '__font-' + fontName.toLowerCase().replace(/_/g, '-');
960
+
961
+ return {
962
+ className,
963
+ variable: className,
964
+ style: {
965
+ fontFamily: '"' + fontFamily + '", ' + fallback.join(', ')
966
+ }
967
+ };
968
+ };
969
+ }
970
+
971
+ /**
972
+ * Use a Proxy to dynamically create font loaders for ANY font name
973
+ * This allows: import { AnyGoogleFont } from "next/font/google"
974
+ */
975
+ const fontProxy = new Proxy({}, {
976
+ get(target, prop) {
977
+ // Handle special properties
978
+ if (prop === '__esModule') return true;
979
+ if (prop === 'default') return fontProxy;
980
+ if (typeof prop !== 'string') return undefined;
981
+
982
+ // Create a font loader for this font name
983
+ return createFontLoader(prop);
984
+ }
985
+ });
986
+
987
+ // Export the proxy as both default and named exports
988
+ export default fontProxy;
989
+
990
+ // Re-export through proxy for named imports
991
+ export const {
992
+ Fraunces, Inter, DM_Sans, DM_Serif_Text, Roboto, Open_Sans, Lato,
993
+ Montserrat, Poppins, Playfair_Display, Merriweather, Raleway, Nunito,
994
+ Ubuntu, Oswald, Quicksand, Work_Sans, Fira_Sans, Barlow, Mulish, Rubik,
995
+ Noto_Sans, Manrope, Space_Grotesk, Geist, Geist_Mono
996
+ } = fontProxy;
997
+ `;
998
+
672
999
  /**
673
1000
  * NextDevServer - A lightweight Next.js-compatible development server
674
1001
  *
@@ -714,6 +1041,21 @@ export class NextDevServer extends DevServer {
714
1041
  /** Store options for later access (e.g., env vars) */
715
1042
  private options: NextDevServerOptions;
716
1043
 
1044
+ /** Transform result cache for performance */
1045
+ private transformCache: Map<string, { code: string; hash: string }> = new Map();
1046
+
1047
+ /** Path aliases from tsconfig.json (e.g., @/* -> ./*) */
1048
+ private pathAliases: Map<string, string> = new Map();
1049
+
1050
+ /** Cached Tailwind config script (injected before CDN) */
1051
+ private tailwindConfigScript: string = '';
1052
+
1053
+ /** Whether Tailwind config has been loaded */
1054
+ private tailwindConfigLoaded: boolean = false;
1055
+
1056
+ /** Asset prefix for static files (e.g., '/marketing') */
1057
+ private assetPrefix: string = '';
1058
+
717
1059
  constructor(vfs: VirtualFS, options: NextDevServerOptions) {
718
1060
  super(vfs, options);
719
1061
  this.options = options;
@@ -729,6 +1071,131 @@ export class NextDevServer extends DevServer {
729
1071
  // Prefer App Router if /app directory exists with a page.jsx file
730
1072
  this.useAppRouter = this.hasAppRouter();
731
1073
  }
1074
+
1075
+ // Load path aliases from tsconfig.json
1076
+ this.loadPathAliases();
1077
+
1078
+ // Load assetPrefix from options or auto-detect from next.config
1079
+ this.loadAssetPrefix(options.assetPrefix);
1080
+ }
1081
+
1082
+ /**
1083
+ * Load path aliases from tsconfig.json
1084
+ * Supports common patterns like @/* -> ./*
1085
+ */
1086
+ private loadPathAliases(): void {
1087
+ try {
1088
+ const tsconfigPath = '/tsconfig.json';
1089
+ if (!this.vfs.existsSync(tsconfigPath)) {
1090
+ return;
1091
+ }
1092
+
1093
+ const content = this.vfs.readFileSync(tsconfigPath, 'utf-8');
1094
+ const tsconfig = JSON.parse(content);
1095
+ const paths = tsconfig?.compilerOptions?.paths;
1096
+
1097
+ if (!paths) {
1098
+ return;
1099
+ }
1100
+
1101
+ // Convert tsconfig paths to a simple alias map
1102
+ // e.g., "@/*": ["./*"] becomes "@/" -> "/"
1103
+ for (const [alias, targets] of Object.entries(paths)) {
1104
+ if (Array.isArray(targets) && targets.length > 0) {
1105
+ // Remove trailing * from alias and target
1106
+ const aliasPrefix = alias.replace(/\*$/, '');
1107
+ const targetPrefix = (targets[0] as string).replace(/\*$/, '').replace(/^\./, '');
1108
+ this.pathAliases.set(aliasPrefix, targetPrefix);
1109
+ }
1110
+ }
1111
+ } catch (e) {
1112
+ // Silently ignore tsconfig parse errors
1113
+ }
1114
+ }
1115
+
1116
+ /**
1117
+ * Load assetPrefix from options or auto-detect from next.config.ts/js
1118
+ * The assetPrefix is used to prefix static asset URLs (e.g., '/marketing')
1119
+ */
1120
+ private loadAssetPrefix(optionValue?: string): void {
1121
+ // If explicitly provided in options, use it
1122
+ if (optionValue !== undefined) {
1123
+ // Normalize: ensure it starts with / and doesn't end with /
1124
+ this.assetPrefix = optionValue.startsWith('/') ? optionValue : `/${optionValue}`;
1125
+ if (this.assetPrefix.endsWith('/')) {
1126
+ this.assetPrefix = this.assetPrefix.slice(0, -1);
1127
+ }
1128
+ return;
1129
+ }
1130
+
1131
+ // Try to auto-detect from next.config.ts or next.config.js
1132
+ try {
1133
+ const configFiles = ['/next.config.ts', '/next.config.js', '/next.config.mjs'];
1134
+
1135
+ for (const configPath of configFiles) {
1136
+ if (!this.vfs.existsSync(configPath)) {
1137
+ continue;
1138
+ }
1139
+
1140
+ const content = this.vfs.readFileSync(configPath, 'utf-8');
1141
+
1142
+ // Extract assetPrefix from config using regex
1143
+ // Matches: assetPrefix: "/marketing" or assetPrefix: '/marketing'
1144
+ const match = content.match(/assetPrefix\s*:\s*["']([^"']+)["']/);
1145
+ if (match) {
1146
+ let prefix = match[1];
1147
+ // Normalize: ensure it starts with / and doesn't end with /
1148
+ if (!prefix.startsWith('/')) {
1149
+ prefix = `/${prefix}`;
1150
+ }
1151
+ if (prefix.endsWith('/')) {
1152
+ prefix = prefix.slice(0, -1);
1153
+ }
1154
+ this.assetPrefix = prefix;
1155
+ return;
1156
+ }
1157
+ }
1158
+ } catch (e) {
1159
+ // Silently ignore config parse errors
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Resolve path aliases in transformed code
1165
+ * Converts imports like "@/components/foo" to "/__virtual__/PORT/components/foo"
1166
+ * This ensures imports go through the virtual server instead of the main server
1167
+ */
1168
+ private resolvePathAliases(code: string, currentFile: string): string {
1169
+ if (this.pathAliases.size === 0) {
1170
+ return code;
1171
+ }
1172
+
1173
+ // Get the virtual server base path
1174
+ const virtualBase = `/__virtual__/${this.port}`;
1175
+
1176
+ let result = code;
1177
+
1178
+ for (const [alias, target] of this.pathAliases) {
1179
+ // Match import/export statements with the alias
1180
+ // Handles: import ... from "@/...", export ... from "@/...", import("@/...")
1181
+ const aliasEscaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1182
+
1183
+ // Pattern to match the alias in import/export statements
1184
+ // This matches: from "@/...", from '@/...', import("@/..."), import('@/...')
1185
+ const pattern = new RegExp(
1186
+ `(from\\s*['"]|import\\s*\\(\\s*['"])${aliasEscaped}([^'"]+)(['"])`,
1187
+ 'g'
1188
+ );
1189
+
1190
+ result = result.replace(pattern, (match, prefix, path, quote) => {
1191
+ // Convert alias to virtual server path
1192
+ // e.g., @/components/faq -> /__virtual__/3001/components/faq
1193
+ const resolvedPath = `${virtualBase}${target}${path}`;
1194
+ return `${prefix}${resolvedPath}${quote}`;
1195
+ });
1196
+ }
1197
+
1198
+ return result;
732
1199
  }
733
1200
 
734
1201
  /**
@@ -758,28 +1225,58 @@ export class NextDevServer extends DevServer {
758
1225
  /**
759
1226
  * Generate a script tag that defines process.env with NEXT_PUBLIC_* variables
760
1227
  * This makes environment variables available to browser code via process.env.NEXT_PUBLIC_*
1228
+ * Also includes all env variables for Server Component compatibility
761
1229
  */
762
1230
  private generateEnvScript(): string {
763
1231
  const env = this.options.env || {};
764
1232
 
765
- // Filter for NEXT_PUBLIC_* variables only (Next.js convention)
766
- const publicEnvVars = Object.entries(env)
767
- .filter(([key]) => key.startsWith('NEXT_PUBLIC_'))
768
- .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {} as Record<string, string>);
769
-
770
- // Return empty string if no public env vars
771
- if (Object.keys(publicEnvVars).length === 0) {
772
- return '';
1233
+ // Only include NEXT_PUBLIC_* vars in the HTML (client-side accessible)
1234
+ // Non-public vars should never be exposed in HTML for security
1235
+ const publicEnvVars: Record<string, string> = {};
1236
+ for (const [key, value] of Object.entries(env)) {
1237
+ if (key.startsWith('NEXT_PUBLIC_')) {
1238
+ publicEnvVars[key] = value;
1239
+ }
773
1240
  }
774
1241
 
1242
+ // Always create process.env even if empty (some code checks for process.env existence)
1243
+ // This prevents "process is not defined" errors
775
1244
  return `<script>
776
- // NEXT_PUBLIC_* environment variables (injected by NextDevServer)
1245
+ // Environment variables (injected by NextDevServer)
777
1246
  window.process = window.process || {};
778
1247
  window.process.env = window.process.env || {};
779
1248
  Object.assign(window.process.env, ${JSON.stringify(publicEnvVars)});
780
1249
  </script>`;
781
1250
  }
782
1251
 
1252
+ /**
1253
+ * Load Tailwind config from tailwind.config.ts and generate a script
1254
+ * that configures the Tailwind CDN at runtime
1255
+ */
1256
+ private async loadTailwindConfigIfNeeded(): Promise<string> {
1257
+ // Return cached script if already loaded
1258
+ if (this.tailwindConfigLoaded) {
1259
+ return this.tailwindConfigScript;
1260
+ }
1261
+
1262
+ try {
1263
+ const result = await loadTailwindConfig(this.vfs, this.root);
1264
+
1265
+ if (result.success) {
1266
+ this.tailwindConfigScript = result.configScript;
1267
+ } else if (result.error) {
1268
+ console.warn('[NextDevServer] Tailwind config warning:', result.error);
1269
+ this.tailwindConfigScript = '';
1270
+ }
1271
+ } catch (error) {
1272
+ console.warn('[NextDevServer] Failed to load tailwind.config:', error);
1273
+ this.tailwindConfigScript = '';
1274
+ }
1275
+
1276
+ this.tailwindConfigLoaded = true;
1277
+ return this.tailwindConfigScript;
1278
+ }
1279
+
783
1280
  /**
784
1281
  * Check if App Router is available
785
1282
  */
@@ -809,13 +1306,39 @@ export class NextDevServer extends DevServer {
809
1306
  body?: Buffer
810
1307
  ): Promise<ResponseData> {
811
1308
  const urlObj = new URL(url, 'http://localhost');
812
- const pathname = urlObj.pathname;
1309
+ let pathname = urlObj.pathname;
1310
+
1311
+ // Strip virtual prefix if present (e.g., /__virtual__/3001/foo -> /foo)
1312
+ const virtualPrefixMatch = pathname.match(/^\/__virtual__\/\d+/);
1313
+ if (virtualPrefixMatch) {
1314
+ pathname = pathname.slice(virtualPrefixMatch[0].length) || '/';
1315
+ }
1316
+
1317
+ // Strip assetPrefix if present (e.g., /marketing/images/foo.png -> /images/foo.png)
1318
+ // This allows static assets to be served from /public when using assetPrefix in next.config
1319
+ // Also handles double-slash case: /marketing//images/foo.png (when assetPrefix ends with /)
1320
+ if (this.assetPrefix && pathname.startsWith(this.assetPrefix)) {
1321
+ const rest = pathname.slice(this.assetPrefix.length);
1322
+ // Handle both /marketing/images and /marketing//images cases
1323
+ if (rest === '' || rest.startsWith('/')) {
1324
+ pathname = rest || '/';
1325
+ // Normalize double slashes that may occur from assetPrefix concatenation
1326
+ if (pathname.startsWith('//')) {
1327
+ pathname = pathname.slice(1);
1328
+ }
1329
+ }
1330
+ }
813
1331
 
814
1332
  // Serve Next.js shims
815
1333
  if (pathname.startsWith('/_next/shims/')) {
816
1334
  return this.serveNextShim(pathname);
817
1335
  }
818
1336
 
1337
+ // Route info endpoint for client-side navigation params extraction
1338
+ if (pathname === '/_next/route-info') {
1339
+ return this.serveRouteInfo(urlObj.searchParams.get('pathname') || '/');
1340
+ }
1341
+
819
1342
  // Serve page components for client-side navigation (Pages Router)
820
1343
  if (pathname.startsWith('/_next/pages/')) {
821
1344
  return this.servePageComponent(pathname);
@@ -847,6 +1370,16 @@ export class NextDevServer extends DevServer {
847
1370
  return this.transformAndServe(pathname, pathname);
848
1371
  }
849
1372
 
1373
+ // Try to resolve file with different extensions (for imports without extensions)
1374
+ // e.g., /components/faq -> /components/faq.tsx
1375
+ const resolvedFile = this.resolveFileWithExtension(pathname);
1376
+ if (resolvedFile) {
1377
+ if (this.needsTransform(resolvedFile)) {
1378
+ return this.transformAndServe(resolvedFile, pathname);
1379
+ }
1380
+ return this.serveFile(resolvedFile);
1381
+ }
1382
+
850
1383
  // Serve regular files directly if they exist
851
1384
  if (this.exists(pathname) && !this.isDirectory(pathname)) {
852
1385
  return this.serveFile(pathname);
@@ -876,6 +1409,18 @@ export class NextDevServer extends DevServer {
876
1409
  case 'navigation':
877
1410
  code = NEXT_NAVIGATION_SHIM;
878
1411
  break;
1412
+ case 'image':
1413
+ code = NEXT_IMAGE_SHIM;
1414
+ break;
1415
+ case 'dynamic':
1416
+ code = NEXT_DYNAMIC_SHIM;
1417
+ break;
1418
+ case 'script':
1419
+ code = NEXT_SCRIPT_SHIM;
1420
+ break;
1421
+ case 'font/google':
1422
+ code = NEXT_FONT_GOOGLE_SHIM;
1423
+ break;
879
1424
  default:
880
1425
  return this.notFound(pathname);
881
1426
  }
@@ -893,6 +1438,32 @@ export class NextDevServer extends DevServer {
893
1438
  };
894
1439
  }
895
1440
 
1441
+ /**
1442
+ * Serve route info for client-side navigation
1443
+ * Returns params extracted from dynamic route segments
1444
+ */
1445
+ private serveRouteInfo(pathname: string): ResponseData {
1446
+ const route = this.resolveAppRoute(pathname);
1447
+
1448
+ const info = route
1449
+ ? { params: route.params, found: true }
1450
+ : { params: {}, found: false };
1451
+
1452
+ const json = JSON.stringify(info);
1453
+ const buffer = Buffer.from(json);
1454
+
1455
+ return {
1456
+ statusCode: 200,
1457
+ statusMessage: 'OK',
1458
+ headers: {
1459
+ 'Content-Type': 'application/json; charset=utf-8',
1460
+ 'Content-Length': String(buffer.length),
1461
+ 'Cache-Control': 'no-cache',
1462
+ },
1463
+ body: buffer,
1464
+ };
1465
+ }
1466
+
896
1467
  /**
897
1468
  * Serve static assets from /_next/static/
898
1469
  */
@@ -1506,7 +2077,7 @@ export class NextDevServer extends DevServer {
1506
2077
  /**
1507
2078
  * Resolve App Router route to page and layout files
1508
2079
  */
1509
- private resolveAppRoute(pathname: string): { page: string; layouts: string[] } | null {
2080
+ private resolveAppRoute(pathname: string): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null {
1510
2081
  const extensions = ['.jsx', '.tsx', '.js', '.ts'];
1511
2082
  const segments = pathname === '/' ? [] : pathname.split('/').filter(Boolean);
1512
2083
 
@@ -1541,7 +2112,8 @@ export class NextDevServer extends DevServer {
1541
2112
  for (const ext of extensions) {
1542
2113
  const pagePath = `${dirPath}/page${ext}`;
1543
2114
  if (this.exists(pagePath)) {
1544
- return { page: pagePath, layouts };
2115
+ // Static route - no params
2116
+ return { page: pagePath, layouts, params: {} };
1545
2117
  }
1546
2118
  }
1547
2119
 
@@ -1551,18 +2123,20 @@ export class NextDevServer extends DevServer {
1551
2123
 
1552
2124
  /**
1553
2125
  * Resolve dynamic App Router routes like /app/[id]/page.jsx
2126
+ * Also extracts route params from dynamic segments
1554
2127
  */
1555
2128
  private resolveAppDynamicRoute(
1556
2129
  pathname: string,
1557
2130
  segments: string[]
1558
- ): { page: string; layouts: string[] } | null {
2131
+ ): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null {
1559
2132
  const extensions = ['.jsx', '.tsx', '.js', '.ts'];
1560
2133
 
1561
2134
  const tryPath = (
1562
2135
  dirPath: string,
1563
2136
  remainingSegments: string[],
1564
- layouts: string[]
1565
- ): { page: string; layouts: string[] } | null => {
2137
+ layouts: string[],
2138
+ params: Record<string, string | string[]>
2139
+ ): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null => {
1566
2140
  // Check for layout at current level
1567
2141
  for (const ext of extensions) {
1568
2142
  const layoutPath = `${dirPath}/layout${ext}`;
@@ -1576,7 +2150,7 @@ export class NextDevServer extends DevServer {
1576
2150
  for (const ext of extensions) {
1577
2151
  const pagePath = `${dirPath}/page${ext}`;
1578
2152
  if (this.exists(pagePath)) {
1579
- return { page: pagePath, layouts };
2153
+ return { page: pagePath, layouts, params };
1580
2154
  }
1581
2155
  }
1582
2156
  return null;
@@ -1587,18 +2161,34 @@ export class NextDevServer extends DevServer {
1587
2161
  // Try exact match first
1588
2162
  const exactPath = `${dirPath}/${current}`;
1589
2163
  if (this.isDirectory(exactPath)) {
1590
- const result = tryPath(exactPath, rest, layouts);
2164
+ const result = tryPath(exactPath, rest, layouts, params);
1591
2165
  if (result) return result;
1592
2166
  }
1593
2167
 
1594
- // Try dynamic segment [param]
2168
+ // Try dynamic segments
1595
2169
  try {
1596
2170
  const entries = this.vfs.readdirSync(dirPath);
1597
2171
  for (const entry of entries) {
1598
- if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
2172
+ // Handle catch-all routes [...slug]
2173
+ if (entry.startsWith('[...') && entry.endsWith(']')) {
2174
+ const dynamicPath = `${dirPath}/${entry}`;
2175
+ if (this.isDirectory(dynamicPath)) {
2176
+ // Extract param name from [...slug]
2177
+ const paramName = entry.slice(4, -1);
2178
+ // Catch-all captures all remaining segments
2179
+ const newParams = { ...params, [paramName]: [current, ...rest] };
2180
+ const result = tryPath(dynamicPath, [], layouts, newParams);
2181
+ if (result) return result;
2182
+ }
2183
+ }
2184
+ // Handle single dynamic segment [param]
2185
+ else if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
1599
2186
  const dynamicPath = `${dirPath}/${entry}`;
1600
2187
  if (this.isDirectory(dynamicPath)) {
1601
- const result = tryPath(dynamicPath, rest, layouts);
2188
+ // Extract param name from [id]
2189
+ const paramName = entry.slice(1, -1);
2190
+ const newParams = { ...params, [paramName]: current };
2191
+ const result = tryPath(dynamicPath, rest, layouts, newParams);
1602
2192
  if (result) return result;
1603
2193
  }
1604
2194
  }
@@ -1620,14 +2210,14 @@ export class NextDevServer extends DevServer {
1620
2210
  }
1621
2211
  }
1622
2212
 
1623
- return tryPath(this.appDir, segments, layouts);
2213
+ return tryPath(this.appDir, segments, layouts, {});
1624
2214
  }
1625
2215
 
1626
2216
  /**
1627
2217
  * Generate HTML for App Router with nested layouts
1628
2218
  */
1629
2219
  private async generateAppRouterHtml(
1630
- route: { page: string; layouts: string[] },
2220
+ route: { page: string; layouts: string[]; params: Record<string, string | string[]> },
1631
2221
  pathname: string
1632
2222
  ): Promise<string> {
1633
2223
  // Use virtual server prefix for all file imports so the service worker can intercept them
@@ -1658,6 +2248,9 @@ export class NextDevServer extends DevServer {
1658
2248
  // Generate env script for NEXT_PUBLIC_* variables
1659
2249
  const envScript = this.generateEnvScript();
1660
2250
 
2251
+ // Load Tailwind config if available (must be injected BEFORE CDN script)
2252
+ const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
2253
+
1661
2254
  return `<!DOCTYPE html>
1662
2255
  <html lang="en">
1663
2256
  <head>
@@ -1667,6 +2260,7 @@ export class NextDevServer extends DevServer {
1667
2260
  <title>Next.js App</title>
1668
2261
  ${envScript}
1669
2262
  ${TAILWIND_CDN_SCRIPT}
2263
+ ${tailwindConfigScript}
1670
2264
  ${CORS_PROXY_SCRIPT}
1671
2265
  ${globalCssLinks.join('\n ')}
1672
2266
  ${REACT_REFRESH_PREAMBLE}
@@ -1688,7 +2282,11 @@ export class NextDevServer extends DevServer {
1688
2282
  "next/link": "${virtualPrefix}/_next/shims/link.js",
1689
2283
  "next/router": "${virtualPrefix}/_next/shims/router.js",
1690
2284
  "next/head": "${virtualPrefix}/_next/shims/head.js",
1691
- "next/navigation": "${virtualPrefix}/_next/shims/navigation.js"
2285
+ "next/navigation": "${virtualPrefix}/_next/shims/navigation.js",
2286
+ "next/image": "${virtualPrefix}/_next/shims/image.js",
2287
+ "next/dynamic": "${virtualPrefix}/_next/shims/dynamic.js",
2288
+ "next/script": "${virtualPrefix}/_next/shims/script.js",
2289
+ "next/font/google": "${virtualPrefix}/_next/shims/font/google.js"
1692
2290
  }
1693
2291
  }
1694
2292
  </script>
@@ -1702,6 +2300,39 @@ export class NextDevServer extends DevServer {
1702
2300
 
1703
2301
  const virtualBase = '${virtualPrefix}';
1704
2302
 
2303
+ // Initial route params (embedded by server for initial page load)
2304
+ const initialRouteParams = ${JSON.stringify(route.params)};
2305
+ const initialPathname = '${pathname}';
2306
+
2307
+ // Route params cache for client-side navigation
2308
+ const routeParamsCache = new Map();
2309
+ routeParamsCache.set(initialPathname, initialRouteParams);
2310
+
2311
+ // Extract route params from server for client-side navigation
2312
+ async function extractRouteParams(pathname) {
2313
+ // Strip virtual base if present
2314
+ let route = pathname;
2315
+ if (route.startsWith(virtualBase)) {
2316
+ route = route.slice(virtualBase.length);
2317
+ }
2318
+ route = route.replace(/^\\/+/, '/') || '/';
2319
+
2320
+ // Check cache first
2321
+ if (routeParamsCache.has(route)) {
2322
+ return routeParamsCache.get(route);
2323
+ }
2324
+
2325
+ try {
2326
+ const response = await fetch(virtualBase + '/_next/route-info?pathname=' + encodeURIComponent(route));
2327
+ const info = await response.json();
2328
+ routeParamsCache.set(route, info.params || {});
2329
+ return info.params || {};
2330
+ } catch (e) {
2331
+ console.error('[Router] Failed to extract route params:', e);
2332
+ return {};
2333
+ }
2334
+ }
2335
+
1705
2336
  // Convert URL path to app router page module path
1706
2337
  function getAppPageModulePath(pathname) {
1707
2338
  let route = pathname;
@@ -1768,11 +2399,60 @@ export class NextDevServer extends DevServer {
1768
2399
  return layouts;
1769
2400
  }
1770
2401
 
2402
+ // Wrapper for async Server Components
2403
+ function AsyncComponent({ component: Component, pathname, search }) {
2404
+ const [content, setContent] = React.useState(null);
2405
+ const [error, setError] = React.useState(null);
2406
+
2407
+ React.useEffect(() => {
2408
+ let cancelled = false;
2409
+ async function render() {
2410
+ try {
2411
+ // Create searchParams as a Promise (Next.js 15 pattern)
2412
+ const url = new URL(window.location.href);
2413
+ const searchParamsObj = Object.fromEntries(url.searchParams);
2414
+ const searchParams = Promise.resolve(searchParamsObj);
2415
+
2416
+ // Extract route params from pathname (fetches from server for dynamic routes)
2417
+ const routeParams = await extractRouteParams(pathname);
2418
+ const params = Promise.resolve(routeParams);
2419
+
2420
+ // Call component with props like Next.js does for page components
2421
+ const result = Component({ searchParams, params });
2422
+ if (result && typeof result.then === 'function') {
2423
+ // It's a Promise (async component)
2424
+ const resolved = await result;
2425
+ if (!cancelled) setContent(resolved);
2426
+ } else {
2427
+ // Synchronous component - result is already JSX
2428
+ if (!cancelled) setContent(result);
2429
+ }
2430
+ } catch (e) {
2431
+ console.error('[AsyncComponent] Error rendering:', e);
2432
+ if (!cancelled) setError(e);
2433
+ }
2434
+ }
2435
+ render();
2436
+ return () => { cancelled = true; };
2437
+ }, [Component, pathname, search]);
2438
+
2439
+ if (error) {
2440
+ return React.createElement('div', { style: { color: 'red', padding: '20px' } },
2441
+ 'Error: ' + error.message
2442
+ );
2443
+ }
2444
+ if (!content) {
2445
+ return React.createElement('div', { style: { padding: '20px' } }, 'Loading...');
2446
+ }
2447
+ return content;
2448
+ }
2449
+
1771
2450
  // Router component
1772
2451
  function Router() {
1773
2452
  const [Page, setPage] = React.useState(null);
1774
2453
  const [layouts, setLayouts] = React.useState([]);
1775
2454
  const [path, setPath] = React.useState(window.location.pathname);
2455
+ const [search, setSearch] = React.useState(window.location.search);
1776
2456
 
1777
2457
  React.useEffect(() => {
1778
2458
  Promise.all([loadPage(path), loadLayouts(path)]).then(([P, L]) => {
@@ -1784,21 +2464,35 @@ export class NextDevServer extends DevServer {
1784
2464
  React.useEffect(() => {
1785
2465
  const handleNavigation = async () => {
1786
2466
  const newPath = window.location.pathname;
2467
+ const newSearch = window.location.search;
2468
+ console.log('[Router] handleNavigation called, newPath:', newPath, 'current path:', path);
2469
+
2470
+ // Always update search params
2471
+ if (newSearch !== search) {
2472
+ setSearch(newSearch);
2473
+ }
2474
+
1787
2475
  if (newPath !== path) {
2476
+ console.log('[Router] Path changed, loading new page...');
1788
2477
  setPath(newPath);
1789
2478
  const [P, L] = await Promise.all([loadPage(newPath), loadLayouts(newPath)]);
2479
+ console.log('[Router] Page loaded:', !!P, 'Layouts:', L.length);
1790
2480
  if (P) setPage(() => P);
1791
2481
  setLayouts(L);
2482
+ } else {
2483
+ console.log('[Router] Path unchanged, skipping navigation');
1792
2484
  }
1793
2485
  };
1794
2486
  window.addEventListener('popstate', handleNavigation);
2487
+ console.log('[Router] Added popstate listener for path:', path);
1795
2488
  return () => window.removeEventListener('popstate', handleNavigation);
1796
- }, [path]);
2489
+ }, [path, search]);
1797
2490
 
1798
2491
  if (!Page) return null;
1799
2492
 
1800
- // Build nested layout structure
1801
- let content = React.createElement(Page);
2493
+ // Use AsyncComponent wrapper to handle async Server Components
2494
+ // Pass search to force re-render when query params change
2495
+ let content = React.createElement(AsyncComponent, { component: Page, pathname: path, search: search });
1802
2496
  for (let i = layouts.length - 1; i >= 0; i--) {
1803
2497
  content = React.createElement(layouts[i], null, content);
1804
2498
  }
@@ -1958,6 +2652,9 @@ export class NextDevServer extends DevServer {
1958
2652
  // Generate env script for NEXT_PUBLIC_* variables
1959
2653
  const envScript = this.generateEnvScript();
1960
2654
 
2655
+ // Load Tailwind config if available (must be injected BEFORE CDN script)
2656
+ const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
2657
+
1961
2658
  return `<!DOCTYPE html>
1962
2659
  <html lang="en">
1963
2660
  <head>
@@ -1967,6 +2664,7 @@ export class NextDevServer extends DevServer {
1967
2664
  <title>Next.js App</title>
1968
2665
  ${envScript}
1969
2666
  ${TAILWIND_CDN_SCRIPT}
2667
+ ${tailwindConfigScript}
1970
2668
  ${CORS_PROXY_SCRIPT}
1971
2669
  ${globalCssLinks.join('\n ')}
1972
2670
  ${REACT_REFRESH_PREAMBLE}
@@ -1980,7 +2678,12 @@ export class NextDevServer extends DevServer {
1980
2678
  "react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
1981
2679
  "next/link": "${virtualPrefix}/_next/shims/link.js",
1982
2680
  "next/router": "${virtualPrefix}/_next/shims/router.js",
1983
- "next/head": "${virtualPrefix}/_next/shims/head.js"
2681
+ "next/head": "${virtualPrefix}/_next/shims/head.js",
2682
+ "next/navigation": "${virtualPrefix}/_next/shims/navigation.js",
2683
+ "next/image": "${virtualPrefix}/_next/shims/image.js",
2684
+ "next/dynamic": "${virtualPrefix}/_next/shims/dynamic.js",
2685
+ "next/script": "${virtualPrefix}/_next/shims/script.js",
2686
+ "next/font/google": "${virtualPrefix}/_next/shims/font/google.js"
1984
2687
  }
1985
2688
  }
1986
2689
  </script>
@@ -2102,6 +2805,39 @@ export class NextDevServer extends DevServer {
2102
2805
  };
2103
2806
  }
2104
2807
 
2808
+ /**
2809
+ * Try to resolve a file path by adding common extensions
2810
+ * e.g., /components/faq -> /components/faq.tsx
2811
+ * Also handles index files in directories
2812
+ */
2813
+ private resolveFileWithExtension(pathname: string): string | null {
2814
+ // If the file already has an extension and exists, return it
2815
+ if (/\.\w+$/.test(pathname) && this.exists(pathname)) {
2816
+ return pathname;
2817
+ }
2818
+
2819
+ // Common extensions to try, in order of preference
2820
+ const extensions = ['.tsx', '.ts', '.jsx', '.js'];
2821
+
2822
+ // Try adding extensions directly
2823
+ for (const ext of extensions) {
2824
+ const withExt = pathname + ext;
2825
+ if (this.exists(withExt)) {
2826
+ return withExt;
2827
+ }
2828
+ }
2829
+
2830
+ // Try as a directory with index file
2831
+ for (const ext of extensions) {
2832
+ const indexPath = pathname + '/index' + ext;
2833
+ if (this.exists(indexPath)) {
2834
+ return indexPath;
2835
+ }
2836
+ }
2837
+
2838
+ return null;
2839
+ }
2840
+
2105
2841
  /**
2106
2842
  * Check if a file needs transformation
2107
2843
  */
@@ -2115,7 +2851,31 @@ export class NextDevServer extends DevServer {
2115
2851
  private async transformAndServe(filePath: string, urlPath: string): Promise<ResponseData> {
2116
2852
  try {
2117
2853
  const content = this.vfs.readFileSync(filePath, 'utf8');
2118
- const transformed = await this.transformCode(content, urlPath);
2854
+ const hash = simpleHash(content);
2855
+
2856
+ // Check transform cache
2857
+ const cached = this.transformCache.get(filePath);
2858
+ if (cached && cached.hash === hash) {
2859
+ const buffer = Buffer.from(cached.code);
2860
+ return {
2861
+ statusCode: 200,
2862
+ statusMessage: 'OK',
2863
+ headers: {
2864
+ 'Content-Type': 'application/javascript; charset=utf-8',
2865
+ 'Content-Length': String(buffer.length),
2866
+ 'Cache-Control': 'no-cache',
2867
+ 'X-Transformed': 'true',
2868
+ 'X-Cache': 'hit',
2869
+ },
2870
+ body: buffer,
2871
+ };
2872
+ }
2873
+
2874
+ // Use filePath (with extension) for transform so loader is correctly determined
2875
+ const transformed = await this.transformCode(content, filePath);
2876
+
2877
+ // Cache the transform result
2878
+ this.transformCache.set(filePath, { code: transformed, hash });
2119
2879
 
2120
2880
  const buffer = Buffer.from(transformed);
2121
2881
  return {
@@ -2164,12 +2924,15 @@ export class NextDevServer extends DevServer {
2164
2924
  // CSS imports in ESM would fail with MIME type errors
2165
2925
  const codeWithoutCssImports = this.stripCssImports(code);
2166
2926
 
2927
+ // Resolve path aliases (e.g., @/ -> /) before transformation
2928
+ const codeWithResolvedAliases = this.resolvePathAliases(codeWithoutCssImports, filename);
2929
+
2167
2930
  let loader: 'js' | 'jsx' | 'ts' | 'tsx' = 'js';
2168
2931
  if (filename.endsWith('.jsx')) loader = 'jsx';
2169
2932
  else if (filename.endsWith('.tsx')) loader = 'tsx';
2170
2933
  else if (filename.endsWith('.ts')) loader = 'ts';
2171
2934
 
2172
- const result = await esbuild.transform(codeWithoutCssImports, {
2935
+ const result = await esbuild.transform(codeWithResolvedAliases, {
2173
2936
  loader,
2174
2937
  format: 'esm',
2175
2938
  target: 'esnext',
@@ -2179,12 +2942,80 @@ export class NextDevServer extends DevServer {
2179
2942
  sourcefile: filename,
2180
2943
  });
2181
2944
 
2945
+ // Redirect bare npm imports to esm.sh CDN
2946
+ const codeWithCdnImports = this.redirectNpmImports(result.code);
2947
+
2182
2948
  // Add React Refresh registration for JSX/TSX files
2183
2949
  if (/\.(jsx|tsx)$/.test(filename)) {
2184
- return this.addReactRefresh(result.code, filename);
2950
+ return this.addReactRefresh(codeWithCdnImports, filename);
2185
2951
  }
2186
2952
 
2187
- return result.code;
2953
+ return codeWithCdnImports;
2954
+ }
2955
+
2956
+ /**
2957
+ * Redirect bare npm package imports to esm.sh CDN
2958
+ * e.g., import { Crisp } from "crisp-sdk-web" -> import { Crisp } from "https://esm.sh/crisp-sdk-web?external=react"
2959
+ *
2960
+ * IMPORTANT: We redirect ALL npm packages to esm.sh URLs (including React)
2961
+ * because import maps don't work reliably for dynamically imported modules.
2962
+ */
2963
+ private redirectNpmImports(code: string): string {
2964
+ // Explicit mappings for common packages (ensures correct esm.sh URLs)
2965
+ const explicitMappings: Record<string, string> = {
2966
+ 'react': 'https://esm.sh/react@18.2.0?dev',
2967
+ 'react/jsx-runtime': 'https://esm.sh/react@18.2.0&dev/jsx-runtime',
2968
+ 'react/jsx-dev-runtime': 'https://esm.sh/react@18.2.0&dev/jsx-dev-runtime',
2969
+ 'react-dom': 'https://esm.sh/react-dom@18.2.0?dev',
2970
+ 'react-dom/client': 'https://esm.sh/react-dom@18.2.0/client?dev',
2971
+ };
2972
+
2973
+ // Packages that are local or have custom shims (NOT npm packages)
2974
+ const localPackages = new Set([
2975
+ 'next/link', 'next/router', 'next/head', 'next/navigation',
2976
+ 'next/dynamic', 'next/image', 'next/script', 'next/font/google',
2977
+ 'convex/_generated/api'
2978
+ ]);
2979
+
2980
+ // Pattern to match import statements with bare package specifiers
2981
+ // Matches: from "package" or from 'package' where package doesn't start with . / or http
2982
+ const importPattern = /(from\s*['"])([^'"./][^'"]*?)(['"])/g;
2983
+
2984
+ return code.replace(importPattern, (match, prefix, packageName, suffix) => {
2985
+ // Skip if already a URL or local virtual path
2986
+ if (packageName.startsWith('http://') ||
2987
+ packageName.startsWith('https://') ||
2988
+ packageName.startsWith('/__virtual__')) {
2989
+ return match;
2990
+ }
2991
+
2992
+ // Check explicit mappings first
2993
+ if (explicitMappings[packageName]) {
2994
+ return `${prefix}${explicitMappings[packageName]}${suffix}`;
2995
+ }
2996
+
2997
+ // Skip local/shimmed packages (they're handled via import map or virtual paths)
2998
+ if (localPackages.has(packageName)) {
2999
+ return match;
3000
+ }
3001
+
3002
+ // Check if it's a subpath import of a local package
3003
+ const basePkg = packageName.includes('/') ? packageName.split('/')[0] : packageName;
3004
+
3005
+ // Handle scoped packages (@org/pkg)
3006
+ const isScoped = basePkg.startsWith('@');
3007
+ const scopedBasePkg = isScoped && packageName.includes('/')
3008
+ ? packageName.split('/').slice(0, 2).join('/')
3009
+ : basePkg;
3010
+
3011
+ if (localPackages.has(scopedBasePkg)) {
3012
+ return match;
3013
+ }
3014
+
3015
+ // Redirect to esm.sh with external=react to avoid bundling React twice
3016
+ const esmUrl = `https://esm.sh/${packageName}?external=react`;
3017
+ return `${prefix}${esmUrl}${suffix}`;
3018
+ });
2188
3019
  }
2189
3020
 
2190
3021
  /**
@@ -2194,13 +3025,18 @@ export class NextDevServer extends DevServer {
2194
3025
  private stripCssImports(code: string): string {
2195
3026
  // Match import statements for CSS files (with or without semicolon)
2196
3027
  // Handles: import './styles.css'; import "./globals.css" import '../path/file.css'
2197
- return code.replace(/import\s+['"][^'"]+\.css['"]\s*;?/g, '// CSS import removed (loaded via <link>)');
3028
+ // NOTE: Don't match trailing whitespace (\s*) as it would consume newlines
3029
+ // and break subsequent imports that start on the next line
3030
+ return code.replace(/import\s+['"][^'"]+\.css['"]\s*;?/g, '');
2198
3031
  }
2199
3032
 
2200
3033
  /**
2201
3034
  * Transform API handler code to CommonJS for eval execution
2202
3035
  */
2203
3036
  private async transformApiHandler(code: string, filename: string): Promise<string> {
3037
+ // Resolve path aliases first
3038
+ const codeWithResolvedAliases = this.resolvePathAliases(code, filename);
3039
+
2204
3040
  if (isBrowser) {
2205
3041
  // Use esbuild in browser
2206
3042
  await initEsbuild();
@@ -2215,7 +3051,7 @@ export class NextDevServer extends DevServer {
2215
3051
  else if (filename.endsWith('.tsx')) loader = 'tsx';
2216
3052
  else if (filename.endsWith('.ts')) loader = 'ts';
2217
3053
 
2218
- const result = await esbuild.transform(code, {
3054
+ const result = await esbuild.transform(codeWithResolvedAliases, {
2219
3055
  loader,
2220
3056
  format: 'cjs', // CommonJS for eval execution
2221
3057
  target: 'esnext',
@@ -2227,7 +3063,7 @@ export class NextDevServer extends DevServer {
2227
3063
  }
2228
3064
 
2229
3065
  // Simple ESM to CJS transform for Node.js/test environment
2230
- let transformed = code;
3066
+ let transformed = codeWithResolvedAliases;
2231
3067
 
2232
3068
  // Convert: import X from 'Y' -> const X = require('Y')
2233
3069
  transformed = transformed.replace(
@@ -2393,6 +3229,76 @@ ${registrations}
2393
3229
  }
2394
3230
  }
2395
3231
 
3232
+ /**
3233
+ * Override serveFile to wrap JSON files as ES modules
3234
+ * This is needed because browsers can't dynamically import raw JSON files
3235
+ */
3236
+ protected serveFile(filePath: string): ResponseData {
3237
+ // For JSON files, wrap as ES module so they can be dynamically imported
3238
+ if (filePath.endsWith('.json')) {
3239
+ try {
3240
+ const normalizedPath = this.resolvePath(filePath);
3241
+ const content = this.vfs.readFileSync(normalizedPath);
3242
+
3243
+ // Properly convert content to string
3244
+ // VirtualFS may return string, Buffer, or Uint8Array
3245
+ let jsonContent: string;
3246
+ if (typeof content === 'string') {
3247
+ jsonContent = content;
3248
+ } else if (content instanceof Uint8Array) {
3249
+ // Use TextDecoder for Uint8Array (includes Buffer in browser)
3250
+ jsonContent = new TextDecoder('utf-8').decode(content);
3251
+ } else {
3252
+ // Fallback for other buffer-like objects
3253
+ jsonContent = Buffer.from(content).toString('utf-8');
3254
+ }
3255
+
3256
+ // Wrap JSON as ES module
3257
+ const esModuleContent = `export default ${jsonContent};`;
3258
+ const buffer = Buffer.from(esModuleContent);
3259
+
3260
+ return {
3261
+ statusCode: 200,
3262
+ statusMessage: 'OK',
3263
+ headers: {
3264
+ 'Content-Type': 'application/javascript; charset=utf-8',
3265
+ 'Content-Length': String(buffer.length),
3266
+ 'Cache-Control': 'no-cache',
3267
+ },
3268
+ body: buffer,
3269
+ };
3270
+ } catch (error) {
3271
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
3272
+ return this.notFound(filePath);
3273
+ }
3274
+ return this.serverError(error);
3275
+ }
3276
+ }
3277
+
3278
+ // For all other files, use the parent implementation
3279
+ return super.serveFile(filePath);
3280
+ }
3281
+
3282
+ /**
3283
+ * Resolve a path (helper to access protected method from parent)
3284
+ */
3285
+ protected resolvePath(urlPath: string): string {
3286
+ // Remove query string and hash
3287
+ let path = urlPath.split('?')[0].split('#')[0];
3288
+
3289
+ // Normalize path
3290
+ if (!path.startsWith('/')) {
3291
+ path = '/' + path;
3292
+ }
3293
+
3294
+ // Join with root
3295
+ if (this.root !== '/') {
3296
+ path = this.root + path;
3297
+ }
3298
+
3299
+ return path;
3300
+ }
3301
+
2396
3302
  /**
2397
3303
  * Stop the server
2398
3304
  */