spark-html-bun 0.1.0 → 0.1.1
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 +1 -1
- package/src/index.js +36 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spark-html-bun",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Dev server, build, and preview for spark-html apps — 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",
|
package/src/index.js
CHANGED
|
@@ -42,10 +42,24 @@
|
|
|
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
|
+
|
|
49
63
|
const MIME = {
|
|
50
64
|
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
51
65
|
'.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
|
|
@@ -208,7 +222,8 @@ export async function dev(overrides = {}) {
|
|
|
208
222
|
development: true,
|
|
209
223
|
async fetch(req, srv) {
|
|
210
224
|
const url = new URL(req.url);
|
|
211
|
-
|
|
225
|
+
const path = decodePath(url.pathname);
|
|
226
|
+
if (path === null) return new Response('Bad request', { status: 400 });
|
|
212
227
|
|
|
213
228
|
// WebSocket channel for scoped component HMR.
|
|
214
229
|
if (path === '/__spark_hmr') {
|
|
@@ -233,11 +248,12 @@ export async function dev(overrides = {}) {
|
|
|
233
248
|
}
|
|
234
249
|
|
|
235
250
|
// Static lookup: project root first (index.html, src/…), then publicDir.
|
|
251
|
+
// Each candidate is guarded against escaping its own base (path traversal).
|
|
236
252
|
const rel = path.replace(/^\/+/, '');
|
|
237
|
-
const candidates = [join(projectRoot, rel), join(pub, rel)];
|
|
238
253
|
let file = null;
|
|
239
|
-
for (const
|
|
240
|
-
|
|
254
|
+
for (const b of [projectRoot, pub]) {
|
|
255
|
+
const c = safeJoin(b, rel);
|
|
256
|
+
if (c && existsSync(c) && statSync(c).isFile()) { file = c; break; }
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
// SPA fallback: extensionless paths serve the app shell (the router
|
|
@@ -345,11 +361,17 @@ export async function build(overrides = {}) {
|
|
|
345
361
|
const msgs = (result.logs || []).map((l) => l.message || String(l)).join('\n');
|
|
346
362
|
throw new Error(`[spark] build failed:\n${msgs}`);
|
|
347
363
|
}
|
|
348
|
-
// Entry outputs come back in entrypoint order
|
|
364
|
+
// Entry outputs come back in entrypoint order (verified for Bun's
|
|
365
|
+
// splitting output, incl. same-basename entries) — map each to its URL.
|
|
349
366
|
const entryOuts = result.outputs.filter((o) => o.kind === 'entry-point');
|
|
350
367
|
found.forEach((f, i) => {
|
|
351
|
-
const name = entryOuts[i] && entryOuts[i].path
|
|
352
|
-
if (name)
|
|
368
|
+
const name = entryOuts[i] && basename(entryOuts[i].path);
|
|
369
|
+
if (!name) return;
|
|
370
|
+
const to = `${base}assets/${name}`;
|
|
371
|
+
// Replace only the quoted attribute value, so a longer URL that ends
|
|
372
|
+
// with this one (e.g. /lib/app.js vs /app.js) is never corrupted the
|
|
373
|
+
// way a bare replaceAll(url) would corrupt it.
|
|
374
|
+
for (const q of ['"', "'"]) html = html.split(`${q}${f.url}${q}`).join(`${q}${to}${q}`);
|
|
353
375
|
});
|
|
354
376
|
}
|
|
355
377
|
await Bun.write(join(outDir, config.entry.split('/').pop()), html);
|
|
@@ -378,13 +400,15 @@ export async function preview(overrides = {}) {
|
|
|
378
400
|
port: overrides.port ?? config.port ?? 4173,
|
|
379
401
|
fetch(req) {
|
|
380
402
|
const url = new URL(req.url);
|
|
381
|
-
let path =
|
|
403
|
+
let path = decodePath(url.pathname);
|
|
404
|
+
if (path === null) return new Response('Bad request', { status: 400 });
|
|
382
405
|
if (base !== '/' && path.startsWith(base)) path = '/' + path.slice(base.length);
|
|
383
406
|
const rel = path.replace(/^\/+/, '');
|
|
407
|
+
// Guard every candidate against escaping outDir (path traversal).
|
|
384
408
|
const tryFiles = [
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
rel !== '' && rel.endsWith('/') ?
|
|
409
|
+
safeJoin(outDir, rel === '' ? 'index.html' : rel),
|
|
410
|
+
safeJoin(outDir, rel + '.html'),
|
|
411
|
+
rel !== '' && rel.endsWith('/') ? safeJoin(outDir, join(rel, 'index.html')) : null,
|
|
388
412
|
].filter(Boolean);
|
|
389
413
|
for (const f of tryFiles) {
|
|
390
414
|
if (existsSync(f) && statSync(f).isFile()) {
|