create-aziz-app 3.1.2 → 3.1.3
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/README.md +658 -207
- package/bin/create-aziz-app.js +302 -302
- package/package.json +1 -1
- package/template/README.md +402 -402
- package/template/_framework/api.js +219 -219
- package/template/_framework/boot.js +33 -33
- package/template/_framework/hooks.js +367 -291
- package/template/_framework/next-response.js +128 -128
- package/template/_framework/route-match.js +145 -145
- package/template/_framework/router.js +465 -259
- package/template/_framework/runtime.js +264 -264
- package/template/app/layout.js +18 -18
- package/template/app/page.js +33 -33
- package/template/docs/01-getting-started.md +151 -151
- package/template/docs/02-project-structure.md +109 -109
- package/template/docs/03-pages-and-layouts.md +140 -140
- package/template/docs/04-routing.md +120 -120
- package/template/docs/05-hooks.md +169 -169
- package/template/docs/06-api-routes.md +210 -210
- package/template/docs/07-api-client.md +151 -151
- package/template/docs/08-styling.md +94 -94
- package/template/docs/09-imports-and-modules.md +97 -97
- package/template/docs/10-database-and-models.md +154 -154
- package/template/docs/11-authentication.md +171 -171
- package/template/docs/12-production-and-deployment.md +121 -121
- package/template/docs/13-internals.md +179 -179
- package/template/docs/README.md +67 -67
- package/template/index.html +20 -20
- package/template/package.json +16 -16
- package/template/scripts/dev-server.js +493 -381
- package/template/scripts/lib/api-router.js +525 -525
- package/template/scripts/lib/js-transform.js +213 -195
- package/template/scripts/lib/module-resolver.js +132 -132
|
@@ -1,525 +1,525 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Aziz.js — server-side API route system
|
|
3
|
-
// - Scans api/**/*.js|mjs → /api/**
|
|
4
|
-
// - Scans app/api/**/route.js|mjs → /api/** (Next.js style)
|
|
5
|
-
// - Dynamic segments ([id]) and catch-alls ([...all])
|
|
6
|
-
// - CJS + ESM (.mjs) route modules
|
|
7
|
-
// - Request/Response helpers (cookies, params, query, json…)
|
|
8
|
-
// ============================================================
|
|
9
|
-
|
|
10
|
-
const fs = require('fs');
|
|
11
|
-
const path = require('path');
|
|
12
|
-
const { pathToFileURL } = require('url');
|
|
13
|
-
|
|
14
|
-
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
15
|
-
|
|
16
|
-
// ─── Pattern matching ────────────────────────────────────────
|
|
17
|
-
// Supports: /api/users, /api/users/[id], /api/files/[...path]
|
|
18
|
-
|
|
19
|
-
function matchApiPattern(pattern, pathname) {
|
|
20
|
-
const patternParts = pattern.split('/').filter(Boolean);
|
|
21
|
-
const pathParts = pathname.split('/').filter(Boolean);
|
|
22
|
-
const params = {};
|
|
23
|
-
|
|
24
|
-
const catchAll = patternParts.findIndex(p => p.startsWith('[...'));
|
|
25
|
-
const prefixLen = catchAll === -1 ? patternParts.length : catchAll;
|
|
26
|
-
|
|
27
|
-
if (catchAll === -1) {
|
|
28
|
-
if (patternParts.length !== pathParts.length) return null;
|
|
29
|
-
} else if (pathParts.length < patternParts.length) {
|
|
30
|
-
// /api/files/[...path] requires at least /api/files/x
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
for (let i = 0; i < prefixLen; i++) {
|
|
35
|
-
const p = patternParts[i];
|
|
36
|
-
if (p.startsWith('[') && p.endsWith(']')) {
|
|
37
|
-
params[p.slice(1, -1)] = pathParts[i];
|
|
38
|
-
} else if (p !== pathParts[i]) {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (catchAll !== -1) {
|
|
44
|
-
params[patternParts[catchAll].slice(4, -1)] = pathParts.slice(catchAll);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return params;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Specificity: more static segments first, catch-alls last.
|
|
51
|
-
function patternScore(pattern) {
|
|
52
|
-
const parts = pattern.split('/').filter(Boolean);
|
|
53
|
-
let statics = 0;
|
|
54
|
-
let dynamics = 0;
|
|
55
|
-
let catchAll = 0;
|
|
56
|
-
for (const p of parts) {
|
|
57
|
-
if (p.startsWith('[...')) catchAll++;
|
|
58
|
-
else if (p.startsWith('[')) dynamics++;
|
|
59
|
-
else statics++;
|
|
60
|
-
}
|
|
61
|
-
return { statics, dynamics, catchAll, depth: parts.length };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function compareSpecificity(a, b) {
|
|
65
|
-
if (a.catchAll !== b.catchAll) return a.catchAll - b.catchAll;
|
|
66
|
-
if (a.statics !== b.statics) return b.statics - a.statics;
|
|
67
|
-
if (a.dynamics !== b.dynamics) return a.dynamics - b.dynamics;
|
|
68
|
-
return b.depth - a.depth;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function findApiRoute(routes, pathname) {
|
|
72
|
-
for (const route of routes) {
|
|
73
|
-
const params = matchApiPattern(route.pattern, pathname);
|
|
74
|
-
if (params !== null) return { route, params };
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ─── Route discovery ─────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
function scanDirForRoutes(dir, urlBase, suffixes, out) {
|
|
82
|
-
if (!fs.existsSync(dir)) return;
|
|
83
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
84
|
-
for (const entry of entries) {
|
|
85
|
-
const full = path.join(dir, entry.name);
|
|
86
|
-
if (entry.isDirectory()) {
|
|
87
|
-
const seg = entry.name; // keeps [param] syntax for the matcher
|
|
88
|
-
scanDirForRoutes(full, urlBase === '/' ? '/' + seg : urlBase + '/' + seg, suffixes, out);
|
|
89
|
-
} else {
|
|
90
|
-
for (const suffix of suffixes) {
|
|
91
|
-
if (entry.name === suffix || entry.name === suffix.replace(/\.js$/, '.mjs')) {
|
|
92
|
-
let pattern = urlBase;
|
|
93
|
-
if (entry.name === 'route.js' || entry.name === 'route.mjs') {
|
|
94
|
-
pattern = urlBase; // /app/api/users/route.js → /api/users
|
|
95
|
-
} else {
|
|
96
|
-
pattern = (urlBase === '/' ? '' : urlBase) + '/' + entry.name.replace(/\.(m)?js$/, '');
|
|
97
|
-
}
|
|
98
|
-
if (!pattern.startsWith('/')) pattern = '/' + pattern;
|
|
99
|
-
// normalize double slashes
|
|
100
|
-
pattern = pattern.replace(/\/+/g, '/');
|
|
101
|
-
out.push({ pattern, file: full });
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function discoverApiFiles(ROOT) {
|
|
109
|
-
const found = [];
|
|
110
|
-
|
|
111
|
-
// Next.js style: app/api/**/route.js
|
|
112
|
-
const appApiDir = path.join(ROOT, 'app', 'api');
|
|
113
|
-
if (fs.existsSync(appApiDir)) {
|
|
114
|
-
const walk = (dir, urlBase) => {
|
|
115
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
-
const full = path.join(dir, entry.name);
|
|
117
|
-
if (entry.isDirectory()) {
|
|
118
|
-
walk(full, urlBase === '/' ? '/' + entry.name : urlBase + '/' + entry.name);
|
|
119
|
-
} else if (entry.name === 'route.js' || entry.name === 'route.mjs') {
|
|
120
|
-
found.push({ pattern: urlBase.replace(/\/+/g, '/'), file: full });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
walk(appApiDir, '/api');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Classic style: api/**/<name>.js
|
|
128
|
-
const apiDir = path.join(ROOT, 'api');
|
|
129
|
-
if (fs.existsSync(apiDir)) {
|
|
130
|
-
const walk = (dir, urlBase) => {
|
|
131
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
132
|
-
const full = path.join(dir, entry.name);
|
|
133
|
-
if (entry.isDirectory()) {
|
|
134
|
-
walk(full, urlBase === '/' ? '/' + entry.name : urlBase + '/' + entry.name);
|
|
135
|
-
} else if (/\.(m)?js$/.test(entry.name)) {
|
|
136
|
-
const name = entry.name.replace(/\.(m)?js$/, '');
|
|
137
|
-
const pattern = (urlBase === '/' ? '' : urlBase) + '/' + name;
|
|
138
|
-
found.push({ pattern: pattern.replace(/\/+/g, '/'), file: full });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
walk(apiDir, '/api');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return found;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ─── Module loading (CJS require + ESM .mjs / export-syntax) ─
|
|
149
|
-
|
|
150
|
-
async function loadRouteModule(filePath) {
|
|
151
|
-
if (filePath.endsWith('.mjs')) {
|
|
152
|
-
return import(pathToFileURL(filePath).href + '?t=' + Date.now());
|
|
153
|
-
}
|
|
154
|
-
try {
|
|
155
|
-
delete require.cache[require.resolve(filePath)];
|
|
156
|
-
return require(filePath);
|
|
157
|
-
} catch (err) {
|
|
158
|
-
if (err.code === 'ERR_REQUIRE_ESM' || err instanceof SyntaxError) {
|
|
159
|
-
try {
|
|
160
|
-
return await import(pathToFileURL(filePath).href + '?t=' + Date.now());
|
|
161
|
-
} catch (err2) {
|
|
162
|
-
throw new Error(
|
|
163
|
-
`${filePath}: ${err2.message}\n` +
|
|
164
|
-
` Hint: API routes can export { GET, POST, … } via module.exports, ` +
|
|
165
|
-
`or use the .mjs extension for native ESM.`
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
throw err;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Normalize any module shape into { methods, legacy }
|
|
174
|
-
// - module.exports = { GET, POST } (CJS named)
|
|
175
|
-
// - export function GET (ESM named)
|
|
176
|
-
// - export default function (req, res) (legacy Express-style)
|
|
177
|
-
// - module.exports = fn (legacy)
|
|
178
|
-
function normalizeHandlers(mod) {
|
|
179
|
-
const methods = {};
|
|
180
|
-
let legacy = null;
|
|
181
|
-
|
|
182
|
-
if (mod && typeof mod === 'object') {
|
|
183
|
-
const candidates = [mod];
|
|
184
|
-
if (mod.default && typeof mod.default === 'object') candidates.push(mod.default);
|
|
185
|
-
if (typeof mod.default === 'function') legacy = mod.default;
|
|
186
|
-
|
|
187
|
-
for (const c of candidates) {
|
|
188
|
-
if (!c || typeof c !== 'object') continue;
|
|
189
|
-
for (const m of METHODS) {
|
|
190
|
-
if (typeof c[m] === 'function') methods[m] = c[m];
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
} else if (typeof mod === 'function') {
|
|
194
|
-
legacy = mod;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return { methods, legacy };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function loadApiRoutes(ROOT, { verbose = true } = {}) {
|
|
201
|
-
const files = discoverApiFiles(ROOT);
|
|
202
|
-
const routes = [];
|
|
203
|
-
const errors = [];
|
|
204
|
-
|
|
205
|
-
for (const { pattern, file } of files) {
|
|
206
|
-
try {
|
|
207
|
-
const mod = await loadRouteModule(file);
|
|
208
|
-
const { methods, legacy } = normalizeHandlers(mod);
|
|
209
|
-
if (Object.keys(methods).length === 0 && !legacy) {
|
|
210
|
-
errors.push(`${file}: no HTTP method exports (expected GET, POST, …)`);
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
const score = patternScore(pattern);
|
|
214
|
-
routes.push({ pattern, file, methods, legacy, ...score });
|
|
215
|
-
} catch (err) {
|
|
216
|
-
errors.push(`${file}: ${err.message}`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
routes.sort(compareSpecificity);
|
|
221
|
-
|
|
222
|
-
if (verbose) {
|
|
223
|
-
console.log(`📡 Loading ${routes.length} API route(s):`);
|
|
224
|
-
for (const r of routes) {
|
|
225
|
-
const names = Object.keys(r.methods);
|
|
226
|
-
const label = names.length ? `[${names.join(', ')}]` : '[legacy handler]';
|
|
227
|
-
console.log(` ✅ ${r.pattern} ${label}`);
|
|
228
|
-
}
|
|
229
|
-
for (const e of errors) console.log(` ❌ ${e}`);
|
|
230
|
-
console.log('');
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return { routes, errors };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ─── Request helpers ─────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
function parseBody(req) {
|
|
239
|
-
return new Promise((resolve, reject) => {
|
|
240
|
-
let body = '';
|
|
241
|
-
req.on('data', c => { body += c; });
|
|
242
|
-
req.on('end', () => resolve(body));
|
|
243
|
-
req.on('error', reject);
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function parseCookies(header) {
|
|
248
|
-
const out = {};
|
|
249
|
-
if (!header) return out;
|
|
250
|
-
for (const part of header.split(';')) {
|
|
251
|
-
const i = part.indexOf('=');
|
|
252
|
-
if (i === -1) continue;
|
|
253
|
-
try {
|
|
254
|
-
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
255
|
-
} catch {
|
|
256
|
-
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return out;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function jsonParseError(message) {
|
|
263
|
-
const err = new SyntaxError(message);
|
|
264
|
-
err.status = 400;
|
|
265
|
-
return err;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Build the framework Request object handed to route handlers.
|
|
269
|
-
function buildRequest(req, params, rawBody, port) {
|
|
270
|
-
const url = new URL(req.url, `http://localhost:${port || 3000}`);
|
|
271
|
-
const contentType = req.headers['content-type'] || '';
|
|
272
|
-
|
|
273
|
-
let parsedBody;
|
|
274
|
-
if (rawBody && contentType.includes('application/json')) {
|
|
275
|
-
try {
|
|
276
|
-
parsedBody = JSON.parse(rawBody);
|
|
277
|
-
} catch {
|
|
278
|
-
parsedBody = undefined; // json() will re-throw a clean 400
|
|
279
|
-
}
|
|
280
|
-
} else {
|
|
281
|
-
parsedBody = rawBody;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const alreadyParsed = parsedBody !== undefined && parsedBody !== rawBody;
|
|
285
|
-
|
|
286
|
-
return {
|
|
287
|
-
method: req.method,
|
|
288
|
-
url: url.href,
|
|
289
|
-
nextUrl: url,
|
|
290
|
-
params,
|
|
291
|
-
query: url.searchParams,
|
|
292
|
-
headers: req.headers,
|
|
293
|
-
cookies: parseCookies(req.headers.cookie),
|
|
294
|
-
rawBody,
|
|
295
|
-
body: alreadyParsed ? parsedBody : rawBody,
|
|
296
|
-
|
|
297
|
-
json: async () => {
|
|
298
|
-
if (alreadyParsed) return parsedBody;
|
|
299
|
-
if (!rawBody) throw jsonParseError('Unexpected end of JSON input');
|
|
300
|
-
try {
|
|
301
|
-
return JSON.parse(rawBody);
|
|
302
|
-
} catch {
|
|
303
|
-
throw jsonParseError('Invalid JSON in request body');
|
|
304
|
-
}
|
|
305
|
-
},
|
|
306
|
-
|
|
307
|
-
text: async () => (typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)),
|
|
308
|
-
|
|
309
|
-
formData: async () => {
|
|
310
|
-
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
311
|
-
const params2 = new URLSearchParams(rawBody);
|
|
312
|
-
const obj = {};
|
|
313
|
-
for (const [k, v] of params2) obj[k] = v;
|
|
314
|
-
return obj;
|
|
315
|
-
}
|
|
316
|
-
if (alreadyParsed) return parsedBody;
|
|
317
|
-
throw new Error(`Unsupported content type for formData(): ${contentType || '(none)'}`);
|
|
318
|
-
},
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ─── Response sending ────────────────────────────────────────
|
|
323
|
-
|
|
324
|
-
const CORS_HEADERS = {
|
|
325
|
-
'Access-Control-Allow-Origin': '*',
|
|
326
|
-
'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS',
|
|
327
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
function headersToPlain(headers) {
|
|
331
|
-
const out = {};
|
|
332
|
-
if (!headers) return out;
|
|
333
|
-
if (headers instanceof Map) {
|
|
334
|
-
for (const [k, v] of headers) out[k] = v;
|
|
335
|
-
} else if (typeof headers.forEach === 'function') {
|
|
336
|
-
// Headers instance (or array of pairs)
|
|
337
|
-
if (Array.isArray(headers)) {
|
|
338
|
-
for (const [k, v] of headers) out[k] = v;
|
|
339
|
-
} else {
|
|
340
|
-
headers.forEach((v, k) => { out[k] = v; });
|
|
341
|
-
}
|
|
342
|
-
} else if (typeof headers === 'object') {
|
|
343
|
-
Object.assign(out, headers);
|
|
344
|
-
}
|
|
345
|
-
return out;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function looksLikeJSON(str) {
|
|
349
|
-
if (typeof str !== 'string') return false;
|
|
350
|
-
const t = str.trimStart();
|
|
351
|
-
return t.startsWith('{') || t.startsWith('[');
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function sendJson(res, status, data, headOnly) {
|
|
355
|
-
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS });
|
|
356
|
-
res.end(headOnly ? undefined : JSON.stringify(data));
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function isResponseLike(result) {
|
|
360
|
-
if (!result || typeof result !== 'object') return false;
|
|
361
|
-
if (result.body !== undefined) return true; // NextResponse / Response
|
|
362
|
-
return typeof result.status === 'number' && result.headers != null;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function sendHandlerResult(result, res, { headOnly = false } = {}) {
|
|
366
|
-
if (res.writableEnded) return;
|
|
367
|
-
|
|
368
|
-
if (result === undefined || result === null) {
|
|
369
|
-
return sendJson(res, 500, {
|
|
370
|
-
error: 'API handler returned no response',
|
|
371
|
-
hint: 'Return NextResponse.json(...), a plain object, or use the legacy res.json(...) API.',
|
|
372
|
-
}, headOnly);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (typeof result === 'string') {
|
|
376
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ...CORS_HEADERS });
|
|
377
|
-
res.end(headOnly ? undefined : result);
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
if (isResponseLike(result)) {
|
|
382
|
-
const headers = headersToPlain(result.headers);
|
|
383
|
-
if (!headers['Content-Type'] && looksLikeJSON(result.body)) {
|
|
384
|
-
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
385
|
-
}
|
|
386
|
-
if (!headers['Access-Control-Allow-Origin']) {
|
|
387
|
-
Object.assign(headers, CORS_HEADERS);
|
|
388
|
-
}
|
|
389
|
-
const status = typeof result.status === 'number' ? result.status : 200;
|
|
390
|
-
res.writeHead(status, headers);
|
|
391
|
-
res.end(headOnly ? undefined : (result.body ?? null));
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Plain object / array → JSON
|
|
396
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS });
|
|
397
|
-
res.end(headOnly ? undefined : JSON.stringify(result));
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Legacy (req, res) handler support — Express/Next-pages style.
|
|
401
|
-
function legacyResponseShim(req, res, request) {
|
|
402
|
-
return {
|
|
403
|
-
statusCode: 200,
|
|
404
|
-
headers: {},
|
|
405
|
-
status(code) { this.statusCode = code; return this; },
|
|
406
|
-
json(data) {
|
|
407
|
-
res.writeHead(this.statusCode, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS, ...this.headers });
|
|
408
|
-
res.end(JSON.stringify(data));
|
|
409
|
-
},
|
|
410
|
-
send(data) {
|
|
411
|
-
const type = typeof data === 'object' ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
412
|
-
res.writeHead(this.statusCode, { 'Content-Type': type, ...CORS_HEADERS, ...this.headers });
|
|
413
|
-
res.end(typeof data === 'object' ? JSON.stringify(data) : data);
|
|
414
|
-
},
|
|
415
|
-
setHeader(k, v) { this.headers[k] = v; return this; },
|
|
416
|
-
redirect(url, code = 307) {
|
|
417
|
-
res.writeHead(code, { 'Location': url, ...CORS_HEADERS, ...this.headers });
|
|
418
|
-
res.end();
|
|
419
|
-
},
|
|
420
|
-
// Enriched request fields for legacy handlers
|
|
421
|
-
params: request.params,
|
|
422
|
-
cookies: request.cookies,
|
|
423
|
-
nextUrl: request.nextUrl,
|
|
424
|
-
query: Object.fromEntries(request.query),
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// ─── Full request dispatcher ─────────────────────────────────
|
|
429
|
-
|
|
430
|
-
async function handleApiRequest(req, res, { routes, port }) {
|
|
431
|
-
const rawPath = req.url.split('?')[0];
|
|
432
|
-
let pathname;
|
|
433
|
-
try {
|
|
434
|
-
pathname = decodeURIComponent(rawPath);
|
|
435
|
-
} catch {
|
|
436
|
-
return sendJson(res, 400, { error: 'Malformed URL' });
|
|
437
|
-
}
|
|
438
|
-
if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1);
|
|
439
|
-
|
|
440
|
-
const match = findApiRoute(routes, pathname);
|
|
441
|
-
|
|
442
|
-
// OPTIONS preflight (route exists or not)
|
|
443
|
-
if (req.method === 'OPTIONS') {
|
|
444
|
-
const allow = match
|
|
445
|
-
? Object.keys(match.route.methods).concat(match.route.legacy ? METHODS : []).join(', ')
|
|
446
|
-
: 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS';
|
|
447
|
-
res.writeHead(204, { 'Allow': allow, ...CORS_HEADERS });
|
|
448
|
-
res.end();
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (!match) {
|
|
453
|
-
return sendJson(res, 404, { error: `API route ${pathname} not found` });
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const { route, params } = match;
|
|
457
|
-
const headOnly = req.method === 'HEAD';
|
|
458
|
-
|
|
459
|
-
try {
|
|
460
|
-
const rawBody = await parseBody(req);
|
|
461
|
-
const request = buildRequest(req, params, rawBody, port);
|
|
462
|
-
|
|
463
|
-
let methodFn = route.methods[req.method];
|
|
464
|
-
if (!methodFn && headOnly) methodFn = route.methods.GET;
|
|
465
|
-
|
|
466
|
-
if (!methodFn && !route.legacy) {
|
|
467
|
-
res.writeHead(405, {
|
|
468
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
469
|
-
'Allow': Object.keys(route.methods).join(', '),
|
|
470
|
-
...CORS_HEADERS,
|
|
471
|
-
});
|
|
472
|
-
res.end(JSON.stringify({
|
|
473
|
-
error: `Method ${req.method} not allowed`,
|
|
474
|
-
allowed: Object.keys(route.methods),
|
|
475
|
-
}));
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (methodFn) {
|
|
480
|
-
const result = await methodFn(request);
|
|
481
|
-
sendHandlerResult(result, res, { headOnly });
|
|
482
|
-
} else {
|
|
483
|
-
// Legacy (req, res) handler writes the response itself.
|
|
484
|
-
const apiRes = legacyResponseShim(req, res, request);
|
|
485
|
-
await route.legacy(request, apiRes);
|
|
486
|
-
if (!res.writableEnded) {
|
|
487
|
-
sendJson(res, 500, { error: 'Legacy handler never sent a response' });
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
} catch (err) {
|
|
491
|
-
const status = err && err.status ? err.status : (err instanceof SyntaxError ? 400 : 500);
|
|
492
|
-
console.error(`❌ API Error ${req.method} ${pathname}:`, err);
|
|
493
|
-
sendJson(res, status, { error: err.message || 'Internal Server Error' });
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Purge require cache for project files (not node_modules) so
|
|
498
|
-
// API routes see fresh lib/ and models/ code after edits.
|
|
499
|
-
function purgeProjectRequireCache(ROOT) {
|
|
500
|
-
const prefix = path.resolve(ROOT) + path.sep;
|
|
501
|
-
for (const key of Object.keys(require.cache)) {
|
|
502
|
-
if (key.startsWith(prefix) && !key.includes('node_modules')) {
|
|
503
|
-
delete require.cache[key];
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
module.exports = {
|
|
509
|
-
METHODS,
|
|
510
|
-
matchApiPattern,
|
|
511
|
-
patternScore,
|
|
512
|
-
compareSpecificity,
|
|
513
|
-
findApiRoute,
|
|
514
|
-
discoverApiFiles,
|
|
515
|
-
loadRouteModule,
|
|
516
|
-
normalizeHandlers,
|
|
517
|
-
loadApiRoutes,
|
|
518
|
-
parseBody,
|
|
519
|
-
parseCookies,
|
|
520
|
-
buildRequest,
|
|
521
|
-
sendHandlerResult,
|
|
522
|
-
handleApiRequest,
|
|
523
|
-
purgeProjectRequireCache,
|
|
524
|
-
headersToPlain,
|
|
525
|
-
};
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Aziz.js — server-side API route system
|
|
3
|
+
// - Scans api/**/*.js|mjs → /api/**
|
|
4
|
+
// - Scans app/api/**/route.js|mjs → /api/** (Next.js style)
|
|
5
|
+
// - Dynamic segments ([id]) and catch-alls ([...all])
|
|
6
|
+
// - CJS + ESM (.mjs) route modules
|
|
7
|
+
// - Request/Response helpers (cookies, params, query, json…)
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { pathToFileURL } = require('url');
|
|
13
|
+
|
|
14
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
15
|
+
|
|
16
|
+
// ─── Pattern matching ────────────────────────────────────────
|
|
17
|
+
// Supports: /api/users, /api/users/[id], /api/files/[...path]
|
|
18
|
+
|
|
19
|
+
function matchApiPattern(pattern, pathname) {
|
|
20
|
+
const patternParts = pattern.split('/').filter(Boolean);
|
|
21
|
+
const pathParts = pathname.split('/').filter(Boolean);
|
|
22
|
+
const params = {};
|
|
23
|
+
|
|
24
|
+
const catchAll = patternParts.findIndex(p => p.startsWith('[...'));
|
|
25
|
+
const prefixLen = catchAll === -1 ? patternParts.length : catchAll;
|
|
26
|
+
|
|
27
|
+
if (catchAll === -1) {
|
|
28
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
29
|
+
} else if (pathParts.length < patternParts.length) {
|
|
30
|
+
// /api/files/[...path] requires at least /api/files/x
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < prefixLen; i++) {
|
|
35
|
+
const p = patternParts[i];
|
|
36
|
+
if (p.startsWith('[') && p.endsWith(']')) {
|
|
37
|
+
params[p.slice(1, -1)] = pathParts[i];
|
|
38
|
+
} else if (p !== pathParts[i]) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (catchAll !== -1) {
|
|
44
|
+
params[patternParts[catchAll].slice(4, -1)] = pathParts.slice(catchAll);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return params;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Specificity: more static segments first, catch-alls last.
|
|
51
|
+
function patternScore(pattern) {
|
|
52
|
+
const parts = pattern.split('/').filter(Boolean);
|
|
53
|
+
let statics = 0;
|
|
54
|
+
let dynamics = 0;
|
|
55
|
+
let catchAll = 0;
|
|
56
|
+
for (const p of parts) {
|
|
57
|
+
if (p.startsWith('[...')) catchAll++;
|
|
58
|
+
else if (p.startsWith('[')) dynamics++;
|
|
59
|
+
else statics++;
|
|
60
|
+
}
|
|
61
|
+
return { statics, dynamics, catchAll, depth: parts.length };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compareSpecificity(a, b) {
|
|
65
|
+
if (a.catchAll !== b.catchAll) return a.catchAll - b.catchAll;
|
|
66
|
+
if (a.statics !== b.statics) return b.statics - a.statics;
|
|
67
|
+
if (a.dynamics !== b.dynamics) return a.dynamics - b.dynamics;
|
|
68
|
+
return b.depth - a.depth;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findApiRoute(routes, pathname) {
|
|
72
|
+
for (const route of routes) {
|
|
73
|
+
const params = matchApiPattern(route.pattern, pathname);
|
|
74
|
+
if (params !== null) return { route, params };
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Route discovery ─────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function scanDirForRoutes(dir, urlBase, suffixes, out) {
|
|
82
|
+
if (!fs.existsSync(dir)) return;
|
|
83
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const full = path.join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
const seg = entry.name; // keeps [param] syntax for the matcher
|
|
88
|
+
scanDirForRoutes(full, urlBase === '/' ? '/' + seg : urlBase + '/' + seg, suffixes, out);
|
|
89
|
+
} else {
|
|
90
|
+
for (const suffix of suffixes) {
|
|
91
|
+
if (entry.name === suffix || entry.name === suffix.replace(/\.js$/, '.mjs')) {
|
|
92
|
+
let pattern = urlBase;
|
|
93
|
+
if (entry.name === 'route.js' || entry.name === 'route.mjs') {
|
|
94
|
+
pattern = urlBase; // /app/api/users/route.js → /api/users
|
|
95
|
+
} else {
|
|
96
|
+
pattern = (urlBase === '/' ? '' : urlBase) + '/' + entry.name.replace(/\.(m)?js$/, '');
|
|
97
|
+
}
|
|
98
|
+
if (!pattern.startsWith('/')) pattern = '/' + pattern;
|
|
99
|
+
// normalize double slashes
|
|
100
|
+
pattern = pattern.replace(/\/+/g, '/');
|
|
101
|
+
out.push({ pattern, file: full });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function discoverApiFiles(ROOT) {
|
|
109
|
+
const found = [];
|
|
110
|
+
|
|
111
|
+
// Next.js style: app/api/**/route.js
|
|
112
|
+
const appApiDir = path.join(ROOT, 'app', 'api');
|
|
113
|
+
if (fs.existsSync(appApiDir)) {
|
|
114
|
+
const walk = (dir, urlBase) => {
|
|
115
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
+
const full = path.join(dir, entry.name);
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
walk(full, urlBase === '/' ? '/' + entry.name : urlBase + '/' + entry.name);
|
|
119
|
+
} else if (entry.name === 'route.js' || entry.name === 'route.mjs') {
|
|
120
|
+
found.push({ pattern: urlBase.replace(/\/+/g, '/'), file: full });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
walk(appApiDir, '/api');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Classic style: api/**/<name>.js
|
|
128
|
+
const apiDir = path.join(ROOT, 'api');
|
|
129
|
+
if (fs.existsSync(apiDir)) {
|
|
130
|
+
const walk = (dir, urlBase) => {
|
|
131
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
132
|
+
const full = path.join(dir, entry.name);
|
|
133
|
+
if (entry.isDirectory()) {
|
|
134
|
+
walk(full, urlBase === '/' ? '/' + entry.name : urlBase + '/' + entry.name);
|
|
135
|
+
} else if (/\.(m)?js$/.test(entry.name)) {
|
|
136
|
+
const name = entry.name.replace(/\.(m)?js$/, '');
|
|
137
|
+
const pattern = (urlBase === '/' ? '' : urlBase) + '/' + name;
|
|
138
|
+
found.push({ pattern: pattern.replace(/\/+/g, '/'), file: full });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(apiDir, '/api');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Module loading (CJS require + ESM .mjs / export-syntax) ─
|
|
149
|
+
|
|
150
|
+
async function loadRouteModule(filePath) {
|
|
151
|
+
if (filePath.endsWith('.mjs')) {
|
|
152
|
+
return import(pathToFileURL(filePath).href + '?t=' + Date.now());
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
delete require.cache[require.resolve(filePath)];
|
|
156
|
+
return require(filePath);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err.code === 'ERR_REQUIRE_ESM' || err instanceof SyntaxError) {
|
|
159
|
+
try {
|
|
160
|
+
return await import(pathToFileURL(filePath).href + '?t=' + Date.now());
|
|
161
|
+
} catch (err2) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`${filePath}: ${err2.message}\n` +
|
|
164
|
+
` Hint: API routes can export { GET, POST, … } via module.exports, ` +
|
|
165
|
+
`or use the .mjs extension for native ESM.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Normalize any module shape into { methods, legacy }
|
|
174
|
+
// - module.exports = { GET, POST } (CJS named)
|
|
175
|
+
// - export function GET (ESM named)
|
|
176
|
+
// - export default function (req, res) (legacy Express-style)
|
|
177
|
+
// - module.exports = fn (legacy)
|
|
178
|
+
function normalizeHandlers(mod) {
|
|
179
|
+
const methods = {};
|
|
180
|
+
let legacy = null;
|
|
181
|
+
|
|
182
|
+
if (mod && typeof mod === 'object') {
|
|
183
|
+
const candidates = [mod];
|
|
184
|
+
if (mod.default && typeof mod.default === 'object') candidates.push(mod.default);
|
|
185
|
+
if (typeof mod.default === 'function') legacy = mod.default;
|
|
186
|
+
|
|
187
|
+
for (const c of candidates) {
|
|
188
|
+
if (!c || typeof c !== 'object') continue;
|
|
189
|
+
for (const m of METHODS) {
|
|
190
|
+
if (typeof c[m] === 'function') methods[m] = c[m];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else if (typeof mod === 'function') {
|
|
194
|
+
legacy = mod;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { methods, legacy };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function loadApiRoutes(ROOT, { verbose = true } = {}) {
|
|
201
|
+
const files = discoverApiFiles(ROOT);
|
|
202
|
+
const routes = [];
|
|
203
|
+
const errors = [];
|
|
204
|
+
|
|
205
|
+
for (const { pattern, file } of files) {
|
|
206
|
+
try {
|
|
207
|
+
const mod = await loadRouteModule(file);
|
|
208
|
+
const { methods, legacy } = normalizeHandlers(mod);
|
|
209
|
+
if (Object.keys(methods).length === 0 && !legacy) {
|
|
210
|
+
errors.push(`${file}: no HTTP method exports (expected GET, POST, …)`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const score = patternScore(pattern);
|
|
214
|
+
routes.push({ pattern, file, methods, legacy, ...score });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
errors.push(`${file}: ${err.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
routes.sort(compareSpecificity);
|
|
221
|
+
|
|
222
|
+
if (verbose) {
|
|
223
|
+
console.log(`📡 Loading ${routes.length} API route(s):`);
|
|
224
|
+
for (const r of routes) {
|
|
225
|
+
const names = Object.keys(r.methods);
|
|
226
|
+
const label = names.length ? `[${names.join(', ')}]` : '[legacy handler]';
|
|
227
|
+
console.log(` ✅ ${r.pattern} ${label}`);
|
|
228
|
+
}
|
|
229
|
+
for (const e of errors) console.log(` ❌ ${e}`);
|
|
230
|
+
console.log('');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { routes, errors };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Request helpers ─────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function parseBody(req) {
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
let body = '';
|
|
241
|
+
req.on('data', c => { body += c; });
|
|
242
|
+
req.on('end', () => resolve(body));
|
|
243
|
+
req.on('error', reject);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseCookies(header) {
|
|
248
|
+
const out = {};
|
|
249
|
+
if (!header) return out;
|
|
250
|
+
for (const part of header.split(';')) {
|
|
251
|
+
const i = part.indexOf('=');
|
|
252
|
+
if (i === -1) continue;
|
|
253
|
+
try {
|
|
254
|
+
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
255
|
+
} catch {
|
|
256
|
+
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function jsonParseError(message) {
|
|
263
|
+
const err = new SyntaxError(message);
|
|
264
|
+
err.status = 400;
|
|
265
|
+
return err;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build the framework Request object handed to route handlers.
|
|
269
|
+
function buildRequest(req, params, rawBody, port) {
|
|
270
|
+
const url = new URL(req.url, `http://localhost:${port || 3000}`);
|
|
271
|
+
const contentType = req.headers['content-type'] || '';
|
|
272
|
+
|
|
273
|
+
let parsedBody;
|
|
274
|
+
if (rawBody && contentType.includes('application/json')) {
|
|
275
|
+
try {
|
|
276
|
+
parsedBody = JSON.parse(rawBody);
|
|
277
|
+
} catch {
|
|
278
|
+
parsedBody = undefined; // json() will re-throw a clean 400
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
parsedBody = rawBody;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const alreadyParsed = parsedBody !== undefined && parsedBody !== rawBody;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
method: req.method,
|
|
288
|
+
url: url.href,
|
|
289
|
+
nextUrl: url,
|
|
290
|
+
params,
|
|
291
|
+
query: url.searchParams,
|
|
292
|
+
headers: req.headers,
|
|
293
|
+
cookies: parseCookies(req.headers.cookie),
|
|
294
|
+
rawBody,
|
|
295
|
+
body: alreadyParsed ? parsedBody : rawBody,
|
|
296
|
+
|
|
297
|
+
json: async () => {
|
|
298
|
+
if (alreadyParsed) return parsedBody;
|
|
299
|
+
if (!rawBody) throw jsonParseError('Unexpected end of JSON input');
|
|
300
|
+
try {
|
|
301
|
+
return JSON.parse(rawBody);
|
|
302
|
+
} catch {
|
|
303
|
+
throw jsonParseError('Invalid JSON in request body');
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
text: async () => (typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)),
|
|
308
|
+
|
|
309
|
+
formData: async () => {
|
|
310
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
311
|
+
const params2 = new URLSearchParams(rawBody);
|
|
312
|
+
const obj = {};
|
|
313
|
+
for (const [k, v] of params2) obj[k] = v;
|
|
314
|
+
return obj;
|
|
315
|
+
}
|
|
316
|
+
if (alreadyParsed) return parsedBody;
|
|
317
|
+
throw new Error(`Unsupported content type for formData(): ${contentType || '(none)'}`);
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── Response sending ────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
const CORS_HEADERS = {
|
|
325
|
+
'Access-Control-Allow-Origin': '*',
|
|
326
|
+
'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS',
|
|
327
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
function headersToPlain(headers) {
|
|
331
|
+
const out = {};
|
|
332
|
+
if (!headers) return out;
|
|
333
|
+
if (headers instanceof Map) {
|
|
334
|
+
for (const [k, v] of headers) out[k] = v;
|
|
335
|
+
} else if (typeof headers.forEach === 'function') {
|
|
336
|
+
// Headers instance (or array of pairs)
|
|
337
|
+
if (Array.isArray(headers)) {
|
|
338
|
+
for (const [k, v] of headers) out[k] = v;
|
|
339
|
+
} else {
|
|
340
|
+
headers.forEach((v, k) => { out[k] = v; });
|
|
341
|
+
}
|
|
342
|
+
} else if (typeof headers === 'object') {
|
|
343
|
+
Object.assign(out, headers);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function looksLikeJSON(str) {
|
|
349
|
+
if (typeof str !== 'string') return false;
|
|
350
|
+
const t = str.trimStart();
|
|
351
|
+
return t.startsWith('{') || t.startsWith('[');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sendJson(res, status, data, headOnly) {
|
|
355
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS });
|
|
356
|
+
res.end(headOnly ? undefined : JSON.stringify(data));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isResponseLike(result) {
|
|
360
|
+
if (!result || typeof result !== 'object') return false;
|
|
361
|
+
if (result.body !== undefined) return true; // NextResponse / Response
|
|
362
|
+
return typeof result.status === 'number' && result.headers != null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function sendHandlerResult(result, res, { headOnly = false } = {}) {
|
|
366
|
+
if (res.writableEnded) return;
|
|
367
|
+
|
|
368
|
+
if (result === undefined || result === null) {
|
|
369
|
+
return sendJson(res, 500, {
|
|
370
|
+
error: 'API handler returned no response',
|
|
371
|
+
hint: 'Return NextResponse.json(...), a plain object, or use the legacy res.json(...) API.',
|
|
372
|
+
}, headOnly);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (typeof result === 'string') {
|
|
376
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ...CORS_HEADERS });
|
|
377
|
+
res.end(headOnly ? undefined : result);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (isResponseLike(result)) {
|
|
382
|
+
const headers = headersToPlain(result.headers);
|
|
383
|
+
if (!headers['Content-Type'] && looksLikeJSON(result.body)) {
|
|
384
|
+
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
385
|
+
}
|
|
386
|
+
if (!headers['Access-Control-Allow-Origin']) {
|
|
387
|
+
Object.assign(headers, CORS_HEADERS);
|
|
388
|
+
}
|
|
389
|
+
const status = typeof result.status === 'number' ? result.status : 200;
|
|
390
|
+
res.writeHead(status, headers);
|
|
391
|
+
res.end(headOnly ? undefined : (result.body ?? null));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Plain object / array → JSON
|
|
396
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS });
|
|
397
|
+
res.end(headOnly ? undefined : JSON.stringify(result));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Legacy (req, res) handler support — Express/Next-pages style.
|
|
401
|
+
function legacyResponseShim(req, res, request) {
|
|
402
|
+
return {
|
|
403
|
+
statusCode: 200,
|
|
404
|
+
headers: {},
|
|
405
|
+
status(code) { this.statusCode = code; return this; },
|
|
406
|
+
json(data) {
|
|
407
|
+
res.writeHead(this.statusCode, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS, ...this.headers });
|
|
408
|
+
res.end(JSON.stringify(data));
|
|
409
|
+
},
|
|
410
|
+
send(data) {
|
|
411
|
+
const type = typeof data === 'object' ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
412
|
+
res.writeHead(this.statusCode, { 'Content-Type': type, ...CORS_HEADERS, ...this.headers });
|
|
413
|
+
res.end(typeof data === 'object' ? JSON.stringify(data) : data);
|
|
414
|
+
},
|
|
415
|
+
setHeader(k, v) { this.headers[k] = v; return this; },
|
|
416
|
+
redirect(url, code = 307) {
|
|
417
|
+
res.writeHead(code, { 'Location': url, ...CORS_HEADERS, ...this.headers });
|
|
418
|
+
res.end();
|
|
419
|
+
},
|
|
420
|
+
// Enriched request fields for legacy handlers
|
|
421
|
+
params: request.params,
|
|
422
|
+
cookies: request.cookies,
|
|
423
|
+
nextUrl: request.nextUrl,
|
|
424
|
+
query: Object.fromEntries(request.query),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Full request dispatcher ─────────────────────────────────
|
|
429
|
+
|
|
430
|
+
async function handleApiRequest(req, res, { routes, port }) {
|
|
431
|
+
const rawPath = req.url.split('?')[0];
|
|
432
|
+
let pathname;
|
|
433
|
+
try {
|
|
434
|
+
pathname = decodeURIComponent(rawPath);
|
|
435
|
+
} catch {
|
|
436
|
+
return sendJson(res, 400, { error: 'Malformed URL' });
|
|
437
|
+
}
|
|
438
|
+
if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1);
|
|
439
|
+
|
|
440
|
+
const match = findApiRoute(routes, pathname);
|
|
441
|
+
|
|
442
|
+
// OPTIONS preflight (route exists or not)
|
|
443
|
+
if (req.method === 'OPTIONS') {
|
|
444
|
+
const allow = match
|
|
445
|
+
? Object.keys(match.route.methods).concat(match.route.legacy ? METHODS : []).join(', ')
|
|
446
|
+
: 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS';
|
|
447
|
+
res.writeHead(204, { 'Allow': allow, ...CORS_HEADERS });
|
|
448
|
+
res.end();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!match) {
|
|
453
|
+
return sendJson(res, 404, { error: `API route ${pathname} not found` });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const { route, params } = match;
|
|
457
|
+
const headOnly = req.method === 'HEAD';
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const rawBody = await parseBody(req);
|
|
461
|
+
const request = buildRequest(req, params, rawBody, port);
|
|
462
|
+
|
|
463
|
+
let methodFn = route.methods[req.method];
|
|
464
|
+
if (!methodFn && headOnly) methodFn = route.methods.GET;
|
|
465
|
+
|
|
466
|
+
if (!methodFn && !route.legacy) {
|
|
467
|
+
res.writeHead(405, {
|
|
468
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
469
|
+
'Allow': Object.keys(route.methods).join(', '),
|
|
470
|
+
...CORS_HEADERS,
|
|
471
|
+
});
|
|
472
|
+
res.end(JSON.stringify({
|
|
473
|
+
error: `Method ${req.method} not allowed`,
|
|
474
|
+
allowed: Object.keys(route.methods),
|
|
475
|
+
}));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (methodFn) {
|
|
480
|
+
const result = await methodFn(request);
|
|
481
|
+
sendHandlerResult(result, res, { headOnly });
|
|
482
|
+
} else {
|
|
483
|
+
// Legacy (req, res) handler writes the response itself.
|
|
484
|
+
const apiRes = legacyResponseShim(req, res, request);
|
|
485
|
+
await route.legacy(request, apiRes);
|
|
486
|
+
if (!res.writableEnded) {
|
|
487
|
+
sendJson(res, 500, { error: 'Legacy handler never sent a response' });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
const status = err && err.status ? err.status : (err instanceof SyntaxError ? 400 : 500);
|
|
492
|
+
console.error(`❌ API Error ${req.method} ${pathname}:`, err);
|
|
493
|
+
sendJson(res, status, { error: err.message || 'Internal Server Error' });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Purge require cache for project files (not node_modules) so
|
|
498
|
+
// API routes see fresh lib/ and models/ code after edits.
|
|
499
|
+
function purgeProjectRequireCache(ROOT) {
|
|
500
|
+
const prefix = path.resolve(ROOT) + path.sep;
|
|
501
|
+
for (const key of Object.keys(require.cache)) {
|
|
502
|
+
if (key.startsWith(prefix) && !key.includes('node_modules')) {
|
|
503
|
+
delete require.cache[key];
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = {
|
|
509
|
+
METHODS,
|
|
510
|
+
matchApiPattern,
|
|
511
|
+
patternScore,
|
|
512
|
+
compareSpecificity,
|
|
513
|
+
findApiRoute,
|
|
514
|
+
discoverApiFiles,
|
|
515
|
+
loadRouteModule,
|
|
516
|
+
normalizeHandlers,
|
|
517
|
+
loadApiRoutes,
|
|
518
|
+
parseBody,
|
|
519
|
+
parseCookies,
|
|
520
|
+
buildRequest,
|
|
521
|
+
sendHandlerResult,
|
|
522
|
+
handleApiRequest,
|
|
523
|
+
purgeProjectRequireCache,
|
|
524
|
+
headersToPlain,
|
|
525
|
+
};
|