create-aziz-app 3.1.1 → 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.
Files changed (33) hide show
  1. package/README.md +658 -207
  2. package/bin/create-aziz-app.js +302 -351
  3. package/package.json +1 -1
  4. package/template/README.md +14 -279
  5. package/template/_framework/api.js +219 -219
  6. package/template/_framework/boot.js +33 -33
  7. package/template/_framework/hooks.js +367 -291
  8. package/template/_framework/next-response.js +128 -128
  9. package/template/_framework/route-match.js +145 -145
  10. package/template/_framework/router.js +465 -259
  11. package/template/_framework/runtime.js +264 -264
  12. package/template/app/layout.js +3 -5
  13. package/template/app/page.js +9 -32
  14. package/template/docs/01-getting-started.md +151 -151
  15. package/template/docs/02-project-structure.md +109 -109
  16. package/template/docs/03-pages-and-layouts.md +140 -140
  17. package/template/docs/04-routing.md +120 -120
  18. package/template/docs/05-hooks.md +169 -169
  19. package/template/docs/06-api-routes.md +210 -210
  20. package/template/docs/07-api-client.md +151 -151
  21. package/template/docs/08-styling.md +94 -94
  22. package/template/docs/09-imports-and-modules.md +97 -97
  23. package/template/docs/10-database-and-models.md +154 -154
  24. package/template/docs/11-authentication.md +171 -171
  25. package/template/docs/12-production-and-deployment.md +121 -121
  26. package/template/docs/13-internals.md +179 -179
  27. package/template/docs/README.md +67 -67
  28. package/template/index.html +20 -20
  29. package/template/package.json +16 -16
  30. package/template/scripts/dev-server.js +493 -381
  31. package/template/scripts/lib/api-router.js +525 -525
  32. package/template/scripts/lib/js-transform.js +213 -141
  33. package/template/scripts/lib/module-resolver.js +132 -132
@@ -1,381 +1,493 @@
1
- #!/usr/bin/env node
2
-
3
- // ============================================================
4
- // Aziz.js Dev Server
5
- // - Serves the app with server-side JSX transform (cached)
6
- // - API routes: api/** and app/api/**/route.js
7
- // - node_modules bare-import resolution (/_modules/…)
8
- // - WebSocket live reload (development only)
9
- // - Production mode: node scripts/dev-server.js --production
10
- // ============================================================
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
- const http = require('http');
15
- const crypto = require('crypto');
16
-
17
- const { hasBabel, transformModule, invalidate: invalidateTransform } = require('./lib/js-transform');
18
- const { resolveNodeModule } = require('./lib/module-resolver');
19
- const apiRouter = require('./lib/api-router');
20
-
21
- const PORT = parseInt(process.env.PORT, 10) || 3000;
22
- const ROOT = process.cwd();
23
- const PRODUCTION = process.argv.includes('--production') || process.env.NODE_ENV === 'production';
24
-
25
- const clients = new Set();
26
- let apiRoutes = [];
27
-
28
- if (!hasBabel()) {
29
- console.warn('⚠️ @babel/standalone not found — JSX files will not be compiled.');
30
- console.warn(' Run: npm install --save-dev @babel/standalone');
31
- }
32
-
33
- console.log(`🚀 Aziz.js dev server starting${PRODUCTION ? ' (production mode)' : ''}...\n`);
34
-
35
- // ─── Route Manifest ──────────────────────────────────────────
36
- // Scans app/ and builds the page/layout/not-found list for the client router.
37
-
38
- function buildRouteManifest() {
39
- const manifest = [];
40
- const appDir = path.join(ROOT, 'app');
41
- if (!fs.existsSync(appDir)) return manifest;
42
-
43
- function scan(dir, routeBase) {
44
- const entries = fs.readdirSync(dir, { withFileTypes: true });
45
-
46
- for (const entry of entries) {
47
- const fullPath = path.join(dir, entry.name);
48
-
49
- if (entry.isDirectory()) {
50
- // Keep [slug] as-is — the router matcher expects bracket syntax
51
- const segment = entry.name;
52
- scan(fullPath, routeBase === '/' ? '/' + segment : routeBase + '/' + segment);
53
- } else if (entry.name === 'page.js' || entry.name === 'page.jsx') {
54
- const pathname = routeBase || '/';
55
- const filePath = '/app' + (pathname === '/' ? '' : pathname) + '/' + entry.name;
56
- manifest.push({ type: 'page', pathname, filePath });
57
- } else if (entry.name === 'layout.js' || entry.name === 'layout.jsx') {
58
- const pathname = routeBase || '/';
59
- const filePath = '/app' + (pathname === '/' ? '' : pathname) + '/' + entry.name;
60
- manifest.push({ type: 'layout', pathname, filePath });
61
- } else if (entry.name === 'not-found.js' || entry.name === 'not-found.jsx') {
62
- if (routeBase === '/') {
63
- manifest.push({ type: 'not-found', pathname: '/not-found', filePath: '/app/not-found.js' });
64
- }
65
- }
66
- // route.js files are API routes — handled by api-router, not the page router
67
- }
68
- }
69
-
70
- scan(appDir, '/');
71
- return manifest;
72
- }
73
-
74
- // ─── API routes ──────────────────────────────────────────────
75
-
76
- async function reloadApiRoutes(verbose = true) {
77
- apiRouter.purgeProjectRequireCache(ROOT);
78
- const { routes } = await apiRouter.loadApiRoutes(ROOT, { verbose });
79
- apiRoutes = routes;
80
- }
81
-
82
- // ─── HTTP Server ─────────────────────────────────────────────
83
-
84
- const MIME = {
85
- '.html': 'text/html; charset=utf-8',
86
- '.js': 'text/javascript; charset=utf-8',
87
- '.jsx': 'text/javascript; charset=utf-8',
88
- '.mjs': 'text/javascript; charset=utf-8',
89
- '.css': 'text/css; charset=utf-8',
90
- '.json': 'application/json; charset=utf-8',
91
- '.png': 'image/png',
92
- '.jpg': 'image/jpeg',
93
- '.jpeg': 'image/jpeg',
94
- '.gif': 'image/gif',
95
- '.webp': 'image/webp',
96
- '.svg': 'image/svg+xml',
97
- '.ico': 'image/x-icon',
98
- '.woff': 'font/woff',
99
- '.woff2':'font/woff2',
100
- '.ttf': 'font/ttf',
101
- '.map': 'application/json',
102
- '.txt': 'text/plain; charset=utf-8',
103
- };
104
-
105
- const IMMUTABLE_EXTS = new Set(['.woff', '.woff2', '.ttf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico']);
106
-
107
- const NO_CACHE = {
108
- 'Cache-Control': 'no-cache, no-store, must-revalidate',
109
- 'Pragma': 'no-cache',
110
- 'Expires': '0'
111
- };
112
-
113
- function cacheHeadersFor(fileExt, isHtml) {
114
- if (!PRODUCTION) return NO_CACHE;
115
- if (isHtml || fileExt === '.json') {
116
- return { 'Cache-Control': 'no-cache, must-revalidate' };
117
- }
118
- if (IMMUTABLE_EXTS.has(fileExt)) {
119
- return { 'Cache-Control': 'public, max-age=86400, immutable' };
120
- }
121
- return { 'Cache-Control': 'no-cache, must-revalidate' };
122
- }
123
-
124
- function etagFor(stat) {
125
- return `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
126
- }
127
-
128
- const server = http.createServer(async (req, res) => {
129
- const rawUrl = req.url;
130
- const urlWithoutQuery = rawUrl.split('?')[0];
131
-
132
- // ── API routes (api/** and app/api/**/route.js)
133
- if (urlWithoutQuery === '/api' || urlWithoutQuery.startsWith('/api/')) {
134
- await apiRouter.handleApiRequest(req, res, { routes: apiRoutes, port: server.address()?.port || PORT });
135
- return;
136
- }
137
-
138
- // ── Route manifest (used by the client router to discover pages)
139
- if (urlWithoutQuery === '/_framework/routes.json') {
140
- const manifest = buildRouteManifest();
141
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...NO_CACHE });
142
- res.end(JSON.stringify({ dev: !PRODUCTION, routes: manifest }));
143
- return;
144
- }
145
-
146
- // ── In production, neutralize the live-reload client (no WS server)
147
- if (PRODUCTION && urlWithoutQuery === '/scripts/live-reload-client.js') {
148
- res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=86400' });
149
- res.end('/* live reload disabled in production */\n');
150
- return;
151
- }
152
-
153
- // ── node_modules bare imports: /_modules/mongoose → node_modules/mongoose
154
- if (urlWithoutQuery.startsWith('/_modules/')) {
155
- let specifier;
156
- try {
157
- specifier = decodeURIComponent(urlWithoutQuery.slice('/_modules/'.length));
158
- } catch {
159
- res.writeHead(400, { 'Content-Type': 'application/json' });
160
- res.end(JSON.stringify({ error: 'Malformed module specifier' }));
161
- return;
162
- }
163
-
164
- const filePath = resolveNodeModule(specifier, ROOT);
165
-
166
- if (!filePath) {
167
- res.writeHead(404, { 'Content-Type': 'application/json' });
168
- res.end(JSON.stringify({ error: `Module not found: ${specifier}. Run: npm install ${specifier.split('/')[0]}` }));
169
- return;
170
- }
171
-
172
- try {
173
- const stat = fs.statSync(filePath);
174
- let code = fs.readFileSync(filePath, 'utf8');
175
- code = transformModule(code, filePath, stat.mtimeMs);
176
- res.writeHead(200, {
177
- 'Content-Type': 'text/javascript; charset=utf-8',
178
- ...cacheHeadersFor('.js', false),
179
- 'ETag': etagFor(stat),
180
- });
181
- res.end(code);
182
- } catch (err) {
183
- res.writeHead(500);
184
- res.end('Error reading module: ' + err.message);
185
- }
186
- return;
187
- }
188
-
189
- // ── Static files + SPA fallback
190
- let filePath;
191
-
192
- try {
193
- if (urlWithoutQuery === '/' || urlWithoutQuery === '') {
194
- filePath = path.join(ROOT, 'index.html');
195
- } else {
196
- let decoded;
197
- try {
198
- decoded = decodeURIComponent(urlWithoutQuery);
199
- } catch {
200
- res.writeHead(400, { 'Content-Type': 'text/plain' });
201
- res.end('Bad request');
202
- return;
203
- }
204
-
205
- // Security: resolve and CONFINE to the project root (blocks path traversal)
206
- const resolved = path.resolve(ROOT, '.' + (decoded.startsWith('/') ? decoded : '/' + decoded));
207
- const rootResolved = path.resolve(ROOT);
208
- if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) {
209
- res.writeHead(403, { 'Content-Type': 'text/plain' });
210
- res.end('Forbidden');
211
- return;
212
- }
213
- filePath = resolved;
214
-
215
- // SPA fallback: path with no file extension → serve index.html
216
- // so the client-side router handles it
217
- const ext = path.extname(filePath);
218
- if (!ext && !fs.existsSync(filePath)) {
219
- filePath = path.join(ROOT, 'index.html');
220
- }
221
- }
222
- } catch {
223
- res.writeHead(400, { 'Content-Type': 'text/plain' });
224
- res.end('Bad request');
225
- return;
226
- }
227
-
228
- if (!fs.existsSync(filePath)) {
229
- // Try appending .js / .jsx (import specifiers without extension)
230
- if (fs.existsSync(filePath + '.js')) {
231
- filePath = filePath + '.js';
232
- } else if (fs.existsSync(filePath + '.jsx')) {
233
- filePath = filePath + '.jsx';
234
- } else if (path.extname(filePath) === '' && fs.existsSync(path.join(ROOT, 'index.html'))) {
235
- filePath = path.join(ROOT, 'index.html');
236
- } else {
237
- res.writeHead(404, { 'Content-Type': 'text/plain' });
238
- res.end('Not found: ' + urlWithoutQuery);
239
- return;
240
- }
241
- }
242
-
243
- try {
244
- const stat = fs.statSync(filePath);
245
- const fileExt = path.extname(filePath).toLowerCase();
246
- const isHtml = fileExt === '.html';
247
- const contentType = MIME[fileExt] || 'application/octet-stream';
248
- const headers = {
249
- 'Content-Type': contentType,
250
- ...cacheHeadersFor(fileExt, isHtml),
251
- 'ETag': etagFor(stat),
252
- };
253
-
254
- // Conditional request → 304
255
- if (req.headers['if-none-match'] === headers.ETag) {
256
- res.writeHead(304, headers);
257
- res.end();
258
- return;
259
- }
260
-
261
- let content = fs.readFileSync(filePath);
262
-
263
- // Transform JS/JSX: JSX → h() + rewrite imports (mtime-cached)
264
- if (fileExt === '.js' || fileExt === '.jsx' || fileExt === '.mjs') {
265
- const code = transformModule(content.toString(), filePath, stat.mtimeMs);
266
- content = Buffer.from(code);
267
- }
268
-
269
- res.writeHead(200, headers);
270
- res.end(content);
271
- } catch (err) {
272
- res.writeHead(500);
273
- res.end('Server error: ' + err.message);
274
- }
275
- });
276
-
277
- // ─── WebSocket (live reload, development only) ───────────────
278
-
279
- if (!PRODUCTION) {
280
- server.on('upgrade', (req, socket) => {
281
- const key = req.headers['sec-websocket-key'];
282
- if (!key) { socket.destroy(); return; }
283
- const accept = crypto.createHash('sha1')
284
- .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
285
- .digest('base64');
286
-
287
- socket.write(
288
- 'HTTP/1.1 101 Switching Protocols\r\n' +
289
- 'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
290
- `Sec-WebSocket-Accept: ${accept}\r\n\r\n`
291
- );
292
-
293
- const client = {
294
- send(msg) {
295
- const buf = Buffer.from(msg);
296
- if (buf.length > 125) return; // control messages are tiny
297
- const frame = Buffer.alloc(buf.length + 2);
298
- frame[0] = 0x81;
299
- frame[1] = buf.length;
300
- buf.copy(frame, 2);
301
- socket.write(frame);
302
- }
303
- };
304
-
305
- clients.add(client);
306
- console.log(`🔌 Browser connected (${clients.size} total)`);
307
-
308
- socket.on('close', () => { clients.delete(client); });
309
- socket.on('error', () => { clients.delete(client); });
310
- });
311
- }
312
-
313
- function notifyClients() {
314
- if (clients.size === 0) return;
315
- console.log(`🔄 Reloading ${clients.size} browser(s)...`);
316
- clients.forEach(c => { try { c.send('reload'); } catch { clients.delete(c); } });
317
- }
318
-
319
- // ─── File Watcher (development only) ─────────────────────────
320
-
321
- const watchDirs = ['app', 'api', 'components', 'lib', 'models', 'styles', '_framework'];
322
- const SERVER_CODE_DIRS = new Set(['api', 'lib', 'models']);
323
- let reloadTimer = null;
324
-
325
- if (!PRODUCTION) {
326
- console.log('👀 Watching:');
327
- watchDirs.forEach(dir => {
328
- const full = path.join(ROOT, dir);
329
- if (!fs.existsSync(full)) return;
330
- console.log(` - ${dir}/`);
331
-
332
- try {
333
- fs.watch(full, { recursive: true }, (_, filename) => {
334
- if (!filename) return;
335
- const ext = path.extname(filename);
336
- if (!['.js', '.jsx', '.mjs', '.css', '.json'].includes(ext)) return;
337
-
338
- console.log(`\n📝 Changed: ${dir}/${filename}`);
339
-
340
- // Keep the transform cache fresh
341
- invalidateTransform(path.join(full, filename));
342
-
343
- if (SERVER_CODE_DIRS.has(dir)) {
344
- // API routes may import from lib/ and models/ — reload them
345
- clearTimeout(reloadTimer);
346
- reloadTimer = setTimeout(() => {
347
- reloadApiRoutes(false).then(notifyClients);
348
- }, 200);
349
- return;
350
- }
351
-
352
- clearTimeout(reloadTimer);
353
- reloadTimer = setTimeout(notifyClients, 300);
354
- });
355
- } catch (err) {
356
- console.warn(` ⚠️ Could not watch ${dir}/: ${err.message}`);
357
- }
358
- });
359
- }
360
-
361
- // ─── Start ────────────────────────────────────────────────────
362
-
363
- function startServer(port) {
364
- server.listen(port, () => {
365
- console.log(`\n✅ Server running!`);
366
- console.log(`🌐 http://localhost:${port}/`);
367
- console.log(PRODUCTION ? '⏹️ Ctrl+C to stop\n' : '⏹️ Ctrl+C to stop\n');
368
- }).on('error', err => {
369
- if (err.code === 'EADDRINUSE') {
370
- console.log(`⚠️ Port ${port} busy, trying ${port + 1}...`);
371
- startServer(port + 1);
372
- } else {
373
- console.error('❌', err);
374
- }
375
- });
376
- }
377
-
378
- reloadApiRoutes().then(() => startServer(PORT)).catch(err => {
379
- console.error('❌ Failed to load API routes:', err);
380
- startServer(PORT);
381
- });
1
+ #!/usr/bin/env node
2
+
3
+ // ============================================================
4
+ // Aziz.js Dev Server
5
+ // - Serves the app with server-side JSX transform (cached)
6
+ // - API routes: api/** and app/api/**/route.js
7
+ // - node_modules bare-import resolution (/_modules/…)
8
+ // - WebSocket live reload (development only)
9
+ // - Production mode: node scripts/dev-server.js --production
10
+ // ============================================================
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const http = require('http');
15
+ const crypto = require('crypto');
16
+
17
+ const { hasBabel, transformModule, invalidate: invalidateTransform } = require('./lib/js-transform');
18
+ const { resolveNodeModule } = require('./lib/module-resolver');
19
+ const apiRouter = require('./lib/api-router');
20
+
21
+ const PORT = parseInt(process.env.PORT, 10) || 3000;
22
+ const ROOT = process.cwd();
23
+ const PRODUCTION = process.argv.includes('--production') || process.env.NODE_ENV === 'production';
24
+
25
+ const clients = new Set();
26
+ let apiRoutes = [];
27
+
28
+ // ─── Files that must never reach the browser ───────────────────
29
+ //
30
+ // Aziz.js has no bundler, so every importable module is served straight
31
+ // from disk. That is what makes `import User from '@/models/User'` work —
32
+ // but it also means anything on disk is one HTTP request away, including
33
+ // .env secrets. These rules are the boundary between "code the browser
34
+ // may import" and "everything else".
35
+
36
+ const BLOCKED_FILES = new Set([
37
+ 'package.json',
38
+ 'package-lock.json',
39
+ 'npm-shrinkwrap.json',
40
+ 'yarn.lock',
41
+ 'pnpm-lock.yaml',
42
+ 'bun.lockb',
43
+ 'tsconfig.json',
44
+ 'jsconfig.json',
45
+ ]);
46
+
47
+ // Directories that only ever hold server-side code or data.
48
+ const BLOCKED_DIRS = new Set([
49
+ 'node_modules', // reachable via /_modules/<pkg> instead
50
+ 'server', // convention: server-only modules
51
+ 'data', // convention: flat-file persistence (sessions, users)
52
+ ]);
53
+
54
+ // scripts/ holds the dev server itself, but index.html needs one file from it.
55
+ const ALLOWED_SCRIPT_FILES = new Set(['live-reload-client.js']);
56
+
57
+ // Explicit server-only modules: anything named *.server.{js,mjs,jsx}
58
+ const SERVER_MODULE_RE = /\.server\.(m?js|jsx)$/;
59
+
60
+ function forbiddenReason(urlPath) {
61
+ const parts = urlPath.split('/').filter(Boolean);
62
+ if (parts.length === 0) return null;
63
+
64
+ // Any dotfile or dot-directory: .env, .env.local, .git/config, .npmrc …
65
+ if (parts.some(p => p.startsWith('.'))) return 'dotfile';
66
+
67
+ const file = parts[parts.length - 1];
68
+
69
+ if (BLOCKED_FILES.has(file)) return 'project metadata file';
70
+
71
+ if (parts[0] === 'scripts') {
72
+ return ALLOWED_SCRIPT_FILES.has(file) ? null : 'dev-server internals';
73
+ }
74
+
75
+ if (parts.some(p => BLOCKED_DIRS.has(p))) return 'server-only directory';
76
+ if (SERVER_MODULE_RE.test(file)) return 'server-only module';
77
+
78
+ return null;
79
+ }
80
+
81
+ function box(lines) {
82
+ const width = Math.max(...lines.map(l => l.length)) + 2;
83
+ const bar = '─'.repeat(width + 2);
84
+ return [
85
+ '',
86
+ '┌' + bar + '┐',
87
+ ...lines.map(l => '│ ' + l.padEnd(width) + ' │'),
88
+ '└' + bar + '┘',
89
+ ''
90
+ ].join('\n');
91
+ }
92
+
93
+ if (!hasBabel()) {
94
+ console.log(box([
95
+ '@babel/standalone is NOT installed — JSX cannot be compiled.',
96
+ '',
97
+ 'Every page and layout will fail to load in the browser, so the',
98
+ 'router falls back to its built-in 404 page. This looks like a',
99
+ 'routing bug, but it is a missing dependency.',
100
+ '',
101
+ `Fix: ${fs.existsSync(path.join(ROOT, 'node_modules')) ? 'npm install --save-dev @babel/standalone' : 'npm install'}`,
102
+ ` in ${ROOT}`,
103
+ '',
104
+ 'Then restart the dev server.',
105
+ ]));
106
+ }
107
+
108
+ console.log(`🚀 Aziz.js dev server starting${PRODUCTION ? ' (production mode)' : ''}...\n`);
109
+
110
+ // ─── Route Manifest ──────────────────────────────────────────
111
+ // Scans app/ and builds the page/layout/not-found/loading/error list for the
112
+ // client router.
113
+
114
+ const PAGE_NAMES = ['page.js', 'page.jsx'];
115
+ const LAYOUT_NAMES = ['layout.js', 'layout.jsx'];
116
+ const LOADING_NAMES = ['loading.js', 'loading.jsx'];
117
+ const ERROR_NAMES = ['error.js', 'error.jsx'];
118
+ // Next.js calls it not-found.js; 404.js is accepted as a friendlier alias.
119
+ const NOTFOUND_NAMES = ['not-found.js', 'not-found.jsx', '404.js', '404.jsx'];
120
+
121
+ function buildRouteManifest() {
122
+ const manifest = [];
123
+ const appDir = path.join(ROOT, 'app');
124
+ if (!fs.existsSync(appDir)) return manifest;
125
+
126
+ const filePathFor = (routeBase, fileName) =>
127
+ '/app' + (routeBase === '/' ? '' : routeBase) + '/' + fileName;
128
+
129
+ function scan(dir, routeBase) {
130
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
131
+
132
+ for (const entry of entries) {
133
+ const fullPath = path.join(dir, entry.name);
134
+
135
+ if (entry.isDirectory()) {
136
+ // Keep [slug] as-is — the router matcher expects bracket syntax
137
+ const segment = entry.name;
138
+ scan(fullPath, routeBase === '/' ? '/' + segment : routeBase + '/' + segment);
139
+ continue;
140
+ }
141
+
142
+ const name = entry.name;
143
+
144
+ if (PAGE_NAMES.includes(name)) {
145
+ manifest.push({ type: 'page', pathname: routeBase || '/', filePath: filePathFor(routeBase, name) });
146
+ } else if (LAYOUT_NAMES.includes(name)) {
147
+ manifest.push({ type: 'layout', pathname: routeBase || '/', filePath: filePathFor(routeBase, name) });
148
+ } else if (LOADING_NAMES.includes(name)) {
149
+ manifest.push({ type: 'loading', pathname: routeBase || '/', filePath: filePathFor(routeBase, name) });
150
+ } else if (ERROR_NAMES.includes(name)) {
151
+ manifest.push({ type: 'error', pathname: routeBase || '/', filePath: filePathFor(routeBase, name) });
152
+ } else if (NOTFOUND_NAMES.includes(name) && routeBase === '/') {
153
+ // filePath must use the real name — the old code hardcoded .js,
154
+ // which broke not-found.jsx.
155
+ manifest.push({ type: 'not-found', pathname: '/not-found', filePath: filePathFor(routeBase, name) });
156
+ }
157
+ // route.js files are API routes — handled by api-router, not the page router
158
+ }
159
+ }
160
+
161
+ scan(appDir, '/');
162
+ return manifest;
163
+ }
164
+
165
+ // ─── API routes ──────────────────────────────────────────────
166
+
167
+ async function reloadApiRoutes(verbose = true) {
168
+ apiRouter.purgeProjectRequireCache(ROOT);
169
+ const { routes } = await apiRouter.loadApiRoutes(ROOT, { verbose });
170
+ apiRoutes = routes;
171
+ }
172
+
173
+ // ─── HTTP Server ─────────────────────────────────────────────
174
+
175
+ const MIME = {
176
+ '.html': 'text/html; charset=utf-8',
177
+ '.js': 'text/javascript; charset=utf-8',
178
+ '.jsx': 'text/javascript; charset=utf-8',
179
+ '.mjs': 'text/javascript; charset=utf-8',
180
+ '.css': 'text/css; charset=utf-8',
181
+ '.json': 'application/json; charset=utf-8',
182
+ '.png': 'image/png',
183
+ '.jpg': 'image/jpeg',
184
+ '.jpeg': 'image/jpeg',
185
+ '.gif': 'image/gif',
186
+ '.webp': 'image/webp',
187
+ '.svg': 'image/svg+xml',
188
+ '.ico': 'image/x-icon',
189
+ '.woff': 'font/woff',
190
+ '.woff2':'font/woff2',
191
+ '.ttf': 'font/ttf',
192
+ '.map': 'application/json',
193
+ '.txt': 'text/plain; charset=utf-8',
194
+ };
195
+
196
+ const IMMUTABLE_EXTS = new Set(['.woff', '.woff2', '.ttf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico']);
197
+
198
+ const NO_CACHE = {
199
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
200
+ 'Pragma': 'no-cache',
201
+ 'Expires': '0'
202
+ };
203
+
204
+ function cacheHeadersFor(fileExt, isHtml) {
205
+ if (!PRODUCTION) return NO_CACHE;
206
+ if (isHtml || fileExt === '.json') {
207
+ return { 'Cache-Control': 'no-cache, must-revalidate' };
208
+ }
209
+ if (IMMUTABLE_EXTS.has(fileExt)) {
210
+ return { 'Cache-Control': 'public, max-age=86400, immutable' };
211
+ }
212
+ return { 'Cache-Control': 'no-cache, must-revalidate' };
213
+ }
214
+
215
+ function etagFor(stat) {
216
+ return `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
217
+ }
218
+
219
+ const server = http.createServer(async (req, res) => {
220
+ const rawUrl = req.url;
221
+ const urlWithoutQuery = rawUrl.split('?')[0];
222
+
223
+ // ── API routes (api/** and app/api/**/route.js)
224
+ if (urlWithoutQuery === '/api' || urlWithoutQuery.startsWith('/api/')) {
225
+ await apiRouter.handleApiRequest(req, res, { routes: apiRoutes, port: server.address()?.port || PORT });
226
+ return;
227
+ }
228
+
229
+ // ── Route manifest (used by the client router to discover pages)
230
+ if (urlWithoutQuery === '/_framework/routes.json') {
231
+ const manifest = buildRouteManifest();
232
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...NO_CACHE });
233
+ res.end(JSON.stringify({ dev: !PRODUCTION, routes: manifest }));
234
+ return;
235
+ }
236
+
237
+ // ── In production, neutralize the live-reload client (no WS server)
238
+ if (PRODUCTION && urlWithoutQuery === '/scripts/live-reload-client.js') {
239
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=86400' });
240
+ res.end('/* live reload disabled in production */\n');
241
+ return;
242
+ }
243
+
244
+ // ── node_modules bare imports: /_modules/mongoose → node_modules/mongoose
245
+ if (urlWithoutQuery.startsWith('/_modules/')) {
246
+ let specifier;
247
+ try {
248
+ specifier = decodeURIComponent(urlWithoutQuery.slice('/_modules/'.length));
249
+ } catch {
250
+ res.writeHead(400, { 'Content-Type': 'application/json' });
251
+ res.end(JSON.stringify({ error: 'Malformed module specifier' }));
252
+ return;
253
+ }
254
+
255
+ const filePath = resolveNodeModule(specifier, ROOT);
256
+
257
+ if (!filePath) {
258
+ res.writeHead(404, { 'Content-Type': 'application/json' });
259
+ res.end(JSON.stringify({ error: `Module not found: ${specifier}. Run: npm install ${specifier.split('/')[0]}` }));
260
+ return;
261
+ }
262
+
263
+ try {
264
+ const stat = fs.statSync(filePath);
265
+ let code = fs.readFileSync(filePath, 'utf8');
266
+ code = transformModule(code, filePath, stat.mtimeMs);
267
+ res.writeHead(200, {
268
+ 'Content-Type': 'text/javascript; charset=utf-8',
269
+ ...cacheHeadersFor('.js', false),
270
+ 'ETag': etagFor(stat),
271
+ });
272
+ res.end(code);
273
+ } catch (err) {
274
+ res.writeHead(500);
275
+ res.end('Error reading module: ' + err.message);
276
+ }
277
+ return;
278
+ }
279
+
280
+ // ── Static files + SPA fallback
281
+ let filePath;
282
+
283
+ // Refuse secrets and server internals before touching the filesystem.
284
+ const blocked = forbiddenReason(urlWithoutQuery);
285
+ if (blocked) {
286
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', ...NO_CACHE });
287
+ res.end(JSON.stringify({
288
+ error: 'Not found',
289
+ detail: `'${urlWithoutQuery}' is not served by Aziz.js (${blocked}).`,
290
+ hint: 'Server-only code belongs in api/, server/, or a *.server.mjs file — import it from an API route, never from a page.',
291
+ }));
292
+ return;
293
+ }
294
+
295
+ try {
296
+ if (urlWithoutQuery === '/' || urlWithoutQuery === '') {
297
+ filePath = path.join(ROOT, 'index.html');
298
+ } else {
299
+ let decoded;
300
+ try {
301
+ decoded = decodeURIComponent(urlWithoutQuery);
302
+ } catch {
303
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
304
+ res.end('Bad request');
305
+ return;
306
+ }
307
+
308
+ // Security: resolve and CONFINE to the project root (blocks path traversal)
309
+ const resolved = path.resolve(ROOT, '.' + (decoded.startsWith('/') ? decoded : '/' + decoded));
310
+ const rootResolved = path.resolve(ROOT);
311
+ if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) {
312
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
313
+ res.end('Forbidden');
314
+ return;
315
+ }
316
+ filePath = resolved;
317
+
318
+ // SPA fallback: path with no file extension → serve index.html
319
+ // so the client-side router handles it
320
+ const ext = path.extname(filePath);
321
+ if (!ext && !fs.existsSync(filePath)) {
322
+ filePath = path.join(ROOT, 'index.html');
323
+ }
324
+ }
325
+ } catch {
326
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
327
+ res.end('Bad request');
328
+ return;
329
+ }
330
+
331
+ if (!fs.existsSync(filePath)) {
332
+ // Try appending .js / .jsx (import specifiers without extension)
333
+ if (fs.existsSync(filePath + '.js')) {
334
+ filePath = filePath + '.js';
335
+ } else if (fs.existsSync(filePath + '.jsx')) {
336
+ filePath = filePath + '.jsx';
337
+ } else if (path.extname(filePath) === '' && fs.existsSync(path.join(ROOT, 'index.html'))) {
338
+ filePath = path.join(ROOT, 'index.html');
339
+ } else {
340
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
341
+ res.end('Not found: ' + urlWithoutQuery);
342
+ return;
343
+ }
344
+ }
345
+
346
+ try {
347
+ const stat = fs.statSync(filePath);
348
+ const fileExt = path.extname(filePath).toLowerCase();
349
+ const isHtml = fileExt === '.html';
350
+ const contentType = MIME[fileExt] || 'application/octet-stream';
351
+ const headers = {
352
+ 'Content-Type': contentType,
353
+ ...cacheHeadersFor(fileExt, isHtml),
354
+ 'ETag': etagFor(stat),
355
+ };
356
+
357
+ // Conditional request → 304
358
+ if (req.headers['if-none-match'] === headers.ETag) {
359
+ res.writeHead(304, headers);
360
+ res.end();
361
+ return;
362
+ }
363
+
364
+ let content = fs.readFileSync(filePath);
365
+
366
+ // Transform JS/JSX: JSX → h() + rewrite imports (mtime-cached)
367
+ if (fileExt === '.js' || fileExt === '.jsx' || fileExt === '.mjs') {
368
+ const code = transformModule(content.toString(), filePath, stat.mtimeMs);
369
+ content = Buffer.from(code);
370
+ }
371
+
372
+ res.writeHead(200, headers);
373
+ res.end(content);
374
+ } catch (err) {
375
+ res.writeHead(500);
376
+ res.end('Server error: ' + err.message);
377
+ }
378
+ });
379
+
380
+ // ─── WebSocket (live reload, development only) ───────────────
381
+
382
+ if (!PRODUCTION) {
383
+ server.on('upgrade', (req, socket) => {
384
+ const key = req.headers['sec-websocket-key'];
385
+ if (!key) { socket.destroy(); return; }
386
+ const accept = crypto.createHash('sha1')
387
+ .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
388
+ .digest('base64');
389
+
390
+ socket.write(
391
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
392
+ 'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
393
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`
394
+ );
395
+
396
+ const client = {
397
+ send(msg) {
398
+ const buf = Buffer.from(msg);
399
+ if (buf.length > 125) return; // control messages are tiny
400
+ const frame = Buffer.alloc(buf.length + 2);
401
+ frame[0] = 0x81;
402
+ frame[1] = buf.length;
403
+ buf.copy(frame, 2);
404
+ socket.write(frame);
405
+ }
406
+ };
407
+
408
+ clients.add(client);
409
+ console.log(`🔌 Browser connected (${clients.size} total)`);
410
+
411
+ socket.on('close', () => { clients.delete(client); });
412
+ socket.on('error', () => { clients.delete(client); });
413
+ });
414
+ }
415
+
416
+ function notifyClients() {
417
+ if (clients.size === 0) return;
418
+ console.log(`🔄 Reloading ${clients.size} browser(s)...`);
419
+ clients.forEach(c => { try { c.send('reload'); } catch { clients.delete(c); } });
420
+ }
421
+
422
+ // ─── File Watcher (development only) ─────────────────────────
423
+ //
424
+ // NOTE: on Windows, Node's directory watcher can abort the process with
425
+ // "Assertion failed: !_wcsnicmp(filename, dir, dirlen)" (src\win\fs-event.c).
426
+ // It is a native assertion that try/catch cannot intercept, it is unrelated
427
+ // to recursive:true (a non-recursive watcher hits it too), and it only shows
428
+ // up on trees with nested subdirectories — roughly 1 start in 8 on the
429
+ // machine this was observed on. It is reproducible enough to be worth
430
+ // reporting upstream, and rare enough that switching watcher strategies
431
+ // costs more than it saves. Restarting the dev server always clears it.
432
+
433
+ const watchDirs = ['app', 'api', 'components', 'lib', 'models', 'styles', '_framework'];
434
+ const SERVER_CODE_DIRS = new Set(['api', 'lib', 'models']);
435
+ let reloadTimer = null;
436
+
437
+ if (!PRODUCTION) {
438
+ console.log('👀 Watching:');
439
+ watchDirs.forEach(dir => {
440
+ const full = path.join(ROOT, dir);
441
+ if (!fs.existsSync(full)) return;
442
+ console.log(` - ${dir}/`);
443
+
444
+ try {
445
+ fs.watch(full, { recursive: true }, (_, filename) => {
446
+ if (!filename) return;
447
+ const ext = path.extname(filename);
448
+ if (!['.js', '.jsx', '.mjs', '.css', '.json'].includes(ext)) return;
449
+
450
+ console.log(`\n📝 Changed: ${dir}/${filename}`);
451
+
452
+ // Keep the transform cache fresh
453
+ invalidateTransform(path.join(full, filename));
454
+
455
+ if (SERVER_CODE_DIRS.has(dir)) {
456
+ // API routes may import from lib/ and models/ — reload them
457
+ clearTimeout(reloadTimer);
458
+ reloadTimer = setTimeout(() => {
459
+ reloadApiRoutes(false).then(notifyClients);
460
+ }, 200);
461
+ return;
462
+ }
463
+
464
+ clearTimeout(reloadTimer);
465
+ reloadTimer = setTimeout(notifyClients, 300);
466
+ });
467
+ } catch (err) {
468
+ console.warn(` ⚠️ Could not watch ${dir}/: ${err.message}`);
469
+ }
470
+ });
471
+ }
472
+
473
+ // ─── Start ────────────────────────────────────────────────────
474
+
475
+ function startServer(port) {
476
+ server.listen(port, () => {
477
+ console.log(`\n✅ Server running!`);
478
+ console.log(`🌐 http://localhost:${port}/`);
479
+ console.log(PRODUCTION ? '⏹️ Ctrl+C to stop\n' : '⏹️ Ctrl+C to stop\n');
480
+ }).on('error', err => {
481
+ if (err.code === 'EADDRINUSE') {
482
+ console.log(`⚠️ Port ${port} busy, trying ${port + 1}...`);
483
+ startServer(port + 1);
484
+ } else {
485
+ console.error('❌', err);
486
+ }
487
+ });
488
+ }
489
+
490
+ reloadApiRoutes().then(() => startServer(PORT)).catch(err => {
491
+ console.error('❌ Failed to load API routes:', err);
492
+ startServer(PORT);
493
+ });