create-fff-app 0.1.10 → 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 +1 -1
- package/src/index.ts +9 -12
- package/template/dev-server.ts +98 -341
- package/template/migrations/0001_init.sql +7 -23
- package/template/package.json +12 -13
- package/template/schema.sql +9 -25
- package/template/src/client/Client.fsproj +1 -1
- package/template/src/server/Server.fsproj +0 -12
- package/template/src/server/Worker.fs +2 -32
- package/template/migrations/0002_employees.sql +0 -16
- package/template/src/server/Domain/Employees/Employee.fs +0 -34
- package/template/src/server/Endpoints/Employees/Routes.fs +0 -35
- package/template/src/server/Endpoints/Pages/About.fjsx +0 -66
- package/template/src/server/Endpoints/Pages/EmployeeEdit.fjsx +0 -66
- package/template/src/server/Endpoints/Pages/EmployeeList.fjsx +0 -54
- package/template/src/server/Endpoints/Pages/EmployeeNew.fjsx +0 -55
- package/template/src/server/Endpoints/Pages/Layout.fjsx +0 -40
- package/template/src/server/Endpoints/Pages/Routes.fs +0 -118
- package/template/src/server/Endpoints/Pages/Templates.fs +0 -44
- package/template/src/server/Workflows/Employees/EmployeeWorkflows.fs +0 -86
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -127,21 +127,18 @@ console.log(`
|
|
|
127
127
|
|
|
128
128
|
${bold('Next steps:')}
|
|
129
129
|
${cdStep}
|
|
130
|
-
${dim('# Install
|
|
131
|
-
${cyan('bun
|
|
130
|
+
${dim('# Install everything at once')}
|
|
131
|
+
${cyan('bun run setup')}
|
|
132
132
|
|
|
133
|
-
${dim('#
|
|
134
|
-
${cyan('
|
|
133
|
+
${dim('# Start local dev server (no Cloudflare account needed)')}
|
|
134
|
+
${cyan('bun run demo')} ${dim('→ http://localhost:3000')}
|
|
135
135
|
|
|
136
|
-
|
|
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('#
|
|
144
|
-
${cyan('
|
|
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
|
`);
|
package/template/dev-server.ts
CHANGED
|
@@ -1,62 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* fff-stack dev server — runs
|
|
3
|
+
* fff-stack dev server — runs without .NET/Fable or Cloudflare account.
|
|
4
4
|
*
|
|
5
|
-
* bun run demo # in-memory
|
|
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,
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
'.
|
|
139
|
-
'.
|
|
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(
|
|
148
|
-
const filePath = join(PUBLIC_DIR,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
new Response(null, { status: 303, headers: { Location: location } });
|
|
63
|
+
// ── Wrangler proxy ─────────────────────────────────────────────────────────────
|
|
160
64
|
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
|
174
|
-
const stream = new ReadableStream
|
|
175
|
-
start(c) { ctrl = 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'
|
|
127
|
+
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
|
180
128
|
});
|
|
181
129
|
}
|
|
182
130
|
|
|
183
|
-
//
|
|
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
|
-
//
|
|
297
|
-
if (path.startsWith('/api/')) {
|
|
298
|
-
return
|
|
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
|
|
144
|
+
// Static files
|
|
302
145
|
const staticRes = serveStatic(path);
|
|
303
146
|
if (staticRes) return staticRes;
|
|
304
147
|
|
|
305
|
-
//
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
// ── Server ─────────────────────────────────────────────────────────────────────
|
|
389
155
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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:${
|
|
171
|
+
█████╗ █████╗ █████╗ http://localhost:${port}
|
|
416
172
|
██╔══╝ ██╔══╝ ██╔══╝
|
|
417
173
|
██║ ██║ ██║ FJSX hot-reload enabled
|
|
418
|
-
╚═╝ ╚═╝ ╚═╝ API
|
|
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
|
|
1
|
+
-- Initial schema
|
|
2
2
|
-- Applied automatically by: bun run migrate
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
-- );
|
package/template/package.json
CHANGED
|
@@ -4,21 +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:
|
|
9
|
-
"build:assets":
|
|
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:
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"dev":
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"deploy": "wrangler deploy",
|
|
21
|
-
"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",
|
|
22
21
|
"migrate:remote": "bun run migrate.ts --remote",
|
|
23
22
|
"migrate:status": "bun run migrate.ts --status"
|
|
24
23
|
},
|