Skip to content

Commit 6b3ff5b

Browse files
petrbrzekclaude
andcommitted
Refactor next-dev-server into modular route resolver and API handler modules
Extract route resolution (~600 lines) and API handler logic (~350 lines) from next-dev-server.ts into standalone modules, reducing it from 2237 to 1357 lines. Adds 115 new unit tests for the extracted functions. Bump to 0.2.10. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bba3e69 commit 6b3ff5b

7 files changed

Lines changed: 2136 additions & 940 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.10] - 2026-02-09
9+
10+
### Changed
11+
12+
- **Next.js dev server refactoring:** Extracted route resolution and API handler logic into standalone modules, reducing `next-dev-server.ts` from ~2240 to ~1360 lines (39% reduction):
13+
- `next-route-resolver.ts` (~600 lines) — App Router/Pages Router route resolution, dynamic routes, route groups, catch-all segments
14+
- `next-api-handler.ts` (~350 lines) — mock request/response objects, cookie parsing, API handler execution, streaming support
15+
- **115 new unit tests** for the extracted modules (63 route resolver + 52 API handler)
16+
817
## [0.2.9] - 2026-02-08
918

1019
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "almostnode",
3-
"version": "0.2.9",
3+
"version": "0.2.10",
44
"description": "Node.js in your browser. Just like that.",
55
"type": "module",
66
"license": "MIT",

src/frameworks/next-api-handler.ts

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
/**
2+
* Next.js API route handling
3+
* Standalone functions extracted from NextDevServer for creating mock
4+
* request/response objects and executing API handlers.
5+
*/
6+
7+
import { ResponseData } from '../dev-server';
8+
import { Buffer } from '../shims/stream';
9+
10+
/**
11+
* Parse cookie header into key-value pairs
12+
*/
13+
export function parseCookies(cookieHeader: string): Record<string, string> {
14+
const cookies: Record<string, string> = {};
15+
if (!cookieHeader) return cookies;
16+
17+
cookieHeader.split(';').forEach(cookie => {
18+
const [name, value] = cookie.trim().split('=');
19+
if (name && value) {
20+
cookies[name] = decodeURIComponent(value);
21+
}
22+
});
23+
24+
return cookies;
25+
}
26+
27+
/**
28+
* Create mock Next.js request object
29+
*/
30+
export function createMockRequest(
31+
method: string,
32+
pathname: string,
33+
headers: Record<string, string>,
34+
body?: Buffer
35+
) {
36+
const url = new URL(pathname, 'http://localhost');
37+
38+
return {
39+
method,
40+
url: pathname,
41+
headers,
42+
query: Object.fromEntries(url.searchParams),
43+
body: body ? JSON.parse(body.toString()) : undefined,
44+
cookies: parseCookies(headers.cookie || ''),
45+
};
46+
}
47+
48+
/**
49+
* Create mock Next.js response object with streaming support
50+
*/
51+
export function createMockResponse() {
52+
let statusCode = 200;
53+
let statusMessage = 'OK';
54+
const headers: Record<string, string> = {};
55+
let responseBody = '';
56+
let ended = false;
57+
let resolveEnded: (() => void) | null = null;
58+
let headersSent = false;
59+
60+
// Promise that resolves when response is ended
61+
const endedPromise = new Promise<void>((resolve) => {
62+
resolveEnded = resolve;
63+
});
64+
65+
const markEnded = () => {
66+
if (!ended) {
67+
ended = true;
68+
if (resolveEnded) resolveEnded();
69+
}
70+
};
71+
72+
return {
73+
// Track if headers have been sent (for streaming)
74+
headersSent: false,
75+
76+
status(code: number) {
77+
statusCode = code;
78+
return this;
79+
},
80+
setHeader(name: string, value: string) {
81+
headers[name] = value;
82+
return this;
83+
},
84+
getHeader(name: string) {
85+
return headers[name];
86+
},
87+
// Write data to response body (for streaming)
88+
write(chunk: string | Buffer): boolean {
89+
if (!headersSent) {
90+
headersSent = true;
91+
this.headersSent = true;
92+
}
93+
responseBody += typeof chunk === 'string' ? chunk : chunk.toString();
94+
return true;
95+
},
96+
// Writable stream interface for AI SDK compatibility
97+
get writable() {
98+
return true;
99+
},
100+
json(data: unknown) {
101+
headers['Content-Type'] = 'application/json; charset=utf-8';
102+
responseBody = JSON.stringify(data);
103+
markEnded();
104+
return this;
105+
},
106+
send(data: string | object) {
107+
if (typeof data === 'object') {
108+
return this.json(data);
109+
}
110+
responseBody = data;
111+
markEnded();
112+
return this;
113+
},
114+
end(data?: string) {
115+
if (data) responseBody += data;
116+
markEnded();
117+
return this;
118+
},
119+
redirect(statusOrUrl: number | string, url?: string) {
120+
if (typeof statusOrUrl === 'number') {
121+
statusCode = statusOrUrl;
122+
headers['Location'] = url || '/';
123+
} else {
124+
statusCode = 307;
125+
headers['Location'] = statusOrUrl;
126+
}
127+
markEnded();
128+
return this;
129+
},
130+
isEnded() {
131+
return ended;
132+
},
133+
waitForEnd() {
134+
return endedPromise;
135+
},
136+
toResponse(): ResponseData {
137+
const buffer = Buffer.from(responseBody);
138+
headers['Content-Length'] = String(buffer.length);
139+
return {
140+
statusCode,
141+
statusMessage,
142+
headers,
143+
body: buffer,
144+
};
145+
},
146+
};
147+
}
148+
149+
/**
150+
* Create a streaming mock response that calls callbacks as data is written
151+
*/
152+
export function createStreamingMockResponse(
153+
onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
154+
onChunk: (chunk: string | Uint8Array) => void,
155+
onEnd: () => void
156+
) {
157+
let statusCode = 200;
158+
let statusMessage = 'OK';
159+
const headers: Record<string, string> = {};
160+
let ended = false;
161+
let headersSent = false;
162+
let resolveEnded: (() => void) | null = null;
163+
164+
const endedPromise = new Promise<void>((resolve) => {
165+
resolveEnded = resolve;
166+
});
167+
168+
const sendHeaders = () => {
169+
if (!headersSent) {
170+
headersSent = true;
171+
onStart(statusCode, statusMessage, headers);
172+
}
173+
};
174+
175+
const markEnded = () => {
176+
if (!ended) {
177+
sendHeaders();
178+
ended = true;
179+
onEnd();
180+
if (resolveEnded) resolveEnded();
181+
}
182+
};
183+
184+
return {
185+
headersSent: false,
186+
187+
status(code: number) {
188+
statusCode = code;
189+
return this;
190+
},
191+
setHeader(name: string, value: string) {
192+
headers[name] = value;
193+
return this;
194+
},
195+
getHeader(name: string) {
196+
return headers[name];
197+
},
198+
// Write data and stream it immediately
199+
write(chunk: string | Buffer): boolean {
200+
sendHeaders();
201+
const data = typeof chunk === 'string' ? chunk : chunk.toString();
202+
onChunk(data);
203+
return true;
204+
},
205+
get writable() {
206+
return true;
207+
},
208+
json(data: unknown) {
209+
headers['Content-Type'] = 'application/json; charset=utf-8';
210+
sendHeaders();
211+
onChunk(JSON.stringify(data));
212+
markEnded();
213+
return this;
214+
},
215+
send(data: string | object) {
216+
if (typeof data === 'object') {
217+
return this.json(data);
218+
}
219+
sendHeaders();
220+
onChunk(data);
221+
markEnded();
222+
return this;
223+
},
224+
end(data?: string) {
225+
if (data) {
226+
sendHeaders();
227+
onChunk(data);
228+
}
229+
markEnded();
230+
return this;
231+
},
232+
redirect(statusOrUrl: number | string, url?: string) {
233+
if (typeof statusOrUrl === 'number') {
234+
statusCode = statusOrUrl;
235+
headers['Location'] = url || '/';
236+
} else {
237+
statusCode = 307;
238+
headers['Location'] = statusOrUrl;
239+
}
240+
markEnded();
241+
return this;
242+
},
243+
isEnded() {
244+
return ended;
245+
},
246+
waitForEnd() {
247+
return endedPromise;
248+
},
249+
toResponse(): ResponseData {
250+
// This shouldn't be called for streaming responses
251+
return {
252+
statusCode,
253+
statusMessage,
254+
headers,
255+
body: Buffer.from(''),
256+
};
257+
},
258+
};
259+
}
260+
261+
/** Type for mock response objects */
262+
export type MockResponse = ReturnType<typeof createMockResponse>;
263+
export type MockRequest = ReturnType<typeof createMockRequest>;
264+
export type StreamingMockResponse = ReturnType<typeof createStreamingMockResponse>;
265+
266+
/**
267+
* Create builtin modules map for API handler execution.
268+
* Optionally includes `fs` shim if a createFsShim function is provided.
269+
*/
270+
export async function createBuiltinModules(
271+
createFsShim?: () => unknown | Promise<unknown>
272+
): Promise<Record<string, unknown>> {
273+
const modules: Record<string, unknown> = {
274+
https: await import('../shims/https'),
275+
http: await import('../shims/http'),
276+
path: await import('../shims/path'),
277+
url: await import('../shims/url'),
278+
querystring: await import('../shims/querystring'),
279+
util: await import('../shims/util'),
280+
events: await import('../shims/events'),
281+
stream: await import('../shims/stream'),
282+
buffer: await import('../shims/buffer'),
283+
crypto: await import('../shims/crypto'),
284+
};
285+
286+
if (createFsShim) {
287+
modules.fs = await createFsShim();
288+
}
289+
290+
return modules;
291+
}
292+
293+
/**
294+
* Execute API handler code in a sandboxed context
295+
*/
296+
export async function executeApiHandler(
297+
code: string,
298+
req: MockRequest,
299+
res: MockResponse | StreamingMockResponse,
300+
env: Record<string, string> | undefined,
301+
builtinModules: Record<string, unknown>
302+
): Promise<void> {
303+
try {
304+
const require = (id: string): unknown => {
305+
// Handle node: prefix
306+
const modId = id.startsWith('node:') ? id.slice(5) : id;
307+
if (builtinModules[modId]) {
308+
return builtinModules[modId];
309+
}
310+
throw new Error(`Module not found: ${id}`);
311+
};
312+
313+
// Create module context
314+
const module = { exports: {} as Record<string, unknown> };
315+
const exports = module.exports;
316+
317+
// Create process object with environment variables
318+
const process = {
319+
env: { ...env },
320+
cwd: () => '/',
321+
platform: 'browser',
322+
version: 'v18.0.0',
323+
versions: { node: '18.0.0' },
324+
};
325+
326+
// Execute the transformed code
327+
const fn = new Function('exports', 'require', 'module', 'process', code);
328+
fn(exports, require, module, process);
329+
330+
// Get the handler - check both module.exports and module.exports.default
331+
let handler: unknown = module.exports.default || module.exports;
332+
333+
// If handler is still an object with a default property, unwrap it
334+
if (typeof handler === 'object' && handler !== null && 'default' in handler) {
335+
handler = (handler as { default: unknown }).default;
336+
}
337+
338+
if (typeof handler !== 'function') {
339+
throw new Error('No default export handler found');
340+
}
341+
342+
// Call the handler - it may be async
343+
const result = (handler as (req: unknown, res: unknown) => unknown)(req, res);
344+
345+
// If the handler returns a promise, wait for it
346+
if (result instanceof Promise) {
347+
await result;
348+
}
349+
} catch (error) {
350+
console.error('[NextDevServer] API handler error:', error);
351+
throw error;
352+
}
353+
}

0 commit comments

Comments
 (0)