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/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 +567 -64
- 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,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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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
|
|
730
|
-
const
|
|
731
|
-
|
|
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
|
|
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) {
|
|
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
|
|
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({
|
|
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
|
|
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
|
},
|