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.
- package/package.json +21 -0
- package/src/index.ts +119 -0
- package/template/.config/dotnet-tools.json +5 -0
- package/template/dev-server.ts +415 -0
- package/template/index.html +23 -0
- package/template/migrate.ts +166 -0
- package/template/migrations/0001_init.sql +25 -0
- package/template/migrations/0002_employees.sql +16 -0
- package/template/package.json +29 -0
- package/template/public/app.js +156 -0
- package/template/public/runtime.js +149 -0
- package/template/public/style.css +769 -0
- package/template/schema.sql +25 -0
- package/template/src/client/App.fs +55 -0
- package/template/src/client/Client.fsproj +24 -0
- package/template/src/client/Nav.fs +75 -0
- package/template/src/server/Domain/Employees/Employee.fs +34 -0
- package/template/src/server/Endpoints/Employees/Routes.fs +35 -0
- package/template/src/server/Endpoints/Pages/About.fjsx +66 -0
- package/template/src/server/Endpoints/Pages/EmployeeEdit.fjsx +66 -0
- package/template/src/server/Endpoints/Pages/EmployeeList.fjsx +54 -0
- package/template/src/server/Endpoints/Pages/EmployeeNew.fjsx +55 -0
- package/template/src/server/Endpoints/Pages/Layout.fjsx +40 -0
- package/template/src/server/Endpoints/Pages/Routes.fs +117 -0
- package/template/src/server/Endpoints/Pages/Templates.fs +45 -0
- package/template/src/server/Server.fsproj +28 -0
- package/template/src/server/Worker.fs +45 -0
- package/template/src/server/Workflows/Employees/EmployeeWorkflows.fs +86 -0
- package/template/webpack.client.js +18 -0
- package/template/wrangler.toml +23 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* D1 migration runner for fff-stack.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun run migrate — apply pending migrations (local D1)
|
|
7
|
+
* bun run migrate --remote — apply pending migrations (production D1)
|
|
8
|
+
* bun run migrate --status — show migration status without applying
|
|
9
|
+
*
|
|
10
|
+
* Migration files must be named NNNN_description.sql (e.g. 0001_init.sql)
|
|
11
|
+
* and placed in the migrations/ directory next to this script.
|
|
12
|
+
*
|
|
13
|
+
* Applied migrations are tracked in a _migrations table inside D1 itself,
|
|
14
|
+
* so the system is self-contained and requires no external state.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readdirSync, existsSync } from "fs";
|
|
18
|
+
import path from "path";
|
|
19
|
+
|
|
20
|
+
const DB_NAME = "fff-stack-db";
|
|
21
|
+
const MIGRATIONS_DIR = path.join(import.meta.dir, "migrations");
|
|
22
|
+
const MIGRATION_PATTERN = /^\d{4}_[\w-]+\.sql$/;
|
|
23
|
+
|
|
24
|
+
// ── Wrangler helpers ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
async function wrangler(args: string[]): Promise<string> {
|
|
27
|
+
const proc = Bun.spawn(["bunx", "wrangler", ...args], {
|
|
28
|
+
stdout: "pipe",
|
|
29
|
+
stderr: "pipe",
|
|
30
|
+
});
|
|
31
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
32
|
+
new Response(proc.stdout).text(),
|
|
33
|
+
new Response(proc.stderr).text(),
|
|
34
|
+
proc.exited,
|
|
35
|
+
]);
|
|
36
|
+
if (code !== 0) {
|
|
37
|
+
throw new Error(`wrangler exited ${code}:\n${stderr.trim()}`);
|
|
38
|
+
}
|
|
39
|
+
return stdout.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function d1args(extra: string[], local: boolean): string[] {
|
|
43
|
+
const args = ["d1", "execute", DB_NAME, ...extra];
|
|
44
|
+
if (local) args.push("--local");
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function execSql(sql: string, local: boolean): Promise<void> {
|
|
49
|
+
await wrangler(d1args(["--command", sql], local));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function execFile(filePath: string, local: boolean): Promise<void> {
|
|
53
|
+
await wrangler(d1args(["--file", filePath], local));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function querySql(
|
|
57
|
+
sql: string,
|
|
58
|
+
local: boolean
|
|
59
|
+
): Promise<Record<string, unknown>[]> {
|
|
60
|
+
const text = await wrangler(d1args(["--command", sql, "--json"], local));
|
|
61
|
+
const parsed = JSON.parse(text) as Array<{ results?: unknown[] }>;
|
|
62
|
+
return (parsed[0]?.results ?? []) as Record<string, unknown>[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Migrations table ──────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
async function ensureTable(local: boolean): Promise<void> {
|
|
68
|
+
await execSql(
|
|
69
|
+
"CREATE TABLE IF NOT EXISTS _migrations (" +
|
|
70
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
|
71
|
+
"name TEXT NOT NULL UNIQUE, " +
|
|
72
|
+
"applied_at TEXT NOT NULL DEFAULT (datetime('now')))",
|
|
73
|
+
local
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function appliedSet(local: boolean): Promise<Set<string>> {
|
|
78
|
+
const rows = await querySql(
|
|
79
|
+
"SELECT name FROM _migrations ORDER BY name",
|
|
80
|
+
local
|
|
81
|
+
);
|
|
82
|
+
return new Set(rows.map((r) => r.name as string));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function recordApplied(name: string, local: boolean): Promise<void> {
|
|
86
|
+
// filename is validated by MIGRATION_PATTERN so no injection risk
|
|
87
|
+
await execSql(`INSERT INTO _migrations (name) VALUES ('${name}')`, local);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Migration files ───────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function listFiles(): string[] {
|
|
93
|
+
if (!existsSync(MIGRATIONS_DIR)) return [];
|
|
94
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
95
|
+
.filter((f) => MIGRATION_PATTERN.test(f))
|
|
96
|
+
.sort();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
async function migrate(local: boolean): Promise<void> {
|
|
102
|
+
console.log(`Migrations (${local ? "local" : "remote"}):`);
|
|
103
|
+
|
|
104
|
+
await ensureTable(local);
|
|
105
|
+
|
|
106
|
+
const files = listFiles();
|
|
107
|
+
if (files.length === 0) {
|
|
108
|
+
console.log(" No migration files found in ./migrations/");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const applied = await appliedSet(local);
|
|
113
|
+
let count = 0;
|
|
114
|
+
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
if (applied.has(file)) {
|
|
117
|
+
console.log(` [skip] ${file}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
process.stdout.write(` [apply] ${file} ... `);
|
|
121
|
+
await execFile(path.join(MIGRATIONS_DIR, file), local);
|
|
122
|
+
await recordApplied(file, local);
|
|
123
|
+
console.log("done");
|
|
124
|
+
count++;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(
|
|
128
|
+
count === 0
|
|
129
|
+
? "\nAlready up to date."
|
|
130
|
+
: `\nApplied ${count} migration(s).`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function status(local: boolean): Promise<void> {
|
|
135
|
+
console.log(`Migration status (${local ? "local" : "remote"}):`);
|
|
136
|
+
|
|
137
|
+
const files = listFiles();
|
|
138
|
+
if (files.length === 0) {
|
|
139
|
+
console.log(" No migration files found.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let applied: Set<string>;
|
|
144
|
+
try {
|
|
145
|
+
await ensureTable(local);
|
|
146
|
+
applied = await appliedSet(local);
|
|
147
|
+
} catch {
|
|
148
|
+
console.log(" (cannot reach D1 — is wrangler configured?)");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
const tag = applied.has(file) ? "[applied]" : "[pending]";
|
|
154
|
+
console.log(` ${tag} ${file}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const argv = process.argv.slice(2);
|
|
161
|
+
const local = !argv.includes("--remote");
|
|
162
|
+
|
|
163
|
+
(argv.includes("--status") ? status(local) : migrate(local)).catch((err) => {
|
|
164
|
+
console.error((err as Error).message ?? String(err));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Initial schema: users and posts tables
|
|
2
|
+
-- Applied automatically by: bun run migrate
|
|
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);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Employee directory table
|
|
2
|
+
-- Applied automatically by: bun run migrate
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS employees (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
name TEXT NOT NULL,
|
|
7
|
+
email TEXT NOT NULL UNIQUE,
|
|
8
|
+
department TEXT NOT NULL,
|
|
9
|
+
role TEXT NOT NULL,
|
|
10
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
11
|
+
joined_at TEXT NOT NULL DEFAULT (date('now'))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_employees_name ON employees(name);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_employees_status ON employees(status);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fff-stack-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"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": "mkdir -p dist/client && cp -r public/. dist/client/",
|
|
10
|
+
"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": "fable src/client/Client.fsproj --outDir dist/client/fable && bun build dist/client/fable/App.js --outdir dist/client --entry-naming bundle.js",
|
|
15
|
+
"build:server": "fable src/server/Server.fsproj --outDir dist/server",
|
|
16
|
+
"dev": "concurrently \"bun run dev:templates\" \"bun run dev:page-templates\" \"bun run dev:client\" \"wrangler dev\"",
|
|
17
|
+
"dev:client": "fable src/client/Client.fsproj --watch --outDir dist/client/fable",
|
|
18
|
+
"deploy": "wrangler deploy",
|
|
19
|
+
"migrate": "bun run migrate.ts",
|
|
20
|
+
"migrate:remote": "bun run migrate.ts --remote",
|
|
21
|
+
"migrate:status": "bun run migrate.ts --status"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@fff-stack/fjsx-compiler": "^0.1.0",
|
|
26
|
+
"concurrently": "^8.0.0",
|
|
27
|
+
"wrangler": "^3.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo app — mirrors template/src/client/App.fs for the dev server demo.
|
|
3
|
+
* In production this is replaced by the Fable-compiled output of App.fs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Component, Template, registerTemplate } from '/runtime.js';
|
|
7
|
+
|
|
8
|
+
import * as CounterTemplate from '/templates/Counter.js';
|
|
9
|
+
import * as UserListTemplate from '/templates/UserList.js';
|
|
10
|
+
|
|
11
|
+
registerTemplate('Counter', CounterTemplate);
|
|
12
|
+
registerTemplate('UserList', UserListTemplate);
|
|
13
|
+
|
|
14
|
+
// ── Component definitions (mirrors Counter.fs / UserList.fs) ─────────────────
|
|
15
|
+
|
|
16
|
+
class Counter extends Component {
|
|
17
|
+
constructor(id) { super(id); this.Count = 0; }
|
|
18
|
+
Increment() { this.Count++; this.Render(); }
|
|
19
|
+
Decrement() { this.Count--; this.Render(); }
|
|
20
|
+
Reset() { this.Count = 0; this.Render(); }
|
|
21
|
+
Render() { Template.load('Counter.fjsx', this); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class UserList extends Component {
|
|
25
|
+
constructor(id) {
|
|
26
|
+
super(id);
|
|
27
|
+
this.Users = [];
|
|
28
|
+
this.Loading = false;
|
|
29
|
+
this.Error = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async FetchUsers() {
|
|
33
|
+
this.Loading = true; this.Error = null; this.Render();
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch('/api/users');
|
|
36
|
+
this.Users = await res.json();
|
|
37
|
+
} catch (e) { this.Error = e.message; }
|
|
38
|
+
this.Loading = false; this.Render();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
OnMount() { this.FetchUsers(); }
|
|
42
|
+
Render() { Template.load('UserList.fjsx', this); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Island registry (mirrors App.fs registry) ─────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const _registry = new Map();
|
|
48
|
+
|
|
49
|
+
function findIsland(name) { return _registry.get(name) ?? null; }
|
|
50
|
+
|
|
51
|
+
// ── Island mounting (mirrors App.fs mountIslands) ─────────────────────────────
|
|
52
|
+
|
|
53
|
+
function mountIslands() {
|
|
54
|
+
document.querySelectorAll('[data-island]').forEach(el => {
|
|
55
|
+
const name = el.dataset.island;
|
|
56
|
+
if (!el.id) el.id = `island-${name.toLowerCase()}`;
|
|
57
|
+
|
|
58
|
+
let instance;
|
|
59
|
+
switch (name) {
|
|
60
|
+
case 'Counter': instance = new Counter(el.id); break;
|
|
61
|
+
case 'UserList': instance = new UserList(el.id); break;
|
|
62
|
+
default: console.warn(`[fff-stack] Unknown island: ${name}`); return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
instance.Mount();
|
|
66
|
+
if (!_registry.has(name)) _registry.set(name, instance);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Add-user form (mirrors App.fs setupUserForm) ──────────────────────────────
|
|
71
|
+
|
|
72
|
+
function setupUserForm() {
|
|
73
|
+
const form = document.getElementById('user-form');
|
|
74
|
+
const input = document.getElementById('user-name-input');
|
|
75
|
+
const error = document.getElementById('user-error');
|
|
76
|
+
if (!form) return;
|
|
77
|
+
|
|
78
|
+
form.addEventListener('submit', async (e) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
if (error) error.textContent = '';
|
|
81
|
+
const res = await fetch('/api/users', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ name: input.value.trim() }),
|
|
85
|
+
});
|
|
86
|
+
if (res.ok) {
|
|
87
|
+
input.value = '';
|
|
88
|
+
findIsland('UserList')?.FetchUsers();
|
|
89
|
+
} else {
|
|
90
|
+
const body = await res.json();
|
|
91
|
+
if (error) error.textContent = body.error ?? 'Error';
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Delete handler (mirrors App.fs setupDeleteHandler) ───────────────────────
|
|
97
|
+
|
|
98
|
+
function setupDeleteHandler() {
|
|
99
|
+
document.addEventListener('click', async (e) => {
|
|
100
|
+
const btn = e.target.closest('[data-delete-id]');
|
|
101
|
+
if (!btn) return;
|
|
102
|
+
await fetch(`/api/users/${btn.dataset.deleteId}`, { method: 'DELETE' });
|
|
103
|
+
findIsland('UserList')?.FetchUsers();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Client-side navigation (mirrors Nav.fs) ───────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function updateNav(pathname) {
|
|
110
|
+
document.querySelectorAll('.site-nav a').forEach(a => {
|
|
111
|
+
if (a.getAttribute('href') === pathname) a.classList.add('active');
|
|
112
|
+
else a.classList.remove('active');
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function navigate(url) {
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(url);
|
|
119
|
+
if (!res.ok) { location.href = url; return; }
|
|
120
|
+
const html = await res.text();
|
|
121
|
+
const parser = new DOMParser();
|
|
122
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
123
|
+
const newMain = doc.querySelector('main');
|
|
124
|
+
const curMain = document.querySelector('main');
|
|
125
|
+
if (newMain && curMain) {
|
|
126
|
+
curMain.innerHTML = newMain.innerHTML;
|
|
127
|
+
const uri = new URL(url, location.href);
|
|
128
|
+
history.pushState(null, '', uri.pathname);
|
|
129
|
+
updateNav(uri.pathname);
|
|
130
|
+
mountIslands(); // re-mount islands in new content
|
|
131
|
+
} else {
|
|
132
|
+
location.href = url;
|
|
133
|
+
}
|
|
134
|
+
} catch { location.href = url; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
document.addEventListener('click', (e) => {
|
|
138
|
+
const a = e.target.closest('a');
|
|
139
|
+
if (!a) return;
|
|
140
|
+
const sameOrigin = a.origin === location.origin;
|
|
141
|
+
const plainClick = !(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey);
|
|
142
|
+
const noTarget = !a.target || a.target === '_self';
|
|
143
|
+
const noDownload = !a.getAttribute('download');
|
|
144
|
+
if (sameOrigin && plainClick && noTarget && noDownload) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
navigate(a.href);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
window.addEventListener('popstate', () => navigate(location.href));
|
|
151
|
+
|
|
152
|
+
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
mountIslands();
|
|
155
|
+
setupUserForm();
|
|
156
|
+
setupDeleteHandler();
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fff-stack client runtime (vanilla JS)
|
|
3
|
+
*
|
|
4
|
+
* In production this is generated by Fable from packages/client-runtime/*.fs.
|
|
5
|
+
* In dev we ship this hand-written equivalent so the demo runs without .NET.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Template registry ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const _registry = new Map();
|
|
11
|
+
|
|
12
|
+
export function registerTemplate(name, mod) {
|
|
13
|
+
_registry.set(name, mod);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Mounted component registry (used by HMR) ─────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const _mounted = new Set();
|
|
19
|
+
|
|
20
|
+
// ── Component base class ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export class Component {
|
|
23
|
+
constructor(containerId) {
|
|
24
|
+
this._containerId = containerId;
|
|
25
|
+
this._mounted = false;
|
|
26
|
+
this._handle = null;
|
|
27
|
+
this._lastTemplate = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get ContainerId() { return this._containerId; }
|
|
31
|
+
get IsMounted() { return this._mounted; }
|
|
32
|
+
|
|
33
|
+
Mount() {
|
|
34
|
+
this._mounted = true;
|
|
35
|
+
_mounted.add(this);
|
|
36
|
+
this.OnMount();
|
|
37
|
+
this.Render();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Hydrate() {
|
|
41
|
+
this._mounted = true;
|
|
42
|
+
this._hydrating = true;
|
|
43
|
+
_mounted.add(this);
|
|
44
|
+
this.OnMount();
|
|
45
|
+
this.Render();
|
|
46
|
+
this._hydrating = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Unmount() {
|
|
50
|
+
this._mounted = false;
|
|
51
|
+
_mounted.delete(this);
|
|
52
|
+
this.OnDispose();
|
|
53
|
+
this._handle = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Override in subclass
|
|
57
|
+
Render() {}
|
|
58
|
+
|
|
59
|
+
// Optional lifecycle hooks
|
|
60
|
+
OnMount() {}
|
|
61
|
+
OnUpdate() {}
|
|
62
|
+
OnDispose() {}
|
|
63
|
+
|
|
64
|
+
// Internal — used by Template.load
|
|
65
|
+
_setHandle(h) { this._handle = h; }
|
|
66
|
+
_getHandle() { return this._handle; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Template.load / Template.hydrate ─────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export const Template = {
|
|
72
|
+
/**
|
|
73
|
+
* Called from Component.Render().
|
|
74
|
+
* First call: finds the registered compiled template, calls init().
|
|
75
|
+
* Subsequent calls: calls the stored handle's update().
|
|
76
|
+
*/
|
|
77
|
+
load(templateName, component) {
|
|
78
|
+
const name = templateName.replace('.fjsx', '');
|
|
79
|
+
const container = document.getElementById(component.ContainerId);
|
|
80
|
+
if (!container)
|
|
81
|
+
throw new Error(`fff-stack: container '#${component.ContainerId}' not found in the DOM`);
|
|
82
|
+
|
|
83
|
+
// Track which template this component last rendered with (for HMR)
|
|
84
|
+
component._lastTemplate = name;
|
|
85
|
+
|
|
86
|
+
const handle = component._getHandle();
|
|
87
|
+
if (handle) {
|
|
88
|
+
handle.update(component);
|
|
89
|
+
component.OnUpdate();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const mod = _registry.get(name);
|
|
94
|
+
if (!mod)
|
|
95
|
+
throw new Error(`fff-stack: template '${name}' not registered. Import it before mounting.`);
|
|
96
|
+
|
|
97
|
+
const newHandle = component._hydrating
|
|
98
|
+
? mod.hydrate(component, container)
|
|
99
|
+
: mod.init(component, container);
|
|
100
|
+
component._setHandle(newHandle);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hydrate a server-rendered container.
|
|
105
|
+
* Attaches to existing server-rendered HTML without clearing it.
|
|
106
|
+
*/
|
|
107
|
+
hydrate(templateName, component) {
|
|
108
|
+
component._hydrating = true;
|
|
109
|
+
component._setHandle(null);
|
|
110
|
+
this.load(templateName, component);
|
|
111
|
+
component._hydrating = false;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ── Hot-reload (dev only) ─────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const sse = new EventSource('/__sse');
|
|
118
|
+
|
|
119
|
+
sse.addEventListener('reload', async (e) => {
|
|
120
|
+
const { template } = JSON.parse(e.data);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
// Re-import the compiled template with a cache-busting query param.
|
|
124
|
+
// ES module cache is keyed by URL, so the ?t= makes this a fresh request.
|
|
125
|
+
const mod = await import(`/templates/${template}.js?t=${Date.now()}`);
|
|
126
|
+
registerTemplate(template, mod);
|
|
127
|
+
|
|
128
|
+
// Re-render every mounted component that uses this template.
|
|
129
|
+
let count = 0;
|
|
130
|
+
for (const comp of _mounted) {
|
|
131
|
+
if (comp._lastTemplate === template) {
|
|
132
|
+
comp._setHandle(null); // force re-init on next Render()
|
|
133
|
+
comp.Render();
|
|
134
|
+
count++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (count > 0) {
|
|
139
|
+
console.log(`[HMR] '${template}' hot-swapped (${count} component${count > 1 ? 's' : ''})`);
|
|
140
|
+
} else {
|
|
141
|
+
// No mounted components use this template — full reload as fallback.
|
|
142
|
+
console.log(`[HMR] '${template}' changed, no mounted components found → reloading`);
|
|
143
|
+
location.reload();
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.warn(`[HMR] hot-swap failed for '${template}', falling back to reload:`, err);
|
|
147
|
+
location.reload();
|
|
148
|
+
}
|
|
149
|
+
});
|