@webjsdev/server 0.8.15 → 0.8.17

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.
@@ -0,0 +1,466 @@
1
+ /**
2
+ * File storage primitive: a pluggable `FileStore` with a streaming local-disk
3
+ * default adapter (issue #247).
4
+ *
5
+ * webjs already round-trips a native `File` / `Blob` / `FormData` over the RPC
6
+ * wire (the serializer), but nothing decided WHERE the bytes land. This module
7
+ * is that answer. The model mirrors `cache.js` exactly: a documented interface,
8
+ * a default adapter (`diskStore`), and a module singleton (`setFileStore` /
9
+ * `getFileStore`) so an app swaps the backend in one call without touching any
10
+ * call site.
11
+ *
12
+ * ```js
13
+ * import { getFileStore, generateKey } from '@webjsdev/server';
14
+ * const key = generateKey(file.name); // opaque, traversal-safe
15
+ * await getFileStore().put(key, file); // streams to disk
16
+ * const handle = await getFileStore().get(key); // { body, size, contentType }
17
+ * ```
18
+ *
19
+ * Design notes:
20
+ *
21
+ * - **Streaming write.** `put` never buffers the whole file. It pipes
22
+ * `file.stream()` -> `Readable.fromWeb` -> `createWriteStream` via
23
+ * `node:stream/promises.pipeline`, so a large upload uses constant memory.
24
+ * The upstream body-size cap (#237, `maxMultipartBytes`) bounds the size
25
+ * BEFORE the bytes ever reach the store, so the store does not re-implement
26
+ * a limit; it only stays streaming.
27
+ * - **Traversal-safe keys.** Every key is resolved to an absolute path under
28
+ * `dir` and rejected if it escapes, using the same `resolve` +
29
+ * `startsWith(dir + sep)` containment guard the `/public/*` serve path uses
30
+ * in dev.js. A key with `..`, an absolute path, a leading slash, a NUL, or a
31
+ * backslash is rejected BEFORE any fs operation.
32
+ * - **S3-pluggability.** The interface operates on web-standard objects only
33
+ * (`File` / `Blob` / `ReadableStream` / `Uint8Array` in, a `{ body, size,
34
+ * contentType }` handle out), so an S3 / R2 / GCS adapter is a drop-in: it
35
+ * implements the same `put` / `get` / `delete` / `url` and the call sites do
36
+ * not change. webjs ships no S3 SDK; see the JSDoc on `FileStore` below.
37
+ *
38
+ * @module file-storage
39
+ */
40
+
41
+ import { createWriteStream, createReadStream } from 'node:fs';
42
+ import { mkdir, stat, unlink } from 'node:fs/promises';
43
+ import { dirname, join, resolve, sep, extname } from 'node:path';
44
+ import { Readable } from 'node:stream';
45
+ import { pipeline } from 'node:stream/promises';
46
+ import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
47
+
48
+ /**
49
+ * @typedef {Object} StoredObjectHandle
50
+ * @property {ReadableStream | import('node:stream').Readable} body
51
+ * The stored object's bytes as a STREAM (never the whole buffer), so a
52
+ * serving route can do `new Response(handle.body, { headers })` without
53
+ * reading the file into memory.
54
+ * @property {number} size Byte length of the stored object.
55
+ * @property {string} contentType The recorded MIME type (`application/octet-stream` when unknown).
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} PutResult
60
+ * @property {string} key The (validated) key the object was stored under.
61
+ * @property {number} size Byte length written.
62
+ * @property {string} contentType The recorded MIME type.
63
+ */
64
+
65
+ /**
66
+ * The pluggable file-storage interface. The default `diskStore` implements it
67
+ * for the local filesystem; an S3-compatible adapter (R2, GCS, MinIO) is a
68
+ * drop-in replacement because every method operates on web-standard objects and
69
+ * the call sites only ever touch this surface.
70
+ *
71
+ * An S3 adapter would implement the SAME four methods:
72
+ * - `put(key, file)`: `PutObject` (stream the body), returning `{ key, size, contentType }`.
73
+ * - `get(key)`: `GetObject`, returning `{ body, size, contentType }` (the SDK's response stream) or `null`.
74
+ * - `delete(key)`: `DeleteObject` (idempotent).
75
+ * - `url(key)`: the object / CDN URL (`https://cdn.example.com/<key>`).
76
+ * Because the shape is identical, `setFileStore(s3Store({ ... }))` switches the
77
+ * whole app with no call-site change.
78
+ *
79
+ * @typedef {Object} FileStore
80
+ * @property {(key: string, file: Blob | File | ReadableStream | Uint8Array, opts?: { contentType?: string }) => Promise<PutResult>} put
81
+ * Stream the bytes to storage under `key`. Captures size + content-type.
82
+ * @property {(key: string) => Promise<StoredObjectHandle | null>} get
83
+ * Return a streaming handle for the stored object, or `null` when absent.
84
+ * @property {(key: string) => Promise<void>} delete
85
+ * Remove the stored object. Idempotent (a missing key is not an error).
86
+ * @property {(key: string) => string} url
87
+ * The URL at which the stored object is served (`<baseUrl>/<key>` for `diskStore`).
88
+ * @property {(key: string) => Promise<boolean>} [has]
89
+ * Whether the key exists (optional).
90
+ */
91
+
92
+ /** Default storage root, relative to the app's cwd. gitignore-friendly. */
93
+ export const DEFAULT_UPLOAD_DIR = '.webjs/uploads';
94
+
95
+ /** Reserved suffix for the per-object content-type sidecar (`<key>.meta`). */
96
+ const META_SUFFIX = '.meta';
97
+
98
+ /**
99
+ * The extension whitelist for `generateKey`. Only these (lowercased) extensions
100
+ * are preserved on a generated key; anything else yields an extensionless key.
101
+ * The point is that a generated key is opaque + safe, NOT that it round-trips an
102
+ * arbitrary filename. This is a conservative set of common, inert media / doc
103
+ * extensions; an app needing more can pass its own key.
104
+ */
105
+ const SAFE_EXTENSIONS = new Set([
106
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico', 'bmp',
107
+ 'pdf', 'txt', 'md', 'csv', 'json', 'xml',
108
+ 'mp3', 'wav', 'ogg', 'mp4', 'webm', 'mov', 'm4a',
109
+ 'zip', 'gz',
110
+ 'woff', 'woff2', 'ttf', 'otf',
111
+ 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
112
+ ]);
113
+
114
+ /**
115
+ * Validate a storage key and reject anything that could escape the root or is
116
+ * otherwise unsafe. Throws on a bad key BEFORE any filesystem operation; this is
117
+ * the security-critical seam. Mirrors the `/public/*` containment guard
118
+ * (`resolve` + `startsWith(root + sep)`) in dev.js.
119
+ *
120
+ * Rejected: an empty / non-string key, a key with a NUL byte, an absolute path,
121
+ * a leading slash or backslash, any `..` segment, any backslash (Windows
122
+ * separator), and ANY key that does not resolve to a path strictly under `dir`.
123
+ *
124
+ * @param {string} dir The (already absolute) storage root.
125
+ * @param {string} key The caller-supplied key.
126
+ * @returns {string} The absolute path the key resolves to, guaranteed under `dir`.
127
+ */
128
+ export function assertSafeKey(dir, key) {
129
+ if (typeof key !== 'string' || key.length === 0) {
130
+ throw new Error('file-storage: key must be a non-empty string');
131
+ }
132
+ // A NUL byte truncates a C string, so a key like `a\0.txt` can desync the
133
+ // path the JS layer validated from the path the syscall opens. Reject it.
134
+ if (key.includes('\0')) {
135
+ throw new Error('file-storage: key must not contain a NUL byte');
136
+ }
137
+ // A backslash is a path separator on Windows and a traversal vector
138
+ // (`..\\win`), so reject it outright rather than reason about platform.
139
+ if (key.includes('\\')) {
140
+ throw new Error('file-storage: key must not contain a backslash');
141
+ }
142
+ // An absolute key or a leading slash would escape `dir` under join/resolve.
143
+ if (key.startsWith('/') || /^[A-Za-z]:/.test(key)) {
144
+ throw new Error('file-storage: key must be relative (no leading slash or drive)');
145
+ }
146
+ // Reject any `..` traversal segment up front for a clear error, even though
147
+ // the containment check below would also catch it.
148
+ const segments = key.split('/');
149
+ if (segments.some((s) => s === '..')) {
150
+ throw new Error('file-storage: key must not contain a ".." segment');
151
+ }
152
+ // The `.meta` suffix is RESERVED for the content-type sidecar `put` writes
153
+ // (`<key>.meta`). Rejecting it eliminates the only namespace collision: a user
154
+ // storing both `foo` and `foo.meta` would otherwise have `foo`'s sidecar
155
+ // clobber the `foo.meta` object. `generateKey` never produces a `.meta` key.
156
+ if (key.endsWith(META_SUFFIX)) {
157
+ throw new Error(`file-storage: key must not end in "${META_SUFFIX}" (reserved for metadata)`);
158
+ }
159
+ // Final containment guard: resolve to an absolute path and confirm it stays
160
+ // strictly under `dir`. This is the authoritative check (the rules above are
161
+ // fast clear-error rejections; this one cannot be fooled).
162
+ const abs = resolve(dir, key);
163
+ const root = dir.endsWith(sep) ? dir : dir + sep;
164
+ if (abs !== dir && !abs.startsWith(root)) {
165
+ throw new Error('file-storage: key resolves outside the storage root');
166
+ }
167
+ // `abs === dir` means the key resolved to the root directory itself (e.g. an
168
+ // empty-ish key), which is not a file path.
169
+ if (abs === dir || abs === dir.replace(/[\\/]+$/, '')) {
170
+ throw new Error('file-storage: key must name a file, not the storage root');
171
+ }
172
+ return abs;
173
+ }
174
+
175
+ /**
176
+ * Normalize an arbitrary input (a `Blob` / `File` / `ReadableStream` /
177
+ * `Uint8Array`) into a Node `Readable` stream plus an optional content type,
178
+ * WITHOUT buffering the whole thing. This keeps the write path streaming.
179
+ *
180
+ * @param {Blob | File | ReadableStream | Uint8Array} file
181
+ * @returns {{ stream: import('node:stream').Readable, contentType: string | null }}
182
+ */
183
+ function toNodeStream(file) {
184
+ // A Blob / File exposes a web ReadableStream via `.stream()`; converting it
185
+ // with Readable.fromWeb keeps it streaming (no `.arrayBuffer()`).
186
+ if (file && typeof file.stream === 'function' && typeof file.size === 'number') {
187
+ const contentType = typeof file.type === 'string' && file.type ? file.type : null;
188
+ return { stream: Readable.fromWeb(file.stream()), contentType };
189
+ }
190
+ // A web ReadableStream directly.
191
+ if (file && typeof file.getReader === 'function') {
192
+ return { stream: Readable.fromWeb(/** @type {ReadableStream} */ (file)), contentType: null };
193
+ }
194
+ // A Uint8Array / Buffer: wrap as a single-chunk readable. This DOES hold the
195
+ // bytes the caller already has in memory, but it does not COPY-buffer them
196
+ // (the caller already chose an in-memory input), and the write still streams.
197
+ if (file instanceof Uint8Array) {
198
+ return { stream: Readable.from([Buffer.from(file.buffer, file.byteOffset, file.byteLength)]), contentType: null };
199
+ }
200
+ throw new Error('file-storage: put() expects a Blob, File, ReadableStream, or Uint8Array');
201
+ }
202
+
203
+ /**
204
+ * Local-disk file store (the default adapter). Streams writes, never buffering
205
+ * the whole file. `dir` is created on demand.
206
+ *
207
+ * @param {{ dir?: string, baseUrl?: string }} [opts]
208
+ * `dir` is the storage root (default `<cwd>/.webjs/uploads`). `baseUrl` is the
209
+ * path prefix `url(key)` returns (default `/uploads`); the app serves that
210
+ * prefix from a `route.{js,ts}` handler that streams `get(key)`.
211
+ * @returns {FileStore}
212
+ */
213
+ export function diskStore(opts = {}) {
214
+ const dir = resolve(opts.dir || join(process.cwd(), DEFAULT_UPLOAD_DIR));
215
+ // Normalize baseUrl to `/prefix` with no trailing slash.
216
+ let baseUrl = opts.baseUrl == null ? '/uploads' : String(opts.baseUrl);
217
+ if (!baseUrl.startsWith('/')) baseUrl = '/' + baseUrl;
218
+ baseUrl = baseUrl.replace(/\/+$/, '');
219
+
220
+ return {
221
+ async put(key, file, putOpts = {}) {
222
+ const abs = assertSafeKey(dir, key);
223
+ const { stream, contentType: detected } = toNodeStream(file);
224
+ const contentType = putOpts.contentType || detected || 'application/octet-stream';
225
+ // Ensure the parent directory exists (supports nested keys like `a/b/c.png`).
226
+ await mkdir(dirname(abs), { recursive: true });
227
+ // Streaming write: pipe the source through to disk. `pipeline` handles
228
+ // backpressure + teardown, so memory stays bounded regardless of size.
229
+ try {
230
+ await pipeline(stream, createWriteStream(abs));
231
+ } catch (err) {
232
+ // A mid-stream failure (source error, disk full) leaves a truncated
233
+ // file. Remove it so a later read never serves a partial object under a
234
+ // key the caller never received, then re-throw.
235
+ try { await unlink(abs); } catch {}
236
+ throw err;
237
+ }
238
+ const { size } = await stat(abs);
239
+ // Record the content type alongside the bytes so `get` can return it. A
240
+ // sidecar `<file>.meta` JSON keeps the object itself byte-for-byte the
241
+ // upload (so it serves cleanly) while still capturing the type.
242
+ try {
243
+ const metaAbs = abs + META_SUFFIX;
244
+ await pipeline(
245
+ Readable.from([JSON.stringify({ contentType })]),
246
+ createWriteStream(metaAbs),
247
+ );
248
+ } catch {
249
+ // A meta-write failure is non-fatal: `get` falls back to octet-stream.
250
+ }
251
+ return { key, size, contentType };
252
+ },
253
+
254
+ async get(key) {
255
+ const abs = assertSafeKey(dir, key);
256
+ let info;
257
+ try {
258
+ info = await stat(abs);
259
+ } catch {
260
+ return null;
261
+ }
262
+ if (!info.isFile()) return null;
263
+ let contentType = 'application/octet-stream';
264
+ try {
265
+ const metaAbs = abs + META_SUFFIX;
266
+ const metaInfo = await stat(metaAbs);
267
+ if (metaInfo.isFile()) {
268
+ // The meta sidecar is tiny (a JSON object with a content type), so a
269
+ // small read here is fine and does not touch the object's bytes.
270
+ const { readFile } = await import('node:fs/promises');
271
+ const parsed = JSON.parse(await readFile(metaAbs, 'utf8'));
272
+ if (parsed && typeof parsed.contentType === 'string') contentType = parsed.contentType;
273
+ }
274
+ } catch {
275
+ // No / unreadable meta: keep the octet-stream default.
276
+ }
277
+ // The body is a STREAM, so a serving route streams it to the client
278
+ // without reading the whole file into memory.
279
+ return {
280
+ body: Readable.toWeb(createReadStream(abs)),
281
+ size: info.size,
282
+ contentType,
283
+ };
284
+ },
285
+
286
+ async delete(key) {
287
+ const abs = assertSafeKey(dir, key);
288
+ // Idempotent: a missing file is not an error.
289
+ try { await unlink(abs); } catch {}
290
+ try { await unlink(abs + META_SUFFIX); } catch {}
291
+ },
292
+
293
+ async has(key) {
294
+ const abs = assertSafeKey(dir, key);
295
+ try {
296
+ const info = await stat(abs);
297
+ return info.isFile();
298
+ } catch {
299
+ return false;
300
+ }
301
+ },
302
+
303
+ url(key) {
304
+ // Validate the key so `url()` cannot mint a traversal path either.
305
+ assertSafeKey(dir, key);
306
+ return `${baseUrl}/${key}`;
307
+ },
308
+ };
309
+ }
310
+
311
+ /** @type {FileStore | null} */
312
+ let _defaultFileStore = null;
313
+
314
+ /**
315
+ * Get the default file store. A `diskStore` rooted at `<cwd>/.webjs/uploads`
316
+ * unless explicitly set via `setFileStore()`. No auto-detection: the app
317
+ * decides. (Add the uploads directory to `.gitignore`.)
318
+ *
319
+ * @returns {FileStore}
320
+ */
321
+ export function getFileStore() {
322
+ if (!_defaultFileStore) _defaultFileStore = diskStore();
323
+ return _defaultFileStore;
324
+ }
325
+
326
+ /**
327
+ * Set the default file store. Call at app startup to point uploads at a custom
328
+ * directory or an S3-compatible backend:
329
+ *
330
+ * ```js
331
+ * import { setFileStore, diskStore } from '@webjsdev/server';
332
+ * setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
333
+ * ```
334
+ *
335
+ * @param {FileStore} store
336
+ */
337
+ export function setFileStore(store) {
338
+ _defaultFileStore = store;
339
+ }
340
+
341
+ /**
342
+ * Generate a random, opaque, traversal-safe storage key, preserving a sanitized
343
+ * (whitelisted) extension from `filename` when present. This is the RECOMMENDED
344
+ * way to derive a key: never trust a user-supplied filename as a key directly.
345
+ *
346
+ * A malicious `filename` (`'../../x.sh'`, `'a/b.png'`, `'evil\0.png'`) yields a
347
+ * fully opaque `<uuid>` or `<uuid>.<ext>` key with no path component and only a
348
+ * whitelisted extension, so the result is always safe to pass to `put`.
349
+ *
350
+ * @param {string} [filename] The original filename (its extension may be preserved).
351
+ * @returns {string} A `<crypto.randomUUID()>` key, with a `.<ext>` suffix when the
352
+ * original extension is whitelisted.
353
+ */
354
+ export function generateKey(filename) {
355
+ const id = randomUUID();
356
+ if (typeof filename !== 'string' || !filename) return id;
357
+ // Take only the basename's extension; `extname` ignores directory parts, so a
358
+ // path-y filename cannot inject a separator. Strip the leading dot, lowercase,
359
+ // and accept ONLY a whitelisted alnum extension.
360
+ const ext = extname(filename).replace(/^\./, '').toLowerCase();
361
+ if (ext && /^[a-z0-9]+$/.test(ext) && SAFE_EXTENSIONS.has(ext)) {
362
+ return `${id}.${ext}`;
363
+ }
364
+ return id;
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Signed URLs (HMAC-SHA256 over key + expiry, base64url). A serving route can
369
+ // gate access without a session lookup: it verifies the signature, then streams
370
+ // `get(key)`. Minimal + standards-based; no library.
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /** base64url-encode a Buffer (no padding, URL-safe alphabet). */
374
+ function b64url(buf) {
375
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
376
+ }
377
+
378
+ /** Compute the HMAC-SHA256 signature (base64url) over `key.exp`. */
379
+ function signKeyExp(key, exp, secret) {
380
+ return b64url(createHmac('sha256', secret).update(`${key}.${exp}`).digest());
381
+ }
382
+
383
+ /**
384
+ * Mint a signed, expiring URL for a stored object. The signature covers the
385
+ * exact key AND the expiry, so neither can be tampered with. The returned string
386
+ * is `<base>?key=<key>&exp=<unixSeconds>&sig=<base64url>` (base defaults to
387
+ * `getFileStore().url(key)`, so it lands on the app's serving route).
388
+ *
389
+ * @param {string} key The storage key.
390
+ * @param {{ secret: string, expiresIn?: number, base?: string }} opts
391
+ * `secret` signs the URL; `expiresIn` is seconds from now (default 3600);
392
+ * `base` is the URL path the query is appended to (default `getFileStore().url(key)`).
393
+ * @returns {string} The signed URL.
394
+ */
395
+ export function signedUrl(key, opts) {
396
+ if (!opts || !opts.secret) throw new Error('signedUrl: a secret is required');
397
+ if (typeof key !== 'string' || !key) throw new Error('signedUrl: a key is required');
398
+ // Default to 1 hour ONLY when expiresIn is omitted. An explicit value is
399
+ // honored literally, so `0` / a negative number fails CLOSED (the minted exp
400
+ // is at or before now, so the URL is already expired) instead of silently
401
+ // granting a 1-hour URL.
402
+ const expiresIn = typeof opts.expiresIn === 'number' && Number.isFinite(opts.expiresIn)
403
+ ? opts.expiresIn
404
+ : 3600;
405
+ const exp = Math.floor(Date.now() / 1000) + Math.floor(expiresIn);
406
+ const sig = signKeyExp(key, exp, opts.secret);
407
+ const base = opts.base != null ? opts.base : getFileStore().url(key);
408
+ const sep2 = base.includes('?') ? '&' : '?';
409
+ return `${base}${sep2}key=${encodeURIComponent(key)}&exp=${exp}&sig=${sig}`;
410
+ }
411
+
412
+ /**
413
+ * Verify a signed URL (or its parsed params). Returns `{ valid, key, reason }`.
414
+ * Fails on a wrong secret (signature mismatch), an expired URL, a tampered key,
415
+ * or missing params. The comparison is constant-time.
416
+ *
417
+ * @param {string | URL | URLSearchParams | { key?: string, exp?: string | number, sig?: string }} input
418
+ * A full URL string, a `URL`, a `URLSearchParams`, or a `{ key, exp, sig }` object.
419
+ * @param {string} secret The same secret passed to `signedUrl`.
420
+ * @returns {{ valid: boolean, key: string | null, reason?: string }}
421
+ */
422
+ export function verifySignedUrl(input, secret) {
423
+ if (!secret) return { valid: false, key: null, reason: 'no secret' };
424
+ let key = null;
425
+ let exp = null;
426
+ let sig = null;
427
+ try {
428
+ if (typeof input === 'string') {
429
+ // Parse against a dummy base so a bare path string works too.
430
+ const u = new URL(input, 'http://x.invalid');
431
+ key = u.searchParams.get('key');
432
+ exp = u.searchParams.get('exp');
433
+ sig = u.searchParams.get('sig');
434
+ } else if (input instanceof URL) {
435
+ key = input.searchParams.get('key');
436
+ exp = input.searchParams.get('exp');
437
+ sig = input.searchParams.get('sig');
438
+ } else if (input instanceof URLSearchParams) {
439
+ key = input.get('key');
440
+ exp = input.get('exp');
441
+ sig = input.get('sig');
442
+ } else if (input && typeof input === 'object') {
443
+ key = input.key != null ? String(input.key) : null;
444
+ exp = input.exp != null ? String(input.exp) : null;
445
+ sig = input.sig != null ? String(input.sig) : null;
446
+ }
447
+ } catch {
448
+ return { valid: false, key: null, reason: 'unparseable' };
449
+ }
450
+ if (!key || !exp || !sig) return { valid: false, key: null, reason: 'missing params' };
451
+ const expNum = Number(exp);
452
+ if (!Number.isFinite(expNum)) return { valid: false, key: null, reason: 'bad expiry' };
453
+ // `>=` so an exp at the current second is expired: a URL minted with
454
+ // `expiresIn: 0` (exp === now) fails closed instead of being valid for the
455
+ // remainder of the current second.
456
+ if (Math.floor(Date.now() / 1000) >= expNum) return { valid: false, key, reason: 'expired' };
457
+ // Recompute the expected signature over the SAME key + exp and compare in
458
+ // constant time. A tampered key changes the expected signature, so it fails.
459
+ const expected = signKeyExp(key, expNum, secret);
460
+ const a = Buffer.from(sig);
461
+ const b = Buffer.from(expected);
462
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
463
+ return { valid: false, key, reason: 'signature mismatch' };
464
+ }
465
+ return { valid: true, key };
466
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Server-side `<webjs-frame>` subtree extraction (#253).
3
+ *
4
+ * When a `<webjs-frame src loading>` self-loads (or a click drives a frame
5
+ * nav), the client sends an `x-webjs-frame: <id>` header and then, from the
6
+ * response, applies ONLY the matching `<webjs-frame id>` subtree (the rest of
7
+ * the page is discarded). Rendering the FULL page just to throw away all but
8
+ * one region wastes the full-page render + transfer cost.
9
+ *
10
+ * This module extracts the requested frame subtree from the already-rendered
11
+ * full-page HTML and returns ONLY that, so the response is BYTE-EQUIVALENT by
12
+ * construction to what the client would have extracted from the full page (a
13
+ * `src` self-load and a click-driven frame nav therefore produce identical DOM)
14
+ * while sending far fewer bytes. "Isolable" means the requested frame id is
15
+ * present in the rendered output; when it is not, the caller falls back to the
16
+ * full-page render (so an auth redirect / a route that dropped the frame is
17
+ * handled exactly as before).
18
+ *
19
+ * The extraction is a structural scan over the rendered HTML, balancing nested
20
+ * `<webjs-frame>` tags so a frame nested inside another frame is matched
21
+ * correctly. It only triggers when the `x-webjs-frame` header is present, so a
22
+ * normal full-page request is completely unaffected (byte-identical).
23
+ */
24
+
25
+ const FRAME_TAG = 'webjs-frame';
26
+
27
+ /**
28
+ * Read the requested frame id off a request's `x-webjs-frame` header.
29
+ * Returns the trimmed id, or `null` when the header is absent / empty (the
30
+ * normal full-page request path).
31
+ *
32
+ * @param {Request | undefined} req
33
+ * @returns {string | null}
34
+ */
35
+ export function requestedFrameId(req) {
36
+ if (!req || !req.headers) return null;
37
+ const raw = req.headers.get('x-webjs-frame');
38
+ if (!raw) return null;
39
+ const id = raw.trim();
40
+ return id ? id : null;
41
+ }
42
+
43
+ /**
44
+ * Find the `<webjs-frame ... id="<frameId>" ...>...</webjs-frame>` subtree in
45
+ * `html` and return it verbatim (opening tag through matching close tag), so
46
+ * the slice is byte-equivalent to the same bytes inside the full page. Nested
47
+ * `<webjs-frame>` tags are balanced, so an outer frame's close is not mistaken
48
+ * for an inner frame's. Returns `null` when no frame with that id is present
49
+ * (the caller then renders / returns the full page).
50
+ *
51
+ * The id match is attribute-aware (it reads the `id` attribute of each opening
52
+ * `<webjs-frame>` tag rather than substring-matching), so a frame whose id is a
53
+ * prefix of another id is not mismatched. A self-closing `<webjs-frame/>` (no
54
+ * children) is matched as an empty subtree.
55
+ *
56
+ * @param {string} html The rendered full-page HTML.
57
+ * @param {string} frameId The requested frame id.
58
+ * @returns {string | null} The frame subtree, or null when absent.
59
+ */
60
+ export function extractFrameSubtree(html, frameId) {
61
+ if (typeof html !== 'string' || !frameId) return null;
62
+ const lower = html.toLowerCase();
63
+ const open = '<' + FRAME_TAG;
64
+ let cursor = 0;
65
+ while (cursor < lower.length) {
66
+ const start = lower.indexOf(open, cursor);
67
+ if (start === -1) return null;
68
+ // Confirm this is a real tag open (`<webjs-frame` followed by whitespace,
69
+ // `>`, or `/`), not a longer tag name that happens to share the prefix.
70
+ const after = lower[start + open.length];
71
+ if (after !== undefined && after !== ' ' && after !== '\t' && after !== '\n'
72
+ && after !== '\r' && after !== '>' && after !== '/') {
73
+ cursor = start + open.length;
74
+ continue;
75
+ }
76
+ // Locate the end of this opening tag.
77
+ const tagEnd = html.indexOf('>', start);
78
+ if (tagEnd === -1) return null;
79
+ const openTag = html.slice(start, tagEnd + 1);
80
+ const selfClosing = /\/\s*>$/.test(openTag);
81
+ const id = readIdAttr(openTag);
82
+ if (id === frameId) {
83
+ if (selfClosing) return openTag;
84
+ const end = findMatchingClose(html, lower, tagEnd + 1);
85
+ if (end === -1) return null;
86
+ return html.slice(start, end);
87
+ }
88
+ cursor = tagEnd + 1;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Read the `id` attribute value out of a single opening `<webjs-frame ...>`
95
+ * tag string. Handles double-quoted, single-quoted, and unquoted forms.
96
+ *
97
+ * @param {string} openTag The opening tag including `<` and `>`.
98
+ * @returns {string | null}
99
+ */
100
+ function readIdAttr(openTag) {
101
+ const m = /\sid\s*=\s*("([^"]*)"|'([^']*)'|([^\s>/]+))/i.exec(openTag);
102
+ if (!m) return null;
103
+ return m[2] ?? m[3] ?? m[4] ?? null;
104
+ }
105
+
106
+ /**
107
+ * Given the index just past a `<webjs-frame ...>` opening tag, return the index
108
+ * just past its matching `</webjs-frame>`, balancing nested `<webjs-frame>`
109
+ * opens. Returns -1 when unbalanced (no matching close).
110
+ *
111
+ * @param {string} html Original-case HTML (returned-slice source).
112
+ * @param {string} lower Lower-cased copy for case-insensitive scanning.
113
+ * @param {number} from Index just past the opening tag's `>`.
114
+ * @returns {number} Index just past the matching `</webjs-frame>`, or -1.
115
+ */
116
+ /**
117
+ * Find the next `needle` (`<webjs-frame` or `</webjs-frame`) in `lower` from
118
+ * `start` that is a REAL tag, i.e. the char right after the tag name is a tag
119
+ * boundary (whitespace, `>`, or `/`). This skips a prefix-collision tag such as
120
+ * `<webjs-frame-nav>` / `</webjs-frame-nav>`, which `indexOf` alone would match
121
+ * and miscount (an unbalanced one would corrupt the depth scan). Returns -1
122
+ * when no real tag remains.
123
+ *
124
+ * @param {string} lower
125
+ * @param {string} needle
126
+ * @param {number} start
127
+ * @returns {number}
128
+ */
129
+ function findRealTag(lower, needle, start) {
130
+ let at = start;
131
+ while (at < lower.length) {
132
+ const idx = lower.indexOf(needle, at);
133
+ if (idx === -1) return -1;
134
+ const after = lower[idx + needle.length];
135
+ if (after === undefined || after === ' ' || after === '\t' || after === '\n'
136
+ || after === '\r' || after === '>' || after === '/') {
137
+ return idx;
138
+ }
139
+ at = idx + needle.length; // a prefix-collision tag, keep scanning
140
+ }
141
+ return -1;
142
+ }
143
+
144
+ function findMatchingClose(html, lower, from) {
145
+ const open = '<' + FRAME_TAG;
146
+ const close = '</' + FRAME_TAG;
147
+ let depth = 1;
148
+ let i = from;
149
+ while (i < lower.length) {
150
+ const nextOpen = findRealTag(lower, open, i);
151
+ const nextClose = findRealTag(lower, close, i);
152
+ if (nextClose === -1) return -1;
153
+ if (nextOpen !== -1 && nextOpen < nextClose) {
154
+ // A nested frame open. Skip past its opening tag, and only increase
155
+ // depth when it is NOT self-closing (a self-closing nested frame opens
156
+ // and closes in one tag, so it does not need a matching close).
157
+ const tagEnd = html.indexOf('>', nextOpen);
158
+ if (tagEnd === -1) return -1;
159
+ const nestedOpenTag = html.slice(nextOpen, tagEnd + 1);
160
+ if (!/\/\s*>$/.test(nestedOpenTag)) depth++;
161
+ i = tagEnd + 1;
162
+ continue;
163
+ }
164
+ // A close tag. Consume through its `>`.
165
+ const closeEnd = html.indexOf('>', nextClose);
166
+ if (closeEnd === -1) return -1;
167
+ depth--;
168
+ i = closeEnd + 1;
169
+ if (depth === 0) return i;
170
+ }
171
+ return -1;
172
+ }