create-fff-app 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.
Files changed (30) hide show
  1. package/package.json +21 -0
  2. package/src/index.ts +119 -0
  3. package/template/.config/dotnet-tools.json +5 -0
  4. package/template/dev-server.ts +415 -0
  5. package/template/index.html +23 -0
  6. package/template/migrate.ts +166 -0
  7. package/template/migrations/0001_init.sql +25 -0
  8. package/template/migrations/0002_employees.sql +16 -0
  9. package/template/package.json +29 -0
  10. package/template/public/app.js +156 -0
  11. package/template/public/runtime.js +149 -0
  12. package/template/public/style.css +769 -0
  13. package/template/schema.sql +25 -0
  14. package/template/src/client/App.fs +55 -0
  15. package/template/src/client/Client.fsproj +24 -0
  16. package/template/src/client/Nav.fs +75 -0
  17. package/template/src/server/Domain/Employees/Employee.fs +34 -0
  18. package/template/src/server/Endpoints/Employees/Routes.fs +35 -0
  19. package/template/src/server/Endpoints/Pages/About.fjsx +66 -0
  20. package/template/src/server/Endpoints/Pages/EmployeeEdit.fjsx +66 -0
  21. package/template/src/server/Endpoints/Pages/EmployeeList.fjsx +54 -0
  22. package/template/src/server/Endpoints/Pages/EmployeeNew.fjsx +55 -0
  23. package/template/src/server/Endpoints/Pages/Layout.fjsx +40 -0
  24. package/template/src/server/Endpoints/Pages/Routes.fs +117 -0
  25. package/template/src/server/Endpoints/Pages/Templates.fs +45 -0
  26. package/template/src/server/Server.fsproj +28 -0
  27. package/template/src/server/Worker.fs +45 -0
  28. package/template/src/server/Workflows/Employees/EmployeeWorkflows.fs +86 -0
  29. package/template/webpack.client.js +18 -0
  30. package/template/wrangler.toml +23 -0
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "create-fff-app",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a new fff-stack project",
5
+ "bin": {
6
+ "create-fff-app": "src/index.ts"
7
+ },
8
+ "files": [
9
+ "src",
10
+ "template"
11
+ ],
12
+ "scripts": {
13
+ "build": "bun build src/index.ts --outfile dist/index.js --target node",
14
+ "prepublishOnly": "bun run scripts/prepare-template.ts",
15
+ "test": "bun test"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.4.0",
19
+ "@types/node": "^20.0.0"
20
+ }
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * create-fff-app — scaffold a new fff-stack project
4
+ *
5
+ * Usage:
6
+ * bunx create-fff-app my-app
7
+ * bun create fff-app my-app
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
11
+ import { join, relative, dirname } from 'path';
12
+ import { execSync } from 'child_process';
13
+
14
+ // Prefer the template bundled alongside this package (npm published).
15
+ // Fall back to the monorepo layout for local development.
16
+ const bundledTemplate = join(import.meta.dir, '../template');
17
+ const monorepTemplate = join(import.meta.dir, '../../../template');
18
+ const TEMPLATE_DIR = existsSync(bundledTemplate) ? bundledTemplate : monorepTemplate;
19
+ const SKIP_DIRS = new Set(['obj', 'bin', 'node_modules', '.git', 'dist']);
20
+
21
+ // ── Helpers ───────────────────────────────────────────────────────────────────
22
+
23
+ function copyDir(src: string, dest: string, projectName: string) {
24
+ const entries = readdirSync(src, { withFileTypes: true });
25
+ mkdirSync(dest, { recursive: true });
26
+
27
+ for (const entry of entries) {
28
+ if (SKIP_DIRS.has(entry.name)) continue;
29
+
30
+ const srcPath = join(src, entry.name);
31
+ const destPath = join(dest, entry.name);
32
+
33
+ if (entry.isDirectory()) {
34
+ copyDir(srcPath, destPath, projectName);
35
+ } else {
36
+ const raw = readFileSync(srcPath, 'utf-8');
37
+ // Replace template project name placeholder
38
+ const content = raw.replace(/fff-stack-app/g, projectName);
39
+ mkdirSync(dirname(destPath), { recursive: true });
40
+ writeFileSync(destPath, content, 'utf-8');
41
+ }
42
+ }
43
+ }
44
+
45
+ function bold(s: string) { return `\x1b[1m${s}\x1b[0m`; }
46
+ function green(s: string) { return `\x1b[32m${s}\x1b[0m`; }
47
+ function cyan(s: string) { return `\x1b[36m${s}\x1b[0m`; }
48
+ function dim(s: string) { return `\x1b[2m${s}\x1b[0m`; }
49
+
50
+ // ── CLI ───────────────────────────────────────────────────────────────────────
51
+
52
+ const args = process.argv.slice(2);
53
+
54
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
55
+ console.log(`
56
+ ${bold('create-fff-app')} — scaffold a new fff-stack project
57
+
58
+ ${bold('Usage:')}
59
+ bunx create-fff-app ${cyan('<project-name>')}
60
+
61
+ ${bold('Example:')}
62
+ bunx create-fff-app my-workers-app
63
+ `);
64
+ process.exit(args.length === 0 ? 1 : 0);
65
+ }
66
+
67
+ const rawName = args[0].trim();
68
+ const projectName = rawName.replace(/[^a-z0-9\-_]/gi, '-').toLowerCase();
69
+ const targetDir = join(process.cwd(), rawName);
70
+
71
+ if (existsSync(targetDir)) {
72
+ console.error(`\n Error: directory '${rawName}' already exists.\n`);
73
+ process.exit(1);
74
+ }
75
+
76
+ if (!existsSync(TEMPLATE_DIR)) {
77
+ console.error(`\n Error: template directory not found at ${TEMPLATE_DIR}\n`);
78
+ process.exit(1);
79
+ }
80
+
81
+ console.log(`\n Creating ${bold(projectName)}…\n`);
82
+
83
+ // Copy template
84
+ copyDir(TEMPLATE_DIR, targetDir, projectName);
85
+ console.log(` ${green('✓')} Scaffolded project files`);
86
+
87
+ // Git init
88
+ try {
89
+ execSync('git init', { cwd: targetDir, stdio: 'ignore' });
90
+ execSync('git add -A', { cwd: targetDir, stdio: 'ignore' });
91
+ execSync(`git commit -m "Initial scaffold from create-fff-app"`, { cwd: targetDir, stdio: 'ignore' });
92
+ console.log(` ${green('✓')} Initialized git repository`);
93
+ } catch {
94
+ console.log(` ${dim('⚠ git init skipped (git not available)')}`);
95
+ }
96
+
97
+ // Print next steps
98
+ console.log(`
99
+ ${green('✓')} Done! Your project is at ${bold(rawName)}/
100
+
101
+ ${bold('Next steps:')}
102
+
103
+ ${cyan(`cd ${rawName}`)}
104
+
105
+ ${dim('# Install JS dependencies')}
106
+ ${cyan('bun install')}
107
+
108
+ ${dim('# Set up Cloudflare D1 (run once)')}
109
+ ${cyan('wrangler d1 create ' + projectName + '-db')}
110
+ ${dim('# → copy the database_id into wrangler.toml')}
111
+
112
+ ${dim('# Apply the schema')}
113
+ ${cyan('bun run db:migrate:local')}
114
+
115
+ ${dim('# Start the full dev stack')}
116
+ ${cyan('bun dev')}
117
+
118
+ ${bold('Docs:')} https://github.com/Alcogy/fff-stack
119
+ `);
@@ -0,0 +1,5 @@
1
+ {
2
+ "version": 1,
3
+ "isRoot": true,
4
+ "tools": {}
5
+ }
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * fff-stack dev server — runs the template demo without .NET/Fable.
4
+ *
5
+ * bun run demo # in-memory mock API
6
+ * WRANGLER_URL=http://localhost:8787 bun run demo # proxy to wrangler dev
7
+ */
8
+ import { compile } from '@fff-stack/fjsx-compiler';
9
+ import { readFileSync, existsSync, watch } from 'fs';
10
+ import { join, extname, basename } from 'path';
11
+
12
+ const PREFERRED_PORT = Number(process.env.PORT ?? 3000);
13
+ const ROOT = import.meta.dir;
14
+ const COMPONENTS_DIR = join(ROOT, 'src/client/Components');
15
+ const SERVER_PAGES_DIR = join(ROOT, 'src/server/Endpoints/Pages');
16
+ const PUBLIC_DIR = join(ROOT, 'public');
17
+ const WRANGLER_URL = (process.env.WRANGLER_URL ?? '').replace(/\/$/, '');
18
+
19
+ // ── In-memory employee store (stand-in for Cloudflare D1) ─────────────────────
20
+
21
+ type Employee = {
22
+ id: string; name: string; email: string;
23
+ department: string; role: string; status: string; joinedAt: string;
24
+ };
25
+
26
+ const employees = new Map<string, Employee>();
27
+ const seed: Omit<Employee, 'id'>[] = [
28
+ { name: 'Alice Chen', email: 'alice@example.com', department: 'Engineering', role: 'Senior Engineer', status: 'active', joinedAt: '2022-03-14' },
29
+ { name: 'Bob Martinez', email: 'bob@example.com', department: 'Sales', role: 'Account Executive', status: 'active', joinedAt: '2021-11-01' },
30
+ { name: 'Carol Williams', email: 'carol@example.com', department: 'Design', role: 'Product Designer', status: 'active', joinedAt: '2023-01-08' },
31
+ { name: 'David Lee', email: 'david@example.com', department: 'HR', role: 'HR Manager', status: 'active', joinedAt: '2020-06-22' },
32
+ { name: 'Eva Johansson', email: 'eva@example.com', department: 'Marketing', role: 'Content Strategist', status: 'inactive', joinedAt: '2021-04-15' },
33
+ ];
34
+ for (const e of seed) {
35
+ const id = crypto.randomUUID();
36
+ employees.set(id, { ...e, id });
37
+ }
38
+
39
+ // Legacy user store — kept for /api/users compatibility
40
+ type User = { id: string; name: string; createdAt: string };
41
+ const users = new Map<string, User>();
42
+ for (const name of ['Alice', 'Bob']) {
43
+ const id = crypto.randomUUID();
44
+ users.set(id, { id, name, createdAt: new Date().toISOString() });
45
+ }
46
+
47
+ // ── SSE hot-reload ────────────────────────────────────────────────────────────
48
+
49
+ const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
50
+ const enc = new TextEncoder();
51
+
52
+ function broadcast(event: string, data: string) {
53
+ const chunk = enc.encode(`event: ${event}\ndata: ${data}\n\n`);
54
+ for (const ctrl of sseClients) {
55
+ try { ctrl.enqueue(chunk); } catch { sseClients.delete(ctrl); }
56
+ }
57
+ }
58
+
59
+ // ── FJSX on-demand compilation ─────────────────────────────────────────────────
60
+
61
+ function compileFjsx(name: string): string | null {
62
+ const filePath = join(COMPONENTS_DIR, `${name}.fjsx`);
63
+ if (!existsSync(filePath)) return null;
64
+ const src = readFileSync(filePath, 'utf-8');
65
+ return compile(src, `${name}.fjsx`).js;
66
+ }
67
+
68
+ function compileServerPage(name: string): string | null {
69
+ const filePath = join(SERVER_PAGES_DIR, `${name}.fjsx`);
70
+ if (!existsSync(filePath)) return null;
71
+ const src = readFileSync(filePath, 'utf-8');
72
+ return compile(src, `${name}.fjsx`).js;
73
+ }
74
+
75
+ function ssrRender(js: string, component: Record<string, unknown>): string {
76
+ const cjs = js.replace(/^export /gm, '');
77
+ return new Function(`${cjs}; return renderToHtml;`)()(component);
78
+ }
79
+
80
+ // ── Wrangler proxy ─────────────────────────────────────────────────────────────
81
+
82
+ async function proxyToWorker(req: Request, path: string, search: string): Promise<Response> {
83
+ const target = `${WRANGLER_URL}${path}${search}`;
84
+ try {
85
+ return await fetch(new Request(target, {
86
+ method: req.method,
87
+ headers: req.headers,
88
+ body: req.method !== 'GET' && req.method !== 'HEAD' ? req.body : undefined,
89
+ }));
90
+ } catch (e: any) {
91
+ return Response.json({ error: `Proxy error: ${e.message}`, target }, { status: 502 });
92
+ }
93
+ }
94
+
95
+ // ── Page rendering ─────────────────────────────────────────────────────────────
96
+
97
+ function renderServerPage(name: string, data: Record<string, unknown>): string {
98
+ const js = compileServerPage(name);
99
+ if (!js) throw new Error(`Server page template '${name}.fjsx' not found`);
100
+ return ssrRender(js, data);
101
+ }
102
+
103
+ function pageShell(title: string, activePath: string, body: string): string {
104
+ return renderServerPage('Layout', {
105
+ title,
106
+ homeActive: activePath === '/',
107
+ aboutActive: activePath === '/about',
108
+ body,
109
+ });
110
+ }
111
+
112
+ function flashBanner(url: URL): string {
113
+ const msg = url.searchParams.get('_flash');
114
+ const type = url.searchParams.get('_type') ?? 'success';
115
+ if (!msg) return '';
116
+ return `<div class="flash flash-${type}">${msg}</div>`;
117
+ }
118
+
119
+ const DEPTS = ['Engineering', 'Design', 'Sales', 'Marketing', 'Operations', 'HR', 'Finance'];
120
+
121
+ function deptChoices(current: string) {
122
+ return DEPTS.map(d => ({ value: d, isSelected: d === current }));
123
+ }
124
+
125
+ function employeeListPage(flash: string): string {
126
+ const list = [...employees.values()].sort((a, b) => a.name.localeCompare(b.name));
127
+ const total = list.length;
128
+ const active = list.filter(e => e.status === 'active').length;
129
+ const rows = list.map(e => ({ ...e, isActive: e.status === 'active', editUrl: `/employees/${e.id}/edit` }));
130
+ const body = renderServerPage('EmployeeList', { employees: rows, isEmpty: total === 0, total, active });
131
+ return pageShell('Employees', '/', flash + body);
132
+ }
133
+
134
+ // ── Static file helper ─────────────────────────────────────────────────────────
135
+
136
+ const MIME: Record<string, string> = {
137
+ '.html': 'text/html; charset=utf-8',
138
+ '.js': 'text/javascript',
139
+ '.css': 'text/css',
140
+ '.json': 'application/json',
141
+ '.ico': 'image/x-icon',
142
+ '.svg': 'image/svg+xml',
143
+ '.png': 'image/png',
144
+ '.webp': 'image/webp',
145
+ };
146
+
147
+ function serveStatic(urlPath: string): Response | null {
148
+ const filePath = join(PUBLIC_DIR, urlPath === '/' ? 'index.html' : urlPath);
149
+ if (!existsSync(filePath)) return null;
150
+ const body = readFileSync(filePath);
151
+ const ct = MIME[extname(filePath)] ?? 'application/octet-stream';
152
+ return new Response(body, { headers: { 'Content-Type': ct } });
153
+ }
154
+
155
+ const html = (body: string, status = 200) =>
156
+ new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
157
+
158
+ const redirect = (location: string) =>
159
+ new Response(null, { status: 303, headers: { Location: location } });
160
+
161
+ const redirectFlash = (location: string, type: string, msg: string) =>
162
+ redirect(`${location}?_flash=${encodeURIComponent(msg)}&_type=${type}`);
163
+
164
+ // ── Request handler ────────────────────────────────────────────────────────────
165
+
166
+ async function handler(req: Request): Promise<Response> {
167
+ const url = new URL(req.url);
168
+ const path = url.pathname;
169
+ const method = req.method.toUpperCase();
170
+
171
+ // SSE hot-reload
172
+ if (path === '/__sse') {
173
+ let ctrl!: ReadableStreamDefaultController<Uint8Array>;
174
+ const stream = new ReadableStream<Uint8Array>({
175
+ start(c) { ctrl = c; c.enqueue(enc.encode('data: connected\n\n')); sseClients.add(c); },
176
+ cancel() { sseClients.delete(ctrl); },
177
+ });
178
+ return new Response(stream, {
179
+ headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' },
180
+ });
181
+ }
182
+
183
+ // ── Page routes ────────────────────────────────────────────────────────────
184
+
185
+ // GET / — employee list
186
+ if (path === '/' && method === 'GET') {
187
+ return html(employeeListPage(flashBanner(url)));
188
+ }
189
+
190
+ // GET /employees/new — add employee form
191
+ if (path === '/employees/new' && method === 'GET') {
192
+ const body = renderServerPage('EmployeeNew', {});
193
+ return html(pageShell('Add Employee', '/', body));
194
+ }
195
+
196
+ // POST /employees — create employee (PRG)
197
+ if (path === '/employees' && method === 'POST') {
198
+ const form = await req.formData();
199
+ const name = (form.get('name') as string ?? '').trim();
200
+ const email = (form.get('email') as string ?? '').trim().toLowerCase();
201
+ const department = (form.get('department') as string ?? '').trim();
202
+ const role = (form.get('role') as string ?? '').trim();
203
+ const status = form.get('status') === 'inactive' ? 'inactive' : 'active';
204
+
205
+ if (name.length < 2) return redirectFlash('/', 'error', 'Name must be at least 2 characters');
206
+ if (!email.includes('@')) return redirectFlash('/', 'error', 'Invalid email address');
207
+ if (!department) return redirectFlash('/', 'error', 'Department is required');
208
+ if (!role) return redirectFlash('/', 'error', 'Job title is required');
209
+
210
+ const existing = [...employees.values()].find(e => e.email === email);
211
+ if (existing) return redirectFlash('/', 'error', `Email ${email} is already registered`);
212
+
213
+ const id = crypto.randomUUID();
214
+ const joined = new Date().toISOString().slice(0, 10);
215
+ employees.set(id, { id, name, email, department, role, status, joinedAt: joined });
216
+ return redirectFlash('/', 'success', `${name} has been added to the directory.`);
217
+ }
218
+
219
+ // GET /employees/:id/edit — edit employee form
220
+ const editMatch = path.match(/^\/employees\/([^/]+)\/edit$/);
221
+ if (editMatch && method === 'GET') {
222
+ const id = editMatch[1];
223
+ const emp = employees.get(id);
224
+ if (!emp) return redirectFlash('/', 'error', 'Employee not found');
225
+ const body = renderServerPage('EmployeeEdit', {
226
+ id: emp.id, name: emp.name, email: emp.email,
227
+ role: emp.role, status: emp.status,
228
+ statusIsActive: emp.status === 'active',
229
+ departments: deptChoices(emp.department),
230
+ });
231
+ return html(pageShell('Edit Employee', '/', body));
232
+ }
233
+
234
+ // POST /employees/update — update employee (PRG)
235
+ if (path === '/employees/update' && method === 'POST') {
236
+ const form = await req.formData();
237
+ const id = form.get('id') as string ?? '';
238
+ const name = (form.get('name') as string ?? '').trim();
239
+ const email = (form.get('email') as string ?? '').trim().toLowerCase();
240
+ const department = (form.get('department') as string ?? '').trim();
241
+ const role = (form.get('role') as string ?? '').trim();
242
+ const status = form.get('status') === 'inactive' ? 'inactive' : 'active';
243
+
244
+ if (!employees.has(id)) return redirectFlash('/', 'error', 'Employee not found');
245
+ if (name.length < 2) return redirectFlash('/', 'error', 'Name must be at least 2 characters');
246
+ if (!email.includes('@')) return redirectFlash('/', 'error', 'Invalid email address');
247
+ if (!department) return redirectFlash('/', 'error', 'Department is required');
248
+ if (!role) return redirectFlash('/', 'error', 'Job title is required');
249
+
250
+ const existing = [...employees.values()].find(e => e.email === email && e.id !== id);
251
+ if (existing) return redirectFlash('/', 'error', `Email ${email} is already registered`);
252
+
253
+ const prev = employees.get(id)!;
254
+ employees.set(id, { ...prev, name, email, department, role, status });
255
+ return redirectFlash('/', 'success', `${name} has been updated.`);
256
+ }
257
+
258
+ // POST /employees/delete — delete employee (PRG)
259
+ if (path === '/employees/delete' && method === 'POST') {
260
+ const form = await req.formData();
261
+ const id = form.get('id') as string ?? '';
262
+ const emp = employees.get(id);
263
+ if (!emp) return redirectFlash('/', 'error', 'Employee not found');
264
+ employees.delete(id);
265
+ return redirectFlash('/', 'success', `${emp.name} has been removed.`);
266
+ }
267
+
268
+ // GET /about
269
+ if (path === '/about' && method === 'GET') {
270
+ const body = renderServerPage('About', {});
271
+ return html(pageShell('About', '/about', body));
272
+ }
273
+
274
+ // SSR demo: /ssr/:ComponentName?Prop=value
275
+ if (path.startsWith('/ssr/')) {
276
+ const name = path.slice(5);
277
+ const js = compileFjsx(name);
278
+ if (!js) return new Response(`<!-- template '${name}' not found -->`, { status: 404, headers: { 'Content-Type': 'text/html' } });
279
+ const component: Record<string, unknown> = {};
280
+ url.searchParams.forEach((v, k) => { const n = Number(v); component[k] = isNaN(n) ? v : n; });
281
+ try {
282
+ return new Response(ssrRender(js, component), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
283
+ } catch (e: any) {
284
+ return new Response(`<!-- SSR error: ${e.message} -->`, { status: 500, headers: { 'Content-Type': 'text/html' } });
285
+ }
286
+ }
287
+
288
+ // FJSX → JS on demand (client components)
289
+ if (path.startsWith('/templates/') && path.endsWith('.js')) {
290
+ const name = basename(path, '.js');
291
+ const js = compileFjsx(name);
292
+ if (!js) return new Response('// template not found', { status: 404, headers: { 'Content-Type': 'text/javascript' } });
293
+ return new Response(js, { headers: { 'Content-Type': 'text/javascript' } });
294
+ }
295
+
296
+ // API routes
297
+ if (path.startsWith('/api/')) {
298
+ return WRANGLER_URL ? proxyToWorker(req, path, url.search) : handleMockApi(req, path, method);
299
+ }
300
+
301
+ // Static files from template/public/
302
+ const staticRes = serveStatic(path);
303
+ if (staticRes) return staticRes;
304
+
305
+ // 404
306
+ const body404 = `<section class="card">
307
+ <div class="card-label">404</div>
308
+ <h2>Page not found</h2>
309
+ <p class="text-muted" style="margin-top:0.5rem">The page you were looking for doesn't exist.</p>
310
+ <a href="/" class="btn btn-primary" style="display:inline-block;margin-top:1.25rem">← Back to Employees</a>
311
+ </section>`;
312
+ return html(pageShell('Not Found', '', body404), 404);
313
+ }
314
+
315
+ // ── In-memory mock API ─────────────────────────────────────────────────────────
316
+
317
+ async function handleMockApi(req: Request, path: string, method: string): Promise<Response> {
318
+ // Employees
319
+ if (path === '/api/employees' && method === 'GET')
320
+ return Response.json([...employees.values()].sort((a, b) => a.name.localeCompare(b.name)));
321
+
322
+ if (path === '/api/employees' && method === 'POST') {
323
+ const body = await req.json() as Partial<Employee>;
324
+ const name = (body?.name ?? '').trim();
325
+ const email = (body?.email ?? '').trim().toLowerCase();
326
+ if (name.length < 2) return Response.json({ error: 'Name must be at least 2 characters' }, { status: 400 });
327
+ if (!email.includes('@')) return Response.json({ error: 'Invalid email address' }, { status: 400 });
328
+ const id = crypto.randomUUID();
329
+ const emp: Employee = {
330
+ id, name, email,
331
+ department: body?.department ?? '',
332
+ role: body?.role ?? '',
333
+ status: body?.status === 'inactive' ? 'inactive' : 'active',
334
+ joinedAt: new Date().toISOString().slice(0, 10),
335
+ };
336
+ employees.set(id, emp);
337
+ return Response.json(emp, { status: 201 });
338
+ }
339
+
340
+ const empDel = path.match(/^\/api\/employees\/([^/]+)$/);
341
+ if (empDel && method === 'DELETE') {
342
+ const id = empDel[1];
343
+ if (!employees.has(id)) return Response.json({ error: 'Employee not found' }, { status: 404 });
344
+ employees.delete(id);
345
+ return new Response(null, { status: 204 });
346
+ }
347
+
348
+ // Legacy users API
349
+ if (path === '/api/users' && method === 'GET')
350
+ return Response.json([...users.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
351
+
352
+ if (path === '/api/users' && method === 'POST') {
353
+ const body = await req.json() as { name?: string };
354
+ const name = (body?.name ?? '').trim();
355
+ if (name.length < 2) return Response.json({ error: 'Name must be at least 2 characters' }, { status: 400 });
356
+ if (name.length > 100) return Response.json({ error: 'Name too long' }, { status: 400 });
357
+ const id = crypto.randomUUID();
358
+ const user: User = { id, name, createdAt: new Date().toISOString() };
359
+ users.set(id, user);
360
+ return Response.json(user, { status: 201 });
361
+ }
362
+
363
+ const userDel = path.match(/^\/api\/users\/([^/]+)$/);
364
+ if (userDel && method === 'DELETE') {
365
+ const id = userDel[1];
366
+ if (!users.has(id)) return Response.json({ error: 'User not found' }, { status: 404 });
367
+ users.delete(id);
368
+ return new Response(null, { status: 204 });
369
+ }
370
+
371
+ return Response.json({ error: `No mock route: ${method} ${path}` }, { status: 404 });
372
+ }
373
+
374
+ // ── Start server ───────────────────────────────────────────────────────────────
375
+
376
+ function startServer(port: number): ReturnType<typeof Bun.serve> {
377
+ try {
378
+ return Bun.serve({ port, fetch: handler });
379
+ } catch (e: any) {
380
+ if (e?.code === 'EADDRINUSE') {
381
+ console.warn(` port ${port} in use, trying ${port + 1}…`);
382
+ return startServer(port + 1);
383
+ }
384
+ throw e;
385
+ }
386
+ }
387
+
388
+ const server = startServer(PREFERRED_PORT);
389
+
390
+ watch(COMPONENTS_DIR, { persistent: false }, (_ev, filename) => {
391
+ if (filename?.endsWith('.fjsx')) {
392
+ console.log(` [HMR] ${filename} changed`);
393
+ broadcast('reload', JSON.stringify({ template: basename(filename, '.fjsx') }));
394
+ }
395
+ });
396
+
397
+ watch(SERVER_PAGES_DIR, { persistent: false }, (_ev, filename) => {
398
+ if (filename?.endsWith('.fjsx')) {
399
+ console.log(` [page] ${filename} changed — reloading`);
400
+ broadcast('reload', JSON.stringify({ template: basename(filename, '.fjsx'), fullReload: true }));
401
+ }
402
+ });
403
+
404
+ const apiMode = WRANGLER_URL
405
+ ? `proxy → ${WRANGLER_URL}`
406
+ : 'in-memory mock (set WRANGLER_URL to proxy to wrangler dev)';
407
+
408
+ console.log(`
409
+ ███████╗███████╗███████╗
410
+ ██╔════╝██╔════╝██╔════╝ fff-stack dev server
411
+ █████╗ █████╗ █████╗ http://localhost:${server.port}
412
+ ██╔══╝ ██╔══╝ ██╔══╝
413
+ ██║ ██║ ██║ FJSX hot-reload enabled
414
+ ╚═╝ ╚═╝ ╚═╝ API: ${apiMode}
415
+ `);
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>fff-stack app</title>
7
+ <link rel="stylesheet" href="/style.css" />
8
+ </head>
9
+ <body>
10
+
11
+ <main class="container">
12
+ <h1>fff-stack</h1>
13
+
14
+ <!-- Counter component mounts here -->
15
+ <section id="counter-root"></section>
16
+
17
+ <!-- UserList component mounts here -->
18
+ <section id="user-list-root"></section>
19
+ </main>
20
+
21
+ <script type="module" src="/bundle.js"></script>
22
+ </body>
23
+ </html>