create-fff-app 0.1.9 → 0.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-fff-app",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Scaffold a new fff-stack project",
5
5
  "bin": {
6
6
  "create-fff-app": "src/index.ts"
package/src/index.ts CHANGED
@@ -127,21 +127,18 @@ console.log(`
127
127
 
128
128
  ${bold('Next steps:')}
129
129
  ${cdStep}
130
- ${dim('# Install JS dependencies')}
131
- ${cyan('bun install')}
130
+ ${dim('# Install everything at once')}
131
+ ${cyan('bun run setup')}
132
132
 
133
- ${dim('# Install Fable (F# JS compiler)')}
134
- ${cyan('dotnet tool restore')}
133
+ ${dim('# Start local dev server (no Cloudflare account needed)')}
134
+ ${cyan('bun run demo')} ${dim('→ http://localhost:3000')}
135
135
 
136
- ${dim('# Set up Cloudflare D1 (run once)')}
137
- ${cyan('wrangler d1 create ' + projectName + '-db')}
138
- ${dim('# → copy the database_id into wrangler.toml')}
139
-
140
- ${dim('# Apply the schema')}
141
- ${cyan('bun run db:migrate:local')}
136
+ ${bold('When ready for Cloudflare Workers:')}
142
137
 
143
- ${dim('# Start the full dev stack')}
144
- ${cyan('bun dev')}
138
+ ${dim('# Set up D1 database (one-time)')}
139
+ ${cyan('wrangler d1 create ' + projectName + '-db')}
140
+ ${dim('# → copy database_id into wrangler.toml, then:')}
141
+ ${cyan('bun dev')} ${dim('→ http://localhost:8787')}
145
142
 
146
143
  ${bold('Docs:')} https://github.com/Alcogy/fff-stack
147
144
  `);
@@ -1,62 +1,21 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * fff-stack dev server — runs the template demo without .NET/Fable.
3
+ * fff-stack dev server — runs without .NET/Fable or Cloudflare account.
4
4
  *
5
- * bun run demo # in-memory mock API
6
- * WRANGLER_URL=http://localhost:8787 bun run demo # proxy to wrangler dev
5
+ * bun run demo # local dev with in-memory data
6
+ * WRANGLER_URL=http://localhost:8787 bun run demo # proxy /api/* to wrangler dev
7
7
  */
8
8
  import { compile } from '@fff-stack/fjsx-compiler';
9
9
  import { readFileSync, existsSync, watch } from 'fs';
10
- import { join, extname, basename } from 'path';
10
+ import { join, basename } from 'path';
11
11
 
12
12
  const PREFERRED_PORT = Number(process.env.PORT ?? 3000);
13
13
  const ROOT = import.meta.dir;
14
14
  const COMPONENTS_DIR = join(ROOT, 'src/client/Components');
15
- const SERVER_PAGES_DIR = join(ROOT, 'src/server/Endpoints/Pages');
16
15
  const PUBLIC_DIR = join(ROOT, 'public');
17
16
  const WRANGLER_URL = (process.env.WRANGLER_URL ?? '').replace(/\/$/, '');
18
17
 
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 ─────────────────────────────────────────────────
18
+ // ── FJSX compiler ──────────────────────────────────────────────────────────────
60
19
 
61
20
  function compileFjsx(name: string): string | null {
62
21
  const filePath = join(COMPONENTS_DIR, `${name}.fjsx`);
@@ -65,227 +24,111 @@ function compileFjsx(name: string): string | null {
65
24
  return compile(src, `${name}.fjsx`).js;
66
25
  }
67
26
 
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
- }
27
+ // ── SSE hot-reload ─────────────────────────────────────────────────────────────
74
28
 
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
- }
29
+ const sseClients = new Set<ReadableStreamDefaultController>();
79
30
 
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 });
31
+ function notifySse() {
32
+ for (const ctrl of sseClients) {
33
+ try { ctrl.enqueue('data: reload\n\n'); } catch { sseClients.delete(ctrl); }
92
34
  }
93
35
  }
94
36
 
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,
37
+ if (existsSync(COMPONENTS_DIR)) {
38
+ watch(COMPONENTS_DIR, { persistent: false }, (_ev, filename) => {
39
+ if (filename?.endsWith('.fjsx')) notifySse();
109
40
  });
110
41
  }
111
42
 
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 ─────────────────────────────────────────────────────────
43
+ // ── Static file server ─────────────────────────────────────────────────────────
135
44
 
136
45
  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',
46
+ '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
47
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
48
+ '.ico': 'image/x-icon', '.json': 'application/json',
145
49
  };
146
50
 
147
- function serveStatic(urlPath: string): Response | null {
148
- const filePath = join(PUBLIC_DIR, urlPath === '/' ? 'index.html' : urlPath);
51
+ function serveStatic(path: string): Response | null {
52
+ const filePath = join(PUBLIC_DIR, path === '/' ? 'index.html' : path);
149
53
  if (!existsSync(filePath)) return null;
54
+ const ext = filePath.slice(filePath.lastIndexOf('.'));
150
55
  const body = readFileSync(filePath);
151
- const ct = MIME[extname(filePath)] ?? 'application/octet-stream';
152
- return new Response(body, { headers: { 'Content-Type': ct } });
56
+ return new Response(body, { headers: { 'Content-Type': MIME[ext] ?? 'application/octet-stream' } });
153
57
  }
154
58
 
155
- const html = (body: string, status = 200) =>
156
- new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
59
+ function html(body: string, status = 200): Response {
60
+ return new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
61
+ }
157
62
 
158
- const redirect = (location: string) =>
159
- new Response(null, { status: 303, headers: { Location: location } });
63
+ // ── Wrangler proxy ─────────────────────────────────────────────────────────────
160
64
 
161
- const redirectFlash = (location: string, type: string, msg: string) =>
162
- redirect(`${location}?_flash=${encodeURIComponent(msg)}&_type=${type}`);
65
+ async function proxyToWorker(req: Request, path: string, search: string): Promise<Response> {
66
+ const url = `${WRANGLER_URL}${path}${search}`;
67
+ return fetch(new Request(url, req));
68
+ }
69
+
70
+ // ── Welcome page ───────────────────────────────────────────────────────────────
71
+
72
+ const welcomePage = `<!DOCTYPE html>
73
+ <html lang="en">
74
+ <head>
75
+ <meta charset="UTF-8">
76
+ <meta name="viewport" content="width=device-width, initial-scale=1">
77
+ <title>fff-stack — Ready</title>
78
+ <link rel="stylesheet" href="/style.css">
79
+ <style>
80
+ body { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
81
+ .center { text-align: center; max-width: 520px; }
82
+ .logo { font-size: 2.5rem; font-weight: 800; letter-spacing: -1px; color: var(--fff-accent); margin-bottom: 0.25rem; }
83
+ .tagline { color: var(--fff-text-dim); margin-bottom: 2rem; }
84
+ pre { text-align: left; background: var(--fff-surface); border: 1px solid var(--fff-border); border-radius: 8px; padding: 1rem 1.25rem; font-size: 0.85rem; }
85
+ .step { color: var(--fff-text-dim); font-size: 0.8rem; margin-bottom: 0.25rem; }
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <div class="center">
90
+ <div class="logo">FFF·Stack</div>
91
+ <p class="tagline">F# · FJSX · Cloudflare Workers</p>
92
+ <div class="card">
93
+ <p class="step">Start building — add routes in <code>src/server/Worker.fs</code>:</p>
94
+ <pre>router
95
+ .Get("/hello", fun _ctx ->
96
+ promise { return Response.json {| message = "Hello!" |} })
97
+ |> ignore</pre>
98
+ <p class="step" style="margin-top:1.25rem">Or scaffold a feature with the fff CLI:</p>
99
+ <pre>fff prototype TodoItem</pre>
100
+ </div>
101
+ <p style="margin-top:1.5rem;font-size:0.8rem;color:var(--fff-text-dim)">
102
+ Docs: <a href="https://github.com/Alcogy/fff-stack">github.com/Alcogy/fff-stack</a>
103
+ </p>
104
+ </div>
105
+ <script>
106
+ const es = new EventSource('/__sse');
107
+ es.onmessage = () => location.reload();
108
+ </script>
109
+ </body>
110
+ </html>`;
163
111
 
164
112
  // ── Request handler ────────────────────────────────────────────────────────────
165
113
 
166
- async function handler(req: Request): Promise<Response> {
114
+ async function handleRequest(req: Request): Promise<Response> {
167
115
  const url = new URL(req.url);
168
116
  const path = url.pathname;
169
117
  const method = req.method.toUpperCase();
170
118
 
171
- // SSE hot-reload
119
+ // SSE hot-reload endpoint
172
120
  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); },
121
+ let ctrl!: ReadableStreamDefaultController;
122
+ const stream = new ReadableStream({
123
+ start(c) { ctrl = c; sseClients.add(c); },
176
124
  cancel() { sseClients.delete(ctrl); },
177
125
  });
178
126
  return new Response(stream, {
179
- headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' },
127
+ headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
180
128
  });
181
129
  }
182
130
 
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)
131
+ // FJSX client components on demand
289
132
  if (path.startsWith('/templates/') && path.endsWith('.js')) {
290
133
  const name = basename(path, '.js');
291
134
  const js = compileFjsx(name);
@@ -293,127 +136,41 @@ async function handler(req: Request): Promise<Response> {
293
136
  return new Response(js, { headers: { 'Content-Type': 'text/javascript' } });
294
137
  }
295
138
 
296
- // API routes
297
- if (path.startsWith('/api/')) {
298
- return WRANGLER_URL ? proxyToWorker(req, path, url.search) : handleMockApi(req, path, method);
139
+ // Proxy /api/* to wrangler dev when WRANGLER_URL is set
140
+ if (path.startsWith('/api/') && WRANGLER_URL) {
141
+ return proxyToWorker(req, path, url.search);
299
142
  }
300
143
 
301
- // Static files from template/public/
144
+ // Static files
302
145
  const staticRes = serveStatic(path);
303
146
  if (staticRes) return staticRes;
304
147
 
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)));
148
+ // Welcome page
149
+ if (path === '/') return html(welcomePage);
321
150
 
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
- }
151
+ return html('<h2>404 Not found</h2>', 404);
386
152
  }
387
153
 
388
- const server = startServer(PREFERRED_PORT);
154
+ // ── Server ─────────────────────────────────────────────────────────────────────
389
155
 
390
- if (existsSync(COMPONENTS_DIR)) {
391
- watch(COMPONENTS_DIR, { persistent: false }, (_ev, filename) => {
392
- if (filename?.endsWith('.fjsx')) {
393
- console.log(` [HMR] ${filename} changed`);
394
- broadcast('reload', JSON.stringify({ template: basename(filename, '.fjsx') }));
395
- }
396
- });
397
- }
398
-
399
- if (existsSync(SERVER_PAGES_DIR)) {
400
- watch(SERVER_PAGES_DIR, { persistent: false }, (_ev, filename) => {
401
- if (filename?.endsWith('.fjsx')) {
402
- console.log(` [page] ${filename} changed — reloading`);
403
- broadcast('reload', JSON.stringify({ template: basename(filename, '.fjsx'), fullReload: true }));
156
+ function tryPort(port: number): Promise<number> {
157
+ return new Promise((resolve, reject) => {
158
+ try {
159
+ Bun.serve({ port, fetch: handleRequest });
160
+ resolve(port);
161
+ } catch {
162
+ resolve(tryPort(port + 1));
404
163
  }
405
164
  });
406
165
  }
407
166
 
408
- const apiMode = WRANGLER_URL
409
- ? `proxy → ${WRANGLER_URL}`
410
- : 'in-memory mock (set WRANGLER_URL to proxy to wrangler dev)';
411
-
412
- console.log(`
167
+ tryPort(PREFERRED_PORT).then(port => {
168
+ console.log(`
413
169
  ███████╗███████╗███████╗
414
170
  ██╔════╝██╔════╝██╔════╝ fff-stack dev server
415
- █████╗ █████╗ █████╗ http://localhost:${server.port}
171
+ █████╗ █████╗ █████╗ http://localhost:${port}
416
172
  ██╔══╝ ██╔══╝ ██╔══╝
417
173
  ██║ ██║ ██║ FJSX hot-reload enabled
418
- ╚═╝ ╚═╝ ╚═╝ API: ${apiMode}
174
+ ╚═╝ ╚═╝ ╚═╝ ${WRANGLER_URL ? `API ${WRANGLER_URL}` : 'set WRANGLER_URL to proxy /api/* to wrangler dev'}
419
175
  `);
176
+ });
@@ -1,25 +1,9 @@
1
- -- Initial schema: users and posts tables
1
+ -- Initial schema
2
2
  -- Applied automatically by: bun run migrate
3
3
 
4
- CREATE TABLE IF NOT EXISTS users (
5
- id TEXT PRIMARY KEY,
6
- name TEXT NOT NULL,
7
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
8
- );
9
-
10
- CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
11
-
12
- CREATE TABLE IF NOT EXISTS posts (
13
- id TEXT PRIMARY KEY,
14
- author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
15
- title TEXT NOT NULL,
16
- slug TEXT NOT NULL UNIQUE,
17
- body TEXT NOT NULL,
18
- published_at TEXT,
19
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
21
- );
22
-
23
- CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
24
- CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
25
- CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
4
+ -- Add your tables here. Example:
5
+ -- CREATE TABLE IF NOT EXISTS items (
6
+ -- id TEXT PRIMARY KEY,
7
+ -- name TEXT NOT NULL,
8
+ -- created_at TEXT NOT NULL DEFAULT (datetime('now'))
9
+ -- );
@@ -4,20 +4,20 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
+ "setup": "bun install && dotnet tool restore",
7
8
  "demo": "bun run dev-server.ts",
8
- "build": "bun run build:assets && bun run build:templates && bun run build:page-templates && bun run build:client && bun run build:server",
9
- "build:assets": "node -e \"require('fs').mkdirSync('dist/client',{recursive:true});require('fs').cpSync('public','dist/client',{recursive:true,force:true})\"",
9
+ "build": "bun run build:assets && bun run build:templates && bun run build:client && bun run build:server",
10
+ "build:assets": "node -e \"require('fs').mkdirSync('dist/client',{recursive:true});require('fs').cpSync('public','dist/client',{recursive:true,force:true})\"",
10
11
  "build:templates": "fjsx --dir src/client --out dist/client/templates",
11
- "build:page-templates": "fjsx --dir src/server/Endpoints/Pages --out dist/server/Pages",
12
- "dev:templates": "fjsx-watcher --dir src/client --out dist/client/templates",
13
- "dev:page-templates": "fjsx-watcher --dir src/server/Endpoints/Pages --out dist/server/Pages",
14
- "build:client": "dotnet fable src/client/Client.fsproj --outDir dist/client/fable --noCache && bun build dist/client/fable/App.js --outdir dist/client --entry-naming bundle.js",
15
- "build:server": "dotnet fable src/server/Server.fsproj --outDir dist/server",
16
- "build:wrangler": "bun run build:assets && bun run build:templates && bun run build:page-templates && bun run build:server",
17
- "dev": "bun run build:client && concurrently \"bun run dev:templates\" \"bun run dev:page-templates\" \"bun run dev:client\" \"wrangler dev\"",
18
- "dev:client": "dotnet fable src/client/Client.fsproj --watch --outDir dist/client/fable",
19
- "deploy": "wrangler deploy",
20
- "migrate": "bun run migrate.ts",
12
+ "build:client": "dotnet fable src/client/Client.fsproj --outDir dist/client/fable --noCache && bun build dist/client/fable/App.js --outdir dist/client --entry-naming bundle.js",
13
+ "build:server": "dotnet fable src/server/Server.fsproj --outDir dist/server --noCache",
14
+ "build:wrangler": "bun run build:assets && bun run build:templates && bun run build:server",
15
+ "dev": "bun run build:client && concurrently \"bun run dev:templates\" \"bun run dev:client\" \"bun run dev:bundle\" \"wrangler dev\"",
16
+ "dev:templates": "fjsx-watcher --dir src/client --out dist/client/templates",
17
+ "dev:client": "dotnet fable src/client/Client.fsproj --watch --outDir dist/client/fable",
18
+ "dev:bundle": "bun build --watch dist/client/fable/App.js --outdir dist/client --entry-naming bundle.js",
19
+ "deploy": "wrangler deploy",
20
+ "migrate": "bun run migrate.ts",
21
21
  "migrate:remote": "bun run migrate.ts --remote",
22
22
  "migrate:status": "bun run migrate.ts --status"
23
23
  },