spark-ssr 0.1.1 → 0.2.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 +50 -4
- package/bin/cli.js +20 -4
- package/package.json +3 -2
- package/src/config.js +5 -0
- package/src/parse.js +16 -2
- package/src/render.js +5 -2
- package/src/server.js +228 -33
package/README.md
CHANGED
|
@@ -47,13 +47,16 @@ No `<script>`. No SQL. No ORM. No server file. No build step.
|
|
|
47
47
|
{
|
|
48
48
|
"db": "postgres://localhost:5432/myapp",
|
|
49
49
|
"auth": { "table": "users", "identity": "email", "secret": "ENV.SESSION_SECRET" },
|
|
50
|
-
"cors": true
|
|
50
|
+
"cors": true,
|
|
51
|
+
"fonts": [{ "family": "Inter", "google": true, "weights": [400, 700] }]
|
|
51
52
|
}
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
`sqlite://./dev.db` works too (Bun ships both drivers). `ENV.*` values resolve
|
|
55
56
|
from the environment at startup. `cors: true` allows all origins on `/api/*`;
|
|
56
|
-
an array allows specific ones.
|
|
57
|
+
an array allows specific ones. `fonts` renders spark-html-font's head tags
|
|
58
|
+
(preloads, `@font-face` with a size-adjusted no-shift fallback, a
|
|
59
|
+
`--font-<slug>` var) into every page — same shapes as its build-pipeline step.
|
|
57
60
|
|
|
58
61
|
## Routing
|
|
59
62
|
|
|
@@ -79,6 +82,41 @@ Without a `pages/` folder, `*.html` at the project root serve the same way.
|
|
|
79
82
|
JSON body (`:body.title`), session (`:session.id`), headers
|
|
80
83
|
(`:header.x-forwarded-for`), uploads (`:file.url`).
|
|
81
84
|
|
|
85
|
+
## The page owns its \<head\>
|
|
86
|
+
|
|
87
|
+
Literal `<title>`/`<meta>`/`<link>` tags at the top of a page lift into the
|
|
88
|
+
document head, `{expr}`-interpolated against the page's data
|
|
89
|
+
(spark-html-head's `/ssr` module does the lifting):
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<!-- pages/blog/[slug].html -->
|
|
93
|
+
<title>{post.title} · My Blog</title>
|
|
94
|
+
<meta name="description" content="{post.excerpt}">
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Client scripts and the family
|
|
98
|
+
|
|
99
|
+
A page's plain `<script>` runs on the **server** (the escape hatch).
|
|
100
|
+
`<script type="module">` and `<script src>` are **client** scripts — they lift
|
|
101
|
+
into `<head>` after an auto-generated importmap, so bare imports of the Spark
|
|
102
|
+
family just work, no build:
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<script type="module" src="/app.js"></script>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
// public/app.js
|
|
110
|
+
import { theme } from 'spark-html-theme';
|
|
111
|
+
theme();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Every `spark-html-*` package in your dependencies is importmap-mapped and
|
|
115
|
+
served at `/@modules/<name>/…`. Depend on **spark-html-theme** and the
|
|
116
|
+
no-flash init snippet is inlined in every head automatically; depend on
|
|
117
|
+
**spark-html-image** and `spark-ssr build` runs its webp/srcset pass over
|
|
118
|
+
`dist/` (options: `"images"` in spark.json).
|
|
119
|
+
|
|
82
120
|
## Custom endpoints — api/
|
|
83
121
|
|
|
84
122
|
`api/stats.html` auto-serves as `GET /api/stats`:
|
|
@@ -104,14 +142,22 @@ scope; the return value becomes the JSON response).
|
|
|
104
142
|
stored URL into your INSERT.
|
|
105
143
|
- **Error pages** — `404.html` / `500.html` at the project root.
|
|
106
144
|
- **Static assets** — `public/` plus co-located page assets, served as-is.
|
|
145
|
+
Project internals (spark.json, package.json, `*.db`, dotfiles) are never
|
|
146
|
+
served.
|
|
107
147
|
- **Hydration** — interactive pages ship fully-rendered HTML plus a generated
|
|
108
148
|
client component; `mount()` takes over with the same spark-html runtime.
|
|
149
|
+
- **Live reload** — in dev, every edit (pages, components, queries,
|
|
150
|
+
middleware, css) refreshes the browser via SSE. No restart, no flags.
|
|
151
|
+
- **Auth-table hygiene** — the auth table's auto CRUD never returns password
|
|
152
|
+
hashes, and PATCH/DELETE are own-account only. Configuring `auth` registers
|
|
153
|
+
the table (login/signup endpoints) without any page declaring it; disable
|
|
154
|
+
public signup in `middleware.html` if the app is invite-only.
|
|
109
155
|
|
|
110
156
|
## Deploy
|
|
111
157
|
|
|
112
158
|
```bash
|
|
113
|
-
bun spark-ssr build # dist/ with a compiled single binary
|
|
114
|
-
bun spark-ssr start # run in production
|
|
159
|
+
bun spark-ssr build # dist/ with a compiled single binary (public/ flattens into dist root)
|
|
160
|
+
bun spark-ssr start # run in production (watch + live reload off)
|
|
115
161
|
```
|
|
116
162
|
|
|
117
163
|
MIT
|
package/bin/cli.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { join, resolve } from 'node:path';
|
|
17
17
|
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
18
|
-
import { serve } from '../src/index.js';
|
|
18
|
+
import { serve, loadConfig } from '../src/index.js';
|
|
19
19
|
|
|
20
20
|
function parseArgs(argv) {
|
|
21
21
|
const opts = { cmd: 'serve', compile: true };
|
|
@@ -41,7 +41,10 @@ Usage:
|
|
|
41
41
|
|
|
42
42
|
// The project files a deployment needs — pages, components, api, public,
|
|
43
43
|
// error pages, middleware, config. node_modules/dist/uploads stay behind.
|
|
44
|
-
|
|
44
|
+
// public/ is FLATTENED into dist root: assets keep the same URLs they had in
|
|
45
|
+
// dev (/style.css, /img/…), and post-build passes (spark-html-image) resolve
|
|
46
|
+
// root-absolute <img> paths against dist directly.
|
|
47
|
+
const SHIP_DIRS = ['pages', 'components', 'api'];
|
|
45
48
|
const SHIP_FILES = ['404.html', '500.html', 'middleware.html', 'spark.json', 'package.json'];
|
|
46
49
|
|
|
47
50
|
async function build(root, compile) {
|
|
@@ -51,6 +54,11 @@ async function build(root, compile) {
|
|
|
51
54
|
for (const d of SHIP_DIRS) {
|
|
52
55
|
if (existsSync(join(root, d))) cpSync(join(root, d), join(dist, d), { recursive: true });
|
|
53
56
|
}
|
|
57
|
+
if (existsSync(join(root, 'public'))) {
|
|
58
|
+
for (const f of readdirSync(join(root, 'public'))) {
|
|
59
|
+
cpSync(join(root, 'public', f), join(dist, f), { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
54
62
|
for (const f of SHIP_FILES) {
|
|
55
63
|
if (existsSync(join(root, f))) cpSync(join(root, f), join(dist, f));
|
|
56
64
|
}
|
|
@@ -67,8 +75,16 @@ async function build(root, compile) {
|
|
|
67
75
|
}
|
|
68
76
|
writeFileSync(join(dist, '__server.js'),
|
|
69
77
|
"import { serve } from 'spark-ssr';\n" +
|
|
70
|
-
'serve({ root: process.cwd(), port: Number(process.env.PORT) || 3000 });\n');
|
|
78
|
+
'serve({ root: process.cwd(), port: Number(process.env.PORT) || 3000, watch: false });\n');
|
|
71
79
|
console.log(`✓ assembled dist/`);
|
|
80
|
+
// spark-html-image, when the app depends on it: the same pass a
|
|
81
|
+
// spark-html-bun pipeline runs — webp variants + srcset for every local
|
|
82
|
+
// <img> in the assembled pages and components. Options: spark.json "images".
|
|
83
|
+
try {
|
|
84
|
+
const image = (await import('spark-html-image')).default;
|
|
85
|
+
await image(loadConfig(root).images || {}).run({ outDir: dist });
|
|
86
|
+
console.log('✓ images optimized (spark-html-image)');
|
|
87
|
+
} catch { /* not installed — plain assets ship as-is */ }
|
|
72
88
|
if (compile) {
|
|
73
89
|
const r = Bun.spawnSync(
|
|
74
90
|
['bun', 'build', '--compile', join(dist, '__server.js'), '--outfile', join(dist, 'app')],
|
|
@@ -89,7 +105,7 @@ if (opts.cmd === 'build') {
|
|
|
89
105
|
await build(root, opts.compile);
|
|
90
106
|
} else if (opts.cmd === 'start') {
|
|
91
107
|
const dist = join(root, 'dist');
|
|
92
|
-
await serve({ root: existsSync(join(dist, '__server.js')) ? dist : root, port });
|
|
108
|
+
await serve({ root: existsSync(join(dist, '__server.js')) ? dist : root, port, watch: false });
|
|
93
109
|
} else {
|
|
94
110
|
await serve({ root, port });
|
|
95
111
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spark-ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Zero-config SSR for spark-html on Bun. The HTML template infers everything: filesystem routing, <spark-ssr> declarative queries, auto CRUD APIs, sessions, uploads, middleware. No build step.",
|
|
5
5
|
"homepage": "https://wilkinnovo.github.io/spark-html",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"linkedom": "^0.18.12",
|
|
33
|
-
"spark-html": "^0.27.
|
|
33
|
+
"spark-html": "^0.27.8",
|
|
34
|
+
"spark-html-head": "^0.3.0"
|
|
34
35
|
},
|
|
35
36
|
"keywords": [
|
|
36
37
|
"spark-html",
|
package/src/config.js
CHANGED
|
@@ -36,5 +36,10 @@ export function loadConfig(root) {
|
|
|
36
36
|
auth: cfg.auth || null,
|
|
37
37
|
cors: cfg.cors ?? false,
|
|
38
38
|
uploads: cfg.uploads || 'uploads',
|
|
39
|
+
// Companion-package config, same shapes their build-pipeline steps take:
|
|
40
|
+
// "fonts" → spark-html-font tags in every <head>; "images" → options for
|
|
41
|
+
// the spark-html-image pass `spark-ssr build` runs when it's installed.
|
|
42
|
+
fonts: cfg.fonts || null,
|
|
43
|
+
images: cfg.images || null,
|
|
39
44
|
};
|
|
40
45
|
}
|
package/src/parse.js
CHANGED
|
@@ -11,16 +11,30 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { parseHTML } from 'linkedom';
|
|
13
13
|
|
|
14
|
+
// ── comment masking ────────────────────────────────────────────────────
|
|
15
|
+
// Every regex-based extraction here must mask comments first, so prose like
|
|
16
|
+
// <!-- declare data in <spark-ssr>, no <script> needed --> never starts (or
|
|
17
|
+
// ends) an extraction. restore() puts the comments back verbatim.
|
|
18
|
+
export function maskComments(source) {
|
|
19
|
+
const comments = [];
|
|
20
|
+
const masked = String(source).replace(/<!--[\s\S]*?-->/g, (m) => {
|
|
21
|
+
comments.push(m);
|
|
22
|
+
return `\u0000c${comments.length - 1}\u0000`;
|
|
23
|
+
});
|
|
24
|
+
return { masked, restore: (s) => String(s).replace(/\u0000c(\d+)\u0000/g, (_, i) => comments[i]) };
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
// ── <spark-ssr> blocks ─────────────────────────────────────────────────
|
|
15
28
|
export function extractBlocks(source) {
|
|
29
|
+
const { masked, restore } = maskComments(source);
|
|
16
30
|
const blocks = [];
|
|
17
31
|
const re = /<spark-ssr\b([^>]*?)\/>|<spark-ssr\b([^>]*)>([\s\S]*?)<\/spark-ssr>/gi;
|
|
18
|
-
const html =
|
|
32
|
+
const html = restore(masked.replace(re, (m, selfAttrs, attrs, inner) => {
|
|
19
33
|
const attrStr = selfAttrs ?? attrs ?? '';
|
|
20
34
|
const table = (attrStr.match(/\btable\s*=\s*"([^"]+)"/) || [])[1] || null;
|
|
21
35
|
blocks.push({ table, routes: inner ? parseRoutes(inner) : [] });
|
|
22
36
|
return '';
|
|
23
|
-
});
|
|
37
|
+
}));
|
|
24
38
|
return { blocks, html };
|
|
25
39
|
}
|
|
26
40
|
|
package/src/render.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* re-attaches them client-side; static pages don't need them).
|
|
7
7
|
*/
|
|
8
8
|
import { parseHTML } from 'linkedom';
|
|
9
|
+
import { maskComments } from './parse.js';
|
|
9
10
|
|
|
10
11
|
const FN_CACHE = new Map();
|
|
11
12
|
function compile(expr) {
|
|
@@ -260,10 +261,12 @@ async function renderImport(node, scope, ctx, depth) {
|
|
|
260
261
|
if (source == null) { node.innerHTML = ''; return; }
|
|
261
262
|
// Components are pure UI on the server: strip <spark-ssr>/<script> from the
|
|
262
263
|
// output, but read literal script defaults so {count} renders as 0.
|
|
264
|
+
// Comments are masked so prose mentioning those tags never truncates one.
|
|
263
265
|
let script = '';
|
|
264
|
-
const
|
|
266
|
+
const { masked, restore } = maskComments(source);
|
|
267
|
+
const clean = restore(masked
|
|
265
268
|
.replace(/<spark-ssr\b[^>]*?\/>|<spark-ssr\b[^>]*>[\s\S]*?<\/spark-ssr>/gi, '')
|
|
266
|
-
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; });
|
|
269
|
+
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; }));
|
|
267
270
|
node.innerHTML = clean;
|
|
268
271
|
const compScope = Object.assign(Object.create(null), scriptLiterals(script), props);
|
|
269
272
|
await walkChildren(node, compScope, ctx, depth + 1);
|
package/src/server.js
CHANGED
|
@@ -10,9 +10,14 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync } from 'node
|
|
|
10
10
|
import { createHmac, timingSafeEqual, randomBytes, randomUUID } from 'node:crypto';
|
|
11
11
|
import { loadConfig } from './config.js';
|
|
12
12
|
import { connect } from './db.js';
|
|
13
|
-
import { extractBlocks, analyze, dataPlan, rewriteParams, singleShaped } from './parse.js';
|
|
14
|
-
import { renderFragment } from './render.js';
|
|
13
|
+
import { extractBlocks, analyze, dataPlan, rewriteParams, singleShaped, maskComments } from './parse.js';
|
|
14
|
+
import { renderFragment, evalExpr } from './render.js';
|
|
15
15
|
import { clientComponent, initModule } from './hydrate.js';
|
|
16
|
+
// Head semantics live in one place for the whole family: spark-html-head owns
|
|
17
|
+
// title/meta on the client (pushState updates); its /ssr module owns them
|
|
18
|
+
// here — pages put literal <title>/<meta>/<link> tags in their markup, we
|
|
19
|
+
// lift them into the document head with {expr} interpolated per request.
|
|
20
|
+
import { liftHead, renderHead } from 'spark-html-head/ssr';
|
|
16
21
|
|
|
17
22
|
const AsyncFunction = (async () => {}).constructor;
|
|
18
23
|
const json = (data, status = 200, headers = {}) =>
|
|
@@ -63,12 +68,18 @@ function matchPage(pages, pathname) {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
// Split the page's <script> (the server-side escape hatch) from its markup.
|
|
71
|
+
// Client scripts — <script src> and inline <script type="module"> — are NOT
|
|
72
|
+
// server code; they stay in the markup and liftHead sends them to the browser.
|
|
66
73
|
function splitScript(html) {
|
|
67
74
|
let code = '';
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
const { masked, restore } = maskComments(html);
|
|
76
|
+
const out = restore(masked.replace(
|
|
77
|
+
/<script\b(?![^>]*\bsrc=)(?![^>]*\btype\s*=\s*["']module["'])[^>]*>([\s\S]*?)<\/script>/gi,
|
|
78
|
+
(m, body) => {
|
|
79
|
+
code += body + '\n';
|
|
80
|
+
return '';
|
|
81
|
+
},
|
|
82
|
+
));
|
|
72
83
|
return { html: out, code: code.trim() };
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -80,10 +91,13 @@ function pageData(page, cache) {
|
|
|
80
91
|
const source = readFileSync(page.file, 'utf8');
|
|
81
92
|
const { blocks, html } = extractBlocks(source);
|
|
82
93
|
const { html: markup, code } = splitScript(html);
|
|
94
|
+
// Analyze BEFORE lifting the head, so a {var} used only in <title>/<meta>
|
|
95
|
+
// still registers as a data need.
|
|
83
96
|
const analysis = analyze(markup);
|
|
84
97
|
analysis.hasScript = !!code;
|
|
85
98
|
const plan = dataPlan(analysis, blocks);
|
|
86
|
-
const
|
|
99
|
+
const { head, scripts, body } = liftHead(markup);
|
|
100
|
+
const data = { mtime, source, blocks, html: body, head, scripts, code, analysis, plan };
|
|
87
101
|
cache.set(page.file, data);
|
|
88
102
|
return data;
|
|
89
103
|
}
|
|
@@ -128,6 +142,88 @@ export async function serve(options = {}) {
|
|
|
128
142
|
|
|
129
143
|
const ctx = { port: 0 };
|
|
130
144
|
|
|
145
|
+
// ── dev live reload ──
|
|
146
|
+
// The server side already re-reads files per request; this closes the loop
|
|
147
|
+
// on the browser side. A cheap mtime sweep (same walk refreshPages does)
|
|
148
|
+
// feeds an SSE channel, and every HTML response carries a two-line client
|
|
149
|
+
// that reloads the page on a ping. Production (`start` / dist) runs with
|
|
150
|
+
// watch:false and ships none of it.
|
|
151
|
+
const live = options.watch !== false;
|
|
152
|
+
const sseClients = new Set();
|
|
153
|
+
const sseEnc = new TextEncoder();
|
|
154
|
+
let watchTimer = null;
|
|
155
|
+
if (live) {
|
|
156
|
+
const IGNORE = new Set(['node_modules', 'dist', 'uploads']);
|
|
157
|
+
const mtimes = new Map();
|
|
158
|
+
const sweep = () => {
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
let changed = false;
|
|
161
|
+
(function walk(dir) {
|
|
162
|
+
let names;
|
|
163
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
164
|
+
for (const f of names) {
|
|
165
|
+
if (f.startsWith('.') || IGNORE.has(f)) continue;
|
|
166
|
+
const full = join(dir, f);
|
|
167
|
+
let st;
|
|
168
|
+
try { st = statSync(full); } catch { continue; }
|
|
169
|
+
if (st.isDirectory()) { walk(full); continue; }
|
|
170
|
+
if (!/\.(html|css|js|json)$/.test(f)) continue;
|
|
171
|
+
seen.add(full);
|
|
172
|
+
if (mtimes.get(full) !== st.mtimeMs) { mtimes.set(full, st.mtimeMs); changed = true; }
|
|
173
|
+
}
|
|
174
|
+
})(root);
|
|
175
|
+
for (const k of mtimes.keys()) if (!seen.has(k)) { mtimes.delete(k); changed = true; }
|
|
176
|
+
return changed;
|
|
177
|
+
};
|
|
178
|
+
sweep(); // baseline — the first pass records, it doesn't reload anyone
|
|
179
|
+
watchTimer = setInterval(() => {
|
|
180
|
+
if (!sweep()) return;
|
|
181
|
+
for (const c of sseClients) {
|
|
182
|
+
try { c.enqueue(sseEnc.encode('data: reload\n\n')); } catch { sseClients.delete(c); }
|
|
183
|
+
}
|
|
184
|
+
}, 250);
|
|
185
|
+
watchTimer.unref?.();
|
|
186
|
+
}
|
|
187
|
+
// Reconnect-then-reload: after a server restart the EventSource reconnects,
|
|
188
|
+
// and a fresh open following an error means "the server came back" — reload.
|
|
189
|
+
const RELOAD_CLIENT = '<script>(()=>{const e=new EventSource("/__spark/reload");let d=0;'
|
|
190
|
+
+ 'e.onmessage=()=>location.reload();e.onerror=()=>{d=1};e.onopen=()=>{if(d)location.reload()}})()</script>';
|
|
191
|
+
|
|
192
|
+
// ── the Spark family, wired in ──
|
|
193
|
+
// Companion packages the app depends on get an importmap entry and are
|
|
194
|
+
// served at /@modules/<name>, so client scripts import them bare — the same
|
|
195
|
+
// packages a spark-html-bun/prerender build uses, working here unbundled.
|
|
196
|
+
let familyDeps = [];
|
|
197
|
+
try {
|
|
198
|
+
const pj = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
199
|
+
familyDeps = Object.keys({ ...pj.dependencies, ...pj.devDependencies })
|
|
200
|
+
.filter((n) => /^spark-html-[\w-]+$/.test(n));
|
|
201
|
+
} catch { /* no package.json — single-file project */ }
|
|
202
|
+
|
|
203
|
+
// spark-html-theme: inline its no-flash snippet in every <head> (the same
|
|
204
|
+
// one spark-html-theme/bun bakes into prerendered pages) so the saved/OS
|
|
205
|
+
// theme is on <html> before first paint.
|
|
206
|
+
let themeInit = '';
|
|
207
|
+
if (familyDeps.includes('spark-html-theme')) {
|
|
208
|
+
try {
|
|
209
|
+
const { themeInitScript } = await import('spark-html-theme/init');
|
|
210
|
+
themeInit = `<script>${themeInitScript()}</script>`;
|
|
211
|
+
} catch { /* older spark-html-theme without /init — theme() still works, with a flash */ }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// spark-html-font: `"fonts"` in spark.json renders the same head tags the
|
|
215
|
+
// font/bun pipeline step bakes at build time — preloads, @font-face with a
|
|
216
|
+
// size-adjusted fallback face, --font-<slug> vars.
|
|
217
|
+
let fontTags = '';
|
|
218
|
+
if (config.fonts) {
|
|
219
|
+
try {
|
|
220
|
+
const { fontHtml } = await import('spark-html-font');
|
|
221
|
+
fontTags = fontHtml({ fonts: config.fonts });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (!quiet) console.warn(`[spark-ssr] "fonts" configured but spark-html-font is not installed — ${e.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
131
227
|
// ── request wrapper ──
|
|
132
228
|
function wrapReq(request, url, params, session, server) {
|
|
133
229
|
const headers = {};
|
|
@@ -228,7 +324,10 @@ export async function serve(options = {}) {
|
|
|
228
324
|
on('GET', `api/${table}`, async (req) => {
|
|
229
325
|
const { scoped } = await tableInfo(table);
|
|
230
326
|
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
231
|
-
|
|
327
|
+
const rows = await tableRows(table, req);
|
|
328
|
+
// Password hashes never leave the auth table, not even to a session.
|
|
329
|
+
if (isAuthTable) for (const r of rows) delete r.password;
|
|
330
|
+
return json(isAuthTable ? [...rows] : rows);
|
|
232
331
|
});
|
|
233
332
|
|
|
234
333
|
on('POST', `api/${table}`, async (req) => {
|
|
@@ -256,26 +355,38 @@ export async function serve(options = {}) {
|
|
|
256
355
|
return json(row, 201);
|
|
257
356
|
});
|
|
258
357
|
|
|
358
|
+
// Auth-table writes are own-account only: anyone could otherwise reset
|
|
359
|
+
// the author's password or delete their account through the auto CRUD.
|
|
360
|
+
const ownAccountOnly = (req) =>
|
|
361
|
+
isAuthTable && (!req.session || String(req.session.id) !== String(req.params.id));
|
|
362
|
+
|
|
259
363
|
on('PATCH', `api/${table}/:id`, async (req) => {
|
|
260
364
|
const { names, scoped } = await tableInfo(table);
|
|
261
365
|
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
366
|
+
if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
|
|
262
367
|
const { fields } = await req.body();
|
|
263
368
|
const data = {};
|
|
264
369
|
for (const [k, v] of Object.entries(fields)) {
|
|
265
370
|
if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
|
|
266
371
|
}
|
|
372
|
+
if (isAuthTable && typeof data.password === 'string') {
|
|
373
|
+
data.password = await Bun.password.hash(data.password);
|
|
374
|
+
}
|
|
267
375
|
const keys = Object.keys(data);
|
|
268
376
|
if (!keys.length) return json({ error: 'empty body' }, 400);
|
|
269
377
|
let sql = `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`;
|
|
270
378
|
const values = [...keys.map((k) => data[k]), req.params.id];
|
|
271
379
|
if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
|
|
272
380
|
const rows = await db.query(sql + ' RETURNING *', values);
|
|
273
|
-
|
|
381
|
+
const row = rows[0];
|
|
382
|
+
if (isAuthTable && row) delete row.password;
|
|
383
|
+
return row ? json(row) : json({ error: 'not found' }, 404);
|
|
274
384
|
});
|
|
275
385
|
|
|
276
386
|
on('DELETE', `api/${table}/:id`, async (req) => {
|
|
277
387
|
const { scoped } = await tableInfo(table);
|
|
278
388
|
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
389
|
+
if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
|
|
279
390
|
let sql = `DELETE FROM ${table} WHERE id = ?`;
|
|
280
391
|
const values = [req.params.id];
|
|
281
392
|
if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
|
|
@@ -345,6 +456,13 @@ export async function serve(options = {}) {
|
|
|
345
456
|
// a plain readdir walk plus mtime-cached parses — so new pages, new tables,
|
|
346
457
|
// and edited queries appear without restarting the server.
|
|
347
458
|
const tables = new Set();
|
|
459
|
+
// Configuring auth IS declaring its table: the login endpoint
|
|
460
|
+
// (POST /api/<table>?auth) and signup exist without any page mentioning
|
|
461
|
+
// them. Single-account apps can turn signup off in middleware.html.
|
|
462
|
+
if (config.auth && config.auth.table && db) {
|
|
463
|
+
tables.add(config.auth.table);
|
|
464
|
+
registerTable(config.auth.table);
|
|
465
|
+
}
|
|
348
466
|
function refreshPages() {
|
|
349
467
|
const scanned = scanPages(root);
|
|
350
468
|
pagesDir = scanned.pagesDir;
|
|
@@ -500,9 +618,15 @@ export async function serve(options = {}) {
|
|
|
500
618
|
if (!rel || rel.includes('..')) return null;
|
|
501
619
|
const candidates = [join(root, 'public', rel)];
|
|
502
620
|
const ext = extname(rel);
|
|
503
|
-
|
|
621
|
+
// The root fallback exists for co-located assets (pages/x.css, img/…) —
|
|
622
|
+
// it must never serve project internals: config (may hold secrets),
|
|
623
|
+
// lockfiles, databases, dotfiles. public/ stays the intentional space.
|
|
624
|
+
const internal = rel.startsWith('.') || rel.includes('/.')
|
|
625
|
+
|| ['spark.json', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'].includes(rel)
|
|
626
|
+
|| ['.db', '.sqlite', '.sqlite3'].includes(ext);
|
|
627
|
+
if (!internal && ext && ext !== '.html') {
|
|
504
628
|
candidates.push(join(root, rel), join(pagesDir, rel));
|
|
505
|
-
} else if (rel.startsWith('components/')) {
|
|
629
|
+
} else if (!internal && rel.startsWith('components/')) {
|
|
506
630
|
candidates.push(join(root, rel));
|
|
507
631
|
}
|
|
508
632
|
for (const file of candidates) {
|
|
@@ -532,19 +656,39 @@ export async function serve(options = {}) {
|
|
|
532
656
|
&& pd.blocks.some((b) => b.table) && !!db;
|
|
533
657
|
}
|
|
534
658
|
|
|
535
|
-
function shell(page, body, { hydrate, mount }) {
|
|
659
|
+
function shell(page, body, { hydrate, mount, headExtra = '', scripts = '' }) {
|
|
536
660
|
const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
|
|
537
661
|
const cssRel = page.key + '.css';
|
|
538
662
|
const hasCss = existsSync(join(pagesDir, cssRel));
|
|
663
|
+
// The importmap must precede EVERY module script in document order (a
|
|
664
|
+
// later one is ignored), so the whole module story lives in <head>:
|
|
665
|
+
// importmap → the page's own client scripts → mount. Page scripts are
|
|
666
|
+
// the app's bootstrap (store()/theme() setup) and modules execute in
|
|
667
|
+
// document order, so they run before components boot — same contract as
|
|
668
|
+
// a hand-written main.js that ends with mount().
|
|
669
|
+
const needModules = mount || scripts.includes('<script');
|
|
670
|
+
const imports = {};
|
|
671
|
+
for (const dep of ['spark-html', ...familyDeps]) {
|
|
672
|
+
const info = moduleEntry(dep);
|
|
673
|
+
if (info) imports[dep] = `/@modules/${dep}/${info.entry}`;
|
|
674
|
+
}
|
|
675
|
+
const importmap = needModules
|
|
676
|
+
? `<script type="importmap">${JSON.stringify({ imports })}</script>\n`
|
|
677
|
+
: '';
|
|
678
|
+
const mountJs = mount
|
|
679
|
+
? `<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
|
|
680
|
+
: '';
|
|
539
681
|
const head =
|
|
540
682
|
'<meta charset="utf-8">\n' +
|
|
541
683
|
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
|
542
|
-
|
|
684
|
+
(themeInit ? themeInit + '\n' : '') +
|
|
685
|
+
(/<title\b/i.test(headExtra) ? '' : `<title>${title}</title>\n`) +
|
|
686
|
+
(headExtra ? headExtra + '\n' : '') +
|
|
687
|
+
(fontTags ? fontTags + '\n' : '') +
|
|
688
|
+
importmap +
|
|
689
|
+
(scripts ? scripts + '\n' : '') +
|
|
690
|
+
mountJs +
|
|
543
691
|
(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
692
|
// A hydrating page host carries BOTH `import` and `name` — that is the
|
|
549
693
|
// runtime's flash-free hydrate contract (same as spark-prerender's
|
|
550
694
|
// makeHydratable): the pre-rendered content stays visible while the
|
|
@@ -555,7 +699,8 @@ export async function serve(options = {}) {
|
|
|
555
699
|
const host = hydrate
|
|
556
700
|
? `<div import="/__spark/page/${page.key}" name="${compName}" data-spark-ssr>${body}</div>`
|
|
557
701
|
: `<div data-spark-ssr>${body}</div>`;
|
|
558
|
-
|
|
702
|
+
const reload = live ? RELOAD_CLIENT + '\n' : '';
|
|
703
|
+
return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}\n${reload}</body>\n</html>\n`;
|
|
559
704
|
}
|
|
560
705
|
|
|
561
706
|
async function buildScope(pd, req) {
|
|
@@ -582,7 +727,10 @@ export async function serve(options = {}) {
|
|
|
582
727
|
// and their own <script> comes alive (counters, demos, …).
|
|
583
728
|
const hasComponents = /\bimport\s*=\s*"/.test(pd.html);
|
|
584
729
|
const body = await renderFragment(pd.html, scope, { loadComponent, keepImports: !hydrate });
|
|
585
|
-
|
|
730
|
+
const headExtra = pd.head ? renderHead(pd.head, (e) => evalExpr(e, scope)) : '';
|
|
731
|
+
return new Response(shell(page, body, {
|
|
732
|
+
hydrate, mount: hydrate || hasComponents, headExtra, scripts: pd.scripts,
|
|
733
|
+
}), {
|
|
586
734
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
587
735
|
});
|
|
588
736
|
}
|
|
@@ -590,22 +738,37 @@ export async function serve(options = {}) {
|
|
|
590
738
|
function errorPage(status) {
|
|
591
739
|
const file = join(root, `${status}.html`);
|
|
592
740
|
if (existsSync(file)) {
|
|
593
|
-
|
|
741
|
+
// The reload client rides along so fixing the page un-sticks the browser.
|
|
742
|
+
const body = readFileSync(file, 'utf8') + (live ? '\n' + RELOAD_CLIENT : '');
|
|
743
|
+
return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
594
744
|
}
|
|
595
745
|
return new Response(status === 404 ? 'Not found' : 'Server error', { status });
|
|
596
746
|
}
|
|
597
747
|
|
|
598
|
-
// spark-html
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
748
|
+
// spark-html + family packages, served as browser modules. The importmap
|
|
749
|
+
// maps each package name to /@modules/<pkg>/<entry>, and sibling files in
|
|
750
|
+
// the package resolve as relative imports under the same prefix (theme's
|
|
751
|
+
// ./init.js, say). Bun's resolver falls back to its GLOBAL install cache
|
|
752
|
+
// when a dir has no node_modules — that can be a different version than
|
|
753
|
+
// the app's, so cache hits only count when nothing real resolves.
|
|
754
|
+
const moduleInfo = new Map(); // pkg → { dir, entry } | null
|
|
755
|
+
function moduleEntry(pkg) {
|
|
756
|
+
if (moduleInfo.has(pkg)) return moduleInfo.get(pkg);
|
|
757
|
+
let lastResort = null;
|
|
602
758
|
for (const dir of [root, dirname(new URL(import.meta.url).pathname)]) {
|
|
603
759
|
try {
|
|
604
|
-
|
|
605
|
-
|
|
760
|
+
const file = Bun.resolveSync(pkg, dir);
|
|
761
|
+
if (file.includes('/install/cache/')) { lastResort = lastResort || file; continue; }
|
|
762
|
+
const info = { dir: dirname(file), entry: file.slice(file.lastIndexOf('/') + 1) };
|
|
763
|
+
moduleInfo.set(pkg, info);
|
|
764
|
+
return info;
|
|
606
765
|
} catch { /* next */ }
|
|
607
766
|
}
|
|
608
|
-
|
|
767
|
+
const info = lastResort
|
|
768
|
+
? { dir: dirname(lastResort), entry: lastResort.slice(lastResort.lastIndexOf('/') + 1) }
|
|
769
|
+
: null;
|
|
770
|
+
moduleInfo.set(pkg, info);
|
|
771
|
+
return info;
|
|
609
772
|
}
|
|
610
773
|
|
|
611
774
|
// ── the server ──
|
|
@@ -616,6 +779,19 @@ export async function serve(options = {}) {
|
|
|
616
779
|
let pathname;
|
|
617
780
|
try { pathname = decodeURIComponent(url.pathname); } catch { pathname = url.pathname; }
|
|
618
781
|
if (pathname.includes('..')) return errorPage(404);
|
|
782
|
+
|
|
783
|
+
// Dev reload channel — before middleware; it's the harness, not the app.
|
|
784
|
+
if (live && pathname === '/__spark/reload') {
|
|
785
|
+
let ctrl;
|
|
786
|
+
const stream = new ReadableStream({
|
|
787
|
+
start(c) { ctrl = c; c.enqueue(sseEnc.encode(': connected\n\n')); sseClients.add(c); },
|
|
788
|
+
cancel() { sseClients.delete(ctrl); },
|
|
789
|
+
});
|
|
790
|
+
return new Response(stream, {
|
|
791
|
+
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' },
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
619
795
|
const session = readSession(request.headers.get('cookie'), secret);
|
|
620
796
|
const extraHeaders = {};
|
|
621
797
|
|
|
@@ -641,11 +817,24 @@ export async function serve(options = {}) {
|
|
|
641
817
|
return res;
|
|
642
818
|
};
|
|
643
819
|
|
|
644
|
-
if (pathname
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
820
|
+
if (pathname.startsWith('/@modules/')) {
|
|
821
|
+
const rest = pathname.slice('/@modules/'.length);
|
|
822
|
+
const slash = rest.indexOf('/');
|
|
823
|
+
const pkg = slash === -1 ? rest : rest.slice(0, slash);
|
|
824
|
+
const subpath = slash === -1 ? '' : rest.slice(slash + 1);
|
|
825
|
+
let mod = null;
|
|
826
|
+
if (/^spark-html(-[\w-]+)?$/.test(pkg)) {
|
|
827
|
+
const info = moduleEntry(pkg);
|
|
828
|
+
if (info) {
|
|
829
|
+
const file = resolve(info.dir, subpath || info.entry);
|
|
830
|
+
if (file.startsWith(info.dir + '/') && existsSync(file) && statSync(file).isFile()) {
|
|
831
|
+
mod = new Response(readFileSync(file, 'utf8'), {
|
|
832
|
+
headers: { 'content-type': 'text/javascript', 'cache-control': 'no-cache' },
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return finish(mod || errorPage(404));
|
|
649
838
|
}
|
|
650
839
|
|
|
651
840
|
if (pathname.startsWith('/__spark/page/')) {
|
|
@@ -725,6 +914,12 @@ export async function serve(options = {}) {
|
|
|
725
914
|
root,
|
|
726
915
|
config,
|
|
727
916
|
db,
|
|
728
|
-
stop(force) {
|
|
917
|
+
stop(force) {
|
|
918
|
+
if (watchTimer) clearInterval(watchTimer);
|
|
919
|
+
for (const c of sseClients) { try { c.close(); } catch { /* gone */ } }
|
|
920
|
+
sseClients.clear();
|
|
921
|
+
server.stop(force);
|
|
922
|
+
return db && db.close();
|
|
923
|
+
},
|
|
729
924
|
};
|
|
730
925
|
}
|