almostnode 0.1.0
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 +21 -0
- package/README.md +731 -0
- package/dist/__sw__.js +394 -0
- package/dist/ai-chatbot-demo-entry.d.ts +6 -0
- package/dist/ai-chatbot-demo-entry.d.ts.map +1 -0
- package/dist/ai-chatbot-demo.d.ts +42 -0
- package/dist/ai-chatbot-demo.d.ts.map +1 -0
- package/dist/assets/runtime-worker-D9x_Ddwz.js +60543 -0
- package/dist/assets/runtime-worker-D9x_Ddwz.js.map +1 -0
- package/dist/convex-app-demo-entry.d.ts +6 -0
- package/dist/convex-app-demo-entry.d.ts.map +1 -0
- package/dist/convex-app-demo.d.ts +68 -0
- package/dist/convex-app-demo.d.ts.map +1 -0
- package/dist/cors-proxy.d.ts +46 -0
- package/dist/cors-proxy.d.ts.map +1 -0
- package/dist/create-runtime.d.ts +42 -0
- package/dist/create-runtime.d.ts.map +1 -0
- package/dist/demo.d.ts +6 -0
- package/dist/demo.d.ts.map +1 -0
- package/dist/dev-server.d.ts +97 -0
- package/dist/dev-server.d.ts.map +1 -0
- package/dist/frameworks/next-dev-server.d.ts +202 -0
- package/dist/frameworks/next-dev-server.d.ts.map +1 -0
- package/dist/frameworks/vite-dev-server.d.ts +85 -0
- package/dist/frameworks/vite-dev-server.d.ts.map +1 -0
- package/dist/index.cjs +14965 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +14867 -0
- package/dist/index.mjs.map +1 -0
- package/dist/next-demo.d.ts +49 -0
- package/dist/next-demo.d.ts.map +1 -0
- package/dist/npm/index.d.ts +71 -0
- package/dist/npm/index.d.ts.map +1 -0
- package/dist/npm/registry.d.ts +66 -0
- package/dist/npm/registry.d.ts.map +1 -0
- package/dist/npm/resolver.d.ts +52 -0
- package/dist/npm/resolver.d.ts.map +1 -0
- package/dist/npm/tarball.d.ts +29 -0
- package/dist/npm/tarball.d.ts.map +1 -0
- package/dist/runtime-interface.d.ts +90 -0
- package/dist/runtime-interface.d.ts.map +1 -0
- package/dist/runtime.d.ts +103 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/sandbox-helpers.d.ts +43 -0
- package/dist/sandbox-helpers.d.ts.map +1 -0
- package/dist/sandbox-runtime.d.ts +65 -0
- package/dist/sandbox-runtime.d.ts.map +1 -0
- package/dist/server-bridge.d.ts +89 -0
- package/dist/server-bridge.d.ts.map +1 -0
- package/dist/shims/assert.d.ts +51 -0
- package/dist/shims/assert.d.ts.map +1 -0
- package/dist/shims/async_hooks.d.ts +37 -0
- package/dist/shims/async_hooks.d.ts.map +1 -0
- package/dist/shims/buffer.d.ts +20 -0
- package/dist/shims/buffer.d.ts.map +1 -0
- package/dist/shims/child_process-browser.d.ts +92 -0
- package/dist/shims/child_process-browser.d.ts.map +1 -0
- package/dist/shims/child_process.d.ts +93 -0
- package/dist/shims/child_process.d.ts.map +1 -0
- package/dist/shims/chokidar.d.ts +55 -0
- package/dist/shims/chokidar.d.ts.map +1 -0
- package/dist/shims/cluster.d.ts +52 -0
- package/dist/shims/cluster.d.ts.map +1 -0
- package/dist/shims/crypto.d.ts +122 -0
- package/dist/shims/crypto.d.ts.map +1 -0
- package/dist/shims/dgram.d.ts +34 -0
- package/dist/shims/dgram.d.ts.map +1 -0
- package/dist/shims/diagnostics_channel.d.ts +80 -0
- package/dist/shims/diagnostics_channel.d.ts.map +1 -0
- package/dist/shims/dns.d.ts +87 -0
- package/dist/shims/dns.d.ts.map +1 -0
- package/dist/shims/domain.d.ts +25 -0
- package/dist/shims/domain.d.ts.map +1 -0
- package/dist/shims/esbuild.d.ts +105 -0
- package/dist/shims/esbuild.d.ts.map +1 -0
- package/dist/shims/events.d.ts +37 -0
- package/dist/shims/events.d.ts.map +1 -0
- package/dist/shims/fs.d.ts +115 -0
- package/dist/shims/fs.d.ts.map +1 -0
- package/dist/shims/fsevents.d.ts +67 -0
- package/dist/shims/fsevents.d.ts.map +1 -0
- package/dist/shims/http.d.ts +217 -0
- package/dist/shims/http.d.ts.map +1 -0
- package/dist/shims/http2.d.ts +81 -0
- package/dist/shims/http2.d.ts.map +1 -0
- package/dist/shims/https.d.ts +36 -0
- package/dist/shims/https.d.ts.map +1 -0
- package/dist/shims/inspector.d.ts +25 -0
- package/dist/shims/inspector.d.ts.map +1 -0
- package/dist/shims/module.d.ts +22 -0
- package/dist/shims/module.d.ts.map +1 -0
- package/dist/shims/net.d.ts +100 -0
- package/dist/shims/net.d.ts.map +1 -0
- package/dist/shims/os.d.ts +159 -0
- package/dist/shims/os.d.ts.map +1 -0
- package/dist/shims/path.d.ts +72 -0
- package/dist/shims/path.d.ts.map +1 -0
- package/dist/shims/perf_hooks.d.ts +50 -0
- package/dist/shims/perf_hooks.d.ts.map +1 -0
- package/dist/shims/process.d.ts +93 -0
- package/dist/shims/process.d.ts.map +1 -0
- package/dist/shims/querystring.d.ts +23 -0
- package/dist/shims/querystring.d.ts.map +1 -0
- package/dist/shims/readdirp.d.ts +52 -0
- package/dist/shims/readdirp.d.ts.map +1 -0
- package/dist/shims/readline.d.ts +62 -0
- package/dist/shims/readline.d.ts.map +1 -0
- package/dist/shims/rollup.d.ts +34 -0
- package/dist/shims/rollup.d.ts.map +1 -0
- package/dist/shims/sentry.d.ts +163 -0
- package/dist/shims/sentry.d.ts.map +1 -0
- package/dist/shims/stream.d.ts +181 -0
- package/dist/shims/stream.d.ts.map +1 -0
- package/dist/shims/tls.d.ts +53 -0
- package/dist/shims/tls.d.ts.map +1 -0
- package/dist/shims/tty.d.ts +30 -0
- package/dist/shims/tty.d.ts.map +1 -0
- package/dist/shims/url.d.ts +64 -0
- package/dist/shims/url.d.ts.map +1 -0
- package/dist/shims/util.d.ts +106 -0
- package/dist/shims/util.d.ts.map +1 -0
- package/dist/shims/v8.d.ts +73 -0
- package/dist/shims/v8.d.ts.map +1 -0
- package/dist/shims/vfs-adapter.d.ts +126 -0
- package/dist/shims/vfs-adapter.d.ts.map +1 -0
- package/dist/shims/vm.d.ts +45 -0
- package/dist/shims/vm.d.ts.map +1 -0
- package/dist/shims/worker_threads.d.ts +66 -0
- package/dist/shims/worker_threads.d.ts.map +1 -0
- package/dist/shims/ws.d.ts +66 -0
- package/dist/shims/ws.d.ts.map +1 -0
- package/dist/shims/zlib.d.ts +161 -0
- package/dist/shims/zlib.d.ts.map +1 -0
- package/dist/transform.d.ts +24 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/virtual-fs.d.ts +226 -0
- package/dist/virtual-fs.d.ts.map +1 -0
- package/dist/vite-demo.d.ts +35 -0
- package/dist/vite-demo.d.ts.map +1 -0
- package/dist/vite-sw.js +132 -0
- package/dist/worker/runtime-worker.d.ts +8 -0
- package/dist/worker/runtime-worker.d.ts.map +1 -0
- package/dist/worker-runtime.d.ts +50 -0
- package/dist/worker-runtime.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/ai-chatbot-demo-entry.ts +244 -0
- package/src/ai-chatbot-demo.ts +509 -0
- package/src/convex-app-demo-entry.ts +1107 -0
- package/src/convex-app-demo.ts +1316 -0
- package/src/cors-proxy.ts +81 -0
- package/src/create-runtime.ts +147 -0
- package/src/demo.ts +304 -0
- package/src/dev-server.ts +274 -0
- package/src/frameworks/next-dev-server.ts +2224 -0
- package/src/frameworks/vite-dev-server.ts +702 -0
- package/src/index.ts +101 -0
- package/src/next-demo.ts +1784 -0
- package/src/npm/index.ts +347 -0
- package/src/npm/registry.ts +152 -0
- package/src/npm/resolver.ts +385 -0
- package/src/npm/tarball.ts +209 -0
- package/src/runtime-interface.ts +103 -0
- package/src/runtime.ts +1046 -0
- package/src/sandbox-helpers.ts +173 -0
- package/src/sandbox-runtime.ts +252 -0
- package/src/server-bridge.ts +426 -0
- package/src/shims/assert.ts +664 -0
- package/src/shims/async_hooks.ts +86 -0
- package/src/shims/buffer.ts +75 -0
- package/src/shims/child_process-browser.ts +217 -0
- package/src/shims/child_process.ts +463 -0
- package/src/shims/chokidar.ts +313 -0
- package/src/shims/cluster.ts +67 -0
- package/src/shims/crypto.ts +830 -0
- package/src/shims/dgram.ts +47 -0
- package/src/shims/diagnostics_channel.ts +196 -0
- package/src/shims/dns.ts +172 -0
- package/src/shims/domain.ts +58 -0
- package/src/shims/esbuild.ts +805 -0
- package/src/shims/events.ts +195 -0
- package/src/shims/fs.ts +803 -0
- package/src/shims/fsevents.ts +63 -0
- package/src/shims/http.ts +904 -0
- package/src/shims/http2.ts +96 -0
- package/src/shims/https.ts +86 -0
- package/src/shims/inspector.ts +30 -0
- package/src/shims/module.ts +82 -0
- package/src/shims/net.ts +359 -0
- package/src/shims/os.ts +195 -0
- package/src/shims/path.ts +199 -0
- package/src/shims/perf_hooks.ts +92 -0
- package/src/shims/process.ts +346 -0
- package/src/shims/querystring.ts +97 -0
- package/src/shims/readdirp.ts +228 -0
- package/src/shims/readline.ts +110 -0
- package/src/shims/rollup.ts +80 -0
- package/src/shims/sentry.ts +133 -0
- package/src/shims/stream.ts +1126 -0
- package/src/shims/tls.ts +95 -0
- package/src/shims/tty.ts +64 -0
- package/src/shims/url.ts +171 -0
- package/src/shims/util.ts +312 -0
- package/src/shims/v8.ts +113 -0
- package/src/shims/vfs-adapter.ts +402 -0
- package/src/shims/vm.ts +83 -0
- package/src/shims/worker_threads.ts +111 -0
- package/src/shims/ws.ts +382 -0
- package/src/shims/zlib.ts +289 -0
- package/src/transform.ts +313 -0
- package/src/types/external.d.ts +67 -0
- package/src/virtual-fs.ts +903 -0
- package/src/vite-demo.ts +577 -0
- package/src/worker/runtime-worker.ts +128 -0
- package/src/worker-runtime.ts +145 -0
|
@@ -0,0 +1,2224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NextDevServer - Next.js-compatible dev server for browser environment
|
|
3
|
+
* Implements file-based routing, API routes, and HMR
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DevServer, DevServerOptions, ResponseData, HMRUpdate } from '../dev-server';
|
|
7
|
+
import { VirtualFS } from '../virtual-fs';
|
|
8
|
+
import { Buffer } from '../shims/stream';
|
|
9
|
+
|
|
10
|
+
// Check if we're in a real browser environment (not jsdom or Node.js)
|
|
11
|
+
const isBrowser = typeof window !== 'undefined' &&
|
|
12
|
+
typeof window.navigator !== 'undefined' &&
|
|
13
|
+
'serviceWorker' in window.navigator;
|
|
14
|
+
|
|
15
|
+
// Window.__esbuild type is declared in src/types/external.d.ts
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initialize esbuild-wasm for browser transforms
|
|
19
|
+
*/
|
|
20
|
+
async function initEsbuild(): Promise<void> {
|
|
21
|
+
if (!isBrowser) return;
|
|
22
|
+
|
|
23
|
+
if (window.__esbuild) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (window.__esbuildInitPromise) {
|
|
28
|
+
return window.__esbuildInitPromise;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
window.__esbuildInitPromise = (async () => {
|
|
32
|
+
try {
|
|
33
|
+
const mod = await import(
|
|
34
|
+
/* @vite-ignore */
|
|
35
|
+
'https://esm.sh/esbuild-wasm@0.20.0'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const esbuildMod = mod.default || mod;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await esbuildMod.initialize({
|
|
42
|
+
wasmURL: 'https://unpkg.com/esbuild-wasm@0.20.0/esbuild.wasm',
|
|
43
|
+
});
|
|
44
|
+
console.log('[NextDevServer] esbuild-wasm initialized');
|
|
45
|
+
} catch (initError) {
|
|
46
|
+
if (initError instanceof Error && initError.message.includes('Cannot call "initialize" more than once')) {
|
|
47
|
+
console.log('[NextDevServer] esbuild-wasm already initialized, reusing');
|
|
48
|
+
} else {
|
|
49
|
+
throw initError;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
window.__esbuild = esbuildMod;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('[NextDevServer] Failed to initialize esbuild:', error);
|
|
56
|
+
window.__esbuildInitPromise = undefined;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
return window.__esbuildInitPromise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getEsbuild(): typeof import('esbuild-wasm') | undefined {
|
|
65
|
+
return isBrowser ? window.__esbuild : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface NextDevServerOptions extends DevServerOptions {
|
|
69
|
+
/** Pages directory (default: '/pages') */
|
|
70
|
+
pagesDir?: string;
|
|
71
|
+
/** App directory for App Router (default: '/app') */
|
|
72
|
+
appDir?: string;
|
|
73
|
+
/** Public directory for static assets (default: '/public') */
|
|
74
|
+
publicDir?: string;
|
|
75
|
+
/** Prefer App Router over Pages Router (default: auto-detect) */
|
|
76
|
+
preferAppRouter?: boolean;
|
|
77
|
+
/** Environment variables (NEXT_PUBLIC_* are available in browser code via process.env) */
|
|
78
|
+
env?: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Tailwind CSS CDN script for runtime JIT compilation
|
|
83
|
+
*/
|
|
84
|
+
const TAILWIND_CDN_SCRIPT = `<script src="https://cdn.tailwindcss.com"></script>`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* CORS Proxy script - provides proxyFetch function in the iframe
|
|
88
|
+
* Reads proxy URL from localStorage (set by parent window)
|
|
89
|
+
*/
|
|
90
|
+
const CORS_PROXY_SCRIPT = `
|
|
91
|
+
<script>
|
|
92
|
+
// CORS Proxy support for external API calls
|
|
93
|
+
window.__getCorsProxy = function() {
|
|
94
|
+
return localStorage.getItem('__corsProxyUrl') || null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
window.__setCorsProxy = function(url) {
|
|
98
|
+
if (url) {
|
|
99
|
+
localStorage.setItem('__corsProxyUrl', url);
|
|
100
|
+
} else {
|
|
101
|
+
localStorage.removeItem('__corsProxyUrl');
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
window.__proxyFetch = async function(url, options) {
|
|
106
|
+
const proxyUrl = window.__getCorsProxy();
|
|
107
|
+
if (proxyUrl) {
|
|
108
|
+
const proxiedUrl = proxyUrl + encodeURIComponent(url);
|
|
109
|
+
return fetch(proxiedUrl, options);
|
|
110
|
+
}
|
|
111
|
+
return fetch(url, options);
|
|
112
|
+
};
|
|
113
|
+
</script>
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* React Refresh preamble - MUST run before React is loaded
|
|
118
|
+
*/
|
|
119
|
+
const REACT_REFRESH_PREAMBLE = `
|
|
120
|
+
<script type="module">
|
|
121
|
+
// Block until React Refresh is loaded and initialized
|
|
122
|
+
const RefreshRuntime = await import('https://esm.sh/react-refresh@0.14.0/runtime').then(m => m.default || m);
|
|
123
|
+
|
|
124
|
+
RefreshRuntime.injectIntoGlobalHook(window);
|
|
125
|
+
window.$RefreshRuntime$ = RefreshRuntime;
|
|
126
|
+
window.$RefreshRegCount$ = 0;
|
|
127
|
+
|
|
128
|
+
window.$RefreshReg$ = (type, id) => {
|
|
129
|
+
window.$RefreshRegCount$++;
|
|
130
|
+
RefreshRuntime.register(type, id);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
window.$RefreshSig$ = () => (type) => type;
|
|
134
|
+
|
|
135
|
+
console.log('[HMR] React Refresh initialized');
|
|
136
|
+
</script>
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* HMR client script for Next.js
|
|
141
|
+
*/
|
|
142
|
+
const HMR_CLIENT_SCRIPT = `
|
|
143
|
+
<script type="module">
|
|
144
|
+
(function() {
|
|
145
|
+
const hotModules = new Map();
|
|
146
|
+
const pendingUpdates = new Map();
|
|
147
|
+
|
|
148
|
+
window.__vite_hot_context__ = function createHotContext(ownerPath) {
|
|
149
|
+
if (hotModules.has(ownerPath)) {
|
|
150
|
+
return hotModules.get(ownerPath);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const hot = {
|
|
154
|
+
data: {},
|
|
155
|
+
accept(callback) {
|
|
156
|
+
hot._acceptCallback = callback;
|
|
157
|
+
},
|
|
158
|
+
dispose(callback) {
|
|
159
|
+
hot._disposeCallback = callback;
|
|
160
|
+
},
|
|
161
|
+
invalidate() {
|
|
162
|
+
location.reload();
|
|
163
|
+
},
|
|
164
|
+
prune(callback) {
|
|
165
|
+
hot._pruneCallback = callback;
|
|
166
|
+
},
|
|
167
|
+
on(event, cb) {},
|
|
168
|
+
off(event, cb) {},
|
|
169
|
+
send(event, data) {},
|
|
170
|
+
_acceptCallback: null,
|
|
171
|
+
_disposeCallback: null,
|
|
172
|
+
_pruneCallback: null,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
hotModules.set(ownerPath, hot);
|
|
176
|
+
return hot;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Listen for HMR updates via postMessage (works with sandboxed iframes)
|
|
180
|
+
window.addEventListener('message', async (event) => {
|
|
181
|
+
// Filter for HMR messages only
|
|
182
|
+
if (!event.data || event.data.channel !== 'next-hmr') return;
|
|
183
|
+
const { type, path, timestamp } = event.data;
|
|
184
|
+
|
|
185
|
+
if (type === 'update') {
|
|
186
|
+
console.log('[HMR] Update:', path);
|
|
187
|
+
|
|
188
|
+
if (path.endsWith('.css')) {
|
|
189
|
+
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
190
|
+
links.forEach(link => {
|
|
191
|
+
const href = link.getAttribute('href');
|
|
192
|
+
if (href && href.includes(path.replace(/^\\//, ''))) {
|
|
193
|
+
link.href = href.split('?')[0] + '?t=' + timestamp;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const styles = document.querySelectorAll('style[data-next-dev-id]');
|
|
198
|
+
styles.forEach(style => {
|
|
199
|
+
const id = style.getAttribute('data-next-dev-id');
|
|
200
|
+
if (id && id.includes(path.replace(/^\\//, ''))) {
|
|
201
|
+
import(path + '?t=' + timestamp).catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
} else if (path.match(/\\.(jsx?|tsx?)$/)) {
|
|
205
|
+
await handleJSUpdate(path, timestamp);
|
|
206
|
+
}
|
|
207
|
+
} else if (type === 'full-reload') {
|
|
208
|
+
console.log('[HMR] Full reload');
|
|
209
|
+
location.reload();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
async function handleJSUpdate(path, timestamp) {
|
|
214
|
+
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
|
215
|
+
const hot = hotModules.get(normalizedPath);
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
if (hot && hot._disposeCallback) {
|
|
219
|
+
hot._disposeCallback(hot.data);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (window.$RefreshRuntime$) {
|
|
223
|
+
pendingUpdates.set(normalizedPath, timestamp);
|
|
224
|
+
|
|
225
|
+
if (pendingUpdates.size === 1) {
|
|
226
|
+
setTimeout(async () => {
|
|
227
|
+
try {
|
|
228
|
+
for (const [modulePath, ts] of pendingUpdates) {
|
|
229
|
+
const moduleUrl = '.' + modulePath + '?t=' + ts;
|
|
230
|
+
await import(moduleUrl);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
window.$RefreshRuntime$.performReactRefresh();
|
|
234
|
+
console.log('[HMR] Updated', pendingUpdates.size, 'module(s)');
|
|
235
|
+
|
|
236
|
+
pendingUpdates.clear();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.error('[HMR] Failed to apply update:', error);
|
|
239
|
+
pendingUpdates.clear();
|
|
240
|
+
location.reload();
|
|
241
|
+
}
|
|
242
|
+
}, 30);
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
console.log('[HMR] React Refresh not available, reloading page');
|
|
246
|
+
location.reload();
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error('[HMR] Update failed:', error);
|
|
250
|
+
location.reload();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
console.log('[HMR] Next.js client ready');
|
|
255
|
+
})();
|
|
256
|
+
</script>
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Next.js Link shim code
|
|
261
|
+
*/
|
|
262
|
+
const NEXT_LINK_SHIM = `
|
|
263
|
+
import React from 'react';
|
|
264
|
+
|
|
265
|
+
const getVirtualBasePath = () => {
|
|
266
|
+
const match = window.location.pathname.match(/^\\/__virtual__\\/\\d+(?:\\/|$)/);
|
|
267
|
+
if (!match) return '';
|
|
268
|
+
return match[0].endsWith('/') ? match[0] : match[0] + '/';
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const applyVirtualBase = (url) => {
|
|
272
|
+
if (typeof url !== 'string') return url;
|
|
273
|
+
if (!url || url.startsWith('#') || url.startsWith('?')) return url;
|
|
274
|
+
if (/^(https?:)?\\/\\//.test(url)) return url;
|
|
275
|
+
|
|
276
|
+
const base = getVirtualBasePath();
|
|
277
|
+
if (!base) return url;
|
|
278
|
+
if (url.startsWith(base)) return url;
|
|
279
|
+
if (url.startsWith('/')) return base + url.slice(1);
|
|
280
|
+
return base + url;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export default function Link({ href, children, ...props }) {
|
|
284
|
+
const handleClick = (e) => {
|
|
285
|
+
if (props.onClick) {
|
|
286
|
+
props.onClick(e);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Allow cmd/ctrl click to open in new tab
|
|
290
|
+
if (e.metaKey || e.ctrlKey) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (typeof href !== 'string' || !href || href.startsWith('#') || href.startsWith('?')) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (/^(https?:)?\\/\\//.test(href)) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
const resolvedHref = applyVirtualBase(href);
|
|
304
|
+
window.history.pushState({}, '', resolvedHref);
|
|
305
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
return React.createElement('a', { href, onClick: handleClick, ...props }, children);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export { Link };
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Next.js Router shim code
|
|
316
|
+
*/
|
|
317
|
+
const NEXT_ROUTER_SHIM = `
|
|
318
|
+
import React, { useState, useEffect, createContext, useContext } from 'react';
|
|
319
|
+
|
|
320
|
+
const RouterContext = createContext(null);
|
|
321
|
+
|
|
322
|
+
const getVirtualBasePath = () => {
|
|
323
|
+
const match = window.location.pathname.match(/^\\/__virtual__\\/\\d+(?:\\/|$)/);
|
|
324
|
+
if (!match) return '';
|
|
325
|
+
return match[0].endsWith('/') ? match[0] : match[0] + '/';
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const applyVirtualBase = (url) => {
|
|
329
|
+
if (typeof url !== 'string') return url;
|
|
330
|
+
if (!url || url.startsWith('#') || url.startsWith('?')) return url;
|
|
331
|
+
if (/^(https?:)?\\/\\//.test(url)) return url;
|
|
332
|
+
|
|
333
|
+
const base = getVirtualBasePath();
|
|
334
|
+
if (!base) return url;
|
|
335
|
+
if (url.startsWith(base)) return url;
|
|
336
|
+
if (url.startsWith('/')) return base + url.slice(1);
|
|
337
|
+
return base + url;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const stripVirtualBase = (pathname) => {
|
|
341
|
+
const match = pathname.match(/^\\/__virtual__\\/\\d+(?:\\/|$)/);
|
|
342
|
+
if (!match) return pathname;
|
|
343
|
+
return '/' + pathname.slice(match[0].length);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
export function useRouter() {
|
|
347
|
+
const [pathname, setPathname] = useState(
|
|
348
|
+
typeof window !== 'undefined' ? stripVirtualBase(window.location.pathname) : '/'
|
|
349
|
+
);
|
|
350
|
+
const [query, setQuery] = useState({});
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
const updateRoute = () => {
|
|
354
|
+
setPathname(stripVirtualBase(window.location.pathname));
|
|
355
|
+
setQuery(Object.fromEntries(new URLSearchParams(window.location.search)));
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
window.addEventListener('popstate', updateRoute);
|
|
359
|
+
updateRoute();
|
|
360
|
+
|
|
361
|
+
return () => window.removeEventListener('popstate', updateRoute);
|
|
362
|
+
}, []);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
pathname,
|
|
366
|
+
query,
|
|
367
|
+
asPath: pathname + window.location.search,
|
|
368
|
+
push: (url, as, options) => {
|
|
369
|
+
if (typeof url === 'string' && /^(https?:)?\\/\\//.test(url)) {
|
|
370
|
+
window.location.href = url;
|
|
371
|
+
return Promise.resolve(true);
|
|
372
|
+
}
|
|
373
|
+
const resolvedUrl = applyVirtualBase(url);
|
|
374
|
+
window.history.pushState({}, '', resolvedUrl);
|
|
375
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
376
|
+
return Promise.resolve(true);
|
|
377
|
+
},
|
|
378
|
+
replace: (url, as, options) => {
|
|
379
|
+
if (typeof url === 'string' && /^(https?:)?\\/\\//.test(url)) {
|
|
380
|
+
window.location.href = url;
|
|
381
|
+
return Promise.resolve(true);
|
|
382
|
+
}
|
|
383
|
+
const resolvedUrl = applyVirtualBase(url);
|
|
384
|
+
window.history.replaceState({}, '', resolvedUrl);
|
|
385
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
386
|
+
return Promise.resolve(true);
|
|
387
|
+
},
|
|
388
|
+
prefetch: () => Promise.resolve(),
|
|
389
|
+
back: () => window.history.back(),
|
|
390
|
+
forward: () => window.history.forward(),
|
|
391
|
+
reload: () => window.location.reload(),
|
|
392
|
+
events: {
|
|
393
|
+
on: () => {},
|
|
394
|
+
off: () => {},
|
|
395
|
+
emit: () => {},
|
|
396
|
+
},
|
|
397
|
+
isFallback: false,
|
|
398
|
+
isReady: true,
|
|
399
|
+
isPreview: false,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export const Router = {
|
|
404
|
+
events: {
|
|
405
|
+
on: () => {},
|
|
406
|
+
off: () => {},
|
|
407
|
+
emit: () => {},
|
|
408
|
+
},
|
|
409
|
+
push: (url) => {
|
|
410
|
+
if (typeof url === 'string' && /^(https?:)?\\/\\//.test(url)) {
|
|
411
|
+
window.location.href = url;
|
|
412
|
+
return Promise.resolve(true);
|
|
413
|
+
}
|
|
414
|
+
const resolvedUrl = applyVirtualBase(url);
|
|
415
|
+
window.history.pushState({}, '', resolvedUrl);
|
|
416
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
417
|
+
return Promise.resolve(true);
|
|
418
|
+
},
|
|
419
|
+
replace: (url) => {
|
|
420
|
+
if (typeof url === 'string' && /^(https?:)?\\/\\//.test(url)) {
|
|
421
|
+
window.location.href = url;
|
|
422
|
+
return Promise.resolve(true);
|
|
423
|
+
}
|
|
424
|
+
const resolvedUrl = applyVirtualBase(url);
|
|
425
|
+
window.history.replaceState({}, '', resolvedUrl);
|
|
426
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
427
|
+
return Promise.resolve(true);
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
export default { useRouter, Router };
|
|
432
|
+
`;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Next.js Navigation shim code (App Router)
|
|
436
|
+
*
|
|
437
|
+
* This shim provides App Router-specific navigation hooks from 'next/navigation'.
|
|
438
|
+
* These are DIFFERENT from the Pages Router hooks in 'next/router':
|
|
439
|
+
*
|
|
440
|
+
* Pages Router (next/router):
|
|
441
|
+
* - useRouter() returns { pathname, query, push, replace, events, ... }
|
|
442
|
+
* - Has router.events for route change subscriptions
|
|
443
|
+
* - query object contains URL params
|
|
444
|
+
*
|
|
445
|
+
* App Router (next/navigation):
|
|
446
|
+
* - useRouter() returns { push, replace, back, forward, refresh, prefetch }
|
|
447
|
+
* - usePathname() for current path
|
|
448
|
+
* - useSearchParams() for URL search params
|
|
449
|
+
* - useParams() for dynamic route segments
|
|
450
|
+
* - No events - use useEffect with pathname/searchParams instead
|
|
451
|
+
*
|
|
452
|
+
* @see https://nextjs.org/docs/app/api-reference/functions/use-router
|
|
453
|
+
*/
|
|
454
|
+
const NEXT_NAVIGATION_SHIM = `
|
|
455
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
456
|
+
|
|
457
|
+
const getVirtualBasePath = () => {
|
|
458
|
+
const match = window.location.pathname.match(/^\\/__virtual__\\/\\d+(?:\\/|$)/);
|
|
459
|
+
if (!match) return '';
|
|
460
|
+
return match[0].endsWith('/') ? match[0] : match[0] + '/';
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const applyVirtualBase = (url) => {
|
|
464
|
+
if (typeof url !== 'string') return url;
|
|
465
|
+
if (!url || url.startsWith('#') || url.startsWith('?')) return url;
|
|
466
|
+
if (/^(https?:)?\\/\\//.test(url)) return url;
|
|
467
|
+
|
|
468
|
+
const base = getVirtualBasePath();
|
|
469
|
+
if (!base) return url;
|
|
470
|
+
if (url.startsWith(base)) return url;
|
|
471
|
+
if (url.startsWith('/')) return base + url.slice(1);
|
|
472
|
+
return base + url;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const stripVirtualBase = (pathname) => {
|
|
476
|
+
const match = pathname.match(/^\\/__virtual__\\/\\d+(?:\\/|$)/);
|
|
477
|
+
if (!match) return pathname;
|
|
478
|
+
return '/' + pathname.slice(match[0].length);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* App Router's useRouter hook
|
|
483
|
+
* Returns navigation methods only (no pathname, no query)
|
|
484
|
+
* Use usePathname() and useSearchParams() for URL info
|
|
485
|
+
*/
|
|
486
|
+
export function useRouter() {
|
|
487
|
+
const push = useCallback((url, options) => {
|
|
488
|
+
if (typeof url === 'string' && /^(https?:)?\\/\\//.test(url)) {
|
|
489
|
+
window.location.href = url;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const resolvedUrl = applyVirtualBase(url);
|
|
493
|
+
window.history.pushState({}, '', resolvedUrl);
|
|
494
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
495
|
+
}, []);
|
|
496
|
+
|
|
497
|
+
const replace = useCallback((url, options) => {
|
|
498
|
+
if (typeof url === 'string' && /^(https?:)?\\/\\//.test(url)) {
|
|
499
|
+
window.location.href = url;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const resolvedUrl = applyVirtualBase(url);
|
|
503
|
+
window.history.replaceState({}, '', resolvedUrl);
|
|
504
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
505
|
+
}, []);
|
|
506
|
+
|
|
507
|
+
const back = useCallback(() => window.history.back(), []);
|
|
508
|
+
const forward = useCallback(() => window.history.forward(), []);
|
|
509
|
+
const refresh = useCallback(() => window.location.reload(), []);
|
|
510
|
+
const prefetch = useCallback(() => Promise.resolve(), []);
|
|
511
|
+
|
|
512
|
+
return useMemo(() => ({
|
|
513
|
+
push,
|
|
514
|
+
replace,
|
|
515
|
+
back,
|
|
516
|
+
forward,
|
|
517
|
+
refresh,
|
|
518
|
+
prefetch,
|
|
519
|
+
}), [push, replace, back, forward, refresh, prefetch]);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* usePathname - Returns the current URL pathname
|
|
524
|
+
* Reactively updates when navigation occurs
|
|
525
|
+
* @example const pathname = usePathname(); // '/dashboard/settings'
|
|
526
|
+
*/
|
|
527
|
+
export function usePathname() {
|
|
528
|
+
const [pathname, setPathname] = useState(
|
|
529
|
+
typeof window !== 'undefined' ? stripVirtualBase(window.location.pathname) : '/'
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
const handler = () => setPathname(stripVirtualBase(window.location.pathname));
|
|
534
|
+
window.addEventListener('popstate', handler);
|
|
535
|
+
return () => window.removeEventListener('popstate', handler);
|
|
536
|
+
}, []);
|
|
537
|
+
|
|
538
|
+
return pathname;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* useSearchParams - Returns the current URL search parameters
|
|
543
|
+
* @example const searchParams = useSearchParams();
|
|
544
|
+
* const query = searchParams.get('q'); // '?q=hello' -> 'hello'
|
|
545
|
+
*/
|
|
546
|
+
export function useSearchParams() {
|
|
547
|
+
const [searchParams, setSearchParams] = useState(() => {
|
|
548
|
+
if (typeof window === 'undefined') return new URLSearchParams();
|
|
549
|
+
return new URLSearchParams(window.location.search);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
const handler = () => {
|
|
554
|
+
setSearchParams(new URLSearchParams(window.location.search));
|
|
555
|
+
};
|
|
556
|
+
window.addEventListener('popstate', handler);
|
|
557
|
+
return () => window.removeEventListener('popstate', handler);
|
|
558
|
+
}, []);
|
|
559
|
+
|
|
560
|
+
return searchParams;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* useParams - Returns dynamic route parameters
|
|
565
|
+
* For route /users/[id]/page.jsx with URL /users/123:
|
|
566
|
+
* @example const { id } = useParams(); // { id: '123' }
|
|
567
|
+
*
|
|
568
|
+
* NOTE: This simplified implementation returns empty object.
|
|
569
|
+
* Full implementation would need route pattern matching.
|
|
570
|
+
*/
|
|
571
|
+
export function useParams() {
|
|
572
|
+
// In a real implementation, this would parse the current route
|
|
573
|
+
// against the route pattern to extract params
|
|
574
|
+
// For now, return empty object - works for basic cases
|
|
575
|
+
return {};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* useSelectedLayoutSegment - Returns the active child segment one level below
|
|
580
|
+
* Useful for styling active nav items in layouts
|
|
581
|
+
* @example For /dashboard/settings, returns 'settings' in dashboard layout
|
|
582
|
+
*/
|
|
583
|
+
export function useSelectedLayoutSegment() {
|
|
584
|
+
const pathname = usePathname();
|
|
585
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
586
|
+
return segments[0] || null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* useSelectedLayoutSegments - Returns all active child segments
|
|
591
|
+
* @example For /dashboard/settings/profile, returns ['dashboard', 'settings', 'profile']
|
|
592
|
+
*/
|
|
593
|
+
export function useSelectedLayoutSegments() {
|
|
594
|
+
const pathname = usePathname();
|
|
595
|
+
return pathname.split('/').filter(Boolean);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* redirect - Programmatic redirect (typically used in Server Components)
|
|
600
|
+
* In this browser implementation, performs immediate navigation
|
|
601
|
+
*/
|
|
602
|
+
export function redirect(url) {
|
|
603
|
+
if (typeof url === 'string' && /^(https?:)?\\/\\//.test(url)) {
|
|
604
|
+
window.location.href = url;
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
window.location.href = applyVirtualBase(url);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* notFound - Trigger the not-found UI
|
|
612
|
+
* In this browser implementation, throws an error
|
|
613
|
+
*/
|
|
614
|
+
export function notFound() {
|
|
615
|
+
throw new Error('NEXT_NOT_FOUND');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Re-export Link for convenience (can import from next/navigation or next/link)
|
|
619
|
+
export { default as Link } from 'next/link';
|
|
620
|
+
`;
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Next.js Head shim code
|
|
624
|
+
*/
|
|
625
|
+
const NEXT_HEAD_SHIM = `
|
|
626
|
+
import React, { useEffect } from 'react';
|
|
627
|
+
|
|
628
|
+
export default function Head({ children }) {
|
|
629
|
+
useEffect(() => {
|
|
630
|
+
// Process children and update document.head
|
|
631
|
+
React.Children.forEach(children, (child) => {
|
|
632
|
+
if (!React.isValidElement(child)) return;
|
|
633
|
+
|
|
634
|
+
const { type, props } = child;
|
|
635
|
+
|
|
636
|
+
if (type === 'title' && props.children) {
|
|
637
|
+
document.title = Array.isArray(props.children)
|
|
638
|
+
? props.children.join('')
|
|
639
|
+
: props.children;
|
|
640
|
+
} else if (type === 'meta') {
|
|
641
|
+
const existingMeta = props.name
|
|
642
|
+
? document.querySelector(\`meta[name="\${props.name}"]\`)
|
|
643
|
+
: props.property
|
|
644
|
+
? document.querySelector(\`meta[property="\${props.property}"]\`)
|
|
645
|
+
: null;
|
|
646
|
+
|
|
647
|
+
if (existingMeta) {
|
|
648
|
+
Object.keys(props).forEach(key => {
|
|
649
|
+
existingMeta.setAttribute(key, props[key]);
|
|
650
|
+
});
|
|
651
|
+
} else {
|
|
652
|
+
const meta = document.createElement('meta');
|
|
653
|
+
Object.keys(props).forEach(key => {
|
|
654
|
+
meta.setAttribute(key, props[key]);
|
|
655
|
+
});
|
|
656
|
+
document.head.appendChild(meta);
|
|
657
|
+
}
|
|
658
|
+
} else if (type === 'link') {
|
|
659
|
+
const link = document.createElement('link');
|
|
660
|
+
Object.keys(props).forEach(key => {
|
|
661
|
+
link.setAttribute(key, props[key]);
|
|
662
|
+
});
|
|
663
|
+
document.head.appendChild(link);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}, [children]);
|
|
667
|
+
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
`;
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* NextDevServer - A lightweight Next.js-compatible development server
|
|
674
|
+
*
|
|
675
|
+
* Supports both routing paradigms:
|
|
676
|
+
*
|
|
677
|
+
* 1. PAGES ROUTER (legacy, /pages directory):
|
|
678
|
+
* - /pages/index.jsx -> /
|
|
679
|
+
* - /pages/about.jsx -> /about
|
|
680
|
+
* - /pages/users/[id].jsx -> /users/:id (dynamic)
|
|
681
|
+
* - /pages/api/hello.js -> /api/hello (API route)
|
|
682
|
+
* - Uses next/router for navigation
|
|
683
|
+
*
|
|
684
|
+
* 2. APP ROUTER (new, /app directory):
|
|
685
|
+
* - /app/page.jsx -> /
|
|
686
|
+
* - /app/about/page.jsx -> /about
|
|
687
|
+
* - /app/users/[id]/page.jsx -> /users/:id (dynamic)
|
|
688
|
+
* - /app/layout.jsx -> Root layout (wraps all pages)
|
|
689
|
+
* - /app/about/layout.jsx -> Nested layout (wraps /about/*)
|
|
690
|
+
* - Uses next/navigation for navigation
|
|
691
|
+
*
|
|
692
|
+
* The server auto-detects which router to use based on directory existence,
|
|
693
|
+
* preferring App Router if both exist. Can be overridden via options.
|
|
694
|
+
*/
|
|
695
|
+
export class NextDevServer extends DevServer {
|
|
696
|
+
/** Pages Router directory (default: '/pages') */
|
|
697
|
+
private pagesDir: string;
|
|
698
|
+
|
|
699
|
+
/** App Router directory (default: '/app') */
|
|
700
|
+
private appDir: string;
|
|
701
|
+
|
|
702
|
+
/** Static assets directory (default: '/public') */
|
|
703
|
+
private publicDir: string;
|
|
704
|
+
|
|
705
|
+
/** Whether to use App Router (true) or Pages Router (false) */
|
|
706
|
+
private useAppRouter: boolean;
|
|
707
|
+
|
|
708
|
+
/** Cleanup function for file watchers */
|
|
709
|
+
private watcherCleanup: (() => void) | null = null;
|
|
710
|
+
|
|
711
|
+
/** Target window for HMR updates (iframe contentWindow) */
|
|
712
|
+
private hmrTargetWindow: Window | null = null;
|
|
713
|
+
|
|
714
|
+
/** Store options for later access (e.g., env vars) */
|
|
715
|
+
private options: NextDevServerOptions;
|
|
716
|
+
|
|
717
|
+
constructor(vfs: VirtualFS, options: NextDevServerOptions) {
|
|
718
|
+
super(vfs, options);
|
|
719
|
+
this.options = options;
|
|
720
|
+
this.pagesDir = options.pagesDir || '/pages';
|
|
721
|
+
this.appDir = options.appDir || '/app';
|
|
722
|
+
this.publicDir = options.publicDir || '/public';
|
|
723
|
+
|
|
724
|
+
// Auto-detect which router to use based on directory existence
|
|
725
|
+
// User can override with preferAppRouter option
|
|
726
|
+
if (options.preferAppRouter !== undefined) {
|
|
727
|
+
this.useAppRouter = options.preferAppRouter;
|
|
728
|
+
} else {
|
|
729
|
+
// Prefer App Router if /app directory exists with a page.jsx file
|
|
730
|
+
this.useAppRouter = this.hasAppRouter();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Set an environment variable at runtime
|
|
736
|
+
* NEXT_PUBLIC_* variables will be available via process.env in browser code
|
|
737
|
+
*/
|
|
738
|
+
setEnv(key: string, value: string): void {
|
|
739
|
+
this.options.env = this.options.env || {};
|
|
740
|
+
this.options.env[key] = value;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Get current environment variables
|
|
745
|
+
*/
|
|
746
|
+
getEnv(): Record<string, string> {
|
|
747
|
+
return { ...this.options.env };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Set the target window for HMR updates (typically iframe.contentWindow)
|
|
752
|
+
* This enables HMR to work with sandboxed iframes via postMessage
|
|
753
|
+
*/
|
|
754
|
+
setHMRTarget(targetWindow: Window): void {
|
|
755
|
+
this.hmrTargetWindow = targetWindow;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Generate a script tag that defines process.env with NEXT_PUBLIC_* variables
|
|
760
|
+
* This makes environment variables available to browser code via process.env.NEXT_PUBLIC_*
|
|
761
|
+
*/
|
|
762
|
+
private generateEnvScript(): string {
|
|
763
|
+
const env = this.options.env || {};
|
|
764
|
+
|
|
765
|
+
// Filter for NEXT_PUBLIC_* variables only (Next.js convention)
|
|
766
|
+
const publicEnvVars = Object.entries(env)
|
|
767
|
+
.filter(([key]) => key.startsWith('NEXT_PUBLIC_'))
|
|
768
|
+
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {} as Record<string, string>);
|
|
769
|
+
|
|
770
|
+
// Return empty string if no public env vars
|
|
771
|
+
if (Object.keys(publicEnvVars).length === 0) {
|
|
772
|
+
return '';
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return `<script>
|
|
776
|
+
// NEXT_PUBLIC_* environment variables (injected by NextDevServer)
|
|
777
|
+
window.process = window.process || {};
|
|
778
|
+
window.process.env = window.process.env || {};
|
|
779
|
+
Object.assign(window.process.env, ${JSON.stringify(publicEnvVars)});
|
|
780
|
+
</script>`;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Check if App Router is available
|
|
785
|
+
*/
|
|
786
|
+
private hasAppRouter(): boolean {
|
|
787
|
+
try {
|
|
788
|
+
// Check if /app directory exists and has a page file
|
|
789
|
+
if (!this.exists(this.appDir)) return false;
|
|
790
|
+
|
|
791
|
+
// Check for root page
|
|
792
|
+
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
793
|
+
for (const ext of extensions) {
|
|
794
|
+
if (this.exists(`${this.appDir}/page${ext}`)) return true;
|
|
795
|
+
}
|
|
796
|
+
return false;
|
|
797
|
+
} catch {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Handle an incoming HTTP request
|
|
804
|
+
*/
|
|
805
|
+
async handleRequest(
|
|
806
|
+
method: string,
|
|
807
|
+
url: string,
|
|
808
|
+
headers: Record<string, string>,
|
|
809
|
+
body?: Buffer
|
|
810
|
+
): Promise<ResponseData> {
|
|
811
|
+
const urlObj = new URL(url, 'http://localhost');
|
|
812
|
+
const pathname = urlObj.pathname;
|
|
813
|
+
|
|
814
|
+
// Serve Next.js shims
|
|
815
|
+
if (pathname.startsWith('/_next/shims/')) {
|
|
816
|
+
return this.serveNextShim(pathname);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Static assets from /_next/static/*
|
|
820
|
+
if (pathname.startsWith('/_next/static/')) {
|
|
821
|
+
return this.serveStaticAsset(pathname);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// API routes: /api/*
|
|
825
|
+
if (pathname.startsWith('/api/')) {
|
|
826
|
+
return this.handleApiRoute(method, pathname, headers, body);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Public directory files
|
|
830
|
+
const publicPath = this.publicDir + pathname;
|
|
831
|
+
if (this.exists(publicPath) && !this.isDirectory(publicPath)) {
|
|
832
|
+
return this.serveFile(publicPath);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Direct file requests (e.g., /pages/index.jsx for HMR re-imports)
|
|
836
|
+
if (this.needsTransform(pathname) && this.exists(pathname)) {
|
|
837
|
+
return this.transformAndServe(pathname, pathname);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Serve regular files directly if they exist
|
|
841
|
+
if (this.exists(pathname) && !this.isDirectory(pathname)) {
|
|
842
|
+
return this.serveFile(pathname);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Page routes: everything else
|
|
846
|
+
return this.handlePageRoute(pathname, urlObj.search);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Serve Next.js shims (link, router, head, navigation)
|
|
851
|
+
*/
|
|
852
|
+
private serveNextShim(pathname: string): ResponseData {
|
|
853
|
+
const shimName = pathname.replace('/_next/shims/', '').replace('.js', '');
|
|
854
|
+
|
|
855
|
+
let code: string;
|
|
856
|
+
switch (shimName) {
|
|
857
|
+
case 'link':
|
|
858
|
+
code = NEXT_LINK_SHIM;
|
|
859
|
+
break;
|
|
860
|
+
case 'router':
|
|
861
|
+
code = NEXT_ROUTER_SHIM;
|
|
862
|
+
break;
|
|
863
|
+
case 'head':
|
|
864
|
+
code = NEXT_HEAD_SHIM;
|
|
865
|
+
break;
|
|
866
|
+
case 'navigation':
|
|
867
|
+
code = NEXT_NAVIGATION_SHIM;
|
|
868
|
+
break;
|
|
869
|
+
default:
|
|
870
|
+
return this.notFound(pathname);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const buffer = Buffer.from(code);
|
|
874
|
+
return {
|
|
875
|
+
statusCode: 200,
|
|
876
|
+
statusMessage: 'OK',
|
|
877
|
+
headers: {
|
|
878
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
879
|
+
'Content-Length': String(buffer.length),
|
|
880
|
+
'Cache-Control': 'no-cache',
|
|
881
|
+
},
|
|
882
|
+
body: buffer,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Serve static assets from /_next/static/
|
|
888
|
+
*/
|
|
889
|
+
private serveStaticAsset(pathname: string): ResponseData {
|
|
890
|
+
// Map /_next/static/* to actual file location
|
|
891
|
+
const filePath = pathname.replace('/_next/static/', '/');
|
|
892
|
+
if (this.exists(filePath)) {
|
|
893
|
+
return this.serveFile(filePath);
|
|
894
|
+
}
|
|
895
|
+
return this.notFound(pathname);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Handle API route requests
|
|
900
|
+
*/
|
|
901
|
+
private async handleApiRoute(
|
|
902
|
+
method: string,
|
|
903
|
+
pathname: string,
|
|
904
|
+
headers: Record<string, string>,
|
|
905
|
+
body?: Buffer
|
|
906
|
+
): Promise<ResponseData> {
|
|
907
|
+
// Map /api/hello → /pages/api/hello.js or .ts
|
|
908
|
+
const apiFile = this.resolveApiFile(pathname);
|
|
909
|
+
|
|
910
|
+
if (!apiFile) {
|
|
911
|
+
return {
|
|
912
|
+
statusCode: 404,
|
|
913
|
+
statusMessage: 'Not Found',
|
|
914
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
915
|
+
body: Buffer.from(JSON.stringify({ error: 'API route not found' })),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
// Read and transform the API handler to CJS for eval execution
|
|
921
|
+
const code = this.vfs.readFileSync(apiFile, 'utf8');
|
|
922
|
+
const transformed = await this.transformApiHandler(code, apiFile);
|
|
923
|
+
|
|
924
|
+
// Create mock req/res objects
|
|
925
|
+
const req = this.createMockRequest(method, pathname, headers, body);
|
|
926
|
+
const res = this.createMockResponse();
|
|
927
|
+
|
|
928
|
+
// Execute the handler
|
|
929
|
+
await this.executeApiHandler(transformed, req, res);
|
|
930
|
+
|
|
931
|
+
// Wait for async handlers (like those using https.get with callbacks)
|
|
932
|
+
// with a reasonable timeout
|
|
933
|
+
if (!res.isEnded()) {
|
|
934
|
+
const timeout = new Promise<void>((_, reject) => {
|
|
935
|
+
setTimeout(() => reject(new Error('API handler timeout')), 30000);
|
|
936
|
+
});
|
|
937
|
+
await Promise.race([res.waitForEnd(), timeout]);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return res.toResponse();
|
|
941
|
+
} catch (error) {
|
|
942
|
+
console.error('[NextDevServer] API error:', error);
|
|
943
|
+
return {
|
|
944
|
+
statusCode: 500,
|
|
945
|
+
statusMessage: 'Internal Server Error',
|
|
946
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
947
|
+
body: Buffer.from(JSON.stringify({
|
|
948
|
+
error: error instanceof Error ? error.message : 'Internal Server Error'
|
|
949
|
+
})),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Handle streaming API route requests
|
|
956
|
+
* This is called by the server bridge for requests that need streaming support
|
|
957
|
+
*/
|
|
958
|
+
async handleStreamingRequest(
|
|
959
|
+
method: string,
|
|
960
|
+
url: string,
|
|
961
|
+
headers: Record<string, string>,
|
|
962
|
+
body: Buffer | undefined,
|
|
963
|
+
onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
|
|
964
|
+
onChunk: (chunk: string | Uint8Array) => void,
|
|
965
|
+
onEnd: () => void
|
|
966
|
+
): Promise<void> {
|
|
967
|
+
const urlObj = new URL(url, 'http://localhost');
|
|
968
|
+
const pathname = urlObj.pathname;
|
|
969
|
+
|
|
970
|
+
// Only handle API routes
|
|
971
|
+
if (!pathname.startsWith('/api/')) {
|
|
972
|
+
onStart(404, 'Not Found', { 'Content-Type': 'application/json' });
|
|
973
|
+
onChunk(JSON.stringify({ error: 'Not found' }));
|
|
974
|
+
onEnd();
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const apiFile = this.resolveApiFile(pathname);
|
|
979
|
+
|
|
980
|
+
if (!apiFile) {
|
|
981
|
+
onStart(404, 'Not Found', { 'Content-Type': 'application/json' });
|
|
982
|
+
onChunk(JSON.stringify({ error: 'API route not found' }));
|
|
983
|
+
onEnd();
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
try {
|
|
988
|
+
const code = this.vfs.readFileSync(apiFile, 'utf8');
|
|
989
|
+
const transformed = await this.transformApiHandler(code, apiFile);
|
|
990
|
+
|
|
991
|
+
const req = this.createMockRequest(method, pathname, headers, body);
|
|
992
|
+
const res = this.createStreamingMockResponse(onStart, onChunk, onEnd);
|
|
993
|
+
|
|
994
|
+
await this.executeApiHandler(transformed, req, res);
|
|
995
|
+
|
|
996
|
+
// Wait for the response to end
|
|
997
|
+
if (!res.isEnded()) {
|
|
998
|
+
const timeout = new Promise<void>((_, reject) => {
|
|
999
|
+
setTimeout(() => reject(new Error('API handler timeout')), 30000);
|
|
1000
|
+
});
|
|
1001
|
+
await Promise.race([res.waitForEnd(), timeout]);
|
|
1002
|
+
}
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
console.error('[NextDevServer] Streaming API error:', error);
|
|
1005
|
+
onStart(500, 'Internal Server Error', { 'Content-Type': 'application/json' });
|
|
1006
|
+
onChunk(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal Server Error' }));
|
|
1007
|
+
onEnd();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Create a streaming mock response that calls callbacks as data is written
|
|
1013
|
+
*/
|
|
1014
|
+
private createStreamingMockResponse(
|
|
1015
|
+
onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
|
|
1016
|
+
onChunk: (chunk: string | Uint8Array) => void,
|
|
1017
|
+
onEnd: () => void
|
|
1018
|
+
) {
|
|
1019
|
+
let statusCode = 200;
|
|
1020
|
+
let statusMessage = 'OK';
|
|
1021
|
+
const headers: Record<string, string> = {};
|
|
1022
|
+
let ended = false;
|
|
1023
|
+
let headersSent = false;
|
|
1024
|
+
let resolveEnded: (() => void) | null = null;
|
|
1025
|
+
|
|
1026
|
+
const endedPromise = new Promise<void>((resolve) => {
|
|
1027
|
+
resolveEnded = resolve;
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const sendHeaders = () => {
|
|
1031
|
+
if (!headersSent) {
|
|
1032
|
+
headersSent = true;
|
|
1033
|
+
onStart(statusCode, statusMessage, headers);
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const markEnded = () => {
|
|
1038
|
+
if (!ended) {
|
|
1039
|
+
sendHeaders();
|
|
1040
|
+
ended = true;
|
|
1041
|
+
onEnd();
|
|
1042
|
+
if (resolveEnded) resolveEnded();
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
return {
|
|
1047
|
+
headersSent: false,
|
|
1048
|
+
|
|
1049
|
+
status(code: number) {
|
|
1050
|
+
statusCode = code;
|
|
1051
|
+
return this;
|
|
1052
|
+
},
|
|
1053
|
+
setHeader(name: string, value: string) {
|
|
1054
|
+
headers[name] = value;
|
|
1055
|
+
return this;
|
|
1056
|
+
},
|
|
1057
|
+
getHeader(name: string) {
|
|
1058
|
+
return headers[name];
|
|
1059
|
+
},
|
|
1060
|
+
// Write data and stream it immediately
|
|
1061
|
+
write(chunk: string | Buffer): boolean {
|
|
1062
|
+
sendHeaders();
|
|
1063
|
+
const data = typeof chunk === 'string' ? chunk : chunk.toString();
|
|
1064
|
+
onChunk(data);
|
|
1065
|
+
return true;
|
|
1066
|
+
},
|
|
1067
|
+
get writable() {
|
|
1068
|
+
return true;
|
|
1069
|
+
},
|
|
1070
|
+
json(data: unknown) {
|
|
1071
|
+
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
1072
|
+
sendHeaders();
|
|
1073
|
+
onChunk(JSON.stringify(data));
|
|
1074
|
+
markEnded();
|
|
1075
|
+
return this;
|
|
1076
|
+
},
|
|
1077
|
+
send(data: string | object) {
|
|
1078
|
+
if (typeof data === 'object') {
|
|
1079
|
+
return this.json(data);
|
|
1080
|
+
}
|
|
1081
|
+
sendHeaders();
|
|
1082
|
+
onChunk(data);
|
|
1083
|
+
markEnded();
|
|
1084
|
+
return this;
|
|
1085
|
+
},
|
|
1086
|
+
end(data?: string) {
|
|
1087
|
+
if (data) {
|
|
1088
|
+
sendHeaders();
|
|
1089
|
+
onChunk(data);
|
|
1090
|
+
}
|
|
1091
|
+
markEnded();
|
|
1092
|
+
return this;
|
|
1093
|
+
},
|
|
1094
|
+
redirect(statusOrUrl: number | string, url?: string) {
|
|
1095
|
+
if (typeof statusOrUrl === 'number') {
|
|
1096
|
+
statusCode = statusOrUrl;
|
|
1097
|
+
headers['Location'] = url || '/';
|
|
1098
|
+
} else {
|
|
1099
|
+
statusCode = 307;
|
|
1100
|
+
headers['Location'] = statusOrUrl;
|
|
1101
|
+
}
|
|
1102
|
+
markEnded();
|
|
1103
|
+
return this;
|
|
1104
|
+
},
|
|
1105
|
+
isEnded() {
|
|
1106
|
+
return ended;
|
|
1107
|
+
},
|
|
1108
|
+
waitForEnd() {
|
|
1109
|
+
return endedPromise;
|
|
1110
|
+
},
|
|
1111
|
+
toResponse(): ResponseData {
|
|
1112
|
+
// This shouldn't be called for streaming responses
|
|
1113
|
+
return {
|
|
1114
|
+
statusCode,
|
|
1115
|
+
statusMessage,
|
|
1116
|
+
headers,
|
|
1117
|
+
body: Buffer.from(''),
|
|
1118
|
+
};
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Resolve API route to file path
|
|
1125
|
+
*/
|
|
1126
|
+
private resolveApiFile(pathname: string): string | null {
|
|
1127
|
+
// Remove /api prefix and look in /pages/api
|
|
1128
|
+
const apiPath = pathname.replace(/^\/api/, `${this.pagesDir}/api`);
|
|
1129
|
+
|
|
1130
|
+
const extensions = ['.js', '.ts', '.jsx', '.tsx'];
|
|
1131
|
+
|
|
1132
|
+
for (const ext of extensions) {
|
|
1133
|
+
const filePath = apiPath + ext;
|
|
1134
|
+
if (this.exists(filePath)) {
|
|
1135
|
+
return filePath;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Try index file
|
|
1140
|
+
for (const ext of extensions) {
|
|
1141
|
+
const filePath = `${apiPath}/index${ext}`;
|
|
1142
|
+
if (this.exists(filePath)) {
|
|
1143
|
+
return filePath;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Create mock Next.js request object
|
|
1152
|
+
*/
|
|
1153
|
+
private createMockRequest(
|
|
1154
|
+
method: string,
|
|
1155
|
+
pathname: string,
|
|
1156
|
+
headers: Record<string, string>,
|
|
1157
|
+
body?: Buffer
|
|
1158
|
+
) {
|
|
1159
|
+
const url = new URL(pathname, 'http://localhost');
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
method,
|
|
1163
|
+
url: pathname,
|
|
1164
|
+
headers,
|
|
1165
|
+
query: Object.fromEntries(url.searchParams),
|
|
1166
|
+
body: body ? JSON.parse(body.toString()) : undefined,
|
|
1167
|
+
cookies: this.parseCookies(headers.cookie || ''),
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Parse cookie header
|
|
1173
|
+
*/
|
|
1174
|
+
private parseCookies(cookieHeader: string): Record<string, string> {
|
|
1175
|
+
const cookies: Record<string, string> = {};
|
|
1176
|
+
if (!cookieHeader) return cookies;
|
|
1177
|
+
|
|
1178
|
+
cookieHeader.split(';').forEach(cookie => {
|
|
1179
|
+
const [name, value] = cookie.trim().split('=');
|
|
1180
|
+
if (name && value) {
|
|
1181
|
+
cookies[name] = decodeURIComponent(value);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
return cookies;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Create mock Next.js response object with streaming support
|
|
1190
|
+
*/
|
|
1191
|
+
private createMockResponse() {
|
|
1192
|
+
let statusCode = 200;
|
|
1193
|
+
let statusMessage = 'OK';
|
|
1194
|
+
const headers: Record<string, string> = {};
|
|
1195
|
+
let responseBody = '';
|
|
1196
|
+
let ended = false;
|
|
1197
|
+
let resolveEnded: (() => void) | null = null;
|
|
1198
|
+
let headersSent = false;
|
|
1199
|
+
|
|
1200
|
+
// Promise that resolves when response is ended
|
|
1201
|
+
const endedPromise = new Promise<void>((resolve) => {
|
|
1202
|
+
resolveEnded = resolve;
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
const markEnded = () => {
|
|
1206
|
+
if (!ended) {
|
|
1207
|
+
ended = true;
|
|
1208
|
+
if (resolveEnded) resolveEnded();
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
return {
|
|
1213
|
+
// Track if headers have been sent (for streaming)
|
|
1214
|
+
headersSent: false,
|
|
1215
|
+
|
|
1216
|
+
status(code: number) {
|
|
1217
|
+
statusCode = code;
|
|
1218
|
+
return this;
|
|
1219
|
+
},
|
|
1220
|
+
setHeader(name: string, value: string) {
|
|
1221
|
+
headers[name] = value;
|
|
1222
|
+
return this;
|
|
1223
|
+
},
|
|
1224
|
+
getHeader(name: string) {
|
|
1225
|
+
return headers[name];
|
|
1226
|
+
},
|
|
1227
|
+
// Write data to response body (for streaming)
|
|
1228
|
+
write(chunk: string | Buffer): boolean {
|
|
1229
|
+
if (!headersSent) {
|
|
1230
|
+
headersSent = true;
|
|
1231
|
+
this.headersSent = true;
|
|
1232
|
+
}
|
|
1233
|
+
responseBody += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
1234
|
+
return true;
|
|
1235
|
+
},
|
|
1236
|
+
// Writable stream interface for AI SDK compatibility
|
|
1237
|
+
get writable() {
|
|
1238
|
+
return true;
|
|
1239
|
+
},
|
|
1240
|
+
json(data: unknown) {
|
|
1241
|
+
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
1242
|
+
responseBody = JSON.stringify(data);
|
|
1243
|
+
markEnded();
|
|
1244
|
+
return this;
|
|
1245
|
+
},
|
|
1246
|
+
send(data: string | object) {
|
|
1247
|
+
if (typeof data === 'object') {
|
|
1248
|
+
return this.json(data);
|
|
1249
|
+
}
|
|
1250
|
+
responseBody = data;
|
|
1251
|
+
markEnded();
|
|
1252
|
+
return this;
|
|
1253
|
+
},
|
|
1254
|
+
end(data?: string) {
|
|
1255
|
+
if (data) responseBody += data;
|
|
1256
|
+
markEnded();
|
|
1257
|
+
return this;
|
|
1258
|
+
},
|
|
1259
|
+
redirect(statusOrUrl: number | string, url?: string) {
|
|
1260
|
+
if (typeof statusOrUrl === 'number') {
|
|
1261
|
+
statusCode = statusOrUrl;
|
|
1262
|
+
headers['Location'] = url || '/';
|
|
1263
|
+
} else {
|
|
1264
|
+
statusCode = 307;
|
|
1265
|
+
headers['Location'] = statusOrUrl;
|
|
1266
|
+
}
|
|
1267
|
+
markEnded();
|
|
1268
|
+
return this;
|
|
1269
|
+
},
|
|
1270
|
+
isEnded() {
|
|
1271
|
+
return ended;
|
|
1272
|
+
},
|
|
1273
|
+
waitForEnd() {
|
|
1274
|
+
return endedPromise;
|
|
1275
|
+
},
|
|
1276
|
+
toResponse(): ResponseData {
|
|
1277
|
+
const buffer = Buffer.from(responseBody);
|
|
1278
|
+
headers['Content-Length'] = String(buffer.length);
|
|
1279
|
+
return {
|
|
1280
|
+
statusCode,
|
|
1281
|
+
statusMessage,
|
|
1282
|
+
headers,
|
|
1283
|
+
body: buffer,
|
|
1284
|
+
};
|
|
1285
|
+
},
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Execute API handler code
|
|
1291
|
+
*/
|
|
1292
|
+
private async executeApiHandler(
|
|
1293
|
+
code: string,
|
|
1294
|
+
req: ReturnType<typeof this.createMockRequest>,
|
|
1295
|
+
res: ReturnType<typeof this.createMockResponse>
|
|
1296
|
+
): Promise<void> {
|
|
1297
|
+
try {
|
|
1298
|
+
// Create a minimal require function for built-in modules
|
|
1299
|
+
const builtinModules: Record<string, unknown> = {
|
|
1300
|
+
https: await import('../shims/https'),
|
|
1301
|
+
http: await import('../shims/http'),
|
|
1302
|
+
path: await import('../shims/path'),
|
|
1303
|
+
fs: await import('../shims/fs').then(m => m.createFsShim(this.vfs)),
|
|
1304
|
+
url: await import('../shims/url'),
|
|
1305
|
+
querystring: await import('../shims/querystring'),
|
|
1306
|
+
util: await import('../shims/util'),
|
|
1307
|
+
events: await import('../shims/events'),
|
|
1308
|
+
stream: await import('../shims/stream'),
|
|
1309
|
+
buffer: await import('../shims/buffer'),
|
|
1310
|
+
crypto: await import('../shims/crypto'),
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
const require = (id: string): unknown => {
|
|
1314
|
+
// Handle node: prefix
|
|
1315
|
+
const modId = id.startsWith('node:') ? id.slice(5) : id;
|
|
1316
|
+
if (builtinModules[modId]) {
|
|
1317
|
+
return builtinModules[modId];
|
|
1318
|
+
}
|
|
1319
|
+
throw new Error(`Module not found: ${id}`);
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
// Create module context
|
|
1323
|
+
const module = { exports: {} as Record<string, unknown> };
|
|
1324
|
+
const exports = module.exports;
|
|
1325
|
+
|
|
1326
|
+
// Create process object with environment variables
|
|
1327
|
+
const process = {
|
|
1328
|
+
env: { ...this.options.env },
|
|
1329
|
+
cwd: () => '/',
|
|
1330
|
+
platform: 'browser',
|
|
1331
|
+
version: 'v18.0.0',
|
|
1332
|
+
versions: { node: '18.0.0' },
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
// Execute the transformed code
|
|
1336
|
+
// The code is already in CJS format from esbuild transform
|
|
1337
|
+
// Use Function constructor instead of eval with template literal
|
|
1338
|
+
// to avoid issues with backticks or ${} in the transformed code
|
|
1339
|
+
const fn = new Function('exports', 'require', 'module', 'process', code);
|
|
1340
|
+
fn(exports, require, module, process);
|
|
1341
|
+
|
|
1342
|
+
// Get the handler - check both module.exports and module.exports.default
|
|
1343
|
+
let handler: unknown = module.exports.default || module.exports;
|
|
1344
|
+
|
|
1345
|
+
// If handler is still an object with a default property, unwrap it
|
|
1346
|
+
if (typeof handler === 'object' && handler !== null && 'default' in handler) {
|
|
1347
|
+
handler = (handler as { default: unknown }).default;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
if (typeof handler !== 'function') {
|
|
1351
|
+
throw new Error('No default export handler found');
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Call the handler - it may be async
|
|
1355
|
+
const result = (handler as (req: unknown, res: unknown) => unknown)(req, res);
|
|
1356
|
+
|
|
1357
|
+
// If the handler returns a promise, wait for it
|
|
1358
|
+
if (result instanceof Promise) {
|
|
1359
|
+
await result;
|
|
1360
|
+
}
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
console.error('[NextDevServer] API handler error:', error);
|
|
1363
|
+
throw error;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Handle page route requests
|
|
1369
|
+
*/
|
|
1370
|
+
private async handlePageRoute(pathname: string, search: string): Promise<ResponseData> {
|
|
1371
|
+
// Use App Router if available
|
|
1372
|
+
if (this.useAppRouter) {
|
|
1373
|
+
return this.handleAppRouterPage(pathname, search);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Resolve pathname to page file (Pages Router)
|
|
1377
|
+
const pageFile = this.resolvePageFile(pathname);
|
|
1378
|
+
|
|
1379
|
+
if (!pageFile) {
|
|
1380
|
+
// Try to serve 404 page if exists
|
|
1381
|
+
const notFoundPage = this.resolvePageFile('/404');
|
|
1382
|
+
if (notFoundPage) {
|
|
1383
|
+
const html = await this.generatePageHtml(notFoundPage, '/404');
|
|
1384
|
+
return {
|
|
1385
|
+
statusCode: 404,
|
|
1386
|
+
statusMessage: 'Not Found',
|
|
1387
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
1388
|
+
body: Buffer.from(html),
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
return this.serve404Page();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Check if this is a direct request for a page file (e.g., /pages/index.jsx)
|
|
1395
|
+
if (this.needsTransform(pathname)) {
|
|
1396
|
+
return this.transformAndServe(pageFile, pathname);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Generate HTML shell with page component
|
|
1400
|
+
const html = await this.generatePageHtml(pageFile, pathname);
|
|
1401
|
+
|
|
1402
|
+
const buffer = Buffer.from(html);
|
|
1403
|
+
return {
|
|
1404
|
+
statusCode: 200,
|
|
1405
|
+
statusMessage: 'OK',
|
|
1406
|
+
headers: {
|
|
1407
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1408
|
+
'Content-Length': String(buffer.length),
|
|
1409
|
+
'Cache-Control': 'no-cache',
|
|
1410
|
+
},
|
|
1411
|
+
body: buffer,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Handle App Router page requests
|
|
1417
|
+
*/
|
|
1418
|
+
private async handleAppRouterPage(pathname: string, search: string): Promise<ResponseData> {
|
|
1419
|
+
// Resolve the route to page and layouts
|
|
1420
|
+
const route = this.resolveAppRoute(pathname);
|
|
1421
|
+
|
|
1422
|
+
if (!route) {
|
|
1423
|
+
// Try not-found page
|
|
1424
|
+
const notFoundRoute = this.resolveAppRoute('/not-found');
|
|
1425
|
+
if (notFoundRoute) {
|
|
1426
|
+
const html = await this.generateAppRouterHtml(notFoundRoute, '/not-found');
|
|
1427
|
+
return {
|
|
1428
|
+
statusCode: 404,
|
|
1429
|
+
statusMessage: 'Not Found',
|
|
1430
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
1431
|
+
body: Buffer.from(html),
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
return this.serve404Page();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const html = await this.generateAppRouterHtml(route, pathname);
|
|
1438
|
+
|
|
1439
|
+
const buffer = Buffer.from(html);
|
|
1440
|
+
return {
|
|
1441
|
+
statusCode: 200,
|
|
1442
|
+
statusMessage: 'OK',
|
|
1443
|
+
headers: {
|
|
1444
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1445
|
+
'Content-Length': String(buffer.length),
|
|
1446
|
+
'Cache-Control': 'no-cache',
|
|
1447
|
+
},
|
|
1448
|
+
body: buffer,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Resolve App Router route to page and layout files
|
|
1454
|
+
*/
|
|
1455
|
+
private resolveAppRoute(pathname: string): { page: string; layouts: string[] } | null {
|
|
1456
|
+
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
1457
|
+
const segments = pathname === '/' ? [] : pathname.split('/').filter(Boolean);
|
|
1458
|
+
|
|
1459
|
+
// Build the directory path
|
|
1460
|
+
let dirPath = this.appDir;
|
|
1461
|
+
const layouts: string[] = [];
|
|
1462
|
+
|
|
1463
|
+
// Collect layouts from root to current directory
|
|
1464
|
+
for (const ext of extensions) {
|
|
1465
|
+
const rootLayout = `${this.appDir}/layout${ext}`;
|
|
1466
|
+
if (this.exists(rootLayout)) {
|
|
1467
|
+
layouts.push(rootLayout);
|
|
1468
|
+
break;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Walk through segments to find page and collect layouts
|
|
1473
|
+
for (const segment of segments) {
|
|
1474
|
+
dirPath = `${dirPath}/${segment}`;
|
|
1475
|
+
|
|
1476
|
+
// Check for layout in this segment
|
|
1477
|
+
for (const ext of extensions) {
|
|
1478
|
+
const layoutPath = `${dirPath}/layout${ext}`;
|
|
1479
|
+
if (this.exists(layoutPath)) {
|
|
1480
|
+
layouts.push(layoutPath);
|
|
1481
|
+
break;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Find the page file
|
|
1487
|
+
for (const ext of extensions) {
|
|
1488
|
+
const pagePath = `${dirPath}/page${ext}`;
|
|
1489
|
+
if (this.exists(pagePath)) {
|
|
1490
|
+
return { page: pagePath, layouts };
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Try dynamic segments
|
|
1495
|
+
return this.resolveAppDynamicRoute(pathname, segments);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Resolve dynamic App Router routes like /app/[id]/page.jsx
|
|
1500
|
+
*/
|
|
1501
|
+
private resolveAppDynamicRoute(
|
|
1502
|
+
pathname: string,
|
|
1503
|
+
segments: string[]
|
|
1504
|
+
): { page: string; layouts: string[] } | null {
|
|
1505
|
+
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
1506
|
+
|
|
1507
|
+
const tryPath = (
|
|
1508
|
+
dirPath: string,
|
|
1509
|
+
remainingSegments: string[],
|
|
1510
|
+
layouts: string[]
|
|
1511
|
+
): { page: string; layouts: string[] } | null => {
|
|
1512
|
+
// Check for layout at current level
|
|
1513
|
+
for (const ext of extensions) {
|
|
1514
|
+
const layoutPath = `${dirPath}/layout${ext}`;
|
|
1515
|
+
if (this.exists(layoutPath) && !layouts.includes(layoutPath)) {
|
|
1516
|
+
layouts = [...layouts, layoutPath];
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (remainingSegments.length === 0) {
|
|
1521
|
+
// Look for page file
|
|
1522
|
+
for (const ext of extensions) {
|
|
1523
|
+
const pagePath = `${dirPath}/page${ext}`;
|
|
1524
|
+
if (this.exists(pagePath)) {
|
|
1525
|
+
return { page: pagePath, layouts };
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const [current, ...rest] = remainingSegments;
|
|
1532
|
+
|
|
1533
|
+
// Try exact match first
|
|
1534
|
+
const exactPath = `${dirPath}/${current}`;
|
|
1535
|
+
if (this.isDirectory(exactPath)) {
|
|
1536
|
+
const result = tryPath(exactPath, rest, layouts);
|
|
1537
|
+
if (result) return result;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Try dynamic segment [param]
|
|
1541
|
+
try {
|
|
1542
|
+
const entries = this.vfs.readdirSync(dirPath);
|
|
1543
|
+
for (const entry of entries) {
|
|
1544
|
+
if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
|
|
1545
|
+
const dynamicPath = `${dirPath}/${entry}`;
|
|
1546
|
+
if (this.isDirectory(dynamicPath)) {
|
|
1547
|
+
const result = tryPath(dynamicPath, rest, layouts);
|
|
1548
|
+
if (result) return result;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
} catch {
|
|
1553
|
+
// Directory doesn't exist
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return null;
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
// Collect root layout
|
|
1560
|
+
const layouts: string[] = [];
|
|
1561
|
+
for (const ext of extensions) {
|
|
1562
|
+
const rootLayout = `${this.appDir}/layout${ext}`;
|
|
1563
|
+
if (this.exists(rootLayout)) {
|
|
1564
|
+
layouts.push(rootLayout);
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return tryPath(this.appDir, segments, layouts);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Generate HTML for App Router with nested layouts
|
|
1574
|
+
*/
|
|
1575
|
+
private async generateAppRouterHtml(
|
|
1576
|
+
route: { page: string; layouts: string[] },
|
|
1577
|
+
pathname: string
|
|
1578
|
+
): Promise<string> {
|
|
1579
|
+
// Use virtual server prefix for all file imports so the service worker can intercept them
|
|
1580
|
+
const virtualPrefix = `/__virtual__/${this.port}`;
|
|
1581
|
+
|
|
1582
|
+
// Check for global CSS files
|
|
1583
|
+
const globalCssLinks: string[] = [];
|
|
1584
|
+
const cssLocations = ['/app/globals.css', '/styles/globals.css', '/styles/global.css'];
|
|
1585
|
+
for (const cssPath of cssLocations) {
|
|
1586
|
+
if (this.exists(cssPath)) {
|
|
1587
|
+
globalCssLinks.push(`<link rel="stylesheet" href="${virtualPrefix}${cssPath}">`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Build the nested component structure
|
|
1592
|
+
// Layouts wrap the page from outside in
|
|
1593
|
+
const pageModulePath = virtualPrefix + route.page; // route.page already starts with /
|
|
1594
|
+
const layoutImports = route.layouts
|
|
1595
|
+
.map((layout, i) => `import Layout${i} from '${virtualPrefix}${layout}';`)
|
|
1596
|
+
.join('\n ');
|
|
1597
|
+
|
|
1598
|
+
// Build nested JSX: Layout0 > Layout1 > ... > Page
|
|
1599
|
+
let nestedJsx = 'React.createElement(Page)';
|
|
1600
|
+
for (let i = route.layouts.length - 1; i >= 0; i--) {
|
|
1601
|
+
nestedJsx = `React.createElement(Layout${i}, null, ${nestedJsx})`;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Generate env script for NEXT_PUBLIC_* variables
|
|
1605
|
+
const envScript = this.generateEnvScript();
|
|
1606
|
+
|
|
1607
|
+
return `<!DOCTYPE html>
|
|
1608
|
+
<html lang="en">
|
|
1609
|
+
<head>
|
|
1610
|
+
<meta charset="UTF-8">
|
|
1611
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1612
|
+
<base href="${virtualPrefix}/">
|
|
1613
|
+
<title>Next.js App</title>
|
|
1614
|
+
${envScript}
|
|
1615
|
+
${TAILWIND_CDN_SCRIPT}
|
|
1616
|
+
${CORS_PROXY_SCRIPT}
|
|
1617
|
+
${globalCssLinks.join('\n ')}
|
|
1618
|
+
${REACT_REFRESH_PREAMBLE}
|
|
1619
|
+
<script type="importmap">
|
|
1620
|
+
{
|
|
1621
|
+
"imports": {
|
|
1622
|
+
"react": "https://esm.sh/react@18.2.0?dev",
|
|
1623
|
+
"react/": "https://esm.sh/react@18.2.0&dev/",
|
|
1624
|
+
"react-dom": "https://esm.sh/react-dom@18.2.0?dev",
|
|
1625
|
+
"react-dom/": "https://esm.sh/react-dom@18.2.0&dev/",
|
|
1626
|
+
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
|
|
1627
|
+
"convex/react": "https://esm.sh/convex@1.21.0/react?external=react",
|
|
1628
|
+
"convex/server": "https://esm.sh/convex@1.21.0/server",
|
|
1629
|
+
"convex/values": "https://esm.sh/convex@1.21.0/values",
|
|
1630
|
+
"convex/_generated/api": "${virtualPrefix}/convex/_generated/api.ts",
|
|
1631
|
+
"ai": "https://esm.sh/ai@4?external=react",
|
|
1632
|
+
"ai/react": "https://esm.sh/ai@4/react?external=react",
|
|
1633
|
+
"@ai-sdk/openai": "https://esm.sh/@ai-sdk/openai@1",
|
|
1634
|
+
"next/link": "${virtualPrefix}/_next/shims/link.js",
|
|
1635
|
+
"next/router": "${virtualPrefix}/_next/shims/router.js",
|
|
1636
|
+
"next/head": "${virtualPrefix}/_next/shims/head.js",
|
|
1637
|
+
"next/navigation": "${virtualPrefix}/_next/shims/navigation.js"
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
</script>
|
|
1641
|
+
${HMR_CLIENT_SCRIPT}
|
|
1642
|
+
</head>
|
|
1643
|
+
<body>
|
|
1644
|
+
<div id="__next"></div>
|
|
1645
|
+
<script type="module">
|
|
1646
|
+
import React from 'react';
|
|
1647
|
+
import ReactDOM from 'react-dom/client';
|
|
1648
|
+
import Page from '${pageModulePath}';
|
|
1649
|
+
${layoutImports}
|
|
1650
|
+
|
|
1651
|
+
function App() {
|
|
1652
|
+
return ${nestedJsx};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
ReactDOM.createRoot(document.getElementById('__next')).render(
|
|
1656
|
+
React.createElement(React.StrictMode, null,
|
|
1657
|
+
React.createElement(App)
|
|
1658
|
+
)
|
|
1659
|
+
);
|
|
1660
|
+
</script>
|
|
1661
|
+
</body>
|
|
1662
|
+
</html>`;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Resolve URL pathname to page file
|
|
1667
|
+
*/
|
|
1668
|
+
private resolvePageFile(pathname: string): string | null {
|
|
1669
|
+
// Handle root path
|
|
1670
|
+
if (pathname === '/') {
|
|
1671
|
+
pathname = '/index';
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
1675
|
+
|
|
1676
|
+
// Try exact match: /about → /pages/about.jsx
|
|
1677
|
+
for (const ext of extensions) {
|
|
1678
|
+
const filePath = `${this.pagesDir}${pathname}${ext}`;
|
|
1679
|
+
if (this.exists(filePath)) {
|
|
1680
|
+
return filePath;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Try index file: /about → /pages/about/index.jsx
|
|
1685
|
+
for (const ext of extensions) {
|
|
1686
|
+
const filePath = `${this.pagesDir}${pathname}/index${ext}`;
|
|
1687
|
+
if (this.exists(filePath)) {
|
|
1688
|
+
return filePath;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// Try dynamic route matching
|
|
1693
|
+
return this.resolveDynamicRoute(pathname);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Resolve dynamic routes like /users/[id]
|
|
1698
|
+
*/
|
|
1699
|
+
private resolveDynamicRoute(pathname: string): string | null {
|
|
1700
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
1701
|
+
if (segments.length === 0) return null;
|
|
1702
|
+
|
|
1703
|
+
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
1704
|
+
|
|
1705
|
+
// Build possible paths with dynamic segments
|
|
1706
|
+
// e.g., /users/123 could match /pages/users/[id].jsx
|
|
1707
|
+
const tryPath = (dirPath: string, remainingSegments: string[]): string | null => {
|
|
1708
|
+
if (remainingSegments.length === 0) {
|
|
1709
|
+
// Try index file
|
|
1710
|
+
for (const ext of extensions) {
|
|
1711
|
+
const indexPath = `${dirPath}/index${ext}`;
|
|
1712
|
+
if (this.exists(indexPath)) {
|
|
1713
|
+
return indexPath;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
return null;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const [current, ...rest] = remainingSegments;
|
|
1720
|
+
|
|
1721
|
+
// Try exact match first
|
|
1722
|
+
const exactPath = `${dirPath}/${current}`;
|
|
1723
|
+
|
|
1724
|
+
// Check if it's a file
|
|
1725
|
+
for (const ext of extensions) {
|
|
1726
|
+
if (rest.length === 0 && this.exists(exactPath + ext)) {
|
|
1727
|
+
return exactPath + ext;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Check if it's a directory
|
|
1732
|
+
if (this.isDirectory(exactPath)) {
|
|
1733
|
+
const exactResult = tryPath(exactPath, rest);
|
|
1734
|
+
if (exactResult) return exactResult;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Try dynamic segment [param]
|
|
1738
|
+
try {
|
|
1739
|
+
const entries = this.vfs.readdirSync(dirPath);
|
|
1740
|
+
for (const entry of entries) {
|
|
1741
|
+
// Check for dynamic file like [id].jsx
|
|
1742
|
+
for (const ext of extensions) {
|
|
1743
|
+
const dynamicFilePattern = /^\[([^\]]+)\]$/;
|
|
1744
|
+
const nameWithoutExt = entry.replace(ext, '');
|
|
1745
|
+
if (entry.endsWith(ext) && dynamicFilePattern.test(nameWithoutExt)) {
|
|
1746
|
+
// It's a dynamic file like [id].jsx
|
|
1747
|
+
if (rest.length === 0) {
|
|
1748
|
+
const filePath = `${dirPath}/${entry}`;
|
|
1749
|
+
if (this.exists(filePath)) {
|
|
1750
|
+
return filePath;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Check for dynamic directory like [id]
|
|
1757
|
+
if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
|
|
1758
|
+
const dynamicPath = `${dirPath}/${entry}`;
|
|
1759
|
+
if (this.isDirectory(dynamicPath)) {
|
|
1760
|
+
const dynamicResult = tryPath(dynamicPath, rest);
|
|
1761
|
+
if (dynamicResult) return dynamicResult;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// Check for catch-all [...param].jsx
|
|
1766
|
+
for (const ext of extensions) {
|
|
1767
|
+
if (entry.startsWith('[...') && entry.endsWith(']' + ext)) {
|
|
1768
|
+
const filePath = `${dirPath}/${entry}`;
|
|
1769
|
+
if (this.exists(filePath)) {
|
|
1770
|
+
return filePath;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
} catch {
|
|
1776
|
+
// Directory doesn't exist
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
return null;
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
return tryPath(this.pagesDir, segments);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Generate HTML shell for a page
|
|
1787
|
+
*/
|
|
1788
|
+
private async generatePageHtml(pageFile: string, pathname: string): Promise<string> {
|
|
1789
|
+
// Use virtual server prefix for all file imports so the service worker can intercept them
|
|
1790
|
+
// Without this, /pages/index.jsx would go to localhost:5173/pages/index.jsx
|
|
1791
|
+
// instead of /__virtual__/3001/pages/index.jsx
|
|
1792
|
+
const virtualPrefix = `/__virtual__/${this.port}`;
|
|
1793
|
+
const pageModulePath = virtualPrefix + pageFile; // pageFile already starts with /
|
|
1794
|
+
|
|
1795
|
+
// Check for global CSS files
|
|
1796
|
+
const globalCssLinks: string[] = [];
|
|
1797
|
+
const cssLocations = ['/styles/globals.css', '/styles/global.css', '/app/globals.css'];
|
|
1798
|
+
for (const cssPath of cssLocations) {
|
|
1799
|
+
if (this.exists(cssPath)) {
|
|
1800
|
+
globalCssLinks.push(`<link rel="stylesheet" href="${virtualPrefix}${cssPath}">`);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Generate env script for NEXT_PUBLIC_* variables
|
|
1805
|
+
const envScript = this.generateEnvScript();
|
|
1806
|
+
|
|
1807
|
+
return `<!DOCTYPE html>
|
|
1808
|
+
<html lang="en">
|
|
1809
|
+
<head>
|
|
1810
|
+
<meta charset="UTF-8">
|
|
1811
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1812
|
+
<base href="${virtualPrefix}/">
|
|
1813
|
+
<title>Next.js App</title>
|
|
1814
|
+
${envScript}
|
|
1815
|
+
${TAILWIND_CDN_SCRIPT}
|
|
1816
|
+
${CORS_PROXY_SCRIPT}
|
|
1817
|
+
${globalCssLinks.join('\n ')}
|
|
1818
|
+
${REACT_REFRESH_PREAMBLE}
|
|
1819
|
+
<script type="importmap">
|
|
1820
|
+
{
|
|
1821
|
+
"imports": {
|
|
1822
|
+
"react": "https://esm.sh/react@18.2.0?dev",
|
|
1823
|
+
"react/": "https://esm.sh/react@18.2.0&dev/",
|
|
1824
|
+
"react-dom": "https://esm.sh/react-dom@18.2.0?dev",
|
|
1825
|
+
"react-dom/": "https://esm.sh/react-dom@18.2.0&dev/",
|
|
1826
|
+
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
|
|
1827
|
+
"next/link": "${virtualPrefix}/_next/shims/link.js",
|
|
1828
|
+
"next/router": "${virtualPrefix}/_next/shims/router.js",
|
|
1829
|
+
"next/head": "${virtualPrefix}/_next/shims/head.js"
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
</script>
|
|
1833
|
+
${HMR_CLIENT_SCRIPT}
|
|
1834
|
+
</head>
|
|
1835
|
+
<body>
|
|
1836
|
+
<div id="__next"></div>
|
|
1837
|
+
<script type="module">
|
|
1838
|
+
import React from 'react';
|
|
1839
|
+
import ReactDOM from 'react-dom/client';
|
|
1840
|
+
import Page from '${pageModulePath}';
|
|
1841
|
+
|
|
1842
|
+
// Handle client-side navigation
|
|
1843
|
+
function App() {
|
|
1844
|
+
const [currentPath, setCurrentPath] = React.useState(window.location.pathname);
|
|
1845
|
+
|
|
1846
|
+
React.useEffect(() => {
|
|
1847
|
+
const handlePopState = () => {
|
|
1848
|
+
setCurrentPath(window.location.pathname);
|
|
1849
|
+
// Re-render the page component
|
|
1850
|
+
window.location.reload();
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
window.addEventListener('popstate', handlePopState);
|
|
1854
|
+
return () => window.removeEventListener('popstate', handlePopState);
|
|
1855
|
+
}, []);
|
|
1856
|
+
|
|
1857
|
+
return React.createElement(Page);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
ReactDOM.createRoot(document.getElementById('__next')).render(
|
|
1861
|
+
React.createElement(React.StrictMode, null,
|
|
1862
|
+
React.createElement(App)
|
|
1863
|
+
)
|
|
1864
|
+
);
|
|
1865
|
+
</script>
|
|
1866
|
+
</body>
|
|
1867
|
+
</html>`;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* Serve a basic 404 page
|
|
1872
|
+
*/
|
|
1873
|
+
private serve404Page(): ResponseData {
|
|
1874
|
+
const virtualPrefix = `/__virtual__/${this.port}`;
|
|
1875
|
+
const html = `<!DOCTYPE html>
|
|
1876
|
+
<html lang="en">
|
|
1877
|
+
<head>
|
|
1878
|
+
<meta charset="UTF-8">
|
|
1879
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1880
|
+
<base href="${virtualPrefix}/">
|
|
1881
|
+
<title>404 - Page Not Found</title>
|
|
1882
|
+
<style>
|
|
1883
|
+
body {
|
|
1884
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1885
|
+
display: flex;
|
|
1886
|
+
flex-direction: column;
|
|
1887
|
+
align-items: center;
|
|
1888
|
+
justify-content: center;
|
|
1889
|
+
min-height: 100vh;
|
|
1890
|
+
margin: 0;
|
|
1891
|
+
background: #fafafa;
|
|
1892
|
+
}
|
|
1893
|
+
h1 { font-size: 48px; margin: 0; }
|
|
1894
|
+
p { color: #666; margin-top: 10px; }
|
|
1895
|
+
a { color: #0070f3; text-decoration: none; }
|
|
1896
|
+
a:hover { text-decoration: underline; }
|
|
1897
|
+
</style>
|
|
1898
|
+
</head>
|
|
1899
|
+
<body>
|
|
1900
|
+
<h1>404</h1>
|
|
1901
|
+
<p>This page could not be found.</p>
|
|
1902
|
+
<p><a href="/">Go back home</a></p>
|
|
1903
|
+
</body>
|
|
1904
|
+
</html>`;
|
|
1905
|
+
|
|
1906
|
+
const buffer = Buffer.from(html);
|
|
1907
|
+
return {
|
|
1908
|
+
statusCode: 404,
|
|
1909
|
+
statusMessage: 'Not Found',
|
|
1910
|
+
headers: {
|
|
1911
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1912
|
+
'Content-Length': String(buffer.length),
|
|
1913
|
+
},
|
|
1914
|
+
body: buffer,
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* Check if a file needs transformation
|
|
1920
|
+
*/
|
|
1921
|
+
private needsTransform(path: string): boolean {
|
|
1922
|
+
return /\.(jsx|tsx|ts)$/.test(path);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Transform and serve a JSX/TS file
|
|
1927
|
+
*/
|
|
1928
|
+
private async transformAndServe(filePath: string, urlPath: string): Promise<ResponseData> {
|
|
1929
|
+
try {
|
|
1930
|
+
const content = this.vfs.readFileSync(filePath, 'utf8');
|
|
1931
|
+
const transformed = await this.transformCode(content, urlPath);
|
|
1932
|
+
|
|
1933
|
+
const buffer = Buffer.from(transformed);
|
|
1934
|
+
return {
|
|
1935
|
+
statusCode: 200,
|
|
1936
|
+
statusMessage: 'OK',
|
|
1937
|
+
headers: {
|
|
1938
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
1939
|
+
'Content-Length': String(buffer.length),
|
|
1940
|
+
'Cache-Control': 'no-cache',
|
|
1941
|
+
'X-Transformed': 'true',
|
|
1942
|
+
},
|
|
1943
|
+
body: buffer,
|
|
1944
|
+
};
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
console.error('[NextDevServer] Transform error:', error);
|
|
1947
|
+
const message = error instanceof Error ? error.message : 'Transform failed';
|
|
1948
|
+
const body = `// Transform Error: ${message}\nconsole.error(${JSON.stringify(message)});`;
|
|
1949
|
+
return {
|
|
1950
|
+
statusCode: 200,
|
|
1951
|
+
statusMessage: 'OK',
|
|
1952
|
+
headers: {
|
|
1953
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
1954
|
+
'X-Transform-Error': 'true',
|
|
1955
|
+
},
|
|
1956
|
+
body: Buffer.from(body),
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/**
|
|
1962
|
+
* Transform JSX/TS code to browser-compatible JavaScript (ESM for browser)
|
|
1963
|
+
*/
|
|
1964
|
+
private async transformCode(code: string, filename: string): Promise<string> {
|
|
1965
|
+
if (!isBrowser) {
|
|
1966
|
+
return code;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
await initEsbuild();
|
|
1970
|
+
|
|
1971
|
+
const esbuild = getEsbuild();
|
|
1972
|
+
if (!esbuild) {
|
|
1973
|
+
throw new Error('esbuild not available');
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Remove CSS imports before transformation - they are handled via <link> tags
|
|
1977
|
+
// CSS imports in ESM would fail with MIME type errors
|
|
1978
|
+
const codeWithoutCssImports = this.stripCssImports(code);
|
|
1979
|
+
|
|
1980
|
+
let loader: 'js' | 'jsx' | 'ts' | 'tsx' = 'js';
|
|
1981
|
+
if (filename.endsWith('.jsx')) loader = 'jsx';
|
|
1982
|
+
else if (filename.endsWith('.tsx')) loader = 'tsx';
|
|
1983
|
+
else if (filename.endsWith('.ts')) loader = 'ts';
|
|
1984
|
+
|
|
1985
|
+
const result = await esbuild.transform(codeWithoutCssImports, {
|
|
1986
|
+
loader,
|
|
1987
|
+
format: 'esm',
|
|
1988
|
+
target: 'esnext',
|
|
1989
|
+
jsx: 'automatic',
|
|
1990
|
+
jsxImportSource: 'react',
|
|
1991
|
+
sourcemap: 'inline',
|
|
1992
|
+
sourcefile: filename,
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
// Add React Refresh registration for JSX/TSX files
|
|
1996
|
+
if (/\.(jsx|tsx)$/.test(filename)) {
|
|
1997
|
+
return this.addReactRefresh(result.code, filename);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
return result.code;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Strip CSS imports from code (they are loaded via <link> tags instead)
|
|
2005
|
+
* Handles: import './styles.css', import '../globals.css', etc.
|
|
2006
|
+
*/
|
|
2007
|
+
private stripCssImports(code: string): string {
|
|
2008
|
+
// Match import statements for CSS files (with or without semicolon)
|
|
2009
|
+
// Handles: import './styles.css'; import "./globals.css" import '../path/file.css'
|
|
2010
|
+
return code.replace(/import\s+['"][^'"]+\.css['"]\s*;?/g, '// CSS import removed (loaded via <link>)');
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Transform API handler code to CommonJS for eval execution
|
|
2015
|
+
*/
|
|
2016
|
+
private async transformApiHandler(code: string, filename: string): Promise<string> {
|
|
2017
|
+
if (isBrowser) {
|
|
2018
|
+
// Use esbuild in browser
|
|
2019
|
+
await initEsbuild();
|
|
2020
|
+
|
|
2021
|
+
const esbuild = getEsbuild();
|
|
2022
|
+
if (!esbuild) {
|
|
2023
|
+
throw new Error('esbuild not available');
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
let loader: 'js' | 'jsx' | 'ts' | 'tsx' = 'js';
|
|
2027
|
+
if (filename.endsWith('.jsx')) loader = 'jsx';
|
|
2028
|
+
else if (filename.endsWith('.tsx')) loader = 'tsx';
|
|
2029
|
+
else if (filename.endsWith('.ts')) loader = 'ts';
|
|
2030
|
+
|
|
2031
|
+
const result = await esbuild.transform(code, {
|
|
2032
|
+
loader,
|
|
2033
|
+
format: 'cjs', // CommonJS for eval execution
|
|
2034
|
+
target: 'esnext',
|
|
2035
|
+
platform: 'neutral',
|
|
2036
|
+
sourcefile: filename,
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
return result.code;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Simple ESM to CJS transform for Node.js/test environment
|
|
2043
|
+
let transformed = code;
|
|
2044
|
+
|
|
2045
|
+
// Convert: import X from 'Y' -> const X = require('Y')
|
|
2046
|
+
transformed = transformed.replace(
|
|
2047
|
+
/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g,
|
|
2048
|
+
'const $1 = require("$2")'
|
|
2049
|
+
);
|
|
2050
|
+
|
|
2051
|
+
// Convert: import { X } from 'Y' -> const { X } = require('Y')
|
|
2052
|
+
transformed = transformed.replace(
|
|
2053
|
+
/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g,
|
|
2054
|
+
'const {$1} = require("$2")'
|
|
2055
|
+
);
|
|
2056
|
+
|
|
2057
|
+
// Convert: export default function X -> module.exports = function X
|
|
2058
|
+
transformed = transformed.replace(
|
|
2059
|
+
/export\s+default\s+function\s+(\w+)/g,
|
|
2060
|
+
'module.exports = function $1'
|
|
2061
|
+
);
|
|
2062
|
+
|
|
2063
|
+
// Convert: export default function -> module.exports = function
|
|
2064
|
+
transformed = transformed.replace(
|
|
2065
|
+
/export\s+default\s+function\s*\(/g,
|
|
2066
|
+
'module.exports = function('
|
|
2067
|
+
);
|
|
2068
|
+
|
|
2069
|
+
// Convert: export default X -> module.exports = X
|
|
2070
|
+
transformed = transformed.replace(
|
|
2071
|
+
/export\s+default\s+/g,
|
|
2072
|
+
'module.exports = '
|
|
2073
|
+
);
|
|
2074
|
+
|
|
2075
|
+
return transformed;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Add React Refresh registration to transformed code
|
|
2080
|
+
*/
|
|
2081
|
+
private addReactRefresh(code: string, filename: string): string {
|
|
2082
|
+
const components: string[] = [];
|
|
2083
|
+
|
|
2084
|
+
const funcDeclRegex = /(?:^|\n)(?:export\s+)?function\s+([A-Z][a-zA-Z0-9]*)\s*\(/g;
|
|
2085
|
+
let match;
|
|
2086
|
+
while ((match = funcDeclRegex.exec(code)) !== null) {
|
|
2087
|
+
if (!components.includes(match[1])) {
|
|
2088
|
+
components.push(match[1]);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const arrowRegex = /(?:^|\n)(?:export\s+)?(?:const|let|var)\s+([A-Z][a-zA-Z0-9]*)\s*=/g;
|
|
2093
|
+
while ((match = arrowRegex.exec(code)) !== null) {
|
|
2094
|
+
if (!components.includes(match[1])) {
|
|
2095
|
+
components.push(match[1]);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
if (components.length === 0) {
|
|
2100
|
+
return `// HMR Setup
|
|
2101
|
+
import.meta.hot = window.__vite_hot_context__("${filename}");
|
|
2102
|
+
|
|
2103
|
+
${code}
|
|
2104
|
+
|
|
2105
|
+
if (import.meta.hot) {
|
|
2106
|
+
import.meta.hot.accept();
|
|
2107
|
+
}
|
|
2108
|
+
`;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
const registrations = components
|
|
2112
|
+
.map(name => ` $RefreshReg$(${name}, "${filename} ${name}");`)
|
|
2113
|
+
.join('\n');
|
|
2114
|
+
|
|
2115
|
+
return `// HMR Setup
|
|
2116
|
+
import.meta.hot = window.__vite_hot_context__("${filename}");
|
|
2117
|
+
|
|
2118
|
+
${code}
|
|
2119
|
+
|
|
2120
|
+
// React Refresh Registration
|
|
2121
|
+
if (import.meta.hot) {
|
|
2122
|
+
${registrations}
|
|
2123
|
+
import.meta.hot.accept(() => {
|
|
2124
|
+
if (window.$RefreshRuntime$) {
|
|
2125
|
+
window.$RefreshRuntime$.performReactRefresh();
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
`;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* Start file watching for HMR
|
|
2134
|
+
*/
|
|
2135
|
+
startWatching(): void {
|
|
2136
|
+
const watchers: Array<{ close: () => void }> = [];
|
|
2137
|
+
|
|
2138
|
+
// Watch /pages directory
|
|
2139
|
+
try {
|
|
2140
|
+
const pagesWatcher = this.vfs.watch(this.pagesDir, { recursive: true }, (eventType, filename) => {
|
|
2141
|
+
if (eventType === 'change' && filename) {
|
|
2142
|
+
const fullPath = filename.startsWith('/') ? filename : `${this.pagesDir}/${filename}`;
|
|
2143
|
+
this.handleFileChange(fullPath);
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
watchers.push(pagesWatcher);
|
|
2147
|
+
} catch (error) {
|
|
2148
|
+
console.warn('[NextDevServer] Could not watch pages directory:', error);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// Watch /app directory for App Router
|
|
2152
|
+
if (this.useAppRouter) {
|
|
2153
|
+
try {
|
|
2154
|
+
const appWatcher = this.vfs.watch(this.appDir, { recursive: true }, (eventType, filename) => {
|
|
2155
|
+
if (eventType === 'change' && filename) {
|
|
2156
|
+
const fullPath = filename.startsWith('/') ? filename : `${this.appDir}/${filename}`;
|
|
2157
|
+
this.handleFileChange(fullPath);
|
|
2158
|
+
}
|
|
2159
|
+
});
|
|
2160
|
+
watchers.push(appWatcher);
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
console.warn('[NextDevServer] Could not watch app directory:', error);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// Watch /public directory for static assets
|
|
2167
|
+
try {
|
|
2168
|
+
const publicWatcher = this.vfs.watch(this.publicDir, { recursive: true }, (eventType, filename) => {
|
|
2169
|
+
if (eventType === 'change' && filename) {
|
|
2170
|
+
this.handleFileChange(`${this.publicDir}/${filename}`);
|
|
2171
|
+
}
|
|
2172
|
+
});
|
|
2173
|
+
watchers.push(publicWatcher);
|
|
2174
|
+
} catch {
|
|
2175
|
+
// Ignore if public directory doesn't exist
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
this.watcherCleanup = () => {
|
|
2179
|
+
watchers.forEach(w => w.close());
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* Handle file change event
|
|
2185
|
+
*/
|
|
2186
|
+
private handleFileChange(path: string): void {
|
|
2187
|
+
const isCSS = path.endsWith('.css');
|
|
2188
|
+
const isJS = /\.(jsx?|tsx?)$/.test(path);
|
|
2189
|
+
const updateType = (isCSS || isJS) ? 'update' : 'full-reload';
|
|
2190
|
+
|
|
2191
|
+
const update: HMRUpdate = {
|
|
2192
|
+
type: updateType,
|
|
2193
|
+
path,
|
|
2194
|
+
timestamp: Date.now(),
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
this.emitHMRUpdate(update);
|
|
2198
|
+
|
|
2199
|
+
// Send HMR update via postMessage (works with sandboxed iframes)
|
|
2200
|
+
if (this.hmrTargetWindow) {
|
|
2201
|
+
try {
|
|
2202
|
+
this.hmrTargetWindow.postMessage({ ...update, channel: 'next-hmr' }, '*');
|
|
2203
|
+
} catch (e) {
|
|
2204
|
+
// Window may be closed or unavailable
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Stop the server
|
|
2211
|
+
*/
|
|
2212
|
+
stop(): void {
|
|
2213
|
+
if (this.watcherCleanup) {
|
|
2214
|
+
this.watcherCleanup();
|
|
2215
|
+
this.watcherCleanup = null;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
this.hmrTargetWindow = null;
|
|
2219
|
+
|
|
2220
|
+
super.stop();
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
export default NextDevServer;
|