almostnode 0.2.5 → 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.
@@ -7,6 +7,7 @@ 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';
10
11
 
11
12
  // Check if we're in a real browser environment (not jsdom or Node.js)
12
13
  const isBrowser = typeof window !== 'undefined' &&
@@ -77,6 +78,8 @@ export interface NextDevServerOptions extends DevServerOptions {
77
78
  preferAppRouter?: boolean;
78
79
  /** Environment variables (NEXT_PUBLIC_* are available in browser code via process.env) */
79
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;
80
83
  }
81
84
 
82
85
  /**
@@ -283,25 +286,31 @@ const applyVirtualBase = (url) => {
283
286
 
284
287
  export default function Link({ href, children, ...props }) {
285
288
  const handleClick = (e) => {
289
+ console.log('[Link] Click handler called, href:', href);
290
+
286
291
  if (props.onClick) {
287
292
  props.onClick(e);
288
293
  }
289
294
 
290
295
  // Allow cmd/ctrl click to open in new tab
291
296
  if (e.metaKey || e.ctrlKey) {
297
+ console.log('[Link] Meta/Ctrl key pressed, allowing default behavior');
292
298
  return;
293
299
  }
294
300
 
295
301
  if (typeof href !== 'string' || !href || href.startsWith('#') || href.startsWith('?')) {
302
+ console.log('[Link] Skipping navigation for href:', href);
296
303
  return;
297
304
  }
298
305
 
299
306
  if (/^(https?:)?\\/\\//.test(href)) {
307
+ console.log('[Link] External URL, allowing default behavior:', href);
300
308
  return;
301
309
  }
302
310
 
303
311
  e.preventDefault();
304
312
  const resolvedHref = applyVirtualBase(href);
313
+ console.log('[Link] Navigating to:', resolvedHref);
305
314
  window.history.pushState({}, '', resolvedHref);
306
315
  window.dispatchEvent(new PopStateEvent('popstate'));
307
316
  };
@@ -670,6 +679,323 @@ export default function Head({ children }) {
670
679
  }
671
680
  `;
672
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
+
673
999
  /**
674
1000
  * NextDevServer - A lightweight Next.js-compatible development server
675
1001
  *
@@ -718,6 +1044,18 @@ export class NextDevServer extends DevServer {
718
1044
  /** Transform result cache for performance */
719
1045
  private transformCache: Map<string, { code: string; hash: string }> = new Map();
720
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
+
721
1059
  constructor(vfs: VirtualFS, options: NextDevServerOptions) {
722
1060
  super(vfs, options);
723
1061
  this.options = options;
@@ -733,6 +1071,131 @@ export class NextDevServer extends DevServer {
733
1071
  // Prefer App Router if /app directory exists with a page.jsx file
734
1072
  this.useAppRouter = this.hasAppRouter();
735
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;
736
1199
  }
737
1200
 
738
1201
  /**
@@ -762,28 +1225,58 @@ export class NextDevServer extends DevServer {
762
1225
  /**
763
1226
  * Generate a script tag that defines process.env with NEXT_PUBLIC_* variables
764
1227
  * This makes environment variables available to browser code via process.env.NEXT_PUBLIC_*
1228
+ * Also includes all env variables for Server Component compatibility
765
1229
  */
766
1230
  private generateEnvScript(): string {
767
1231
  const env = this.options.env || {};
768
1232
 
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 '';
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
+ }
777
1240
  }
778
1241
 
1242
+ // Always create process.env even if empty (some code checks for process.env existence)
1243
+ // This prevents "process is not defined" errors
779
1244
  return `<script>
780
- // NEXT_PUBLIC_* environment variables (injected by NextDevServer)
1245
+ // Environment variables (injected by NextDevServer)
781
1246
  window.process = window.process || {};
782
1247
  window.process.env = window.process.env || {};
783
1248
  Object.assign(window.process.env, ${JSON.stringify(publicEnvVars)});
784
1249
  </script>`;
785
1250
  }
786
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
+
787
1280
  /**
788
1281
  * Check if App Router is available
789
1282
  */
@@ -813,13 +1306,39 @@ export class NextDevServer extends DevServer {
813
1306
  body?: Buffer
814
1307
  ): Promise<ResponseData> {
815
1308
  const urlObj = new URL(url, 'http://localhost');
816
- 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
+ }
817
1331
 
818
1332
  // Serve Next.js shims
819
1333
  if (pathname.startsWith('/_next/shims/')) {
820
1334
  return this.serveNextShim(pathname);
821
1335
  }
822
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
+
823
1342
  // Serve page components for client-side navigation (Pages Router)
824
1343
  if (pathname.startsWith('/_next/pages/')) {
825
1344
  return this.servePageComponent(pathname);
@@ -851,6 +1370,16 @@ export class NextDevServer extends DevServer {
851
1370
  return this.transformAndServe(pathname, pathname);
852
1371
  }
853
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
+
854
1383
  // Serve regular files directly if they exist
855
1384
  if (this.exists(pathname) && !this.isDirectory(pathname)) {
856
1385
  return this.serveFile(pathname);
@@ -880,6 +1409,18 @@ export class NextDevServer extends DevServer {
880
1409
  case 'navigation':
881
1410
  code = NEXT_NAVIGATION_SHIM;
882
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;
883
1424
  default:
884
1425
  return this.notFound(pathname);
885
1426
  }
@@ -897,6 +1438,32 @@ export class NextDevServer extends DevServer {
897
1438
  };
898
1439
  }
899
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
+
900
1467
  /**
901
1468
  * Serve static assets from /_next/static/
902
1469
  */
@@ -1510,7 +2077,7 @@ export class NextDevServer extends DevServer {
1510
2077
  /**
1511
2078
  * Resolve App Router route to page and layout files
1512
2079
  */
1513
- private resolveAppRoute(pathname: string): { page: string; layouts: string[] } | null {
2080
+ private resolveAppRoute(pathname: string): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null {
1514
2081
  const extensions = ['.jsx', '.tsx', '.js', '.ts'];
1515
2082
  const segments = pathname === '/' ? [] : pathname.split('/').filter(Boolean);
1516
2083
 
@@ -1545,7 +2112,8 @@ export class NextDevServer extends DevServer {
1545
2112
  for (const ext of extensions) {
1546
2113
  const pagePath = `${dirPath}/page${ext}`;
1547
2114
  if (this.exists(pagePath)) {
1548
- return { page: pagePath, layouts };
2115
+ // Static route - no params
2116
+ return { page: pagePath, layouts, params: {} };
1549
2117
  }
1550
2118
  }
1551
2119
 
@@ -1555,18 +2123,20 @@ export class NextDevServer extends DevServer {
1555
2123
 
1556
2124
  /**
1557
2125
  * Resolve dynamic App Router routes like /app/[id]/page.jsx
2126
+ * Also extracts route params from dynamic segments
1558
2127
  */
1559
2128
  private resolveAppDynamicRoute(
1560
2129
  pathname: string,
1561
2130
  segments: string[]
1562
- ): { page: string; layouts: string[] } | null {
2131
+ ): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null {
1563
2132
  const extensions = ['.jsx', '.tsx', '.js', '.ts'];
1564
2133
 
1565
2134
  const tryPath = (
1566
2135
  dirPath: string,
1567
2136
  remainingSegments: string[],
1568
- layouts: string[]
1569
- ): { 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 => {
1570
2140
  // Check for layout at current level
1571
2141
  for (const ext of extensions) {
1572
2142
  const layoutPath = `${dirPath}/layout${ext}`;
@@ -1580,7 +2150,7 @@ export class NextDevServer extends DevServer {
1580
2150
  for (const ext of extensions) {
1581
2151
  const pagePath = `${dirPath}/page${ext}`;
1582
2152
  if (this.exists(pagePath)) {
1583
- return { page: pagePath, layouts };
2153
+ return { page: pagePath, layouts, params };
1584
2154
  }
1585
2155
  }
1586
2156
  return null;
@@ -1591,18 +2161,34 @@ export class NextDevServer extends DevServer {
1591
2161
  // Try exact match first
1592
2162
  const exactPath = `${dirPath}/${current}`;
1593
2163
  if (this.isDirectory(exactPath)) {
1594
- const result = tryPath(exactPath, rest, layouts);
2164
+ const result = tryPath(exactPath, rest, layouts, params);
1595
2165
  if (result) return result;
1596
2166
  }
1597
2167
 
1598
- // Try dynamic segment [param]
2168
+ // Try dynamic segments
1599
2169
  try {
1600
2170
  const entries = this.vfs.readdirSync(dirPath);
1601
2171
  for (const entry of entries) {
1602
- 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('.')) {
1603
2186
  const dynamicPath = `${dirPath}/${entry}`;
1604
2187
  if (this.isDirectory(dynamicPath)) {
1605
- 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);
1606
2192
  if (result) return result;
1607
2193
  }
1608
2194
  }
@@ -1624,14 +2210,14 @@ export class NextDevServer extends DevServer {
1624
2210
  }
1625
2211
  }
1626
2212
 
1627
- return tryPath(this.appDir, segments, layouts);
2213
+ return tryPath(this.appDir, segments, layouts, {});
1628
2214
  }
1629
2215
 
1630
2216
  /**
1631
2217
  * Generate HTML for App Router with nested layouts
1632
2218
  */
1633
2219
  private async generateAppRouterHtml(
1634
- route: { page: string; layouts: string[] },
2220
+ route: { page: string; layouts: string[]; params: Record<string, string | string[]> },
1635
2221
  pathname: string
1636
2222
  ): Promise<string> {
1637
2223
  // Use virtual server prefix for all file imports so the service worker can intercept them
@@ -1662,6 +2248,9 @@ export class NextDevServer extends DevServer {
1662
2248
  // Generate env script for NEXT_PUBLIC_* variables
1663
2249
  const envScript = this.generateEnvScript();
1664
2250
 
2251
+ // Load Tailwind config if available (must be injected BEFORE CDN script)
2252
+ const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
2253
+
1665
2254
  return `<!DOCTYPE html>
1666
2255
  <html lang="en">
1667
2256
  <head>
@@ -1671,6 +2260,7 @@ export class NextDevServer extends DevServer {
1671
2260
  <title>Next.js App</title>
1672
2261
  ${envScript}
1673
2262
  ${TAILWIND_CDN_SCRIPT}
2263
+ ${tailwindConfigScript}
1674
2264
  ${CORS_PROXY_SCRIPT}
1675
2265
  ${globalCssLinks.join('\n ')}
1676
2266
  ${REACT_REFRESH_PREAMBLE}
@@ -1692,7 +2282,11 @@ export class NextDevServer extends DevServer {
1692
2282
  "next/link": "${virtualPrefix}/_next/shims/link.js",
1693
2283
  "next/router": "${virtualPrefix}/_next/shims/router.js",
1694
2284
  "next/head": "${virtualPrefix}/_next/shims/head.js",
1695
- "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"
1696
2290
  }
1697
2291
  }
1698
2292
  </script>
@@ -1706,6 +2300,39 @@ export class NextDevServer extends DevServer {
1706
2300
 
1707
2301
  const virtualBase = '${virtualPrefix}';
1708
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
+
1709
2336
  // Convert URL path to app router page module path
1710
2337
  function getAppPageModulePath(pathname) {
1711
2338
  let route = pathname;
@@ -1772,11 +2399,60 @@ export class NextDevServer extends DevServer {
1772
2399
  return layouts;
1773
2400
  }
1774
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
+
1775
2450
  // Router component
1776
2451
  function Router() {
1777
2452
  const [Page, setPage] = React.useState(null);
1778
2453
  const [layouts, setLayouts] = React.useState([]);
1779
2454
  const [path, setPath] = React.useState(window.location.pathname);
2455
+ const [search, setSearch] = React.useState(window.location.search);
1780
2456
 
1781
2457
  React.useEffect(() => {
1782
2458
  Promise.all([loadPage(path), loadLayouts(path)]).then(([P, L]) => {
@@ -1788,21 +2464,35 @@ export class NextDevServer extends DevServer {
1788
2464
  React.useEffect(() => {
1789
2465
  const handleNavigation = async () => {
1790
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
+
1791
2475
  if (newPath !== path) {
2476
+ console.log('[Router] Path changed, loading new page...');
1792
2477
  setPath(newPath);
1793
2478
  const [P, L] = await Promise.all([loadPage(newPath), loadLayouts(newPath)]);
2479
+ console.log('[Router] Page loaded:', !!P, 'Layouts:', L.length);
1794
2480
  if (P) setPage(() => P);
1795
2481
  setLayouts(L);
2482
+ } else {
2483
+ console.log('[Router] Path unchanged, skipping navigation');
1796
2484
  }
1797
2485
  };
1798
2486
  window.addEventListener('popstate', handleNavigation);
2487
+ console.log('[Router] Added popstate listener for path:', path);
1799
2488
  return () => window.removeEventListener('popstate', handleNavigation);
1800
- }, [path]);
2489
+ }, [path, search]);
1801
2490
 
1802
2491
  if (!Page) return null;
1803
2492
 
1804
- // Build nested layout structure
1805
- 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 });
1806
2496
  for (let i = layouts.length - 1; i >= 0; i--) {
1807
2497
  content = React.createElement(layouts[i], null, content);
1808
2498
  }
@@ -1962,6 +2652,9 @@ export class NextDevServer extends DevServer {
1962
2652
  // Generate env script for NEXT_PUBLIC_* variables
1963
2653
  const envScript = this.generateEnvScript();
1964
2654
 
2655
+ // Load Tailwind config if available (must be injected BEFORE CDN script)
2656
+ const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
2657
+
1965
2658
  return `<!DOCTYPE html>
1966
2659
  <html lang="en">
1967
2660
  <head>
@@ -1971,6 +2664,7 @@ export class NextDevServer extends DevServer {
1971
2664
  <title>Next.js App</title>
1972
2665
  ${envScript}
1973
2666
  ${TAILWIND_CDN_SCRIPT}
2667
+ ${tailwindConfigScript}
1974
2668
  ${CORS_PROXY_SCRIPT}
1975
2669
  ${globalCssLinks.join('\n ')}
1976
2670
  ${REACT_REFRESH_PREAMBLE}
@@ -1984,7 +2678,12 @@ export class NextDevServer extends DevServer {
1984
2678
  "react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
1985
2679
  "next/link": "${virtualPrefix}/_next/shims/link.js",
1986
2680
  "next/router": "${virtualPrefix}/_next/shims/router.js",
1987
- "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"
1988
2687
  }
1989
2688
  }
1990
2689
  </script>
@@ -2106,6 +2805,39 @@ export class NextDevServer extends DevServer {
2106
2805
  };
2107
2806
  }
2108
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
+
2109
2841
  /**
2110
2842
  * Check if a file needs transformation
2111
2843
  */
@@ -2139,7 +2871,8 @@ export class NextDevServer extends DevServer {
2139
2871
  };
2140
2872
  }
2141
2873
 
2142
- const transformed = await this.transformCode(content, urlPath);
2874
+ // Use filePath (with extension) for transform so loader is correctly determined
2875
+ const transformed = await this.transformCode(content, filePath);
2143
2876
 
2144
2877
  // Cache the transform result
2145
2878
  this.transformCache.set(filePath, { code: transformed, hash });
@@ -2191,12 +2924,15 @@ export class NextDevServer extends DevServer {
2191
2924
  // CSS imports in ESM would fail with MIME type errors
2192
2925
  const codeWithoutCssImports = this.stripCssImports(code);
2193
2926
 
2927
+ // Resolve path aliases (e.g., @/ -> /) before transformation
2928
+ const codeWithResolvedAliases = this.resolvePathAliases(codeWithoutCssImports, filename);
2929
+
2194
2930
  let loader: 'js' | 'jsx' | 'ts' | 'tsx' = 'js';
2195
2931
  if (filename.endsWith('.jsx')) loader = 'jsx';
2196
2932
  else if (filename.endsWith('.tsx')) loader = 'tsx';
2197
2933
  else if (filename.endsWith('.ts')) loader = 'ts';
2198
2934
 
2199
- const result = await esbuild.transform(codeWithoutCssImports, {
2935
+ const result = await esbuild.transform(codeWithResolvedAliases, {
2200
2936
  loader,
2201
2937
  format: 'esm',
2202
2938
  target: 'esnext',
@@ -2206,12 +2942,80 @@ export class NextDevServer extends DevServer {
2206
2942
  sourcefile: filename,
2207
2943
  });
2208
2944
 
2945
+ // Redirect bare npm imports to esm.sh CDN
2946
+ const codeWithCdnImports = this.redirectNpmImports(result.code);
2947
+
2209
2948
  // Add React Refresh registration for JSX/TSX files
2210
2949
  if (/\.(jsx|tsx)$/.test(filename)) {
2211
- return this.addReactRefresh(result.code, filename);
2950
+ return this.addReactRefresh(codeWithCdnImports, filename);
2212
2951
  }
2213
2952
 
2214
- 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
+ });
2215
3019
  }
2216
3020
 
2217
3021
  /**
@@ -2221,13 +3025,18 @@ export class NextDevServer extends DevServer {
2221
3025
  private stripCssImports(code: string): string {
2222
3026
  // Match import statements for CSS files (with or without semicolon)
2223
3027
  // 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>)');
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, '');
2225
3031
  }
2226
3032
 
2227
3033
  /**
2228
3034
  * Transform API handler code to CommonJS for eval execution
2229
3035
  */
2230
3036
  private async transformApiHandler(code: string, filename: string): Promise<string> {
3037
+ // Resolve path aliases first
3038
+ const codeWithResolvedAliases = this.resolvePathAliases(code, filename);
3039
+
2231
3040
  if (isBrowser) {
2232
3041
  // Use esbuild in browser
2233
3042
  await initEsbuild();
@@ -2242,7 +3051,7 @@ export class NextDevServer extends DevServer {
2242
3051
  else if (filename.endsWith('.tsx')) loader = 'tsx';
2243
3052
  else if (filename.endsWith('.ts')) loader = 'ts';
2244
3053
 
2245
- const result = await esbuild.transform(code, {
3054
+ const result = await esbuild.transform(codeWithResolvedAliases, {
2246
3055
  loader,
2247
3056
  format: 'cjs', // CommonJS for eval execution
2248
3057
  target: 'esnext',
@@ -2254,7 +3063,7 @@ export class NextDevServer extends DevServer {
2254
3063
  }
2255
3064
 
2256
3065
  // Simple ESM to CJS transform for Node.js/test environment
2257
- let transformed = code;
3066
+ let transformed = codeWithResolvedAliases;
2258
3067
 
2259
3068
  // Convert: import X from 'Y' -> const X = require('Y')
2260
3069
  transformed = transformed.replace(
@@ -2420,6 +3229,76 @@ ${registrations}
2420
3229
  }
2421
3230
  }
2422
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
+
2423
3302
  /**
2424
3303
  * Stop the server
2425
3304
  */