spark-ssr 0.2.0 → 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,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,22 @@ 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
+ // ── 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
+
192
319
  // ── the Spark family, wired in ──
193
320
  // Companion packages the app depends on get an importmap entry and are
194
321
  // served at /@modules/<name>, so client scripts import them bare — the same
@@ -224,6 +351,10 @@ export async function serve(options = {}) {
224
351
  }
225
352
  }
226
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
+
227
358
  // ── request wrapper ──
228
359
  function wrapReq(request, url, params, session, server) {
229
360
  const headers = {};
@@ -267,7 +398,15 @@ export async function serve(options = {}) {
267
398
  const name = randomUUID() + ext;
268
399
  mkdirSync(uploadsDir, { recursive: true });
269
400
  await Bun.write(join(uploadsDir, name), v);
270
- 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
+ }
271
410
  fields[k] = file.url;
272
411
  } else {
273
412
  fields[k] = v;
@@ -290,11 +429,17 @@ export async function serve(options = {}) {
290
429
  return null;
291
430
  }
292
431
 
293
- async function runSql(sqlText, req) {
432
+ async function runSql(sqlText, req, ttl = 0) {
294
433
  const { sql, tokens } = rewriteParams(sqlText);
295
434
  const values = [];
296
435
  for (const t of tokens) values.push(await resolveToken(t, req));
297
- 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;
298
443
  }
299
444
 
300
445
  // ── auto-CRUD for <spark-ssr table="…"> ──
@@ -302,6 +447,11 @@ export async function serve(options = {}) {
302
447
  const on = (method, path, handler) =>
303
448
  apiRoutes.push({ method, segs: path.split('/').filter(Boolean), handler });
304
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
+
305
455
  async function tableInfo(table) {
306
456
  const cols = await db.columns(table);
307
457
  const names = cols.map((c) => c.name);
@@ -309,13 +459,39 @@ export async function serve(options = {}) {
309
459
  return { cols, names, scoped };
310
460
  }
311
461
 
312
- async function tableRows(table, req) {
313
- 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 = [];
314
469
  if (scoped) {
315
470
  if (!req.session) return [];
316
- 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
+ }
317
479
  }
318
- 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;
319
495
  }
320
496
 
321
497
  function registerTable(table) {
@@ -324,7 +500,7 @@ export async function serve(options = {}) {
324
500
  on('GET', `api/${table}`, async (req) => {
325
501
  const { scoped } = await tableInfo(table);
326
502
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
327
- const rows = await tableRows(table, req);
503
+ const rows = await tableRows(table, req, tableOpts.get(table) || {});
328
504
  // Password hashes never leave the auth table, not even to a session.
329
505
  if (isAuthTable) for (const r of rows) delete r.password;
330
506
  return json(isAuthTable ? [...rows] : rows);
@@ -335,6 +511,12 @@ export async function serve(options = {}) {
335
511
  const { names, scoped } = await tableInfo(table);
336
512
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
337
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
+ }
338
520
  const data = {};
339
521
  for (const [k, v] of Object.entries(fields)) {
340
522
  if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
@@ -350,6 +532,7 @@ export async function serve(options = {}) {
350
532
  `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')}) RETURNING *`,
351
533
  keys.map((k) => data[k]),
352
534
  );
535
+ broadcast(table);
353
536
  const row = rows[0] ?? { ok: true };
354
537
  if (isAuthTable && row.password) delete row.password;
355
538
  return json(row, 201);
@@ -365,6 +548,11 @@ export async function serve(options = {}) {
365
548
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
366
549
  if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
367
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
+ }
368
556
  const data = {};
369
557
  for (const [k, v] of Object.entries(fields)) {
370
558
  if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
@@ -376,8 +564,9 @@ export async function serve(options = {}) {
376
564
  if (!keys.length) return json({ error: 'empty body' }, 400);
377
565
  let sql = `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`;
378
566
  const values = [...keys.map((k) => data[k]), req.params.id];
379
- 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); }
380
568
  const rows = await db.query(sql + ' RETURNING *', values);
569
+ broadcast(table);
381
570
  const row = rows[0];
382
571
  if (isAuthTable && row) delete row.password;
383
572
  return row ? json(row) : json({ error: 'not found' }, 404);
@@ -389,8 +578,9 @@ export async function serve(options = {}) {
389
578
  if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
390
579
  let sql = `DELETE FROM ${table} WHERE id = ?`;
391
580
  const values = [req.params.id];
392
- 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); }
393
582
  const rows = await db.query(sql + ' RETURNING *', values);
583
+ broadcast(table);
394
584
  return rows[0] ? json({ ok: true }) : json({ error: 'not found' }, 404);
395
585
  });
396
586
  }
@@ -409,6 +599,9 @@ export async function serve(options = {}) {
409
599
  : stored !== '' && stored === supplied);
410
600
  if (!ok) return json({ error: 'invalid credentials' }, 401);
411
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;
412
605
  const safe = { ...user };
413
606
  delete safe.password;
414
607
  return json(safe, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
@@ -421,6 +614,8 @@ export async function serve(options = {}) {
421
614
  const user = await authPlugin.login(req);
422
615
  if (!user) return json({ error: 'invalid credentials' }, 401);
423
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;
424
619
  return json(user, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
425
620
  });
426
621
  }
@@ -435,8 +630,8 @@ export async function serve(options = {}) {
435
630
  function registerQuery(route) {
436
631
  const key = route.method + ' ' + route.path;
437
632
  const existing = queryDefs.get(key);
438
- if (existing) { existing.sql = route.sql; return; }
439
- 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 };
440
635
  queryDefs.set(key, def);
441
636
  const segs = route.path.split('/').filter(Boolean)
442
637
  .map((s) => s.replace(/^\[(\w+)\]$/, ':$1'));
@@ -444,7 +639,8 @@ export async function serve(options = {}) {
444
639
  method: route.method,
445
640
  segs,
446
641
  handler: async (req) => {
447
- 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);
448
644
  if (route.method === 'GET') return json(singleShaped(def.sql) ? rows[0] ?? null : [...rows]);
449
645
  if (Array.isArray(rows) && rows.length) return json(rows.length === 1 ? rows[0] : [...rows]);
450
646
  return json({ ok: true, changes: rows.changes ?? 0 });
@@ -456,6 +652,8 @@ export async function serve(options = {}) {
456
652
  // a plain readdir walk plus mtime-cached parses — so new pages, new tables,
457
653
  // and edited queries appear without restarting the server.
458
654
  const tables = new Set();
655
+ const seedFiles = new Set(); // never served as static assets
656
+ let schemaDirty = false;
459
657
  // Configuring auth IS declaring its table: the login endpoint
460
658
  // (POST /api/<table>?auth) and signup exist without any page mentioning
461
659
  // them. Single-account apps can turn signup off in middleware.html.
@@ -467,18 +665,59 @@ export async function serve(options = {}) {
467
665
  const scanned = scanPages(root);
468
666
  pagesDir = scanned.pagesDir;
469
667
  pages.splice(0, pages.length, ...scanned.pages);
668
+ const nextValidators = new Map();
470
669
  for (const page of pages) {
471
670
  let pd;
472
- try { pd = pageData(page, cache); } catch { continue; }
671
+ try { pd = pageData(page, cache, pagesDir); } catch { continue; }
473
672
  for (const b of pd.blocks) {
474
- 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
+ }
475
686
  for (const r of b.routes) {
476
- if (r.path) registerQuery(r);
687
+ if (r.path) registerQuery({ ...r, cache: b.cache });
477
688
  }
478
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 */ }
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}`);
479
716
  }
717
+ schemaDirty = false;
480
718
  }
481
719
  refreshPages();
720
+ await ensureSchema();
482
721
 
483
722
  // ── api/ folder — custom endpoints ──
484
723
  function makeAppFetch(req) {
@@ -522,7 +761,7 @@ export async function serve(options = {}) {
522
761
  const { blocks, html } = extractBlocks(source);
523
762
  const { code } = splitScript(html);
524
763
  for (const b of blocks) {
525
- 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 });
526
765
  }
527
766
  def.fn = null;
528
767
  if (code) {
@@ -620,8 +859,9 @@ export async function serve(options = {}) {
620
859
  const ext = extname(rel);
621
860
  // The root fallback exists for co-located assets (pages/x.css, img/…) —
622
861
  // it must never serve project internals: config (may hold secrets),
623
- // lockfiles, databases, dotfiles. public/ stays the intentional space.
862
+ // lockfiles, databases, dotfiles, seed data. public/ stays intentional.
624
863
  const internal = rel.startsWith('.') || rel.includes('/.')
864
+ || rel.startsWith('seed/')
625
865
  || ['spark.json', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'].includes(rel)
626
866
  || ['.db', '.sqlite', '.sqlite3'].includes(ext);
627
867
  if (!internal && ext && ext !== '.html') {
@@ -632,6 +872,7 @@ export async function serve(options = {}) {
632
872
  for (const file of candidates) {
633
873
  const abs = resolve(file);
634
874
  if (!abs.startsWith(root)) continue;
875
+ if (seedFiles.has(abs)) continue;
635
876
  if (existsSync(abs) && statSync(abs).isFile()) return Bun.file(abs);
636
877
  }
637
878
  return null;
@@ -647,6 +888,7 @@ export async function serve(options = {}) {
647
888
  try { return await fn(req, db, makeAppFetch(req)); }
648
889
  catch (e) {
649
890
  if (!quiet) console.warn(`[spark-ssr] page <script> threw: ${e.message}`);
891
+ if (live) e.__sparkPageScript = true;
650
892
  return {};
651
893
  }
652
894
  }
@@ -656,6 +898,22 @@ export async function serve(options = {}) {
656
898
  && pd.blocks.some((b) => b.table) && !!db;
657
899
  }
658
900
 
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
+
659
917
  function shell(page, body, { hydrate, mount, headExtra = '', scripts = '' }) {
660
918
  const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
661
919
  const cssRel = page.key + '.css';
@@ -703,38 +961,107 @@ export async function serve(options = {}) {
703
961
  return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}\n${reload}</body>\n</html>\n`;
704
962
  }
705
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;
1007
+ }
1008
+
706
1009
  async function buildScope(pd, req) {
707
1010
  const scope = { ...req.query, ...req.params, session: req.session };
708
1011
  if (pd.code) Object.assign(scope, await runPageScript(pd.code, req));
709
1012
  for (const p of pd.plan) {
710
1013
  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
- }
1014
+ scope[p.var] = await resolveSource(p, req);
717
1015
  }
718
1016
  return scope;
719
1017
  }
720
1018
 
721
- async function servePage(page, req) {
722
- 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);
723
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
+
724
1043
  const hydrate = shouldHydrate(pd);
725
1044
  // Component imports keep their host (import + name + props) on pages the
726
1045
  // page host won't rebuild wholesale, so a client mount re-resolves them
727
1046
  // and their own <script> comes alive (counters, demos, …).
728
1047
  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, {
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, {
732
1053
  hydrate, mount: hydrate || hasComponents, headExtra, scripts: pd.scripts,
733
- }), {
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,
734
1060
  headers: { 'content-type': 'text/html; charset=utf-8' },
735
1061
  });
736
1062
  }
737
1063
 
1064
+ const STATUS_TEXT = { 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not found' };
738
1065
  function errorPage(status) {
739
1066
  const file = join(root, `${status}.html`);
740
1067
  if (existsSync(file)) {
@@ -742,7 +1069,122 @@ export async function serve(options = {}) {
742
1069
  const body = readFileSync(file, 'utf8') + (live ? '\n' + RELOAD_CLIENT : '');
743
1070
  return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
744
1071
  }
745
- return new Response(status === 404 ? 'Not found' : 'Server error', { status });
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))));
1170
+ }
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' } });
1176
+ }
1177
+
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' } });
746
1188
  }
747
1189
 
748
1190
  // spark-html + family packages, served as browser modules. The importmap
@@ -792,13 +1234,30 @@ export async function serve(options = {}) {
792
1234
  });
793
1235
  }
794
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
+
795
1249
  const session = readSession(request.headers.get('cookie'), secret);
796
1250
  const extraHeaders = {};
797
1251
 
798
1252
  try {
799
1253
  // Pick up new/edited pages, api files, and middleware without a
800
1254
  // restart (readdir walk + mtime-cached parses — cheap).
801
- 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();
802
1261
 
803
1262
  // middleware.html runs first, on every request.
804
1263
  if (middleware) {
@@ -841,10 +1300,14 @@ export async function serve(options = {}) {
841
1300
  const key = pathname.slice('/__spark/page/'.length).replace(/\.html$/, '');
842
1301
  const page = pages.find((p) => p.key === key);
843
1302
  if (!page) return finish(errorPage(404));
844
- const pd = pageData(page, cache);
845
- 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;
846
1306
  const cols = table ? await db.columns(table) : [];
847
- 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
+ });
848
1311
  return finish(new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-cache' } }));
849
1312
  }
850
1313
 
@@ -852,16 +1315,10 @@ export async function serve(options = {}) {
852
1315
  const key = pathname.slice('/__spark/data/'.length).replace(/\.js$/, '');
853
1316
  const page = pages.find((p) => p.key === key);
854
1317
  if (!page) return finish(errorPage(404));
855
- const pd = pageData(page, cache);
1318
+ const pd = pageData(page, cache, pagesDir);
856
1319
  const req = wrapReq(request, url, {}, session, srv);
857
1320
  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
- }
1321
+ for (const p of pd.plan) data[p.var] = await resolveSource(p, req);
865
1322
  return finish(new Response(initModule(data), {
866
1323
  headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' },
867
1324
  }));
@@ -884,6 +1341,46 @@ export async function serve(options = {}) {
884
1341
  if (!hit) return finish(json({ error: 'not found' }, 404, cors || {}));
885
1342
  const req = wrapReq(request, url, hit.params, session, srv);
886
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
+
887
1384
  if (cors) for (const [k, v] of Object.entries(cors)) res.headers.set(k, v);
888
1385
  return finish(res);
889
1386
  }
@@ -891,6 +1388,9 @@ export async function serve(options = {}) {
891
1388
  const file = staticFile(pathname);
892
1389
  if (file) return finish(new Response(file));
893
1390
 
1391
+ if (pathname === '/sitemap.xml') return finish(await sitemapXml(url.origin));
1392
+ if (pathname === '/robots.txt') return finish(robotsTxt(url.origin));
1393
+
894
1394
  const hit = matchPage(pages, pathname);
895
1395
  if (hit) {
896
1396
  const req = wrapReq(request, url, hit.params, session, srv);
@@ -900,7 +1400,8 @@ export async function serve(options = {}) {
900
1400
  return finish(errorPage(404));
901
1401
  } catch (e) {
902
1402
  if (!quiet) console.error(`[spark-ssr] ${request.method} ${pathname} — ${e.stack || e.message}`);
903
- const res = errorPage(500);
1403
+ const wantsHtml = (request.headers.get('accept') || '').includes('text/html');
1404
+ const res = live && wantsHtml ? devErrorPage(e, pathname) : errorPage(500);
904
1405
  for (const [k, v] of Object.entries(extraHeaders)) res.headers.set(k, v);
905
1406
  return res;
906
1407
  }
@@ -918,6 +1419,8 @@ export async function serve(options = {}) {
918
1419
  if (watchTimer) clearInterval(watchTimer);
919
1420
  for (const c of sseClients) { try { c.close(); } catch { /* gone */ } }
920
1421
  sseClients.clear();
1422
+ for (const c of liveClients) { try { c.close(); } catch { /* gone */ } }
1423
+ liveClients.clear();
921
1424
  server.stop(force);
922
1425
  return db && db.close();
923
1426
  },