spark-ssr 0.1.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 +117 -0
- package/bin/cli.js +95 -0
- package/package.json +45 -0
- package/src/config.js +40 -0
- package/src/db.js +72 -0
- package/src/hydrate.js +161 -0
- package/src/index.js +14 -0
- package/src/parse.js +221 -0
- package/src/render.js +227 -0
- package/src/server.js +674 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-ssr server — `bun spark-ssr` and it serves.
|
|
3
|
+
*
|
|
4
|
+
* The filesystem is the router (pages/, api/, public/, 404.html, 500.html,
|
|
5
|
+
* middleware.html), <spark-ssr> blocks declare the data, and everything else
|
|
6
|
+
* is inferred from the template. No route handlers, no controllers, no build.
|
|
7
|
+
*/
|
|
8
|
+
import { join, resolve, extname, dirname } from 'node:path';
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { createHmac, timingSafeEqual, randomBytes, randomUUID } from 'node:crypto';
|
|
11
|
+
import { loadConfig } from './config.js';
|
|
12
|
+
import { connect } from './db.js';
|
|
13
|
+
import { extractBlocks, analyze, dataPlan, rewriteParams, singleShaped } from './parse.js';
|
|
14
|
+
import { renderFragment } from './render.js';
|
|
15
|
+
import { clientComponent, initModule } from './hydrate.js';
|
|
16
|
+
|
|
17
|
+
const AsyncFunction = (async () => {}).constructor;
|
|
18
|
+
const json = (data, status = 200, headers = {}) =>
|
|
19
|
+
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
|
|
20
|
+
const dig = (obj, path) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
|
|
21
|
+
|
|
22
|
+
// ── pages ──────────────────────────────────────────────────────────────
|
|
23
|
+
const RESERVED_ROOT_DIRS = new Set(['components', 'api', 'public', 'pages', 'node_modules', 'dist', 'uploads']);
|
|
24
|
+
const RESERVED_FILES = new Set(['404.html', '500.html', 'middleware.html']);
|
|
25
|
+
|
|
26
|
+
function scanPages(root) {
|
|
27
|
+
const pagesDir = existsSync(join(root, 'pages')) ? join(root, 'pages') : root;
|
|
28
|
+
const pages = [];
|
|
29
|
+
(function scan(dir, prefix) {
|
|
30
|
+
if (!existsSync(dir)) return;
|
|
31
|
+
for (const f of readdirSync(dir)) {
|
|
32
|
+
if (f.startsWith('.')) continue;
|
|
33
|
+
const full = join(dir, f);
|
|
34
|
+
const st = statSync(full);
|
|
35
|
+
if (st.isDirectory()) {
|
|
36
|
+
if (pagesDir === root && RESERVED_ROOT_DIRS.has(f)) continue;
|
|
37
|
+
scan(full, prefix + f + '/');
|
|
38
|
+
} else if (f.endsWith('.html') && !(prefix === '' && RESERVED_FILES.has(f))) {
|
|
39
|
+
const key = prefix + f.slice(0, -5); // blog/[slug]
|
|
40
|
+
const route = key === 'index' ? '/' : '/' + key.replace(/\/index$/, '');
|
|
41
|
+
pages.push({ key, file: full, route, segs: route.split('/').filter(Boolean) });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})(pagesDir, '');
|
|
45
|
+
// Static routes match before dynamic ones.
|
|
46
|
+
pages.sort((a, b) => a.segs.filter((s) => s.startsWith('[')).length - b.segs.filter((s) => s.startsWith('[')).length);
|
|
47
|
+
return { pagesDir, pages };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function matchPage(pages, pathname) {
|
|
51
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
52
|
+
outer: for (const p of pages) {
|
|
53
|
+
if (p.segs.length !== parts.length) continue;
|
|
54
|
+
const params = {};
|
|
55
|
+
for (let i = 0; i < parts.length; i++) {
|
|
56
|
+
const dm = p.segs[i].match(/^\[(\w+)\]$/);
|
|
57
|
+
if (dm) params[dm[1]] = decodeURIComponent(parts[i]);
|
|
58
|
+
else if (p.segs[i] !== parts[i]) continue outer;
|
|
59
|
+
}
|
|
60
|
+
return { page: p, params };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Split the page's <script> (the server-side escape hatch) from its markup.
|
|
66
|
+
function splitScript(html) {
|
|
67
|
+
let code = '';
|
|
68
|
+
const out = String(html).replace(/<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => {
|
|
69
|
+
code += body + '\n';
|
|
70
|
+
return '';
|
|
71
|
+
});
|
|
72
|
+
return { html: out, code: code.trim() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Parsed-page cache, invalidated by mtime.
|
|
76
|
+
function pageData(page, cache) {
|
|
77
|
+
const mtime = statSync(page.file).mtimeMs;
|
|
78
|
+
const hit = cache.get(page.file);
|
|
79
|
+
if (hit && hit.mtime === mtime) return hit;
|
|
80
|
+
const source = readFileSync(page.file, 'utf8');
|
|
81
|
+
const { blocks, html } = extractBlocks(source);
|
|
82
|
+
const { html: markup, code } = splitScript(html);
|
|
83
|
+
const analysis = analyze(markup);
|
|
84
|
+
analysis.hasScript = !!code;
|
|
85
|
+
const plan = dataPlan(analysis, blocks);
|
|
86
|
+
const data = { mtime, source, blocks, html: markup, code, analysis, plan };
|
|
87
|
+
cache.set(page.file, data);
|
|
88
|
+
return data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── sessions ───────────────────────────────────────────────────────────
|
|
92
|
+
const b64 = (buf) => Buffer.from(buf).toString('base64url');
|
|
93
|
+
function signSession(payload, secret) {
|
|
94
|
+
const data = b64(JSON.stringify(payload));
|
|
95
|
+
const mac = createHmac('sha256', secret).update(data).digest('base64url');
|
|
96
|
+
return data + '.' + mac;
|
|
97
|
+
}
|
|
98
|
+
function readSession(cookieHeader, secret) {
|
|
99
|
+
const jar = {};
|
|
100
|
+
for (const part of String(cookieHeader || '').split(/;\s*/)) {
|
|
101
|
+
const i = part.indexOf('=');
|
|
102
|
+
if (i > 0) jar[part.slice(0, i).trim()] = part.slice(i + 1);
|
|
103
|
+
}
|
|
104
|
+
const raw = jar.spark_session;
|
|
105
|
+
if (!raw) return null;
|
|
106
|
+
const [data, mac] = raw.split('.');
|
|
107
|
+
if (!data || !mac) return null;
|
|
108
|
+
const expect = createHmac('sha256', secret).update(data).digest('base64url');
|
|
109
|
+
try {
|
|
110
|
+
if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expect))) return null;
|
|
111
|
+
return JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
|
|
112
|
+
} catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
const SESSION_COOKIE = (value, clear = false) =>
|
|
115
|
+
`spark_session=${clear ? '' : value}; Path=/; HttpOnly; SameSite=Lax${clear ? '; Max-Age=0' : ''}`;
|
|
116
|
+
|
|
117
|
+
// ── serve ──────────────────────────────────────────────────────────────
|
|
118
|
+
export async function serve(options = {}) {
|
|
119
|
+
const root = resolve(options.root || process.cwd());
|
|
120
|
+
const config = { ...loadConfig(root), ...(options.config || {}) };
|
|
121
|
+
const db = await connect(config.db);
|
|
122
|
+
const secret = (config.auth && config.auth.secret) || randomBytes(32).toString('hex');
|
|
123
|
+
const { pagesDir, pages } = scanPages(root);
|
|
124
|
+
const cache = new Map();
|
|
125
|
+
const uploadsDir = join(root, config.uploads);
|
|
126
|
+
const quiet = !!options.quiet;
|
|
127
|
+
|
|
128
|
+
const ctx = { root, config, db, secret, pagesDir, pages, cache, uploadsDir, port: 0 };
|
|
129
|
+
|
|
130
|
+
// ── request wrapper ──
|
|
131
|
+
function wrapReq(request, url, params, session, server) {
|
|
132
|
+
const headers = {};
|
|
133
|
+
for (const [k, v] of request.headers) headers[k.toLowerCase()] = v;
|
|
134
|
+
let bodyMemo = null;
|
|
135
|
+
const req = {
|
|
136
|
+
raw: request,
|
|
137
|
+
method: request.method,
|
|
138
|
+
url: url.href,
|
|
139
|
+
path: url.pathname,
|
|
140
|
+
params,
|
|
141
|
+
query: Object.fromEntries(url.searchParams),
|
|
142
|
+
headers,
|
|
143
|
+
session,
|
|
144
|
+
ip: server?.requestIP?.(request)?.address || headers['x-forwarded-for'] || '',
|
|
145
|
+
json: () => request.json(),
|
|
146
|
+
text: () => request.text(),
|
|
147
|
+
formData: () => request.formData(),
|
|
148
|
+
body() {
|
|
149
|
+
if (!bodyMemo) bodyMemo = parseBody(request);
|
|
150
|
+
return bodyMemo;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
return req;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function parseBody(request) {
|
|
157
|
+
const ct = request.headers.get('content-type') || '';
|
|
158
|
+
try {
|
|
159
|
+
if (ct.includes('application/json')) {
|
|
160
|
+
const fields = await request.json();
|
|
161
|
+
return { fields: fields && typeof fields === 'object' ? fields : {}, file: null };
|
|
162
|
+
}
|
|
163
|
+
if (ct.includes('multipart/form-data') || ct.includes('application/x-www-form-urlencoded')) {
|
|
164
|
+
const fd = await request.formData();
|
|
165
|
+
const fields = {};
|
|
166
|
+
let file = null;
|
|
167
|
+
for (const [k, v] of fd.entries()) {
|
|
168
|
+
if (v && typeof v === 'object' && typeof v.arrayBuffer === 'function') {
|
|
169
|
+
const ext = ((v.name || '').match(/\.\w+$/) || [''])[0];
|
|
170
|
+
const name = randomUUID() + ext;
|
|
171
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
172
|
+
await Bun.write(join(uploadsDir, name), v);
|
|
173
|
+
file = { url: '/uploads/' + name, name: v.name || name, size: v.size, type: v.type };
|
|
174
|
+
fields[k] = file.url;
|
|
175
|
+
} else {
|
|
176
|
+
fields[k] = v;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { fields, file };
|
|
180
|
+
}
|
|
181
|
+
} catch { /* malformed body → empty */ }
|
|
182
|
+
return { fields: {}, file: null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── param injection: resolve one :token from the request ──
|
|
186
|
+
async function resolveToken(tok, req) {
|
|
187
|
+
if (tok.startsWith('body.')) return dig((await req.body()).fields, tok.slice(5)) ?? null;
|
|
188
|
+
if (tok.startsWith('session.')) return dig(req.session || {}, tok.slice(8)) ?? null;
|
|
189
|
+
if (tok.startsWith('header.')) return req.headers[tok.slice(7).toLowerCase()] ?? null;
|
|
190
|
+
if (tok.startsWith('file.')) return dig((await req.body()).file || {}, tok.slice(5)) ?? null;
|
|
191
|
+
if (req.params[tok] !== undefined) return req.params[tok];
|
|
192
|
+
if (req.query[tok] !== undefined) return req.query[tok];
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function runSql(sqlText, req) {
|
|
197
|
+
const { sql, tokens } = rewriteParams(sqlText);
|
|
198
|
+
const values = [];
|
|
199
|
+
for (const t of tokens) values.push(await resolveToken(t, req));
|
|
200
|
+
return db.query(sql, values);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── auto-CRUD for <spark-ssr table="…"> ──
|
|
204
|
+
const apiRoutes = []; // { method, segs: ['api','todos',':id'], handler }
|
|
205
|
+
const on = (method, path, handler) =>
|
|
206
|
+
apiRoutes.push({ method, segs: path.split('/').filter(Boolean), handler });
|
|
207
|
+
|
|
208
|
+
async function tableInfo(table) {
|
|
209
|
+
const cols = await db.columns(table);
|
|
210
|
+
const names = cols.map((c) => c.name);
|
|
211
|
+
const scoped = !!config.auth && names.includes('user_id') && config.auth.table !== table;
|
|
212
|
+
return { cols, names, scoped };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function tableRows(table, req) {
|
|
216
|
+
const { scoped } = await tableInfo(table);
|
|
217
|
+
if (scoped) {
|
|
218
|
+
if (!req.session) return [];
|
|
219
|
+
return db.query(`SELECT * FROM ${table} WHERE user_id = ?`, [req.session.id]);
|
|
220
|
+
}
|
|
221
|
+
return db.query(`SELECT * FROM ${table}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function registerTable(table) {
|
|
225
|
+
const isAuthTable = config.auth && config.auth.table === table;
|
|
226
|
+
|
|
227
|
+
on('GET', `api/${table}`, async (req) => {
|
|
228
|
+
const { scoped } = await tableInfo(table);
|
|
229
|
+
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
230
|
+
return json(await tableRows(table, req));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
on('POST', `api/${table}`, async (req) => {
|
|
234
|
+
if (isAuthTable && 'auth' in req.query) return login(req);
|
|
235
|
+
const { names, scoped } = await tableInfo(table);
|
|
236
|
+
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
237
|
+
const { fields } = await req.body();
|
|
238
|
+
const data = {};
|
|
239
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
240
|
+
if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
|
|
241
|
+
}
|
|
242
|
+
// Passwords never land in the auth table as plaintext.
|
|
243
|
+
if (isAuthTable && typeof data.password === 'string') {
|
|
244
|
+
data.password = await Bun.password.hash(data.password);
|
|
245
|
+
}
|
|
246
|
+
if (scoped) data.user_id = req.session.id;
|
|
247
|
+
const keys = Object.keys(data);
|
|
248
|
+
if (!keys.length) return json({ error: 'empty body' }, 400);
|
|
249
|
+
const rows = await db.query(
|
|
250
|
+
`INSERT INTO ${table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')}) RETURNING *`,
|
|
251
|
+
keys.map((k) => data[k]),
|
|
252
|
+
);
|
|
253
|
+
const row = rows[0] ?? { ok: true };
|
|
254
|
+
if (isAuthTable && row.password) delete row.password;
|
|
255
|
+
return json(row, 201);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
on('PATCH', `api/${table}/:id`, async (req) => {
|
|
259
|
+
const { names, scoped } = await tableInfo(table);
|
|
260
|
+
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
261
|
+
const { fields } = await req.body();
|
|
262
|
+
const data = {};
|
|
263
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
264
|
+
if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
|
|
265
|
+
}
|
|
266
|
+
const keys = Object.keys(data);
|
|
267
|
+
if (!keys.length) return json({ error: 'empty body' }, 400);
|
|
268
|
+
let sql = `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`;
|
|
269
|
+
const values = [...keys.map((k) => data[k]), req.params.id];
|
|
270
|
+
if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
|
|
271
|
+
const rows = await db.query(sql + ' RETURNING *', values);
|
|
272
|
+
return rows[0] ? json(rows[0]) : json({ error: 'not found' }, 404);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
on('DELETE', `api/${table}/:id`, async (req) => {
|
|
276
|
+
const { scoped } = await tableInfo(table);
|
|
277
|
+
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
278
|
+
let sql = `DELETE FROM ${table} WHERE id = ?`;
|
|
279
|
+
const values = [req.params.id];
|
|
280
|
+
if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
|
|
281
|
+
const rows = await db.query(sql + ' RETURNING *', values);
|
|
282
|
+
return rows[0] ? json({ ok: true }) : json({ error: 'not found' }, 404);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── auth ──
|
|
287
|
+
async function login(req) {
|
|
288
|
+
const { auth } = config;
|
|
289
|
+
const identity = auth.identity || 'email';
|
|
290
|
+
const { fields } = await req.body();
|
|
291
|
+
const rows = await db.query(`SELECT * FROM ${auth.table} WHERE ${identity} = ?`, [fields[identity] ?? null]);
|
|
292
|
+
const user = rows[0];
|
|
293
|
+
const supplied = String(fields.password ?? '');
|
|
294
|
+
const stored = user ? String(user.password ?? '') : '';
|
|
295
|
+
const ok = user && (stored.startsWith('$')
|
|
296
|
+
? await Bun.password.verify(supplied, stored).catch(() => false)
|
|
297
|
+
: stored !== '' && stored === supplied);
|
|
298
|
+
if (!ok) return json({ error: 'invalid credentials' }, 401);
|
|
299
|
+
const session = { id: user.id, [identity]: user[identity] };
|
|
300
|
+
const safe = { ...user };
|
|
301
|
+
delete safe.password;
|
|
302
|
+
return json(safe, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let authPlugin = null;
|
|
306
|
+
if (config.auth && config.auth.plugin) {
|
|
307
|
+
authPlugin = (await import(resolve(root, config.auth.plugin))).default;
|
|
308
|
+
on('POST', 'api/auth', async (req) => {
|
|
309
|
+
const user = await authPlugin.login(req);
|
|
310
|
+
if (!user) return json({ error: 'invalid credentials' }, 401);
|
|
311
|
+
const session = { id: user.id, email: user.email, name: user.name };
|
|
312
|
+
return json(user, 200, { 'set-cookie': SESSION_COOKIE(signSession(session, secret)) });
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (config.auth) {
|
|
316
|
+
on('POST', 'api/logout', async () => json({ ok: true }, 200, { 'set-cookie': SESSION_COOKIE('', true) }));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── explicit <spark-ssr> query endpoints ──
|
|
320
|
+
const registered = new Set();
|
|
321
|
+
function registerQuery(route) {
|
|
322
|
+
const key = route.method + ' ' + route.path;
|
|
323
|
+
if (registered.has(key)) return;
|
|
324
|
+
registered.add(key);
|
|
325
|
+
const segs = route.path.split('/').filter(Boolean)
|
|
326
|
+
.map((s) => s.replace(/^\[(\w+)\]$/, ':$1'));
|
|
327
|
+
apiRoutes.push({
|
|
328
|
+
method: route.method,
|
|
329
|
+
segs,
|
|
330
|
+
handler: async (req) => {
|
|
331
|
+
const rows = await runSql(route.sql, req);
|
|
332
|
+
if (route.method === 'GET') return json(singleShaped(route.sql) ? rows[0] ?? null : [...rows]);
|
|
333
|
+
if (Array.isArray(rows) && rows.length) return json(rows.length === 1 ? rows[0] : [...rows]);
|
|
334
|
+
return json({ ok: true, changes: rows.changes ?? 0 });
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Register everything the pages declare.
|
|
340
|
+
const tables = new Set();
|
|
341
|
+
for (const page of pages) {
|
|
342
|
+
const pd = pageData(page, cache);
|
|
343
|
+
for (const b of pd.blocks) {
|
|
344
|
+
if (b.table && !tables.has(b.table)) { tables.add(b.table); registerTable(b.table); }
|
|
345
|
+
for (const r of b.routes) {
|
|
346
|
+
if (r.path) registerQuery(r);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── api/ folder — custom endpoints ──
|
|
352
|
+
function makeAppFetch(req) {
|
|
353
|
+
return (input, init = {}) => {
|
|
354
|
+
let url = String(input);
|
|
355
|
+
if (url.startsWith('/')) url = `http://localhost:${ctx.port}${url}`;
|
|
356
|
+
init = { ...init };
|
|
357
|
+
const b = init.body;
|
|
358
|
+
const isPlainObject = b && typeof b === 'object'
|
|
359
|
+
&& !(b instanceof FormData) && !(b instanceof URLSearchParams)
|
|
360
|
+
&& !(b instanceof ArrayBuffer) && typeof b.arrayBuffer !== 'function'
|
|
361
|
+
&& typeof b.getReader !== 'function';
|
|
362
|
+
if (isPlainObject) {
|
|
363
|
+
init.body = JSON.stringify(b);
|
|
364
|
+
init.headers = { 'content-type': 'application/json', ...(init.headers || {}) };
|
|
365
|
+
}
|
|
366
|
+
if (req && req.headers.cookie) init.headers = { cookie: req.headers.cookie, ...(init.headers || {}) };
|
|
367
|
+
return fetch(url, init);
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const apiDir = join(root, 'api');
|
|
372
|
+
if (existsSync(apiDir)) {
|
|
373
|
+
(function scanApi(dir, prefix) {
|
|
374
|
+
for (const f of readdirSync(dir)) {
|
|
375
|
+
if (f.startsWith('.')) continue;
|
|
376
|
+
const full = join(dir, f);
|
|
377
|
+
if (statSync(full).isDirectory()) { scanApi(full, prefix + f + '/'); continue; }
|
|
378
|
+
if (!f.endsWith('.html')) continue;
|
|
379
|
+
const route = '/api/' + prefix + f.slice(0, -5);
|
|
380
|
+
const source = readFileSync(full, 'utf8');
|
|
381
|
+
const { blocks, html } = extractBlocks(source);
|
|
382
|
+
const { code } = splitScript(html);
|
|
383
|
+
for (const b of blocks) {
|
|
384
|
+
for (const r of b.routes) registerQuery({ ...r, path: r.path || route });
|
|
385
|
+
}
|
|
386
|
+
if (code) {
|
|
387
|
+
const fn = new AsyncFunction('req', 'res', 'db', 'fetch', code);
|
|
388
|
+
const segs = route.split('/').filter(Boolean);
|
|
389
|
+
for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) {
|
|
390
|
+
apiRoutes.push({
|
|
391
|
+
method,
|
|
392
|
+
segs,
|
|
393
|
+
handler: async (req, res) => {
|
|
394
|
+
const out = await fn(req, res, db, makeAppFetch(req));
|
|
395
|
+
if (out instanceof Response) return out;
|
|
396
|
+
if (out && typeof out === 'object' && 'status' in out && 'body' in out) {
|
|
397
|
+
return new Response(typeof out.body === 'string' ? out.body : JSON.stringify(out.body), { status: out.status });
|
|
398
|
+
}
|
|
399
|
+
return json(out ?? { ok: true });
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
})(apiDir, '');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function matchApi(method, pathname) {
|
|
409
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
410
|
+
outer: for (const r of apiRoutes) {
|
|
411
|
+
if (r.method !== method || r.segs.length !== parts.length) continue;
|
|
412
|
+
const params = {};
|
|
413
|
+
for (let i = 0; i < parts.length; i++) {
|
|
414
|
+
if (r.segs[i].startsWith(':')) params[r.segs[i].slice(1)] = decodeURIComponent(parts[i]);
|
|
415
|
+
else if (r.segs[i] !== parts[i]) continue outer;
|
|
416
|
+
}
|
|
417
|
+
return { route: r, params };
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── middleware.html ──
|
|
423
|
+
let middleware = null;
|
|
424
|
+
const mwState = { rateLimit: new Map(), state: {} };
|
|
425
|
+
const mwFile = join(root, 'middleware.html');
|
|
426
|
+
if (existsSync(mwFile)) {
|
|
427
|
+
const { code } = splitScript(readFileSync(mwFile, 'utf8'));
|
|
428
|
+
if (code) middleware = new AsyncFunction('req', 'res', 'rateLimit', 'state', 'fetch', code);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── CORS ──
|
|
432
|
+
function corsHeaders(origin) {
|
|
433
|
+
if (!config.cors) return null;
|
|
434
|
+
const allowed = config.cors === true ? '*'
|
|
435
|
+
: Array.isArray(config.cors) && origin && config.cors.includes(origin) ? origin : null;
|
|
436
|
+
if (!allowed) return null;
|
|
437
|
+
return {
|
|
438
|
+
'access-control-allow-origin': allowed,
|
|
439
|
+
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
440
|
+
'access-control-allow-headers': 'content-type, authorization',
|
|
441
|
+
...(allowed !== '*' ? { vary: 'origin' } : {}),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── components + static ──
|
|
446
|
+
async function loadComponent(spec) {
|
|
447
|
+
let rel = String(spec).split(/[?#]/)[0].replace(/^\/+/, '');
|
|
448
|
+
if (!rel.endsWith('.html')) rel += '.html';
|
|
449
|
+
for (const base of [root, join(root, 'public'), pagesDir]) {
|
|
450
|
+
const file = resolve(base, rel);
|
|
451
|
+
if (file.startsWith(base) && existsSync(file) && statSync(file).isFile()) {
|
|
452
|
+
return readFileSync(file, 'utf8');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function staticFile(pathname) {
|
|
459
|
+
const rel = pathname.replace(/^\/+/, '');
|
|
460
|
+
if (!rel || rel.includes('..')) return null;
|
|
461
|
+
const candidates = [join(root, 'public', rel)];
|
|
462
|
+
const ext = extname(rel);
|
|
463
|
+
if (ext && ext !== '.html') {
|
|
464
|
+
candidates.push(join(root, rel), join(pagesDir, rel));
|
|
465
|
+
} else if (rel.startsWith('components/')) {
|
|
466
|
+
candidates.push(join(root, rel));
|
|
467
|
+
}
|
|
468
|
+
for (const file of candidates) {
|
|
469
|
+
const abs = resolve(file);
|
|
470
|
+
if (!abs.startsWith(root)) continue;
|
|
471
|
+
if (existsSync(abs) && statSync(abs).isFile()) return Bun.file(abs);
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── page rendering ──
|
|
477
|
+
const namesOf = (code) =>
|
|
478
|
+
[...String(code).matchAll(/^\s*(?:let|const|var)\s+([a-zA-Z_$][\w$]*)/gm)].map((m) => m[1]);
|
|
479
|
+
|
|
480
|
+
async function runPageScript(code, req) {
|
|
481
|
+
const names = namesOf(code);
|
|
482
|
+
const fn = new AsyncFunction('req', 'db', 'fetch', code + '\n;return { ' + names.join(', ') + ' };');
|
|
483
|
+
try { return await fn(req, db, makeAppFetch(req)); }
|
|
484
|
+
catch (e) {
|
|
485
|
+
if (!quiet) console.warn(`[spark-ssr] page <script> threw: ${e.message}`);
|
|
486
|
+
return {};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function shouldHydrate(pd) {
|
|
491
|
+
return pd.analysis.interactive && !pd.analysis.hasScript
|
|
492
|
+
&& pd.blocks.some((b) => b.table) && !!db;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function shell(page, body, { hydrate }) {
|
|
496
|
+
const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
|
|
497
|
+
const cssRel = page.key + '.css';
|
|
498
|
+
const hasCss = existsSync(join(pagesDir, cssRel));
|
|
499
|
+
const head =
|
|
500
|
+
'<meta charset="utf-8">\n' +
|
|
501
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
|
502
|
+
`<title>${title}</title>\n` +
|
|
503
|
+
(hasCss ? `<link rel="stylesheet" href="/${cssRel}">\n` : '');
|
|
504
|
+
const hydration = hydrate
|
|
505
|
+
? `\n<script type="importmap">{"imports":{"spark-html":"/@modules/spark-html"}}</script>\n` +
|
|
506
|
+
`<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
|
|
507
|
+
: '\n';
|
|
508
|
+
const host = hydrate
|
|
509
|
+
? `<div import="/__spark/page/${page.key}" data-spark-ssr>${body}</div>`
|
|
510
|
+
: `<div data-spark-ssr>${body}</div>`;
|
|
511
|
+
return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}${hydration}</body>\n</html>\n`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function buildScope(pd, req) {
|
|
515
|
+
const scope = { ...req.query, ...req.params, session: req.session };
|
|
516
|
+
if (pd.code) Object.assign(scope, await runPageScript(pd.code, req));
|
|
517
|
+
for (const p of pd.plan) {
|
|
518
|
+
if (scope[p.var] !== undefined) continue; // the page <script> won
|
|
519
|
+
if (p.source.kind === 'table') {
|
|
520
|
+
scope[p.var] = await tableRows(p.source.table, req);
|
|
521
|
+
} else {
|
|
522
|
+
const rows = await runSql(p.source.route.sql, req);
|
|
523
|
+
scope[p.var] = p.shape === 'list' ? [...rows] : rows[0] ?? null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return scope;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function servePage(page, req) {
|
|
530
|
+
const pd = pageData(page, cache);
|
|
531
|
+
const scope = await buildScope(pd, req);
|
|
532
|
+
const body = await renderFragment(pd.html, scope, { loadComponent });
|
|
533
|
+
return new Response(shell(page, body, { hydrate: shouldHydrate(pd) }), {
|
|
534
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function errorPage(status) {
|
|
539
|
+
const file = join(root, `${status}.html`);
|
|
540
|
+
if (existsSync(file)) {
|
|
541
|
+
return new Response(readFileSync(file, 'utf8'), { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
542
|
+
}
|
|
543
|
+
return new Response(status === 404 ? 'Not found' : 'Server error', { status });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// spark-html runtime, served for hydration (importmap target).
|
|
547
|
+
let runtimeJs = null;
|
|
548
|
+
function runtimeFile() {
|
|
549
|
+
if (runtimeJs) return runtimeJs;
|
|
550
|
+
for (const dir of [root, dirname(new URL(import.meta.url).pathname)]) {
|
|
551
|
+
try {
|
|
552
|
+
runtimeJs = readFileSync(Bun.resolveSync('spark-html', dir), 'utf8');
|
|
553
|
+
return runtimeJs;
|
|
554
|
+
} catch { /* next */ }
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── the server ──
|
|
560
|
+
const server = Bun.serve({
|
|
561
|
+
port: options.port ?? 3000,
|
|
562
|
+
async fetch(request, srv) {
|
|
563
|
+
const url = new URL(request.url);
|
|
564
|
+
let pathname;
|
|
565
|
+
try { pathname = decodeURIComponent(url.pathname); } catch { pathname = url.pathname; }
|
|
566
|
+
if (pathname.includes('..')) return errorPage(404);
|
|
567
|
+
const session = readSession(request.headers.get('cookie'), secret);
|
|
568
|
+
const extraHeaders = {};
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
// middleware.html runs first, on every request.
|
|
572
|
+
if (middleware) {
|
|
573
|
+
const req = wrapReq(request, url, {}, session, srv);
|
|
574
|
+
const res = { headers: {}, status: null };
|
|
575
|
+
const out = await middleware(req, res, mwState.rateLimit, mwState.state, makeAppFetch(req));
|
|
576
|
+
Object.assign(extraHeaders, res.headers);
|
|
577
|
+
if (out && typeof out === 'object' && out.status) {
|
|
578
|
+
return new Response(typeof out.body === 'string' ? out.body : JSON.stringify(out.body ?? ''), {
|
|
579
|
+
status: out.status, headers: extraHeaders,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const finish = (res) => {
|
|
584
|
+
for (const [k, v] of Object.entries(extraHeaders)) res.headers.set(k, v);
|
|
585
|
+
return res;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
if (pathname === '/@modules/spark-html') {
|
|
589
|
+
const js = runtimeFile();
|
|
590
|
+
return finish(js
|
|
591
|
+
? new Response(js, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-cache' } })
|
|
592
|
+
: errorPage(404));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (pathname.startsWith('/__spark/page/')) {
|
|
596
|
+
const key = pathname.slice('/__spark/page/'.length).replace(/\.html$/, '');
|
|
597
|
+
const page = pages.find((p) => p.key === key);
|
|
598
|
+
if (!page) return finish(errorPage(404));
|
|
599
|
+
const pd = pageData(page, cache);
|
|
600
|
+
const table = (pd.blocks.find((b) => b.table) || {}).table || null;
|
|
601
|
+
const cols = table ? await db.columns(table) : [];
|
|
602
|
+
const html = clientComponent({ html: pd.html, analysis: pd.analysis, plan: pd.plan, table, cols, key });
|
|
603
|
+
return finish(new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-cache' } }));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (pathname.startsWith('/__spark/data/')) {
|
|
607
|
+
const key = pathname.slice('/__spark/data/'.length).replace(/\.js$/, '');
|
|
608
|
+
const page = pages.find((p) => p.key === key);
|
|
609
|
+
if (!page) return finish(errorPage(404));
|
|
610
|
+
const pd = pageData(page, cache);
|
|
611
|
+
const req = wrapReq(request, url, {}, session, srv);
|
|
612
|
+
const data = {};
|
|
613
|
+
for (const p of pd.plan) {
|
|
614
|
+
if (p.source.kind === 'table') data[p.var] = await tableRows(p.source.table, req);
|
|
615
|
+
else {
|
|
616
|
+
const rows = await runSql(p.source.route.sql, req);
|
|
617
|
+
data[p.var] = p.shape === 'list' ? [...rows] : rows[0] ?? null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return finish(new Response(initModule(data), {
|
|
621
|
+
headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' },
|
|
622
|
+
}));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (pathname.startsWith('/uploads/')) {
|
|
626
|
+
const abs = resolve(join(uploadsDir, pathname.slice('/uploads/'.length)));
|
|
627
|
+
if (abs.startsWith(uploadsDir) && existsSync(abs) && statSync(abs).isFile()) {
|
|
628
|
+
return finish(new Response(Bun.file(abs)));
|
|
629
|
+
}
|
|
630
|
+
return finish(errorPage(404));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (pathname.startsWith('/api/')) {
|
|
634
|
+
const cors = corsHeaders(request.headers.get('origin'));
|
|
635
|
+
if (request.method === 'OPTIONS') {
|
|
636
|
+
return new Response(null, { status: 204, headers: { ...(cors || {}), ...extraHeaders } });
|
|
637
|
+
}
|
|
638
|
+
const hit = matchApi(request.method, pathname);
|
|
639
|
+
if (!hit) return finish(json({ error: 'not found' }, 404, cors || {}));
|
|
640
|
+
const req = wrapReq(request, url, hit.params, session, srv);
|
|
641
|
+
const res = await hit.route.handler(req, { headers: {} });
|
|
642
|
+
if (cors) for (const [k, v] of Object.entries(cors)) res.headers.set(k, v);
|
|
643
|
+
return finish(res);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const file = staticFile(pathname);
|
|
647
|
+
if (file) return finish(new Response(file));
|
|
648
|
+
|
|
649
|
+
const hit = matchPage(pages, pathname);
|
|
650
|
+
if (hit) {
|
|
651
|
+
const req = wrapReq(request, url, hit.params, session, srv);
|
|
652
|
+
return finish(await servePage(hit.page, req));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return finish(errorPage(404));
|
|
656
|
+
} catch (e) {
|
|
657
|
+
if (!quiet) console.error(`[spark-ssr] ${request.method} ${pathname} — ${e.stack || e.message}`);
|
|
658
|
+
const res = errorPage(500);
|
|
659
|
+
for (const [k, v] of Object.entries(extraHeaders)) res.headers.set(k, v);
|
|
660
|
+
return res;
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
ctx.port = server.port;
|
|
666
|
+
if (!quiet) console.log(`⚡ spark-ssr serving ${root} on http://localhost:${server.port}`);
|
|
667
|
+
return {
|
|
668
|
+
port: server.port,
|
|
669
|
+
root,
|
|
670
|
+
config,
|
|
671
|
+
db,
|
|
672
|
+
stop(force) { server.stop(force); return db && db.close(); },
|
|
673
|
+
};
|
|
674
|
+
}
|