spark-ssr 0.1.1 → 0.3.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/src/server.js CHANGED
@@ -1,35 +1,51 @@
1
1
  /**
2
2
  * spark-ssr server — `bun spark-ssr` and it serves.
3
3
  *
4
- * The filesystem is the router (pages/, api/, public/, 404.html, 500.html,
5
- * middleware.html), <spark-ssr> blocks declare the data, and everything else
6
- * is inferred from the template. No route handlers, no controllers, no build.
4
+ * The filesystem is the router (pages/, _layout.html, api/, public/,
5
+ * 404.html, 500.html, middleware.html), <spark-ssr> blocks declare the data
6
+ * (SQL, URLs, file globs, modules named or inferred), and everything else
7
+ * is read from the template: auto CRUD, guards, form validation, schema,
8
+ * seeds, live updates. No route handlers, no controllers, no build.
7
9
  */
8
- import { join, resolve, extname, dirname } from 'node:path';
10
+ import { join, resolve, extname, dirname, relative, sep } from 'node:path';
9
11
  import { existsSync, readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
10
12
  import { createHmac, timingSafeEqual, randomBytes, randomUUID } from 'node:crypto';
11
13
  import { loadConfig } from './config.js';
12
14
  import { connect } from './db.js';
13
- import { extractBlocks, analyze, dataPlan, rewriteParams, singleShaped } from './parse.js';
14
- import { renderFragment } from './render.js';
15
+ import {
16
+ extractBlocks, analyze, mergeAnalyses, dataPlan, rewriteParams, singleShaped,
17
+ maskComments, extractForms, validateFields, sqlTables,
18
+ } from './parse.js';
19
+ import { renderFragment, evalExpr } from './render.js';
15
20
  import { clientComponent, initModule } from './hydrate.js';
21
+ import { urlSource, globSource, moduleSource, makeSourceCache } from './sources.js';
22
+ import { inferSchema, diffSchema, pushSchema, seedTables } from './schema.js';
23
+ // Head semantics live in one place for the whole family: spark-html-head owns
24
+ // title/meta on the client (pushState updates); its /ssr module owns them
25
+ // here — pages put literal <title>/<meta>/<link> tags in their markup, we
26
+ // lift them into the document head with {expr} interpolated per request.
27
+ import { liftHead, renderHead } from 'spark-html-head/ssr';
16
28
 
17
29
  const AsyncFunction = (async () => {}).constructor;
18
30
  const json = (data, status = 200, headers = {}) =>
19
31
  new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
20
32
  const dig = (obj, path) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
33
+ const escapeHtml = (s) => String(s)
34
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
21
35
 
22
36
  // ── pages ──────────────────────────────────────────────────────────────
23
- const RESERVED_ROOT_DIRS = new Set(['components', 'api', 'public', 'pages', 'node_modules', 'dist', 'uploads']);
37
+ const RESERVED_ROOT_DIRS = new Set(['components', 'api', 'public', 'pages', 'node_modules', 'dist', 'uploads', 'seed']);
24
38
  const RESERVED_FILES = new Set(['404.html', '500.html', 'middleware.html']);
25
39
 
26
- function scanPages(root) {
40
+ export function scanPages(root) {
27
41
  const pagesDir = existsSync(join(root, 'pages')) ? join(root, 'pages') : root;
28
42
  const pages = [];
29
43
  (function scan(dir, prefix) {
30
44
  if (!existsSync(dir)) return;
31
45
  for (const f of readdirSync(dir)) {
32
- if (f.startsWith('.')) continue;
46
+ // `_`-prefixed files are structure, not pages: _layout.html wraps the
47
+ // folder's pages instead of serving as one.
48
+ if (f.startsWith('.') || f.startsWith('_')) continue;
33
49
  const full = join(dir, f);
34
50
  const st = statSync(full);
35
51
  if (st.isDirectory()) {
@@ -63,31 +79,135 @@ function matchPage(pages, pathname) {
63
79
  }
64
80
 
65
81
  // Split the page's <script> (the server-side escape hatch) from its markup.
82
+ // Client scripts — <script src> and inline <script type="module"> — are NOT
83
+ // server code; they stay in the markup and liftHead sends them to the browser.
66
84
  function splitScript(html) {
67
85
  let code = '';
68
- const out = String(html).replace(/<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => {
69
- code += body + '\n';
70
- return '';
71
- });
86
+ const { masked, restore } = maskComments(html);
87
+ const out = restore(masked.replace(
88
+ /<script\b(?![^>]*\bsrc=)(?![^>]*\btype\s*=\s*["']module["'])[^>]*>([\s\S]*?)<\/script>/gi,
89
+ (m, body) => {
90
+ code += body + '\n';
91
+ return '';
92
+ },
93
+ ));
72
94
  return { html: out, code: code.trim() };
73
95
  }
74
96
 
75
- // Parsed-page cache, invalidated by mtime.
76
- function pageData(page, cache) {
77
- const mtime = statSync(page.file).mtimeMs;
78
- const hit = cache.get(page.file);
79
- if (hit && hit.mtime === mtime) return hit;
80
- const source = readFileSync(page.file, 'utf8');
97
+ // One page-or-layout file, parsed. Analyze BEFORE lifting the head, so a
98
+ // {var} used only in <title>/<meta> still registers as a data need.
99
+ function parseFile(source) {
81
100
  const { blocks, html } = extractBlocks(source);
82
101
  const { html: markup, code } = splitScript(html);
83
102
  const analysis = analyze(markup);
103
+ const forms = extractForms(markup);
104
+ const { head, scripts, body } = liftHead(markup);
105
+ return { blocks, code, analysis, forms, head, scripts, body };
106
+ }
107
+
108
+ // Layouts: every _layout.html from the pages root down to the page's folder,
109
+ // outermost first. A layout is a component the folder wraps around its pages;
110
+ // <slot> is the page.
111
+ function layoutChain(pageFile, pagesDir) {
112
+ const rel = relative(pagesDir, dirname(pageFile));
113
+ const parts = rel === '' || rel === '.' ? [] : rel.split(sep);
114
+ const chain = [];
115
+ let dir = pagesDir;
116
+ const rootLayout = join(dir, '_layout.html');
117
+ if (existsSync(rootLayout)) chain.push(rootLayout);
118
+ for (const p of parts) {
119
+ dir = join(dir, p);
120
+ const f = join(dir, '_layout.html');
121
+ if (existsSync(f)) chain.push(f);
122
+ }
123
+ return chain;
124
+ }
125
+
126
+ // Head merge: layout tags first, page tags after — and the page wins on
127
+ // conflicts (<title>, <meta> with the same name/property). <link>s stack.
128
+ function mergeHeads(parts) {
129
+ const out = new Map();
130
+ let n = 0;
131
+ for (const part of parts) {
132
+ for (const line of String(part || '').split('\n')) {
133
+ const tag = line.trim();
134
+ if (!tag) continue;
135
+ let key = null;
136
+ if (/^<title\b/i.test(tag)) key = 'title';
137
+ else {
138
+ const nm = tag.match(/\b(?:name|property|http-equiv)\s*=\s*["']([^"']+)["']/i);
139
+ if (/^<meta\b/i.test(tag) && nm) key = 'meta:' + nm[1].toLowerCase();
140
+ }
141
+ out.set(key || 'x' + n++, tag);
142
+ }
143
+ }
144
+ return [...out.values()].join('\n');
145
+ }
146
+
147
+ // Client scripts merge: a layout and a page may both pull the same module —
148
+ // ship it once.
149
+ function mergeScripts(parts) {
150
+ const seen = new Set();
151
+ const out = [];
152
+ for (const part of parts) {
153
+ for (const tag of String(part || '').split('\n')) {
154
+ const t = tag.trim();
155
+ if (!t || seen.has(t)) continue;
156
+ seen.add(t);
157
+ out.push(t);
158
+ }
159
+ }
160
+ return out.join('\n');
161
+ }
162
+
163
+ // Parsed-page cache, invalidated by mtime — the page's AND its layouts'.
164
+ function pageData(page, cache, pagesDir) {
165
+ const files = [...layoutChain(page.file, pagesDir), page.file];
166
+ const stamps = files.map((f) => ({ file: f, mtime: statSync(f).mtimeMs }));
167
+ const hit = cache.get(page.file);
168
+ if (hit && hit.files.length === stamps.length
169
+ && hit.files.every((s, i) => s.file === stamps[i].file && s.mtime === stamps[i].mtime)) return hit;
170
+
171
+ const parsed = files.map((f) => parseFile(readFileSync(f, 'utf8')));
172
+ const pageP = parsed[parsed.length - 1];
173
+
174
+ // Compose bodies innermost-out: the page replaces each layout's <slot>.
175
+ let body = pageP.body;
176
+ for (let i = parsed.length - 2; i >= 0; i--) {
177
+ const lay = parsed[i].body;
178
+ const SLOT = /<slot\b[^>]*>(?:\s*<\/slot>)?/i;
179
+ body = SLOT.test(lay) ? lay.replace(SLOT, () => body) : lay + body;
180
+ }
181
+
182
+ const blocks = parsed.flatMap((p) => p.blocks);
183
+ const code = parsed.map((p) => p.code).filter(Boolean).join('\n');
184
+ const analysis = mergeAnalyses(parsed.map((p) => p.analysis));
84
185
  analysis.hasScript = !!code;
85
186
  const plan = dataPlan(analysis, blocks);
86
- const data = { mtime, source, blocks, html: markup, code, analysis, plan };
187
+ const forms = parsed.flatMap((p) => p.forms);
188
+ const head = mergeHeads(parsed.map((p) => p.head));
189
+ const scripts = mergeScripts(parsed.map((p) => p.scripts));
190
+
191
+ const data = { files: stamps, blocks, html: body, head, scripts, code, analysis, plan, forms };
87
192
  cache.set(page.file, data);
88
193
  return data;
89
194
  }
90
195
 
196
+ // The schema/CLI entry: scan a project the same way serve() does and infer
197
+ // its schema — `bun spark-ssr db` runs on this.
198
+ export async function projectSchema(root) {
199
+ const config = loadConfig(root);
200
+ const db = await connect(config.db, root);
201
+ const { pagesDir, pages } = scanPages(root);
202
+ const cache = new Map();
203
+ const pds = [];
204
+ for (const p of pages) {
205
+ try { pds.push(pageData(p, cache, pagesDir)); } catch { /* broken page — skip */ }
206
+ }
207
+ const schema = inferSchema(pds, config, root);
208
+ return { config, db, schema };
209
+ }
210
+
91
211
  // ── sessions ───────────────────────────────────────────────────────────
92
212
  const b64 = (buf) => Buffer.from(buf).toString('base64url');
93
213
  function signSession(payload, secret) {
@@ -114,20 +234,127 @@ function readSession(cookieHeader, secret) {
114
234
  const SESSION_COOKIE = (value, clear = false) =>
115
235
  `spark_session=${clear ? '' : value}; Path=/; HttpOnly; SameSite=Lax${clear ? '; Max-Age=0' : ''}`;
116
236
 
237
+ // Roles in one column: an is_admin (or role) column on the auth table
238
+ // unlocks guard="session.is_admin" and unscoped reads for admins.
239
+ const isAdmin = (s) => !!s && (s.is_admin === 1 || s.is_admin === true || s.role === 'admin');
240
+
117
241
  // ── serve ──────────────────────────────────────────────────────────────
118
242
  export async function serve(options = {}) {
119
243
  const root = resolve(options.root || process.cwd());
120
244
  const config = { ...loadConfig(root), ...(options.config || {}) };
121
- const db = await connect(config.db);
245
+ const db = await connect(config.db, root);
122
246
  const secret = (config.auth && config.auth.secret) || randomBytes(32).toString('hex');
123
247
  const cache = new Map();
124
248
  const pages = [];
125
249
  let pagesDir = root;
126
250
  const uploadsDir = join(root, config.uploads);
127
251
  const quiet = !!options.quiet;
252
+ const log = quiet ? () => {} : (m) => console.log(`[spark-ssr] ${m}`);
128
253
 
129
254
  const ctx = { port: 0 };
130
255
 
256
+ // ── dev live reload ──
257
+ // The server side already re-reads files per request; this closes the loop
258
+ // on the browser side. A cheap mtime sweep (same walk refreshPages does)
259
+ // feeds an SSE channel, and every HTML response carries a two-line client
260
+ // that reloads the page on a ping. Production (`start` / dist) runs with
261
+ // watch:false and ships none of it.
262
+ const live = options.watch !== false;
263
+ const sseClients = new Set();
264
+ const sseEnc = new TextEncoder();
265
+ let watchTimer = null;
266
+ if (live) {
267
+ const IGNORE = new Set(['node_modules', 'dist', 'uploads']);
268
+ const mtimes = new Map();
269
+ const sweep = () => {
270
+ const seen = new Set();
271
+ let changed = false;
272
+ (function walk(dir) {
273
+ let names;
274
+ try { names = readdirSync(dir); } catch { return; }
275
+ for (const f of names) {
276
+ if (f.startsWith('.') || IGNORE.has(f)) continue;
277
+ const full = join(dir, f);
278
+ let st;
279
+ try { st = statSync(full); } catch { continue; }
280
+ if (st.isDirectory()) { walk(full); continue; }
281
+ if (!/\.(html|css|js|json|md)$/.test(f)) continue;
282
+ seen.add(full);
283
+ if (mtimes.get(full) !== st.mtimeMs) { mtimes.set(full, st.mtimeMs); changed = true; }
284
+ }
285
+ })(root);
286
+ for (const k of mtimes.keys()) if (!seen.has(k)) { mtimes.delete(k); changed = true; }
287
+ return changed;
288
+ };
289
+ sweep(); // baseline — the first pass records, it doesn't reload anyone
290
+ watchTimer = setInterval(() => {
291
+ if (!sweep()) return;
292
+ for (const c of sseClients) {
293
+ try { c.enqueue(sseEnc.encode('data: reload\n\n')); } catch { sseClients.delete(c); }
294
+ }
295
+ }, 250);
296
+ watchTimer.unref?.();
297
+ }
298
+ // Reconnect-then-reload: after a server restart the EventSource reconnects,
299
+ // and a fresh open following an error means "the server came back" — reload.
300
+ const RELOAD_CLIENT = '<script>(()=>{const e=new EventSource("/__spark/reload");let d=0;'
301
+ + 'e.onmessage=()=>location.reload();e.onerror=()=>{d=1};e.onopen=()=>{if(d)location.reload()}})()</script>';
302
+
303
+ // ── live data channel (§9) — a production feature, unlike dev reload ──
304
+ // Any write through the server pings /__spark/live with the table name;
305
+ // hydrated pages refetch through their own session (scoping intact) and
306
+ // the source cache drops entries that read the table.
307
+ const liveTables = new Set();
308
+ const liveClients = new Set();
309
+ const sourceCache = makeSourceCache();
310
+ function broadcast(table) {
311
+ sourceCache.invalidate(table);
312
+ if (!liveTables.has(table)) return;
313
+ for (const c of liveClients) {
314
+ try { c.enqueue(sseEnc.encode('data: ' + table + '\n\n')); } catch { liveClients.delete(c); }
315
+ }
316
+ }
317
+ const broadcastSql = (sql) => { for (const t of sqlTables(sql)) broadcast(t); };
318
+
319
+ // ── the Spark family, wired in ──
320
+ // Companion packages the app depends on get an importmap entry and are
321
+ // served at /@modules/<name>, so client scripts import them bare — the same
322
+ // packages a spark-html-bun/prerender build uses, working here unbundled.
323
+ let familyDeps = [];
324
+ try {
325
+ const pj = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
326
+ familyDeps = Object.keys({ ...pj.dependencies, ...pj.devDependencies })
327
+ .filter((n) => /^spark-html-[\w-]+$/.test(n));
328
+ } catch { /* no package.json — single-file project */ }
329
+
330
+ // spark-html-theme: inline its no-flash snippet in every <head> (the same
331
+ // one spark-html-theme/bun bakes into prerendered pages) so the saved/OS
332
+ // theme is on <html> before first paint.
333
+ let themeInit = '';
334
+ if (familyDeps.includes('spark-html-theme')) {
335
+ try {
336
+ const { themeInitScript } = await import('spark-html-theme/init');
337
+ themeInit = `<script>${themeInitScript()}</script>`;
338
+ } catch { /* older spark-html-theme without /init — theme() still works, with a flash */ }
339
+ }
340
+
341
+ // spark-html-font: `"fonts"` in spark.json renders the same head tags the
342
+ // font/bun pipeline step bakes at build time — preloads, @font-face with a
343
+ // size-adjusted fallback face, --font-<slug> vars.
344
+ let fontTags = '';
345
+ if (config.fonts) {
346
+ try {
347
+ const { fontHtml } = await import('spark-html-font');
348
+ fontTags = fontHtml({ fonts: config.fonts });
349
+ } catch (e) {
350
+ if (!quiet) console.warn(`[spark-ssr] "fonts" configured but spark-html-font is not installed — ${e.message}`);
351
+ }
352
+ }
353
+
354
+ // spark-html-image, at write time (Tier 3): uploaded rasters get a webp
355
+ // sibling, and :file.url points at it (original stays as :file.original).
356
+ const uploadWebp = familyDeps.includes('spark-html-image');
357
+
131
358
  // ── request wrapper ──
132
359
  function wrapReq(request, url, params, session, server) {
133
360
  const headers = {};
@@ -171,7 +398,15 @@ export async function serve(options = {}) {
171
398
  const name = randomUUID() + ext;
172
399
  mkdirSync(uploadsDir, { recursive: true });
173
400
  await Bun.write(join(uploadsDir, name), v);
174
- file = { url: '/uploads/' + name, name: v.name || name, size: v.size, type: v.type };
401
+ file = { url: '/uploads/' + name, original: '/uploads/' + name, name: v.name || name, size: v.size, type: v.type };
402
+ if (uploadWebp && /\.(png|jpe?g)$/i.test(name)) {
403
+ try {
404
+ const sharp = (await import('sharp')).default;
405
+ const webpName = name.replace(/\.\w+$/, '.webp');
406
+ await sharp(join(uploadsDir, name)).webp({ quality: 82 }).toFile(join(uploadsDir, webpName));
407
+ file.url = '/uploads/' + webpName;
408
+ } catch { /* sharp unavailable — original serves fine */ }
409
+ }
175
410
  fields[k] = file.url;
176
411
  } else {
177
412
  fields[k] = v;
@@ -194,11 +429,17 @@ export async function serve(options = {}) {
194
429
  return null;
195
430
  }
196
431
 
197
- async function runSql(sqlText, req) {
432
+ async function runSql(sqlText, req, ttl = 0) {
198
433
  const { sql, tokens } = rewriteParams(sqlText);
199
434
  const values = [];
200
435
  for (const t of tokens) values.push(await resolveToken(t, req));
201
- return db.query(sql, values);
436
+ if (!ttl) return db.query(sql, values);
437
+ const key = 'q|' + sql + '|' + JSON.stringify(values);
438
+ const hit = sourceCache.get(key);
439
+ if (hit) return hit.value;
440
+ const rows = await db.query(sql, values);
441
+ sourceCache.set(key, rows, ttl, sqlTables(sqlText));
442
+ return rows;
202
443
  }
203
444
 
204
445
  // ── auto-CRUD for <spark-ssr table="…"> ──
@@ -206,6 +447,11 @@ export async function serve(options = {}) {
206
447
  const on = (method, path, handler) =>
207
448
  apiRoutes.push({ method, segs: path.split('/').filter(Boolean), handler });
208
449
 
450
+ // Block attributes per table (limit, search, live) and the form-derived
451
+ // validation rules (§6) — both refreshed with the pages.
452
+ const tableOpts = new Map();
453
+ let validators = new Map();
454
+
209
455
  async function tableInfo(table) {
210
456
  const cols = await db.columns(table);
211
457
  const names = cols.map((c) => c.name);
@@ -213,13 +459,39 @@ export async function serve(options = {}) {
213
459
  return { cols, names, scoped };
214
460
  }
215
461
 
216
- async function tableRows(table, req) {
217
- const { scoped } = await tableInfo(table);
462
+ // List conventions (§10): ?page → LIMIT/OFFSET (+ .total/.pages on the
463
+ // array), ?sort=col:dir validated against real columns, ?q across the
464
+ // block's search="…" columns. Admins read unscoped (Tier 3 roles).
465
+ async function tableRows(table, req, opts = {}) {
466
+ const { names, scoped } = await tableInfo(table);
467
+ const where = [];
468
+ const values = [];
218
469
  if (scoped) {
219
470
  if (!req.session) return [];
220
- return db.query(`SELECT * FROM ${table} WHERE user_id = ?`, [req.session.id]);
471
+ if (!isAdmin(req.session)) { where.push('user_id = ?'); values.push(req.session.id); }
472
+ }
473
+ if (opts.search && req.query.q) {
474
+ const cols = opts.search.filter((c) => names.includes(c));
475
+ if (cols.length) {
476
+ where.push('(' + cols.map((c) => `${c} LIKE ?`).join(' OR ') + ')');
477
+ for (const c of cols) { values.push('%' + req.query.q + '%'); void c; }
478
+ }
221
479
  }
222
- return db.query(`SELECT * FROM ${table}`);
480
+ const whereSql = where.length ? ' WHERE ' + where.join(' AND ') : '';
481
+ let sql = `SELECT * FROM ${table}` + whereSql;
482
+ const sm = String(req.query.sort || '').match(/^(\w+)(?::(asc|desc))?$/i);
483
+ if (sm && names.includes(sm[1])) sql += ` ORDER BY ${sm[1]} ${(sm[2] || 'asc').toUpperCase()}`;
484
+ const paged = req.query.page !== undefined || opts.limit;
485
+ if (!paged) return db.query(sql, values);
486
+ const size = opts.limit || 20;
487
+ const pageN = Math.max(1, Number(req.query.page) || 1);
488
+ const totalRows = await db.query(`SELECT COUNT(*) AS n FROM ${table}` + whereSql, values);
489
+ const total = Number(totalRows[0]?.n ?? 0);
490
+ const rows = [...await db.query(sql + ` LIMIT ${size} OFFSET ${(pageN - 1) * size}`, values)];
491
+ rows.total = total;
492
+ rows.pages = Math.max(1, Math.ceil(total / size));
493
+ rows.page = pageN;
494
+ return rows;
223
495
  }
224
496
 
225
497
  function registerTable(table) {
@@ -228,7 +500,10 @@ export async function serve(options = {}) {
228
500
  on('GET', `api/${table}`, async (req) => {
229
501
  const { scoped } = await tableInfo(table);
230
502
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
231
- return json(await tableRows(table, req));
503
+ const rows = await tableRows(table, req, tableOpts.get(table) || {});
504
+ // Password hashes never leave the auth table, not even to a session.
505
+ if (isAuthTable) for (const r of rows) delete r.password;
506
+ return json(isAuthTable ? [...rows] : rows);
232
507
  });
233
508
 
234
509
  on('POST', `api/${table}`, async (req) => {
@@ -236,6 +511,12 @@ export async function serve(options = {}) {
236
511
  const { names, scoped } = await tableInfo(table);
237
512
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
238
513
  const { fields } = await req.body();
514
+ // The markup's constraint attributes are the validation spec (§6).
515
+ const rules = validators.get(table);
516
+ if (rules) {
517
+ const errors = validateFields(rules, fields);
518
+ if (errors) return json({ errors }, 422);
519
+ }
239
520
  const data = {};
240
521
  for (const [k, v] of Object.entries(fields)) {
241
522
  if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
@@ -251,35 +532,55 @@ export async function serve(options = {}) {
251
532
  `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')}) RETURNING *`,
252
533
  keys.map((k) => data[k]),
253
534
  );
535
+ broadcast(table);
254
536
  const row = rows[0] ?? { ok: true };
255
537
  if (isAuthTable && row.password) delete row.password;
256
538
  return json(row, 201);
257
539
  });
258
540
 
541
+ // Auth-table writes are own-account only: anyone could otherwise reset
542
+ // the author's password or delete their account through the auto CRUD.
543
+ const ownAccountOnly = (req) =>
544
+ isAuthTable && (!req.session || String(req.session.id) !== String(req.params.id));
545
+
259
546
  on('PATCH', `api/${table}/:id`, async (req) => {
260
547
  const { names, scoped } = await tableInfo(table);
261
548
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
549
+ if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
262
550
  const { fields } = await req.body();
551
+ const rules = validators.get(table);
552
+ if (rules) {
553
+ const errors = validateFields(rules, fields, { partial: true });
554
+ if (errors) return json({ errors }, 422);
555
+ }
263
556
  const data = {};
264
557
  for (const [k, v] of Object.entries(fields)) {
265
558
  if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
266
559
  }
560
+ if (isAuthTable && typeof data.password === 'string') {
561
+ data.password = await Bun.password.hash(data.password);
562
+ }
267
563
  const keys = Object.keys(data);
268
564
  if (!keys.length) return json({ error: 'empty body' }, 400);
269
565
  let sql = `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`;
270
566
  const values = [...keys.map((k) => data[k]), req.params.id];
271
- if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
567
+ if (scoped && !isAdmin(req.session)) { sql += ' AND user_id = ?'; values.push(req.session.id); }
272
568
  const rows = await db.query(sql + ' RETURNING *', values);
273
- return rows[0] ? json(rows[0]) : json({ error: 'not found' }, 404);
569
+ broadcast(table);
570
+ const row = rows[0];
571
+ if (isAuthTable && row) delete row.password;
572
+ return row ? json(row) : json({ error: 'not found' }, 404);
274
573
  });
275
574
 
276
575
  on('DELETE', `api/${table}/:id`, async (req) => {
277
576
  const { scoped } = await tableInfo(table);
278
577
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
578
+ if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
279
579
  let sql = `DELETE FROM ${table} WHERE id = ?`;
280
580
  const values = [req.params.id];
281
- if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
581
+ if (scoped && !isAdmin(req.session)) { sql += ' AND user_id = ?'; values.push(req.session.id); }
282
582
  const rows = await db.query(sql + ' RETURNING *', values);
583
+ broadcast(table);
283
584
  return rows[0] ? json({ ok: true }) : json({ error: 'not found' }, 404);
284
585
  });
285
586
  }
@@ -298,6 +599,9 @@ export async function serve(options = {}) {
298
599
  : stored !== '' && stored === supplied);
299
600
  if (!ok) return json({ error: 'invalid credentials' }, 401);
300
601
  const session = { id: user.id, [identity]: user[identity] };
602
+ // Roles ride in the session: is_admin / role columns, when they exist.
603
+ if ('is_admin' in user) session.is_admin = user.is_admin;
604
+ if ('role' in user) session.role = user.role;
301
605
  const safe = { ...user };
302
606
  delete safe.password;
303
607
  return json(safe, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
@@ -310,6 +614,8 @@ export async function serve(options = {}) {
310
614
  const user = await authPlugin.login(req);
311
615
  if (!user) return json({ error: 'invalid credentials' }, 401);
312
616
  const session = { id: user.id, email: user.email, name: user.name };
617
+ if (user.is_admin !== undefined) session.is_admin = user.is_admin;
618
+ if (user.role !== undefined) session.role = user.role;
313
619
  return json(user, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
314
620
  });
315
621
  }
@@ -324,8 +630,8 @@ export async function serve(options = {}) {
324
630
  function registerQuery(route) {
325
631
  const key = route.method + ' ' + route.path;
326
632
  const existing = queryDefs.get(key);
327
- if (existing) { existing.sql = route.sql; return; }
328
- const def = { sql: route.sql };
633
+ if (existing) { existing.sql = route.sql; existing.cache = route.cache || 0; return; }
634
+ const def = { sql: route.sql, cache: route.cache || 0 };
329
635
  queryDefs.set(key, def);
330
636
  const segs = route.path.split('/').filter(Boolean)
331
637
  .map((s) => s.replace(/^\[(\w+)\]$/, ':$1'));
@@ -333,7 +639,8 @@ export async function serve(options = {}) {
333
639
  method: route.method,
334
640
  segs,
335
641
  handler: async (req) => {
336
- const rows = await runSql(def.sql, req);
642
+ const rows = await runSql(def.sql, req, route.method === 'GET' ? def.cache : 0);
643
+ if (route.method !== 'GET') broadcastSql(def.sql);
337
644
  if (route.method === 'GET') return json(singleShaped(def.sql) ? rows[0] ?? null : [...rows]);
338
645
  if (Array.isArray(rows) && rows.length) return json(rows.length === 1 ? rows[0] : [...rows]);
339
646
  return json({ ok: true, changes: rows.changes ?? 0 });
@@ -345,22 +652,72 @@ export async function serve(options = {}) {
345
652
  // a plain readdir walk plus mtime-cached parses — so new pages, new tables,
346
653
  // and edited queries appear without restarting the server.
347
654
  const tables = new Set();
655
+ const seedFiles = new Set(); // never served as static assets
656
+ let schemaDirty = false;
657
+ // Configuring auth IS declaring its table: the login endpoint
658
+ // (POST /api/<table>?auth) and signup exist without any page mentioning
659
+ // them. Single-account apps can turn signup off in middleware.html.
660
+ if (config.auth && config.auth.table && db) {
661
+ tables.add(config.auth.table);
662
+ registerTable(config.auth.table);
663
+ }
348
664
  function refreshPages() {
349
665
  const scanned = scanPages(root);
350
666
  pagesDir = scanned.pagesDir;
351
667
  pages.splice(0, pages.length, ...scanned.pages);
668
+ const nextValidators = new Map();
352
669
  for (const page of pages) {
353
670
  let pd;
354
- try { pd = pageData(page, cache); } catch { continue; }
671
+ try { pd = pageData(page, cache, pagesDir); } catch { continue; }
355
672
  for (const b of pd.blocks) {
356
- if (b.table && !tables.has(b.table)) { tables.add(b.table); registerTable(b.table); }
673
+ if (b.table) {
674
+ if (!tables.has(b.table)) { tables.add(b.table); registerTable(b.table); schemaDirty = true; }
675
+ if (b.live) liveTables.add(b.table);
676
+ if (b.seed) {
677
+ seedFiles.add(resolve(root, b.seed.replace(/^\.\//, '')));
678
+ schemaDirty = schemaDirty || !seededOnce.has(b.table);
679
+ }
680
+ const opts = tableOpts.get(b.table) || {};
681
+ if (b.limit) opts.limit = b.limit;
682
+ if (b.search) opts.search = b.search;
683
+ if (b.cache) opts.cache = b.cache;
684
+ tableOpts.set(b.table, opts);
685
+ }
357
686
  for (const r of b.routes) {
358
- if (r.path) registerQuery(r);
687
+ if (r.path) registerQuery({ ...r, cache: b.cache });
359
688
  }
360
689
  }
690
+ for (const form of pd.forms) {
691
+ if (!form.table) continue;
692
+ const rules = nextValidators.get(form.table) || {};
693
+ Object.assign(rules, form.fields);
694
+ nextValidators.set(form.table, rules);
695
+ }
696
+ }
697
+ validators = nextValidators;
698
+ }
699
+ // The template is the schema (§7): at startup (and whenever a new table
700
+ // appears in dev) missing tables are created and seeds applied — a fresh
701
+ // clone runs on `bun spark-ssr` alone. Alters stay explicit: `db push`.
702
+ const seededOnce = new Set();
703
+ async function ensureSchema() {
704
+ if (!db) { schemaDirty = false; return; }
705
+ const pds = [];
706
+ for (const p of pages) {
707
+ try { pds.push(pageData(p, cache, pagesDir)); } catch { /* skip */ }
361
708
  }
709
+ const schema = inferSchema(pds, config, root);
710
+ try {
711
+ await pushSchema(db, schema, { createOnly: true, log: (m) => log(`db: ${m}`) });
712
+ await seedTables(db, schema, config, root, (m) => log(`db: ${m}`));
713
+ for (const t of Object.keys(schema)) seededOnce.add(t);
714
+ } catch (e) {
715
+ if (!quiet) console.warn(`[spark-ssr] schema: ${e.message}`);
716
+ }
717
+ schemaDirty = false;
362
718
  }
363
719
  refreshPages();
720
+ await ensureSchema();
364
721
 
365
722
  // ── api/ folder — custom endpoints ──
366
723
  function makeAppFetch(req) {
@@ -404,7 +761,7 @@ export async function serve(options = {}) {
404
761
  const { blocks, html } = extractBlocks(source);
405
762
  const { code } = splitScript(html);
406
763
  for (const b of blocks) {
407
- for (const r of b.routes) registerQuery({ ...r, path: r.path || route });
764
+ for (const r of b.routes) registerQuery({ ...r, path: r.path || route, cache: b.cache });
408
765
  }
409
766
  def.fn = null;
410
767
  if (code) {
@@ -500,14 +857,22 @@ export async function serve(options = {}) {
500
857
  if (!rel || rel.includes('..')) return null;
501
858
  const candidates = [join(root, 'public', rel)];
502
859
  const ext = extname(rel);
503
- if (ext && ext !== '.html') {
860
+ // The root fallback exists for co-located assets (pages/x.css, img/…)
861
+ // it must never serve project internals: config (may hold secrets),
862
+ // lockfiles, databases, dotfiles, seed data. public/ stays intentional.
863
+ const internal = rel.startsWith('.') || rel.includes('/.')
864
+ || rel.startsWith('seed/')
865
+ || ['spark.json', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'].includes(rel)
866
+ || ['.db', '.sqlite', '.sqlite3'].includes(ext);
867
+ if (!internal && ext && ext !== '.html') {
504
868
  candidates.push(join(root, rel), join(pagesDir, rel));
505
- } else if (rel.startsWith('components/')) {
869
+ } else if (!internal && rel.startsWith('components/')) {
506
870
  candidates.push(join(root, rel));
507
871
  }
508
872
  for (const file of candidates) {
509
873
  const abs = resolve(file);
510
874
  if (!abs.startsWith(root)) continue;
875
+ if (seedFiles.has(abs)) continue;
511
876
  if (existsSync(abs) && statSync(abs).isFile()) return Bun.file(abs);
512
877
  }
513
878
  return null;
@@ -523,6 +888,7 @@ export async function serve(options = {}) {
523
888
  try { return await fn(req, db, makeAppFetch(req)); }
524
889
  catch (e) {
525
890
  if (!quiet) console.warn(`[spark-ssr] page <script> threw: ${e.message}`);
891
+ if (live) e.__sparkPageScript = true;
526
892
  return {};
527
893
  }
528
894
  }
@@ -532,19 +898,55 @@ export async function serve(options = {}) {
532
898
  && pd.blocks.some((b) => b.table) && !!db;
533
899
  }
534
900
 
535
- function shell(page, body, { hydrate, mount }) {
901
+ // Open Graph completeness (Tier 3): og:title / og:description derive from
902
+ // the lifted <title> and description unless the page overrides them.
903
+ function withOgTags(head) {
904
+ let out = head;
905
+ const title = (head.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1];
906
+ const desc = (head.match(/<meta\b[^>]*\bname\s*=\s*["']description["'][^>]*\bcontent\s*=\s*["']([^"']*)["']/i) || [])[1]
907
+ || (head.match(/<meta\b[^>]*\bcontent\s*=\s*["']([^"']*)["'][^>]*\bname\s*=\s*["']description["']/i) || [])[1];
908
+ if (title && !/property\s*=\s*["']og:title/i.test(head)) {
909
+ out += `\n<meta property="og:title" content="${title.trim()}">`;
910
+ }
911
+ if (desc && !/property\s*=\s*["']og:description/i.test(head)) {
912
+ out += `\n<meta property="og:description" content="${desc}">`;
913
+ }
914
+ return out;
915
+ }
916
+
917
+ function shell(page, body, { hydrate, mount, headExtra = '', scripts = '' }) {
536
918
  const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
537
919
  const cssRel = page.key + '.css';
538
920
  const hasCss = existsSync(join(pagesDir, cssRel));
921
+ // The importmap must precede EVERY module script in document order (a
922
+ // later one is ignored), so the whole module story lives in <head>:
923
+ // importmap → the page's own client scripts → mount. Page scripts are
924
+ // the app's bootstrap (store()/theme() setup) and modules execute in
925
+ // document order, so they run before components boot — same contract as
926
+ // a hand-written main.js that ends with mount().
927
+ const needModules = mount || scripts.includes('<script');
928
+ const imports = {};
929
+ for (const dep of ['spark-html', ...familyDeps]) {
930
+ const info = moduleEntry(dep);
931
+ if (info) imports[dep] = `/@modules/${dep}/${info.entry}`;
932
+ }
933
+ const importmap = needModules
934
+ ? `<script type="importmap">${JSON.stringify({ imports })}</script>\n`
935
+ : '';
936
+ const mountJs = mount
937
+ ? `<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
938
+ : '';
539
939
  const head =
540
940
  '<meta charset="utf-8">\n' +
541
941
  '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
542
- `<title>${title}</title>\n` +
942
+ (themeInit ? themeInit + '\n' : '') +
943
+ (/<title\b/i.test(headExtra) ? '' : `<title>${title}</title>\n`) +
944
+ (headExtra ? headExtra + '\n' : '') +
945
+ (fontTags ? fontTags + '\n' : '') +
946
+ importmap +
947
+ (scripts ? scripts + '\n' : '') +
948
+ mountJs +
543
949
  (hasCss ? `<link rel="stylesheet" href="/${cssRel}">\n` : '');
544
- const hydration = mount
545
- ? `\n<script type="importmap">{"imports":{"spark-html":"/@modules/spark-html"}}</script>\n` +
546
- `<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
547
- : '\n';
548
950
  // A hydrating page host carries BOTH `import` and `name` — that is the
549
951
  // runtime's flash-free hydrate contract (same as spark-prerender's
550
952
  // makeHydratable): the pre-rendered content stays visible while the
@@ -555,7 +957,53 @@ export async function serve(options = {}) {
555
957
  const host = hydrate
556
958
  ? `<div import="/__spark/page/${page.key}" name="${compName}" data-spark-ssr>${body}</div>`
557
959
  : `<div data-spark-ssr>${body}</div>`;
558
- return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}${hydration}</body>\n</html>\n`;
960
+ const reload = live ? RELOAD_CLIENT + '\n' : '';
961
+ return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}\n${reload}</body>\n</html>\n`;
962
+ }
963
+
964
+ // Resolve one plan entry — table, query, named SQL, URL, glob, module —
965
+ // honoring the block's cache="…" TTL.
966
+ async function resolveSource(p, req) {
967
+ const src = p.source;
968
+ const ttl = (src.opts && src.opts.cache) || 0;
969
+ if (src.kind === 'table') {
970
+ if (!ttl) return tableRows(src.table, req, src.opts || {});
971
+ const key = ['t', src.table, req.session?.id ?? '', req.query.q ?? '', req.query.sort ?? '', req.query.page ?? ''].join('|');
972
+ const hit = sourceCache.get(key);
973
+ if (hit) return hit.value;
974
+ const rows = await tableRows(src.table, req, src.opts || {});
975
+ sourceCache.set(key, rows, ttl, new Set([src.table]));
976
+ return rows;
977
+ }
978
+ if (src.kind === 'query' || src.kind === 'sql') {
979
+ const sql = src.kind === 'query' ? src.route.sql : src.binding.sql;
980
+ const rows = await runSql(sql, req, ttl);
981
+ return p.shape === 'list' ? [...rows] : rows[0] ?? null;
982
+ }
983
+ if (src.kind === 'url') {
984
+ const key = 'u|' + src.binding.value + '|' + JSON.stringify(req.params) + '|' + (req.query.q ?? '');
985
+ if (ttl) {
986
+ const hit = sourceCache.get(key);
987
+ if (hit) return hit.value;
988
+ }
989
+ const value = await urlSource(src.binding.value, req);
990
+ if (ttl) sourceCache.set(key, value, ttl);
991
+ return value;
992
+ }
993
+ if (src.kind === 'glob') {
994
+ const key = 'g|' + src.binding.value;
995
+ if (ttl) {
996
+ const hit = sourceCache.get(key);
997
+ if (hit) return hit.value;
998
+ }
999
+ const value = globSource(src.binding.value, root);
1000
+ if (ttl) sourceCache.set(key, value, ttl);
1001
+ return value;
1002
+ }
1003
+ if (src.kind === 'module') {
1004
+ return moduleSource(src.binding.value, root, req, db, { watch: live });
1005
+ }
1006
+ return null;
559
1007
  }
560
1008
 
561
1009
  async function buildScope(pd, req) {
@@ -563,49 +1011,206 @@ export async function serve(options = {}) {
563
1011
  if (pd.code) Object.assign(scope, await runPageScript(pd.code, req));
564
1012
  for (const p of pd.plan) {
565
1013
  if (scope[p.var] !== undefined) continue; // the page <script> won
566
- if (p.source.kind === 'table') {
567
- scope[p.var] = await tableRows(p.source.table, req);
568
- } else {
569
- const rows = await runSql(p.source.route.sql, req);
570
- scope[p.var] = p.shape === 'list' ? [...rows] : rows[0] ?? null;
571
- }
1014
+ scope[p.var] = await resolveSource(p, req);
572
1015
  }
573
1016
  return scope;
574
1017
  }
575
1018
 
576
- async function servePage(page, req) {
577
- const pd = pageData(page, cache);
1019
+ // The dev banner for the silent-blank class of bug: "this page reads
1020
+ // {posts} but no source provides it — nearest source: published".
1021
+ function unresolvedBanner(unresolved) {
1022
+ const items = unresolved.map((u) =>
1023
+ `<code>{${escapeHtml(u.name)}}</code>${u.nearest ? ` — nearest source: <code>${escapeHtml(u.nearest)}</code>` : ''}`).join('; ');
1024
+ return '<div style="position:fixed;bottom:0;left:0;right:0;background:#7c2d12;color:#fed7aa;'
1025
+ + 'font:13px/1.6 monospace;padding:8px 14px;z-index:99999">'
1026
+ + `spark-ssr: this page reads ${items} but no source provides it</div>`;
1027
+ }
1028
+
1029
+ async function servePage(page, req, extra = null) {
1030
+ const pd = pageData(page, cache, pagesDir);
578
1031
  const scope = await buildScope(pd, req);
1032
+ if (extra) Object.assign(scope, extra.scope || {});
1033
+
1034
+ // Declarative guard (§3): <spark-ssr guard="session" redirect="/login" />
1035
+ for (const b of pd.blocks) {
1036
+ if (!b.guard) continue;
1037
+ if (!evalExpr(b.guard, scope)) {
1038
+ if (b.redirect) return new Response(null, { status: 303, headers: { location: b.redirect } });
1039
+ return errorPage(b.status || 403);
1040
+ }
1041
+ }
1042
+
579
1043
  const hydrate = shouldHydrate(pd);
580
1044
  // Component imports keep their host (import + name + props) on pages the
581
1045
  // page host won't rebuild wholesale, so a client mount re-resolves them
582
1046
  // and their own <script> comes alive (counters, demos, …).
583
1047
  const hasComponents = /\bimport\s*=\s*"/.test(pd.html);
584
- const body = await renderFragment(pd.html, scope, { loadComponent, keepImports: !hydrate });
585
- return new Response(shell(page, body, { hydrate, mount: hydrate || hasComponents }), {
1048
+ const rctx = { loadComponent, keepImports: !hydrate };
1049
+ const body = await renderFragment(pd.html, scope, rctx);
1050
+ let headExtra = pd.head ? renderHead(pd.head, (e) => evalExpr(e, scope)) : '';
1051
+ if (headExtra) headExtra = withOgTags(headExtra);
1052
+ let html = shell(page, body, {
1053
+ hydrate, mount: hydrate || hasComponents, headExtra, scripts: pd.scripts,
1054
+ });
1055
+ if (live && pd.plan.unresolved && pd.plan.unresolved.length) {
1056
+ html = html.replace('</body>', unresolvedBanner(pd.plan.unresolved) + '\n</body>');
1057
+ }
1058
+ return new Response(html, {
1059
+ status: (extra && extra.status) || rctx.status || 200,
586
1060
  headers: { 'content-type': 'text/html; charset=utf-8' },
587
1061
  });
588
1062
  }
589
1063
 
1064
+ const STATUS_TEXT = { 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not found' };
590
1065
  function errorPage(status) {
591
1066
  const file = join(root, `${status}.html`);
592
1067
  if (existsSync(file)) {
593
- return new Response(readFileSync(file, 'utf8'), { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
1068
+ // The reload client rides along so fixing the page un-sticks the browser.
1069
+ const body = readFileSync(file, 'utf8') + (live ? '\n' + RELOAD_CLIENT : '');
1070
+ return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
1071
+ }
1072
+ return new Response(STATUS_TEXT[status] || 'Server error', { status });
1073
+ }
1074
+
1075
+ // Dev-only error overlay (§4): the real error — SQL, file, line — on the
1076
+ // page instead of a bare 500. The reload client rides along, so fixing the
1077
+ // file un-sticks the browser.
1078
+ function devErrorPage(e, pathname) {
1079
+ const body = '<!doctype html><html><head><title>spark-ssr error</title></head>'
1080
+ + '<body style="background:#1c1917;color:#fafaf9;font:15px/1.6 system-ui;padding:2rem">'
1081
+ + `<h1 style="color:#fca5a5">500 — ${escapeHtml(e.message || String(e))}</h1>`
1082
+ + `<p style="color:#a8a29e">while serving <code>${escapeHtml(pathname)}</code></p>`
1083
+ + `<pre style="background:#292524;padding:1rem;border-radius:8px;overflow:auto;color:#fdba74">${escapeHtml(e.stack || '')}</pre>`
1084
+ + RELOAD_CLIENT + '</body></html>';
1085
+ return new Response(body, { status: 500, headers: { 'content-type': 'text/html; charset=utf-8' } });
1086
+ }
1087
+
1088
+ // /__spark/plan (§4): "view source" for the inferred backend — every route,
1089
+ // its var → source bindings, tables, endpoints. Dev only.
1090
+ function planPage() {
1091
+ const esc = escapeHtml;
1092
+ const srcLabel = (s) =>
1093
+ s.kind === 'table' ? `table ${s.table}`
1094
+ : s.kind === 'query' ? `${s.route.method} ${s.route.path} — ${s.route.sql.replace(/\s+/g, ' ')}`
1095
+ : s.kind === 'sql' ? `SQL — ${s.binding.sql.replace(/\s+/g, ' ')}`
1096
+ : `${s.kind} — ${s.binding.value}`;
1097
+ let rows = '';
1098
+ for (const page of pages) {
1099
+ let pd;
1100
+ try { pd = pageData(page, cache, pagesDir); } catch { continue; }
1101
+ const vars = pd.plan.map((p) => `<code>${esc(p.var)}</code> ← ${esc(srcLabel(p.source))} <em>(${p.shape})</em>`).join('<br>');
1102
+ const un = (pd.plan.unresolved || []).map((u) => `<code>{${esc(u.name)}}</code> unresolved`).join('<br>');
1103
+ const guards = pd.blocks.filter((b) => b.guard)
1104
+ .map((b) => `guard <code>${esc(b.guard)}</code>${b.redirect ? ` → ${esc(b.redirect)}` : ''}`).join('<br>');
1105
+ rows += `<tr><td><code>${esc(page.route)}</code></td><td>${esc(relative(root, page.file))}</td>`
1106
+ + `<td>${vars}${un ? '<br><span style="color:#f87171">' + un + '</span>' : ''}${guards ? '<br>' + guards : ''}</td></tr>\n`;
1107
+ }
1108
+ const endpoints = apiRoutes.map((r) => `<li><code>${esc(r.method)} /${esc(r.segs.join('/'))}</code></li>`).join('\n');
1109
+ const tbls = [...tables].map((t) => `<code>${esc(t)}${liveTables.has(t) ? ' (live)' : ''}</code>`).join(', ');
1110
+ const body = `<!doctype html><html><head><title>spark-ssr plan</title>
1111
+ <style>body{font:15px/1.6 system-ui;max-width:70rem;margin:2rem auto;padding:0 1rem;background:#1c1917;color:#fafaf9}
1112
+ table{border-collapse:collapse;width:100%}td,th{border:1px solid #44403c;padding:.5rem;text-align:left;vertical-align:top}
1113
+ code{color:#fdba74}em{color:#a8a29e}</style></head><body>
1114
+ <h1>⚡ The inferred backend</h1>
1115
+ <p>Tables: ${tbls || '<em>none</em>'}</p>
1116
+ <table><tr><th>Route</th><th>File</th><th>Data plan</th></tr>${rows}</table>
1117
+ <h2>Endpoints</h2><ul>${endpoints}</ul>
1118
+ </body></html>`;
1119
+ return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
1120
+ }
1121
+
1122
+ // ── SEO (Tier 3): sitemap.xml + robots.txt, generated when not authored ──
1123
+ function indexablePages() {
1124
+ const out = [];
1125
+ for (const page of pages) {
1126
+ let pd;
1127
+ try { pd = pageData(page, cache, pagesDir); } catch { continue; }
1128
+ const noindex = /<meta\b[^>]*\bname\s*=\s*["']robots["'][^>]*\bcontent\s*=\s*["'][^"']*noindex/i.test(pd.head)
1129
+ || /<meta\b[^>]*\bcontent\s*=\s*["'][^"']*noindex[^"']*["'][^>]*\bname\s*=\s*["']robots["']/i.test(pd.head);
1130
+ const guarded = pd.blocks.some((b) => b.guard);
1131
+ out.push({ page, pd, noindex, guarded });
1132
+ }
1133
+ return out;
1134
+ }
1135
+
1136
+ // Enumerate a [param] route's values by re-running its bound query with the
1137
+ // param comparison neutralized (`slug = :slug` → `slug = slug`) and every
1138
+ // other token null — spark-prerender's route-enumeration idea, DB-backed.
1139
+ async function enumerateParam(sql, param) {
1140
+ const cmp = new RegExp(`([a-zA-Z_]\\w*)\\s*=\\s*:${param}\\b`);
1141
+ const cm = String(sql).match(cmp);
1142
+ if (!cm) return [];
1143
+ const col = cm[1];
1144
+ const neutral = String(sql).replace(cmp, '$1 = $1').replace(/\blimit\s+\d+\b/i, '');
1145
+ const { sql: rewritten, tokens } = rewriteParams(neutral);
1146
+ const rows = await db.query(rewritten, tokens.map(() => null));
1147
+ return [...new Set([...rows].map((r) => r[col]).filter((v) => v != null))];
1148
+ }
1149
+
1150
+ async function sitemapXml(origin) {
1151
+ const urls = [];
1152
+ for (const { page, pd, noindex, guarded } of indexablePages()) {
1153
+ if (noindex || guarded) continue;
1154
+ const params = page.segs.filter((s) => s.startsWith('['));
1155
+ if (!params.length) { urls.push(page.route); continue; }
1156
+ if (params.length > 1) continue;
1157
+ const param = params[0].slice(1, -1);
1158
+ let vals = [];
1159
+ const bound = pd.plan.find((p) =>
1160
+ (p.source.kind === 'query' && p.source.route.sql.includes(':' + param))
1161
+ || (p.source.kind === 'sql' && p.source.binding.sql.includes(':' + param)));
1162
+ if (bound && db) {
1163
+ const sql = bound.source.kind === 'query' ? bound.source.route.sql : bound.source.binding.sql;
1164
+ try { vals = await enumerateParam(sql, param); } catch { /* dynamic route stays out */ }
1165
+ } else {
1166
+ const glob = pd.plan.find((p) => p.source.kind === 'glob');
1167
+ if (glob) vals = globSource(glob.source.binding.value, root).map((r) => r.slug);
1168
+ }
1169
+ for (const v of vals) urls.push(page.route.replace(`[${param}]`, encodeURIComponent(String(v))));
594
1170
  }
595
- return new Response(status === 404 ? 'Not found' : 'Server error', { status });
1171
+ const xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
1172
+ + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1173
+ + urls.map((u) => ` <url><loc>${origin}${escapeHtml(u)}</loc></url>`).join('\n')
1174
+ + '\n</urlset>\n';
1175
+ return new Response(xml, { headers: { 'content-type': 'application/xml' } });
596
1176
  }
597
1177
 
598
- // spark-html runtime, served for hydration (importmap target).
599
- let runtimeJs = null;
600
- function runtimeFile() {
601
- if (runtimeJs) return runtimeJs;
1178
+ function robotsTxt(origin) {
1179
+ const lines = ['User-agent: *'];
1180
+ for (const { page, noindex, guarded } of indexablePages()) {
1181
+ if ((noindex || guarded) && !page.segs.some((s) => s.startsWith('['))) {
1182
+ lines.push(`Disallow: ${page.route}`);
1183
+ }
1184
+ }
1185
+ if (lines.length === 1) lines.push('Disallow:');
1186
+ lines.push(`Sitemap: ${origin}/sitemap.xml`);
1187
+ return new Response(lines.join('\n') + '\n', { headers: { 'content-type': 'text/plain' } });
1188
+ }
1189
+
1190
+ // spark-html + family packages, served as browser modules. The importmap
1191
+ // maps each package name to /@modules/<pkg>/<entry>, and sibling files in
1192
+ // the package resolve as relative imports under the same prefix (theme's
1193
+ // ./init.js, say). Bun's resolver falls back to its GLOBAL install cache
1194
+ // when a dir has no node_modules — that can be a different version than
1195
+ // the app's, so cache hits only count when nothing real resolves.
1196
+ const moduleInfo = new Map(); // pkg → { dir, entry } | null
1197
+ function moduleEntry(pkg) {
1198
+ if (moduleInfo.has(pkg)) return moduleInfo.get(pkg);
1199
+ let lastResort = null;
602
1200
  for (const dir of [root, dirname(new URL(import.meta.url).pathname)]) {
603
1201
  try {
604
- runtimeJs = readFileSync(Bun.resolveSync('spark-html', dir), 'utf8');
605
- return runtimeJs;
1202
+ const file = Bun.resolveSync(pkg, dir);
1203
+ if (file.includes('/install/cache/')) { lastResort = lastResort || file; continue; }
1204
+ const info = { dir: dirname(file), entry: file.slice(file.lastIndexOf('/') + 1) };
1205
+ moduleInfo.set(pkg, info);
1206
+ return info;
606
1207
  } catch { /* next */ }
607
1208
  }
608
- return null;
1209
+ const info = lastResort
1210
+ ? { dir: dirname(lastResort), entry: lastResort.slice(lastResort.lastIndexOf('/') + 1) }
1211
+ : null;
1212
+ moduleInfo.set(pkg, info);
1213
+ return info;
609
1214
  }
610
1215
 
611
1216
  // ── the server ──
@@ -616,13 +1221,43 @@ export async function serve(options = {}) {
616
1221
  let pathname;
617
1222
  try { pathname = decodeURIComponent(url.pathname); } catch { pathname = url.pathname; }
618
1223
  if (pathname.includes('..')) return errorPage(404);
1224
+
1225
+ // Dev reload channel — before middleware; it's the harness, not the app.
1226
+ if (live && pathname === '/__spark/reload') {
1227
+ let ctrl;
1228
+ const stream = new ReadableStream({
1229
+ start(c) { ctrl = c; c.enqueue(sseEnc.encode(': connected\n\n')); sseClients.add(c); },
1230
+ cancel() { sseClients.delete(ctrl); },
1231
+ });
1232
+ return new Response(stream, {
1233
+ headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' },
1234
+ });
1235
+ }
1236
+
1237
+ // The live data channel (§9) ships in production too — it's the app.
1238
+ if (pathname === '/__spark/live') {
1239
+ let ctrl;
1240
+ const stream = new ReadableStream({
1241
+ start(c) { ctrl = c; c.enqueue(sseEnc.encode(': connected\n\n')); liveClients.add(c); },
1242
+ cancel() { liveClients.delete(ctrl); },
1243
+ });
1244
+ return new Response(stream, {
1245
+ headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' },
1246
+ });
1247
+ }
1248
+
619
1249
  const session = readSession(request.headers.get('cookie'), secret);
620
1250
  const extraHeaders = {};
621
1251
 
622
1252
  try {
623
1253
  // Pick up new/edited pages, api files, and middleware without a
624
1254
  // restart (readdir walk + mtime-cached parses — cheap).
625
- if (options.watch !== false) { refreshPages(); refreshApi(); refreshMiddleware(); }
1255
+ if (options.watch !== false) {
1256
+ refreshPages(); refreshApi(); refreshMiddleware();
1257
+ if (schemaDirty) await ensureSchema();
1258
+ }
1259
+
1260
+ if (live && pathname === '/__spark/plan') return planPage();
626
1261
 
627
1262
  // middleware.html runs first, on every request.
628
1263
  if (middleware) {
@@ -641,21 +1276,38 @@ export async function serve(options = {}) {
641
1276
  return res;
642
1277
  };
643
1278
 
644
- if (pathname === '/@modules/spark-html') {
645
- const js = runtimeFile();
646
- return finish(js
647
- ? new Response(js, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-cache' } })
648
- : errorPage(404));
1279
+ if (pathname.startsWith('/@modules/')) {
1280
+ const rest = pathname.slice('/@modules/'.length);
1281
+ const slash = rest.indexOf('/');
1282
+ const pkg = slash === -1 ? rest : rest.slice(0, slash);
1283
+ const subpath = slash === -1 ? '' : rest.slice(slash + 1);
1284
+ let mod = null;
1285
+ if (/^spark-html(-[\w-]+)?$/.test(pkg)) {
1286
+ const info = moduleEntry(pkg);
1287
+ if (info) {
1288
+ const file = resolve(info.dir, subpath || info.entry);
1289
+ if (file.startsWith(info.dir + '/') && existsSync(file) && statSync(file).isFile()) {
1290
+ mod = new Response(readFileSync(file, 'utf8'), {
1291
+ headers: { 'content-type': 'text/javascript', 'cache-control': 'no-cache' },
1292
+ });
1293
+ }
1294
+ }
1295
+ }
1296
+ return finish(mod || errorPage(404));
649
1297
  }
650
1298
 
651
1299
  if (pathname.startsWith('/__spark/page/')) {
652
1300
  const key = pathname.slice('/__spark/page/'.length).replace(/\.html$/, '');
653
1301
  const page = pages.find((p) => p.key === key);
654
1302
  if (!page) return finish(errorPage(404));
655
- const pd = pageData(page, cache);
656
- const table = (pd.blocks.find((b) => b.table) || {}).table || null;
1303
+ const pd = pageData(page, cache, pagesDir);
1304
+ const tableBlock = pd.blocks.find((b) => b.table) || {};
1305
+ const table = tableBlock.table || null;
657
1306
  const cols = table ? await db.columns(table) : [];
658
- const html = clientComponent({ html: pd.html, analysis: pd.analysis, plan: pd.plan, table, cols, key });
1307
+ const html = clientComponent({
1308
+ html: pd.html, analysis: pd.analysis, plan: pd.plan, table, cols, key,
1309
+ live: !!(table && liveTables.has(table)),
1310
+ });
659
1311
  return finish(new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-cache' } }));
660
1312
  }
661
1313
 
@@ -663,16 +1315,10 @@ export async function serve(options = {}) {
663
1315
  const key = pathname.slice('/__spark/data/'.length).replace(/\.js$/, '');
664
1316
  const page = pages.find((p) => p.key === key);
665
1317
  if (!page) return finish(errorPage(404));
666
- const pd = pageData(page, cache);
1318
+ const pd = pageData(page, cache, pagesDir);
667
1319
  const req = wrapReq(request, url, {}, session, srv);
668
1320
  const data = {};
669
- for (const p of pd.plan) {
670
- if (p.source.kind === 'table') data[p.var] = await tableRows(p.source.table, req);
671
- else {
672
- const rows = await runSql(p.source.route.sql, req);
673
- data[p.var] = p.shape === 'list' ? [...rows] : rows[0] ?? null;
674
- }
675
- }
1321
+ for (const p of pd.plan) data[p.var] = await resolveSource(p, req);
676
1322
  return finish(new Response(initModule(data), {
677
1323
  headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' },
678
1324
  }));
@@ -695,6 +1341,46 @@ export async function serve(options = {}) {
695
1341
  if (!hit) return finish(json({ error: 'not found' }, 404, cors || {}));
696
1342
  const req = wrapReq(request, url, hit.params, session, srv);
697
1343
  const res = await hit.route.handler(req, { headers: {} });
1344
+
1345
+ // Answer a browser like a browser (§5): a plain form post that
1346
+ // succeeded 303s back (the _redirect field or the referrer) — the
1347
+ // app works with JavaScript disabled. A failed one re-renders the
1348
+ // referring page with {errors} (and {values}) in scope.
1349
+ const ct = request.headers.get('content-type') || '';
1350
+ const isForm = request.method !== 'GET'
1351
+ && (ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data'));
1352
+ const wantsHtml = (request.headers.get('accept') || '').includes('text/html');
1353
+ if (isForm && wantsHtml) {
1354
+ const { fields } = await req.body();
1355
+ const referer = request.headers.get('referer');
1356
+ let back = '/';
1357
+ try { if (referer) { const r = new URL(referer); back = r.pathname + r.search; } } catch { /* keep / */ }
1358
+ if (typeof fields._redirect === 'string' && fields._redirect.startsWith('/')) back = fields._redirect;
1359
+ if (res.status < 400) {
1360
+ const headers = new Headers({ location: back });
1361
+ const sc = res.headers.get('set-cookie');
1362
+ if (sc) headers.set('set-cookie', sc);
1363
+ return finish(new Response(null, { status: 303, headers }));
1364
+ }
1365
+ let errors = null;
1366
+ try {
1367
+ const j = await res.clone().json();
1368
+ errors = j.errors || (j.error ? { _: j.error } : null);
1369
+ } catch { /* non-JSON error */ }
1370
+ if (errors && referer) {
1371
+ try {
1372
+ const r = new URL(referer);
1373
+ const rp = matchPage(pages, decodeURIComponent(r.pathname));
1374
+ if (rp) {
1375
+ const rreq = wrapReq(request, r, rp.params, session, srv);
1376
+ return finish(await servePage(rp.page, rreq, {
1377
+ scope: { errors, values: fields }, status: res.status,
1378
+ }));
1379
+ }
1380
+ } catch { /* fall through to the raw response */ }
1381
+ }
1382
+ }
1383
+
698
1384
  if (cors) for (const [k, v] of Object.entries(cors)) res.headers.set(k, v);
699
1385
  return finish(res);
700
1386
  }
@@ -702,6 +1388,9 @@ export async function serve(options = {}) {
702
1388
  const file = staticFile(pathname);
703
1389
  if (file) return finish(new Response(file));
704
1390
 
1391
+ if (pathname === '/sitemap.xml') return finish(await sitemapXml(url.origin));
1392
+ if (pathname === '/robots.txt') return finish(robotsTxt(url.origin));
1393
+
705
1394
  const hit = matchPage(pages, pathname);
706
1395
  if (hit) {
707
1396
  const req = wrapReq(request, url, hit.params, session, srv);
@@ -711,7 +1400,8 @@ export async function serve(options = {}) {
711
1400
  return finish(errorPage(404));
712
1401
  } catch (e) {
713
1402
  if (!quiet) console.error(`[spark-ssr] ${request.method} ${pathname} — ${e.stack || e.message}`);
714
- const res = errorPage(500);
1403
+ const wantsHtml = (request.headers.get('accept') || '').includes('text/html');
1404
+ const res = live && wantsHtml ? devErrorPage(e, pathname) : errorPage(500);
715
1405
  for (const [k, v] of Object.entries(extraHeaders)) res.headers.set(k, v);
716
1406
  return res;
717
1407
  }
@@ -725,6 +1415,14 @@ export async function serve(options = {}) {
725
1415
  root,
726
1416
  config,
727
1417
  db,
728
- stop(force) { server.stop(force); return db && db.close(); },
1418
+ stop(force) {
1419
+ if (watchTimer) clearInterval(watchTimer);
1420
+ for (const c of sseClients) { try { c.close(); } catch { /* gone */ } }
1421
+ sseClients.clear();
1422
+ for (const c of liveClients) { try { c.close(); } catch { /* gone */ } }
1423
+ liveClients.clear();
1424
+ server.stop(force);
1425
+ return db && db.close();
1426
+ },
729
1427
  };
730
1428
  }