spark-ssr 0.2.0 → 0.3.1

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,18 +1,25 @@
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, maskComments } from './parse.js';
15
+ import {
16
+ extractBlocks, analyze, mergeAnalyses, dataPlan, rewriteParams, singleShaped,
17
+ maskComments, extractForms, validateFields, sqlTables,
18
+ } from './parse.js';
14
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';
16
23
  // Head semantics live in one place for the whole family: spark-html-head owns
17
24
  // title/meta on the client (pushState updates); its /ssr module owns them
18
25
  // here — pages put literal <title>/<meta>/<link> tags in their markup, we
@@ -23,18 +30,22 @@ const AsyncFunction = (async () => {}).constructor;
23
30
  const json = (data, status = 200, headers = {}) =>
24
31
  new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
25
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;');
26
35
 
27
36
  // ── pages ──────────────────────────────────────────────────────────────
28
- 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']);
29
38
  const RESERVED_FILES = new Set(['404.html', '500.html', 'middleware.html']);
30
39
 
31
- function scanPages(root) {
40
+ export function scanPages(root) {
32
41
  const pagesDir = existsSync(join(root, 'pages')) ? join(root, 'pages') : root;
33
42
  const pages = [];
34
43
  (function scan(dir, prefix) {
35
44
  if (!existsSync(dir)) return;
36
45
  for (const f of readdirSync(dir)) {
37
- 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;
38
49
  const full = join(dir, f);
39
50
  const st = statSync(full);
40
51
  if (st.isDirectory()) {
@@ -83,25 +94,120 @@ function splitScript(html) {
83
94
  return { html: out, code: code.trim() };
84
95
  }
85
96
 
86
- // Parsed-page cache, invalidated by mtime.
87
- function pageData(page, cache) {
88
- const mtime = statSync(page.file).mtimeMs;
89
- const hit = cache.get(page.file);
90
- if (hit && hit.mtime === mtime) return hit;
91
- 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) {
92
100
  const { blocks, html } = extractBlocks(source);
93
101
  const { html: markup, code } = splitScript(html);
94
- // Analyze BEFORE lifting the head, so a {var} used only in <title>/<meta>
95
- // still registers as a data need.
96
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));
97
185
  analysis.hasScript = !!code;
98
186
  const plan = dataPlan(analysis, blocks);
99
- const { head, scripts, body } = liftHead(markup);
100
- const data = { mtime, source, blocks, html: body, head, scripts, 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 };
101
192
  cache.set(page.file, data);
102
193
  return data;
103
194
  }
104
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
+
105
211
  // ── sessions ───────────────────────────────────────────────────────────
106
212
  const b64 = (buf) => Buffer.from(buf).toString('base64url');
107
213
  function signSession(payload, secret) {
@@ -128,17 +234,22 @@ function readSession(cookieHeader, secret) {
128
234
  const SESSION_COOKIE = (value, clear = false) =>
129
235
  `spark_session=${clear ? '' : value}; Path=/; HttpOnly; SameSite=Lax${clear ? '; Max-Age=0' : ''}`;
130
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
+
131
241
  // ── serve ──────────────────────────────────────────────────────────────
132
242
  export async function serve(options = {}) {
133
243
  const root = resolve(options.root || process.cwd());
134
244
  const config = { ...loadConfig(root), ...(options.config || {}) };
135
- const db = await connect(config.db);
245
+ const db = await connect(config.db, root);
136
246
  const secret = (config.auth && config.auth.secret) || randomBytes(32).toString('hex');
137
247
  const cache = new Map();
138
248
  const pages = [];
139
249
  let pagesDir = root;
140
250
  const uploadsDir = join(root, config.uploads);
141
251
  const quiet = !!options.quiet;
252
+ const log = quiet ? () => {} : (m) => console.log(`[spark-ssr] ${m}`);
142
253
 
143
254
  const ctx = { port: 0 };
144
255
 
@@ -167,7 +278,7 @@ export async function serve(options = {}) {
167
278
  let st;
168
279
  try { st = statSync(full); } catch { continue; }
169
280
  if (st.isDirectory()) { walk(full); continue; }
170
- if (!/\.(html|css|js|json)$/.test(f)) continue;
281
+ if (!/\.(html|css|js|json|md)$/.test(f)) continue;
171
282
  seen.add(full);
172
283
  if (mtimes.get(full) !== st.mtimeMs) { mtimes.set(full, st.mtimeMs); changed = true; }
173
284
  }
@@ -189,6 +300,35 @@ export async function serve(options = {}) {
189
300
  const RELOAD_CLIENT = '<script>(()=>{const e=new EventSource("/__spark/reload");let d=0;'
190
301
  + 'e.onmessage=()=>location.reload();e.onerror=()=>{d=1};e.onopen=()=>{if(d)location.reload()}})()</script>';
191
302
 
303
+ // Heartbeats keep every SSE socket outside Bun's idleTimeout (the default
304
+ // would kill them at 10 s — and a killed reload socket reconnects, which
305
+ // the client reads as "the server came back": a spurious reload). The ping
306
+ // also flushes dead clients (enqueue throws → drop).
307
+ const heartbeat = setInterval(() => {
308
+ for (const set of [sseClients, liveClients]) {
309
+ for (const c of set) {
310
+ try { c.enqueue(sseEnc.encode(': ping\n\n')); } catch { set.delete(c); }
311
+ }
312
+ }
313
+ }, 25000);
314
+ heartbeat.unref?.();
315
+
316
+ // ── live data channel (§9) — a production feature, unlike dev reload ──
317
+ // Any write through the server pings /__spark/live with the table name;
318
+ // hydrated pages refetch through their own session (scoping intact) and
319
+ // the source cache drops entries that read the table.
320
+ const liveTables = new Set();
321
+ const liveClients = new Set();
322
+ const sourceCache = makeSourceCache();
323
+ function broadcast(table) {
324
+ sourceCache.invalidate(table);
325
+ if (!liveTables.has(table)) return;
326
+ for (const c of liveClients) {
327
+ try { c.enqueue(sseEnc.encode('data: ' + table + '\n\n')); } catch { liveClients.delete(c); }
328
+ }
329
+ }
330
+ const broadcastSql = (sql) => { for (const t of sqlTables(sql)) broadcast(t); };
331
+
192
332
  // ── the Spark family, wired in ──
193
333
  // Companion packages the app depends on get an importmap entry and are
194
334
  // served at /@modules/<name>, so client scripts import them bare — the same
@@ -224,6 +364,10 @@ export async function serve(options = {}) {
224
364
  }
225
365
  }
226
366
 
367
+ // spark-html-image, at write time (Tier 3): uploaded rasters get a webp
368
+ // sibling, and :file.url points at it (original stays as :file.original).
369
+ const uploadWebp = familyDeps.includes('spark-html-image');
370
+
227
371
  // ── request wrapper ──
228
372
  function wrapReq(request, url, params, session, server) {
229
373
  const headers = {};
@@ -267,7 +411,15 @@ export async function serve(options = {}) {
267
411
  const name = randomUUID() + ext;
268
412
  mkdirSync(uploadsDir, { recursive: true });
269
413
  await Bun.write(join(uploadsDir, name), v);
270
- file = { url: '/uploads/' + name, name: v.name || name, size: v.size, type: v.type };
414
+ file = { url: '/uploads/' + name, original: '/uploads/' + name, name: v.name || name, size: v.size, type: v.type };
415
+ if (uploadWebp && /\.(png|jpe?g)$/i.test(name)) {
416
+ try {
417
+ const sharp = (await import('sharp')).default;
418
+ const webpName = name.replace(/\.\w+$/, '.webp');
419
+ await sharp(join(uploadsDir, name)).webp({ quality: 82 }).toFile(join(uploadsDir, webpName));
420
+ file.url = '/uploads/' + webpName;
421
+ } catch { /* sharp unavailable — original serves fine */ }
422
+ }
271
423
  fields[k] = file.url;
272
424
  } else {
273
425
  fields[k] = v;
@@ -290,11 +442,17 @@ export async function serve(options = {}) {
290
442
  return null;
291
443
  }
292
444
 
293
- async function runSql(sqlText, req) {
445
+ async function runSql(sqlText, req, ttl = 0) {
294
446
  const { sql, tokens } = rewriteParams(sqlText);
295
447
  const values = [];
296
448
  for (const t of tokens) values.push(await resolveToken(t, req));
297
- return db.query(sql, values);
449
+ if (!ttl) return db.query(sql, values);
450
+ const key = 'q|' + sql + '|' + JSON.stringify(values);
451
+ const hit = sourceCache.get(key);
452
+ if (hit) return hit.value;
453
+ const rows = await db.query(sql, values);
454
+ sourceCache.set(key, rows, ttl, sqlTables(sqlText));
455
+ return rows;
298
456
  }
299
457
 
300
458
  // ── auto-CRUD for <spark-ssr table="…"> ──
@@ -302,6 +460,11 @@ export async function serve(options = {}) {
302
460
  const on = (method, path, handler) =>
303
461
  apiRoutes.push({ method, segs: path.split('/').filter(Boolean), handler });
304
462
 
463
+ // Block attributes per table (limit, search, live) and the form-derived
464
+ // validation rules (§6) — both refreshed with the pages.
465
+ const tableOpts = new Map();
466
+ let validators = new Map();
467
+
305
468
  async function tableInfo(table) {
306
469
  const cols = await db.columns(table);
307
470
  const names = cols.map((c) => c.name);
@@ -309,13 +472,39 @@ export async function serve(options = {}) {
309
472
  return { cols, names, scoped };
310
473
  }
311
474
 
312
- async function tableRows(table, req) {
313
- const { scoped } = await tableInfo(table);
475
+ // List conventions (§10): ?page → LIMIT/OFFSET (+ .total/.pages on the
476
+ // array), ?sort=col:dir validated against real columns, ?q across the
477
+ // block's search="…" columns. Admins read unscoped (Tier 3 roles).
478
+ async function tableRows(table, req, opts = {}) {
479
+ const { names, scoped } = await tableInfo(table);
480
+ const where = [];
481
+ const values = [];
314
482
  if (scoped) {
315
483
  if (!req.session) return [];
316
- return db.query(`SELECT * FROM ${table} WHERE user_id = ?`, [req.session.id]);
484
+ if (!isAdmin(req.session)) { where.push('user_id = ?'); values.push(req.session.id); }
485
+ }
486
+ if (opts.search && req.query.q) {
487
+ const cols = opts.search.filter((c) => names.includes(c));
488
+ if (cols.length) {
489
+ where.push('(' + cols.map((c) => `${c} LIKE ?`).join(' OR ') + ')');
490
+ for (const c of cols) { values.push('%' + req.query.q + '%'); void c; }
491
+ }
317
492
  }
318
- return db.query(`SELECT * FROM ${table}`);
493
+ const whereSql = where.length ? ' WHERE ' + where.join(' AND ') : '';
494
+ let sql = `SELECT * FROM ${table}` + whereSql;
495
+ const sm = String(req.query.sort || '').match(/^(\w+)(?::(asc|desc))?$/i);
496
+ if (sm && names.includes(sm[1])) sql += ` ORDER BY ${sm[1]} ${(sm[2] || 'asc').toUpperCase()}`;
497
+ const paged = req.query.page !== undefined || opts.limit;
498
+ if (!paged) return db.query(sql, values);
499
+ const size = opts.limit || 20;
500
+ const pageN = Math.max(1, Number(req.query.page) || 1);
501
+ const totalRows = await db.query(`SELECT COUNT(*) AS n FROM ${table}` + whereSql, values);
502
+ const total = Number(totalRows[0]?.n ?? 0);
503
+ const rows = [...await db.query(sql + ` LIMIT ${size} OFFSET ${(pageN - 1) * size}`, values)];
504
+ rows.total = total;
505
+ rows.pages = Math.max(1, Math.ceil(total / size));
506
+ rows.page = pageN;
507
+ return rows;
319
508
  }
320
509
 
321
510
  function registerTable(table) {
@@ -324,7 +513,7 @@ export async function serve(options = {}) {
324
513
  on('GET', `api/${table}`, async (req) => {
325
514
  const { scoped } = await tableInfo(table);
326
515
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
327
- const rows = await tableRows(table, req);
516
+ const rows = await tableRows(table, req, tableOpts.get(table) || {});
328
517
  // Password hashes never leave the auth table, not even to a session.
329
518
  if (isAuthTable) for (const r of rows) delete r.password;
330
519
  return json(isAuthTable ? [...rows] : rows);
@@ -335,6 +524,12 @@ export async function serve(options = {}) {
335
524
  const { names, scoped } = await tableInfo(table);
336
525
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
337
526
  const { fields } = await req.body();
527
+ // The markup's constraint attributes are the validation spec (§6).
528
+ const rules = validators.get(table);
529
+ if (rules) {
530
+ const errors = validateFields(rules, fields);
531
+ if (errors) return json({ errors }, 422);
532
+ }
338
533
  const data = {};
339
534
  for (const [k, v] of Object.entries(fields)) {
340
535
  if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
@@ -350,6 +545,7 @@ export async function serve(options = {}) {
350
545
  `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')}) RETURNING *`,
351
546
  keys.map((k) => data[k]),
352
547
  );
548
+ broadcast(table);
353
549
  const row = rows[0] ?? { ok: true };
354
550
  if (isAuthTable && row.password) delete row.password;
355
551
  return json(row, 201);
@@ -365,6 +561,11 @@ export async function serve(options = {}) {
365
561
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
366
562
  if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
367
563
  const { fields } = await req.body();
564
+ const rules = validators.get(table);
565
+ if (rules) {
566
+ const errors = validateFields(rules, fields, { partial: true });
567
+ if (errors) return json({ errors }, 422);
568
+ }
368
569
  const data = {};
369
570
  for (const [k, v] of Object.entries(fields)) {
370
571
  if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
@@ -376,8 +577,9 @@ export async function serve(options = {}) {
376
577
  if (!keys.length) return json({ error: 'empty body' }, 400);
377
578
  let sql = `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`;
378
579
  const values = [...keys.map((k) => data[k]), req.params.id];
379
- if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
580
+ if (scoped && !isAdmin(req.session)) { sql += ' AND user_id = ?'; values.push(req.session.id); }
380
581
  const rows = await db.query(sql + ' RETURNING *', values);
582
+ broadcast(table);
381
583
  const row = rows[0];
382
584
  if (isAuthTable && row) delete row.password;
383
585
  return row ? json(row) : json({ error: 'not found' }, 404);
@@ -389,8 +591,9 @@ export async function serve(options = {}) {
389
591
  if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
390
592
  let sql = `DELETE FROM ${table} WHERE id = ?`;
391
593
  const values = [req.params.id];
392
- if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
594
+ if (scoped && !isAdmin(req.session)) { sql += ' AND user_id = ?'; values.push(req.session.id); }
393
595
  const rows = await db.query(sql + ' RETURNING *', values);
596
+ broadcast(table);
394
597
  return rows[0] ? json({ ok: true }) : json({ error: 'not found' }, 404);
395
598
  });
396
599
  }
@@ -409,6 +612,9 @@ export async function serve(options = {}) {
409
612
  : stored !== '' && stored === supplied);
410
613
  if (!ok) return json({ error: 'invalid credentials' }, 401);
411
614
  const session = { id: user.id, [identity]: user[identity] };
615
+ // Roles ride in the session: is_admin / role columns, when they exist.
616
+ if ('is_admin' in user) session.is_admin = user.is_admin;
617
+ if ('role' in user) session.role = user.role;
412
618
  const safe = { ...user };
413
619
  delete safe.password;
414
620
  return json(safe, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
@@ -421,6 +627,8 @@ export async function serve(options = {}) {
421
627
  const user = await authPlugin.login(req);
422
628
  if (!user) return json({ error: 'invalid credentials' }, 401);
423
629
  const session = { id: user.id, email: user.email, name: user.name };
630
+ if (user.is_admin !== undefined) session.is_admin = user.is_admin;
631
+ if (user.role !== undefined) session.role = user.role;
424
632
  return json(user, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
425
633
  });
426
634
  }
@@ -435,8 +643,8 @@ export async function serve(options = {}) {
435
643
  function registerQuery(route) {
436
644
  const key = route.method + ' ' + route.path;
437
645
  const existing = queryDefs.get(key);
438
- if (existing) { existing.sql = route.sql; return; }
439
- const def = { sql: route.sql };
646
+ if (existing) { existing.sql = route.sql; existing.cache = route.cache || 0; return; }
647
+ const def = { sql: route.sql, cache: route.cache || 0 };
440
648
  queryDefs.set(key, def);
441
649
  const segs = route.path.split('/').filter(Boolean)
442
650
  .map((s) => s.replace(/^\[(\w+)\]$/, ':$1'));
@@ -444,7 +652,8 @@ export async function serve(options = {}) {
444
652
  method: route.method,
445
653
  segs,
446
654
  handler: async (req) => {
447
- const rows = await runSql(def.sql, req);
655
+ const rows = await runSql(def.sql, req, route.method === 'GET' ? def.cache : 0);
656
+ if (route.method !== 'GET') broadcastSql(def.sql);
448
657
  if (route.method === 'GET') return json(singleShaped(def.sql) ? rows[0] ?? null : [...rows]);
449
658
  if (Array.isArray(rows) && rows.length) return json(rows.length === 1 ? rows[0] : [...rows]);
450
659
  return json({ ok: true, changes: rows.changes ?? 0 });
@@ -456,6 +665,8 @@ export async function serve(options = {}) {
456
665
  // a plain readdir walk plus mtime-cached parses — so new pages, new tables,
457
666
  // and edited queries appear without restarting the server.
458
667
  const tables = new Set();
668
+ const seedFiles = new Set(); // never served as static assets
669
+ let schemaDirty = false;
459
670
  // Configuring auth IS declaring its table: the login endpoint
460
671
  // (POST /api/<table>?auth) and signup exist without any page mentioning
461
672
  // them. Single-account apps can turn signup off in middleware.html.
@@ -467,18 +678,59 @@ export async function serve(options = {}) {
467
678
  const scanned = scanPages(root);
468
679
  pagesDir = scanned.pagesDir;
469
680
  pages.splice(0, pages.length, ...scanned.pages);
681
+ const nextValidators = new Map();
470
682
  for (const page of pages) {
471
683
  let pd;
472
- try { pd = pageData(page, cache); } catch { continue; }
684
+ try { pd = pageData(page, cache, pagesDir); } catch { continue; }
473
685
  for (const b of pd.blocks) {
474
- if (b.table && !tables.has(b.table)) { tables.add(b.table); registerTable(b.table); }
686
+ if (b.table) {
687
+ if (!tables.has(b.table)) { tables.add(b.table); registerTable(b.table); schemaDirty = true; }
688
+ if (b.live) liveTables.add(b.table);
689
+ if (b.seed) {
690
+ seedFiles.add(resolve(root, b.seed.replace(/^\.\//, '')));
691
+ schemaDirty = schemaDirty || !seededOnce.has(b.table);
692
+ }
693
+ const opts = tableOpts.get(b.table) || {};
694
+ if (b.limit) opts.limit = b.limit;
695
+ if (b.search) opts.search = b.search;
696
+ if (b.cache) opts.cache = b.cache;
697
+ tableOpts.set(b.table, opts);
698
+ }
475
699
  for (const r of b.routes) {
476
- if (r.path) registerQuery(r);
700
+ if (r.path) registerQuery({ ...r, cache: b.cache });
477
701
  }
478
702
  }
703
+ for (const form of pd.forms) {
704
+ if (!form.table) continue;
705
+ const rules = nextValidators.get(form.table) || {};
706
+ Object.assign(rules, form.fields);
707
+ nextValidators.set(form.table, rules);
708
+ }
479
709
  }
710
+ validators = nextValidators;
711
+ }
712
+ // The template is the schema (§7): at startup (and whenever a new table
713
+ // appears in dev) missing tables are created and seeds applied — a fresh
714
+ // clone runs on `bun spark-ssr` alone. Alters stay explicit: `db push`.
715
+ const seededOnce = new Set();
716
+ async function ensureSchema() {
717
+ if (!db) { schemaDirty = false; return; }
718
+ const pds = [];
719
+ for (const p of pages) {
720
+ try { pds.push(pageData(p, cache, pagesDir)); } catch { /* skip */ }
721
+ }
722
+ const schema = inferSchema(pds, config, root);
723
+ try {
724
+ await pushSchema(db, schema, { createOnly: true, log: (m) => log(`db: ${m}`) });
725
+ await seedTables(db, schema, config, root, (m) => log(`db: ${m}`));
726
+ for (const t of Object.keys(schema)) seededOnce.add(t);
727
+ } catch (e) {
728
+ if (!quiet) console.warn(`[spark-ssr] schema: ${e.message}`);
729
+ }
730
+ schemaDirty = false;
480
731
  }
481
732
  refreshPages();
733
+ await ensureSchema();
482
734
 
483
735
  // ── api/ folder — custom endpoints ──
484
736
  function makeAppFetch(req) {
@@ -522,7 +774,7 @@ export async function serve(options = {}) {
522
774
  const { blocks, html } = extractBlocks(source);
523
775
  const { code } = splitScript(html);
524
776
  for (const b of blocks) {
525
- for (const r of b.routes) registerQuery({ ...r, path: r.path || route });
777
+ for (const r of b.routes) registerQuery({ ...r, path: r.path || route, cache: b.cache });
526
778
  }
527
779
  def.fn = null;
528
780
  if (code) {
@@ -620,8 +872,9 @@ export async function serve(options = {}) {
620
872
  const ext = extname(rel);
621
873
  // The root fallback exists for co-located assets (pages/x.css, img/…) —
622
874
  // it must never serve project internals: config (may hold secrets),
623
- // lockfiles, databases, dotfiles. public/ stays the intentional space.
875
+ // lockfiles, databases, dotfiles, seed data. public/ stays intentional.
624
876
  const internal = rel.startsWith('.') || rel.includes('/.')
877
+ || rel.startsWith('seed/')
625
878
  || ['spark.json', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'].includes(rel)
626
879
  || ['.db', '.sqlite', '.sqlite3'].includes(ext);
627
880
  if (!internal && ext && ext !== '.html') {
@@ -632,6 +885,7 @@ export async function serve(options = {}) {
632
885
  for (const file of candidates) {
633
886
  const abs = resolve(file);
634
887
  if (!abs.startsWith(root)) continue;
888
+ if (seedFiles.has(abs)) continue;
635
889
  if (existsSync(abs) && statSync(abs).isFile()) return Bun.file(abs);
636
890
  }
637
891
  return null;
@@ -647,6 +901,7 @@ export async function serve(options = {}) {
647
901
  try { return await fn(req, db, makeAppFetch(req)); }
648
902
  catch (e) {
649
903
  if (!quiet) console.warn(`[spark-ssr] page <script> threw: ${e.message}`);
904
+ if (live) e.__sparkPageScript = true;
650
905
  return {};
651
906
  }
652
907
  }
@@ -656,6 +911,22 @@ export async function serve(options = {}) {
656
911
  && pd.blocks.some((b) => b.table) && !!db;
657
912
  }
658
913
 
914
+ // Open Graph completeness (Tier 3): og:title / og:description derive from
915
+ // the lifted <title> and description unless the page overrides them.
916
+ function withOgTags(head) {
917
+ let out = head;
918
+ const title = (head.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1];
919
+ const desc = (head.match(/<meta\b[^>]*\bname\s*=\s*["']description["'][^>]*\bcontent\s*=\s*["']([^"']*)["']/i) || [])[1]
920
+ || (head.match(/<meta\b[^>]*\bcontent\s*=\s*["']([^"']*)["'][^>]*\bname\s*=\s*["']description["']/i) || [])[1];
921
+ if (title && !/property\s*=\s*["']og:title/i.test(head)) {
922
+ out += `\n<meta property="og:title" content="${title.trim()}">`;
923
+ }
924
+ if (desc && !/property\s*=\s*["']og:description/i.test(head)) {
925
+ out += `\n<meta property="og:description" content="${desc}">`;
926
+ }
927
+ return out;
928
+ }
929
+
659
930
  function shell(page, body, { hydrate, mount, headExtra = '', scripts = '' }) {
660
931
  const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
661
932
  const cssRel = page.key + '.css';
@@ -703,38 +974,109 @@ export async function serve(options = {}) {
703
974
  return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}\n${reload}</body>\n</html>\n`;
704
975
  }
705
976
 
977
+ // Resolve one plan entry — table, query, named SQL, URL, glob, module —
978
+ // honoring the block's cache="…" TTL.
979
+ async function resolveSource(p, req) {
980
+ const src = p.source;
981
+ const ttl = (src.opts && src.opts.cache) || 0;
982
+ if (src.kind === 'table') {
983
+ if (!ttl) return tableRows(src.table, req, src.opts || {});
984
+ const key = ['t', src.table, req.session?.id ?? '', req.query.q ?? '', req.query.sort ?? '', req.query.page ?? ''].join('|');
985
+ const hit = sourceCache.get(key);
986
+ if (hit) return hit.value;
987
+ const rows = await tableRows(src.table, req, src.opts || {});
988
+ sourceCache.set(key, rows, ttl, new Set([src.table]));
989
+ return rows;
990
+ }
991
+ if (src.kind === 'query' || src.kind === 'sql') {
992
+ const sql = src.kind === 'query' ? src.route.sql : src.binding.sql;
993
+ const rows = await runSql(sql, req, ttl);
994
+ return p.shape === 'list' ? [...rows] : rows[0] ?? null;
995
+ }
996
+ if (src.kind === 'url') {
997
+ const key = 'u|' + src.binding.value + '|' + JSON.stringify(req.params) + '|' + (req.query.q ?? '');
998
+ if (ttl) {
999
+ const hit = sourceCache.get(key);
1000
+ if (hit) return hit.value;
1001
+ }
1002
+ const value = await urlSource(src.binding.value, req);
1003
+ if (ttl) sourceCache.set(key, value, ttl);
1004
+ return value;
1005
+ }
1006
+ if (src.kind === 'glob') {
1007
+ const key = 'g|' + src.binding.value;
1008
+ if (ttl) {
1009
+ const hit = sourceCache.get(key);
1010
+ if (hit) return hit.value;
1011
+ }
1012
+ const value = globSource(src.binding.value, root);
1013
+ if (ttl) sourceCache.set(key, value, ttl);
1014
+ return value;
1015
+ }
1016
+ if (src.kind === 'module') {
1017
+ return moduleSource(src.binding.value, root, req, db, { watch: live });
1018
+ }
1019
+ return null;
1020
+ }
1021
+
706
1022
  async function buildScope(pd, req) {
707
- const scope = { ...req.query, ...req.params, session: req.session };
1023
+ // `path` is ambient like `session` the layout's nav highlights the
1024
+ // current page with it. Query/params may shadow it deliberately.
1025
+ const scope = { path: req.path, ...req.query, ...req.params, session: req.session };
708
1026
  if (pd.code) Object.assign(scope, await runPageScript(pd.code, req));
709
1027
  for (const p of pd.plan) {
710
1028
  if (scope[p.var] !== undefined) continue; // the page <script> won
711
- if (p.source.kind === 'table') {
712
- scope[p.var] = await tableRows(p.source.table, req);
713
- } else {
714
- const rows = await runSql(p.source.route.sql, req);
715
- scope[p.var] = p.shape === 'list' ? [...rows] : rows[0] ?? null;
716
- }
1029
+ scope[p.var] = await resolveSource(p, req);
717
1030
  }
718
1031
  return scope;
719
1032
  }
720
1033
 
721
- async function servePage(page, req) {
722
- const pd = pageData(page, cache);
1034
+ // The dev banner for the silent-blank class of bug: "this page reads
1035
+ // {posts} but no source provides it — nearest source: published".
1036
+ function unresolvedBanner(unresolved) {
1037
+ const items = unresolved.map((u) =>
1038
+ `<code>{${escapeHtml(u.name)}}</code>${u.nearest ? ` — nearest source: <code>${escapeHtml(u.nearest)}</code>` : ''}`).join('; ');
1039
+ return '<div style="position:fixed;bottom:0;left:0;right:0;background:#7c2d12;color:#fed7aa;'
1040
+ + 'font:13px/1.6 monospace;padding:8px 14px;z-index:99999">'
1041
+ + `spark-ssr: this page reads ${items} but no source provides it</div>`;
1042
+ }
1043
+
1044
+ async function servePage(page, req, extra = null) {
1045
+ const pd = pageData(page, cache, pagesDir);
723
1046
  const scope = await buildScope(pd, req);
1047
+ if (extra) Object.assign(scope, extra.scope || {});
1048
+
1049
+ // Declarative guard (§3): <spark-ssr guard="session" redirect="/login" />
1050
+ for (const b of pd.blocks) {
1051
+ if (!b.guard) continue;
1052
+ if (!evalExpr(b.guard, scope)) {
1053
+ if (b.redirect) return new Response(null, { status: 303, headers: { location: b.redirect } });
1054
+ return errorPage(b.status || 403);
1055
+ }
1056
+ }
1057
+
724
1058
  const hydrate = shouldHydrate(pd);
725
1059
  // Component imports keep their host (import + name + props) on pages the
726
1060
  // page host won't rebuild wholesale, so a client mount re-resolves them
727
1061
  // and their own <script> comes alive (counters, demos, …).
728
1062
  const hasComponents = /\bimport\s*=\s*"/.test(pd.html);
729
- const body = await renderFragment(pd.html, scope, { loadComponent, keepImports: !hydrate });
730
- const headExtra = pd.head ? renderHead(pd.head, (e) => evalExpr(e, scope)) : '';
731
- return new Response(shell(page, body, {
1063
+ const rctx = { loadComponent, keepImports: !hydrate };
1064
+ const body = await renderFragment(pd.html, scope, rctx);
1065
+ let headExtra = pd.head ? renderHead(pd.head, (e) => evalExpr(e, scope)) : '';
1066
+ if (headExtra) headExtra = withOgTags(headExtra);
1067
+ let html = shell(page, body, {
732
1068
  hydrate, mount: hydrate || hasComponents, headExtra, scripts: pd.scripts,
733
- }), {
1069
+ });
1070
+ if (live && pd.plan.unresolved && pd.plan.unresolved.length) {
1071
+ html = html.replace('</body>', unresolvedBanner(pd.plan.unresolved) + '\n</body>');
1072
+ }
1073
+ return new Response(html, {
1074
+ status: (extra && extra.status) || rctx.status || 200,
734
1075
  headers: { 'content-type': 'text/html; charset=utf-8' },
735
1076
  });
736
1077
  }
737
1078
 
1079
+ const STATUS_TEXT = { 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not found' };
738
1080
  function errorPage(status) {
739
1081
  const file = join(root, `${status}.html`);
740
1082
  if (existsSync(file)) {
@@ -742,7 +1084,122 @@ export async function serve(options = {}) {
742
1084
  const body = readFileSync(file, 'utf8') + (live ? '\n' + RELOAD_CLIENT : '');
743
1085
  return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
744
1086
  }
745
- return new Response(status === 404 ? 'Not found' : 'Server error', { status });
1087
+ return new Response(STATUS_TEXT[status] || 'Server error', { status });
1088
+ }
1089
+
1090
+ // Dev-only error overlay (§4): the real error — SQL, file, line — on the
1091
+ // page instead of a bare 500. The reload client rides along, so fixing the
1092
+ // file un-sticks the browser.
1093
+ function devErrorPage(e, pathname) {
1094
+ const body = '<!doctype html><html><head><title>spark-ssr error</title></head>'
1095
+ + '<body style="background:#1c1917;color:#fafaf9;font:15px/1.6 system-ui;padding:2rem">'
1096
+ + `<h1 style="color:#fca5a5">500 — ${escapeHtml(e.message || String(e))}</h1>`
1097
+ + `<p style="color:#a8a29e">while serving <code>${escapeHtml(pathname)}</code></p>`
1098
+ + `<pre style="background:#292524;padding:1rem;border-radius:8px;overflow:auto;color:#fdba74">${escapeHtml(e.stack || '')}</pre>`
1099
+ + RELOAD_CLIENT + '</body></html>';
1100
+ return new Response(body, { status: 500, headers: { 'content-type': 'text/html; charset=utf-8' } });
1101
+ }
1102
+
1103
+ // /__spark/plan (§4): "view source" for the inferred backend — every route,
1104
+ // its var → source bindings, tables, endpoints. Dev only.
1105
+ function planPage() {
1106
+ const esc = escapeHtml;
1107
+ const srcLabel = (s) =>
1108
+ s.kind === 'table' ? `table ${s.table}`
1109
+ : s.kind === 'query' ? `${s.route.method} ${s.route.path} — ${s.route.sql.replace(/\s+/g, ' ')}`
1110
+ : s.kind === 'sql' ? `SQL — ${s.binding.sql.replace(/\s+/g, ' ')}`
1111
+ : `${s.kind} — ${s.binding.value}`;
1112
+ let rows = '';
1113
+ for (const page of pages) {
1114
+ let pd;
1115
+ try { pd = pageData(page, cache, pagesDir); } catch { continue; }
1116
+ const vars = pd.plan.map((p) => `<code>${esc(p.var)}</code> ← ${esc(srcLabel(p.source))} <em>(${p.shape})</em>`).join('<br>');
1117
+ const un = (pd.plan.unresolved || []).map((u) => `<code>{${esc(u.name)}}</code> unresolved`).join('<br>');
1118
+ const guards = pd.blocks.filter((b) => b.guard)
1119
+ .map((b) => `guard <code>${esc(b.guard)}</code>${b.redirect ? ` → ${esc(b.redirect)}` : ''}`).join('<br>');
1120
+ rows += `<tr><td><code>${esc(page.route)}</code></td><td>${esc(relative(root, page.file))}</td>`
1121
+ + `<td>${vars}${un ? '<br><span style="color:#f87171">' + un + '</span>' : ''}${guards ? '<br>' + guards : ''}</td></tr>\n`;
1122
+ }
1123
+ const endpoints = apiRoutes.map((r) => `<li><code>${esc(r.method)} /${esc(r.segs.join('/'))}</code></li>`).join('\n');
1124
+ const tbls = [...tables].map((t) => `<code>${esc(t)}${liveTables.has(t) ? ' (live)' : ''}</code>`).join(', ');
1125
+ const body = `<!doctype html><html><head><title>spark-ssr plan</title>
1126
+ <style>body{font:15px/1.6 system-ui;max-width:70rem;margin:2rem auto;padding:0 1rem;background:#1c1917;color:#fafaf9}
1127
+ table{border-collapse:collapse;width:100%}td,th{border:1px solid #44403c;padding:.5rem;text-align:left;vertical-align:top}
1128
+ code{color:#fdba74}em{color:#a8a29e}</style></head><body>
1129
+ <h1>⚡ The inferred backend</h1>
1130
+ <p>Tables: ${tbls || '<em>none</em>'}</p>
1131
+ <table><tr><th>Route</th><th>File</th><th>Data plan</th></tr>${rows}</table>
1132
+ <h2>Endpoints</h2><ul>${endpoints}</ul>
1133
+ </body></html>`;
1134
+ return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
1135
+ }
1136
+
1137
+ // ── SEO (Tier 3): sitemap.xml + robots.txt, generated when not authored ──
1138
+ function indexablePages() {
1139
+ const out = [];
1140
+ for (const page of pages) {
1141
+ let pd;
1142
+ try { pd = pageData(page, cache, pagesDir); } catch { continue; }
1143
+ const noindex = /<meta\b[^>]*\bname\s*=\s*["']robots["'][^>]*\bcontent\s*=\s*["'][^"']*noindex/i.test(pd.head)
1144
+ || /<meta\b[^>]*\bcontent\s*=\s*["'][^"']*noindex[^"']*["'][^>]*\bname\s*=\s*["']robots["']/i.test(pd.head);
1145
+ const guarded = pd.blocks.some((b) => b.guard);
1146
+ out.push({ page, pd, noindex, guarded });
1147
+ }
1148
+ return out;
1149
+ }
1150
+
1151
+ // Enumerate a [param] route's values by re-running its bound query with the
1152
+ // param comparison neutralized (`slug = :slug` → `slug = slug`) and every
1153
+ // other token null — spark-prerender's route-enumeration idea, DB-backed.
1154
+ async function enumerateParam(sql, param) {
1155
+ const cmp = new RegExp(`([a-zA-Z_]\\w*)\\s*=\\s*:${param}\\b`);
1156
+ const cm = String(sql).match(cmp);
1157
+ if (!cm) return [];
1158
+ const col = cm[1];
1159
+ const neutral = String(sql).replace(cmp, '$1 = $1').replace(/\blimit\s+\d+\b/i, '');
1160
+ const { sql: rewritten, tokens } = rewriteParams(neutral);
1161
+ const rows = await db.query(rewritten, tokens.map(() => null));
1162
+ return [...new Set([...rows].map((r) => r[col]).filter((v) => v != null))];
1163
+ }
1164
+
1165
+ async function sitemapXml(origin) {
1166
+ const urls = [];
1167
+ for (const { page, pd, noindex, guarded } of indexablePages()) {
1168
+ if (noindex || guarded) continue;
1169
+ const params = page.segs.filter((s) => s.startsWith('['));
1170
+ if (!params.length) { urls.push(page.route); continue; }
1171
+ if (params.length > 1) continue;
1172
+ const param = params[0].slice(1, -1);
1173
+ let vals = [];
1174
+ const bound = pd.plan.find((p) =>
1175
+ (p.source.kind === 'query' && p.source.route.sql.includes(':' + param))
1176
+ || (p.source.kind === 'sql' && p.source.binding.sql.includes(':' + param)));
1177
+ if (bound && db) {
1178
+ const sql = bound.source.kind === 'query' ? bound.source.route.sql : bound.source.binding.sql;
1179
+ try { vals = await enumerateParam(sql, param); } catch { /* dynamic route stays out */ }
1180
+ } else {
1181
+ const glob = pd.plan.find((p) => p.source.kind === 'glob');
1182
+ if (glob) vals = globSource(glob.source.binding.value, root).map((r) => r.slug);
1183
+ }
1184
+ for (const v of vals) urls.push(page.route.replace(`[${param}]`, encodeURIComponent(String(v))));
1185
+ }
1186
+ const xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
1187
+ + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1188
+ + urls.map((u) => ` <url><loc>${origin}${escapeHtml(u)}</loc></url>`).join('\n')
1189
+ + '\n</urlset>\n';
1190
+ return new Response(xml, { headers: { 'content-type': 'application/xml' } });
1191
+ }
1192
+
1193
+ function robotsTxt(origin) {
1194
+ const lines = ['User-agent: *'];
1195
+ for (const { page, noindex, guarded } of indexablePages()) {
1196
+ if ((noindex || guarded) && !page.segs.some((s) => s.startsWith('['))) {
1197
+ lines.push(`Disallow: ${page.route}`);
1198
+ }
1199
+ }
1200
+ if (lines.length === 1) lines.push('Disallow:');
1201
+ lines.push(`Sitemap: ${origin}/sitemap.xml`);
1202
+ return new Response(lines.join('\n') + '\n', { headers: { 'content-type': 'text/plain' } });
746
1203
  }
747
1204
 
748
1205
  // spark-html + family packages, served as browser modules. The importmap
@@ -774,6 +1231,9 @@ export async function serve(options = {}) {
774
1231
  // ── the server ──
775
1232
  const server = Bun.serve({
776
1233
  port: options.port ?? 3000,
1234
+ // SSE channels idle between events (heartbeat every 25 s keeps them
1235
+ // alive); slow queries and big uploads get headroom too.
1236
+ idleTimeout: 60,
777
1237
  async fetch(request, srv) {
778
1238
  const url = new URL(request.url);
779
1239
  let pathname;
@@ -792,13 +1252,30 @@ export async function serve(options = {}) {
792
1252
  });
793
1253
  }
794
1254
 
1255
+ // The live data channel (§9) ships in production too — it's the app.
1256
+ if (pathname === '/__spark/live') {
1257
+ let ctrl;
1258
+ const stream = new ReadableStream({
1259
+ start(c) { ctrl = c; c.enqueue(sseEnc.encode(': connected\n\n')); liveClients.add(c); },
1260
+ cancel() { liveClients.delete(ctrl); },
1261
+ });
1262
+ return new Response(stream, {
1263
+ headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' },
1264
+ });
1265
+ }
1266
+
795
1267
  const session = readSession(request.headers.get('cookie'), secret);
796
1268
  const extraHeaders = {};
797
1269
 
798
1270
  try {
799
1271
  // Pick up new/edited pages, api files, and middleware without a
800
1272
  // restart (readdir walk + mtime-cached parses — cheap).
801
- if (options.watch !== false) { refreshPages(); refreshApi(); refreshMiddleware(); }
1273
+ if (options.watch !== false) {
1274
+ refreshPages(); refreshApi(); refreshMiddleware();
1275
+ if (schemaDirty) await ensureSchema();
1276
+ }
1277
+
1278
+ if (live && pathname === '/__spark/plan') return planPage();
802
1279
 
803
1280
  // middleware.html runs first, on every request.
804
1281
  if (middleware) {
@@ -841,10 +1318,14 @@ export async function serve(options = {}) {
841
1318
  const key = pathname.slice('/__spark/page/'.length).replace(/\.html$/, '');
842
1319
  const page = pages.find((p) => p.key === key);
843
1320
  if (!page) return finish(errorPage(404));
844
- const pd = pageData(page, cache);
845
- const table = (pd.blocks.find((b) => b.table) || {}).table || null;
1321
+ const pd = pageData(page, cache, pagesDir);
1322
+ const tableBlock = pd.blocks.find((b) => b.table) || {};
1323
+ const table = tableBlock.table || null;
846
1324
  const cols = table ? await db.columns(table) : [];
847
- const html = clientComponent({ html: pd.html, analysis: pd.analysis, plan: pd.plan, table, cols, key });
1325
+ const html = clientComponent({
1326
+ html: pd.html, analysis: pd.analysis, plan: pd.plan, table, cols, key,
1327
+ live: !!(table && liveTables.has(table)),
1328
+ });
848
1329
  return finish(new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-cache' } }));
849
1330
  }
850
1331
 
@@ -852,16 +1333,10 @@ export async function serve(options = {}) {
852
1333
  const key = pathname.slice('/__spark/data/'.length).replace(/\.js$/, '');
853
1334
  const page = pages.find((p) => p.key === key);
854
1335
  if (!page) return finish(errorPage(404));
855
- const pd = pageData(page, cache);
1336
+ const pd = pageData(page, cache, pagesDir);
856
1337
  const req = wrapReq(request, url, {}, session, srv);
857
1338
  const data = {};
858
- for (const p of pd.plan) {
859
- if (p.source.kind === 'table') data[p.var] = await tableRows(p.source.table, req);
860
- else {
861
- const rows = await runSql(p.source.route.sql, req);
862
- data[p.var] = p.shape === 'list' ? [...rows] : rows[0] ?? null;
863
- }
864
- }
1339
+ for (const p of pd.plan) data[p.var] = await resolveSource(p, req);
865
1340
  return finish(new Response(initModule(data), {
866
1341
  headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' },
867
1342
  }));
@@ -884,6 +1359,46 @@ export async function serve(options = {}) {
884
1359
  if (!hit) return finish(json({ error: 'not found' }, 404, cors || {}));
885
1360
  const req = wrapReq(request, url, hit.params, session, srv);
886
1361
  const res = await hit.route.handler(req, { headers: {} });
1362
+
1363
+ // Answer a browser like a browser (§5): a plain form post that
1364
+ // succeeded 303s back (the _redirect field or the referrer) — the
1365
+ // app works with JavaScript disabled. A failed one re-renders the
1366
+ // referring page with {errors} (and {values}) in scope.
1367
+ const ct = request.headers.get('content-type') || '';
1368
+ const isForm = request.method !== 'GET'
1369
+ && (ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data'));
1370
+ const wantsHtml = (request.headers.get('accept') || '').includes('text/html');
1371
+ if (isForm && wantsHtml) {
1372
+ const { fields } = await req.body();
1373
+ const referer = request.headers.get('referer');
1374
+ let back = '/';
1375
+ try { if (referer) { const r = new URL(referer); back = r.pathname + r.search; } } catch { /* keep / */ }
1376
+ if (typeof fields._redirect === 'string' && fields._redirect.startsWith('/')) back = fields._redirect;
1377
+ if (res.status < 400) {
1378
+ const headers = new Headers({ location: back });
1379
+ const sc = res.headers.get('set-cookie');
1380
+ if (sc) headers.set('set-cookie', sc);
1381
+ return finish(new Response(null, { status: 303, headers }));
1382
+ }
1383
+ let errors = null;
1384
+ try {
1385
+ const j = await res.clone().json();
1386
+ errors = j.errors || (j.error ? { _: j.error } : null);
1387
+ } catch { /* non-JSON error */ }
1388
+ if (errors && referer) {
1389
+ try {
1390
+ const r = new URL(referer);
1391
+ const rp = matchPage(pages, decodeURIComponent(r.pathname));
1392
+ if (rp) {
1393
+ const rreq = wrapReq(request, r, rp.params, session, srv);
1394
+ return finish(await servePage(rp.page, rreq, {
1395
+ scope: { errors, values: fields }, status: res.status,
1396
+ }));
1397
+ }
1398
+ } catch { /* fall through to the raw response */ }
1399
+ }
1400
+ }
1401
+
887
1402
  if (cors) for (const [k, v] of Object.entries(cors)) res.headers.set(k, v);
888
1403
  return finish(res);
889
1404
  }
@@ -891,6 +1406,9 @@ export async function serve(options = {}) {
891
1406
  const file = staticFile(pathname);
892
1407
  if (file) return finish(new Response(file));
893
1408
 
1409
+ if (pathname === '/sitemap.xml') return finish(await sitemapXml(url.origin));
1410
+ if (pathname === '/robots.txt') return finish(robotsTxt(url.origin));
1411
+
894
1412
  const hit = matchPage(pages, pathname);
895
1413
  if (hit) {
896
1414
  const req = wrapReq(request, url, hit.params, session, srv);
@@ -900,7 +1418,8 @@ export async function serve(options = {}) {
900
1418
  return finish(errorPage(404));
901
1419
  } catch (e) {
902
1420
  if (!quiet) console.error(`[spark-ssr] ${request.method} ${pathname} — ${e.stack || e.message}`);
903
- const res = errorPage(500);
1421
+ const wantsHtml = (request.headers.get('accept') || '').includes('text/html');
1422
+ const res = live && wantsHtml ? devErrorPage(e, pathname) : errorPage(500);
904
1423
  for (const [k, v] of Object.entries(extraHeaders)) res.headers.set(k, v);
905
1424
  return res;
906
1425
  }
@@ -916,8 +1435,11 @@ export async function serve(options = {}) {
916
1435
  db,
917
1436
  stop(force) {
918
1437
  if (watchTimer) clearInterval(watchTimer);
1438
+ clearInterval(heartbeat);
919
1439
  for (const c of sseClients) { try { c.close(); } catch { /* gone */ } }
920
1440
  sseClients.clear();
1441
+ for (const c of liveClients) { try { c.close(); } catch { /* gone */ } }
1442
+ liveClients.clear();
921
1443
  server.stop(force);
922
1444
  return db && db.close();
923
1445
  },