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