spark-html-bun 0.1.0 → 0.1.2
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/package.json +2 -2
- package/src/index.js +55 -17
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spark-html-bun",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Dev server, build, and preview for spark-html apps
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Dev server, build, and preview for spark-html apps \u2014 built entirely on Bun. Scoped component HMR over a plain WebSocket, import-map dev serving (no bundling in dev), Bun.build for the app shell, and an explicit post-build pipeline (prerender, image, font, manifest, offline, sri). Zero dependencies.",
|
|
5
5
|
"homepage": "https://wilkinnovo.github.io/spark",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
package/src/index.js
CHANGED
|
@@ -42,10 +42,31 @@
|
|
|
42
42
|
* devRoutes?({ config }) → { '/path': { type, body() } }, // dev serving
|
|
43
43
|
* transformHtml?(html, { dev }) } // dev page injection
|
|
44
44
|
*/
|
|
45
|
-
import { join, resolve, extname } from 'node:path';
|
|
45
|
+
import { join, resolve, extname, basename, sep } from 'node:path';
|
|
46
46
|
import { existsSync, watch, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
47
47
|
import { rm, mkdir, cp, readFile } from 'node:fs/promises';
|
|
48
48
|
|
|
49
|
+
// Resolve `rel` under `base` and refuse anything that escapes it — a static
|
|
50
|
+
// server must never serve outside its root. `..` is normalized away by the URL
|
|
51
|
+
// parser, but `decodeURIComponent` can reintroduce it (e.g. `%2e%2e%2f`), so
|
|
52
|
+
// the check runs on the final resolved path.
|
|
53
|
+
function safeJoin(base, rel) {
|
|
54
|
+
const p = resolve(base, rel);
|
|
55
|
+
return p === base || p.startsWith(base + sep) ? p : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// URL pathname, decoded — null on malformed percent-encoding (rejected as 400).
|
|
59
|
+
function decodePath(pathname) {
|
|
60
|
+
try { return decodeURIComponent(pathname); } catch { return null; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One stat instead of existsSync + statSync — this runs per request candidate
|
|
64
|
+
// on the dev/preview hot path.
|
|
65
|
+
function isFile(p) {
|
|
66
|
+
const s = statSync(p, { throwIfNoEntry: false });
|
|
67
|
+
return s !== undefined && s.isFile();
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
const MIME = {
|
|
50
71
|
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
51
72
|
'.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
|
|
@@ -208,7 +229,8 @@ export async function dev(overrides = {}) {
|
|
|
208
229
|
development: true,
|
|
209
230
|
async fetch(req, srv) {
|
|
210
231
|
const url = new URL(req.url);
|
|
211
|
-
|
|
232
|
+
const path = decodePath(url.pathname);
|
|
233
|
+
if (path === null) return new Response('Bad request', { status: 400 });
|
|
212
234
|
|
|
213
235
|
// WebSocket channel for scoped component HMR.
|
|
214
236
|
if (path === '/__spark_hmr') {
|
|
@@ -233,11 +255,12 @@ export async function dev(overrides = {}) {
|
|
|
233
255
|
}
|
|
234
256
|
|
|
235
257
|
// Static lookup: project root first (index.html, src/…), then publicDir.
|
|
258
|
+
// Each candidate is guarded against escaping its own base (path traversal).
|
|
236
259
|
const rel = path.replace(/^\/+/, '');
|
|
237
|
-
const candidates = [join(projectRoot, rel), join(pub, rel)];
|
|
238
260
|
let file = null;
|
|
239
|
-
for (const
|
|
240
|
-
|
|
261
|
+
for (const b of [projectRoot, pub]) {
|
|
262
|
+
const c = safeJoin(b, rel);
|
|
263
|
+
if (c && isFile(c)) { file = c; break; }
|
|
241
264
|
}
|
|
242
265
|
|
|
243
266
|
// SPA fallback: extensionless paths serve the app shell (the router
|
|
@@ -327,12 +350,17 @@ export async function build(overrides = {}) {
|
|
|
327
350
|
const file = clean.startsWith('/') ? join(projectRoot, clean.slice(1)) : join(entryDir, clean);
|
|
328
351
|
// Only bundle files that live in the PROJECT (src/…) — anything served
|
|
329
352
|
// from publicDir ships verbatim and its URL already works.
|
|
330
|
-
if (existsSync(file) && !file.startsWith(pub +
|
|
353
|
+
if (existsSync(file) && !file.startsWith(pub + sep)) found.push({ url, file });
|
|
331
354
|
}
|
|
332
355
|
|
|
333
356
|
if (found.length) {
|
|
357
|
+
// Bun.build DEDUPES duplicate entrypoints (the same file listed twice —
|
|
358
|
+
// or reached via two URL spellings — yields ONE output), so bundle the
|
|
359
|
+
// UNIQUE files and map file → hashed name; never map outputs back by
|
|
360
|
+
// found-index, which would splice the wrong asset URL into the page.
|
|
361
|
+
const files = [...new Set(found.map((f) => f.file))];
|
|
334
362
|
const result = await Bun.build({
|
|
335
|
-
entrypoints:
|
|
363
|
+
entrypoints: files,
|
|
336
364
|
outdir: join(outDir, 'assets'),
|
|
337
365
|
minify: true,
|
|
338
366
|
splitting: true,
|
|
@@ -345,12 +373,20 @@ export async function build(overrides = {}) {
|
|
|
345
373
|
const msgs = (result.logs || []).map((l) => l.message || String(l)).join('\n');
|
|
346
374
|
throw new Error(`[spark] build failed:\n${msgs}`);
|
|
347
375
|
}
|
|
348
|
-
// Entry outputs come back in entrypoint order
|
|
376
|
+
// Entry outputs come back in entrypoint order (verified for Bun's
|
|
377
|
+
// splitting output, incl. same-basename entries) — map each unique
|
|
378
|
+
// file to its hashed name, then rewrite every tag that referenced it.
|
|
349
379
|
const entryOuts = result.outputs.filter((o) => o.kind === 'entry-point');
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
380
|
+
const outName = new Map(files.map((file, i) => [file, entryOuts[i] && basename(entryOuts[i].path)]));
|
|
381
|
+
for (const f of found) {
|
|
382
|
+
const name = outName.get(f.file);
|
|
383
|
+
if (!name) continue;
|
|
384
|
+
const to = `${base}assets/${name}`;
|
|
385
|
+
// Replace only the quoted attribute value, so a longer URL that ends
|
|
386
|
+
// with this one (e.g. /lib/app.js vs /app.js) is never corrupted the
|
|
387
|
+
// way a bare replaceAll(url) would corrupt it.
|
|
388
|
+
for (const q of ['"', "'"]) html = html.split(`${q}${f.url}${q}`).join(`${q}${to}${q}`);
|
|
389
|
+
}
|
|
354
390
|
}
|
|
355
391
|
await Bun.write(join(outDir, config.entry.split('/').pop()), html);
|
|
356
392
|
}
|
|
@@ -378,16 +414,18 @@ export async function preview(overrides = {}) {
|
|
|
378
414
|
port: overrides.port ?? config.port ?? 4173,
|
|
379
415
|
fetch(req) {
|
|
380
416
|
const url = new URL(req.url);
|
|
381
|
-
let path =
|
|
417
|
+
let path = decodePath(url.pathname);
|
|
418
|
+
if (path === null) return new Response('Bad request', { status: 400 });
|
|
382
419
|
if (base !== '/' && path.startsWith(base)) path = '/' + path.slice(base.length);
|
|
383
420
|
const rel = path.replace(/^\/+/, '');
|
|
421
|
+
// Guard every candidate against escaping outDir (path traversal).
|
|
384
422
|
const tryFiles = [
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
rel !== '' && rel.endsWith('/') ?
|
|
423
|
+
safeJoin(outDir, rel === '' ? 'index.html' : rel),
|
|
424
|
+
safeJoin(outDir, rel + '.html'),
|
|
425
|
+
rel !== '' && rel.endsWith('/') ? safeJoin(outDir, join(rel, 'index.html')) : null,
|
|
388
426
|
].filter(Boolean);
|
|
389
427
|
for (const f of tryFiles) {
|
|
390
|
-
if (
|
|
428
|
+
if (isFile(f)) {
|
|
391
429
|
return new Response(Bun.file(f), { headers: { 'Content-Type': mime(f) } });
|
|
392
430
|
}
|
|
393
431
|
}
|