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