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/README.md +182 -27
- package/bin/cli.js +71 -13
- package/package.json +2 -2
- package/src/db.js +4 -1
- package/src/hydrate.js +10 -3
- package/src/index.js +11 -4
- package/src/parse.js +0 -0
- package/src/render.js +22 -1
- package/src/schema.js +226 -0
- package/src/server.js +587 -65
- package/src/sources.js +131 -0
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/,
|
|
5
|
-
* middleware.html), <spark-ssr> blocks declare the data
|
|
6
|
-
*
|
|
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 {
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
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
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
100
|
-
const
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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
|
|
730
|
-
const
|
|
731
|
-
|
|
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
|
|
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) {
|
|
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
|
|
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({
|
|
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
|
|
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
|
},
|