@entrinsik/vite-plugin-informer 2.7.0 → 2.11.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.
@@ -0,0 +1,660 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { extname } from 'node:path';
3
+
4
+ /**
5
+ * Dev-mode app streams (I5-12979) — the local stand-in for the production
6
+ * harness's staged uploads/downloads:
7
+ *
8
+ * - production stages bytes in Redis (the updown resource store) behind
9
+ * /apps/{id}/view/_uploads and /view/_downloads; here an in-memory store behind the
10
+ * same-origin /_uploads and /_downloads the origin-mode client uses
11
+ * - production's `uploads`/`downloads` bag services move bytes host-side
12
+ * (COPY FROM STDIN, pg-query-stream); dev has no pg client, only the
13
+ * `_sql` proxy `query()` rides on, so copyInto() is emulated as batched
14
+ * parameterized INSERTs, a bytea query() parameter becomes a `\x…` hex
15
+ * string (Postgres' text-format bytea input), and fromQuery() reads rows
16
+ * through query() and encodes them locally
17
+ *
18
+ * The protocol, geometry rules, error statuses (412 + missing chunks, 409
19
+ * incomplete, 413 over the cap) and handle shapes mirror
20
+ * modules/app/lib/app-streams.js so an app that works here works deployed.
21
+ * The emulation gaps (no extractText, INSERT vs COPY semantics) are named in
22
+ * the errors and docs rather than papered over.
23
+ */
24
+
25
+ const MB = 1024 * 1024;
26
+
27
+ export const LIMITS = Object.freeze({
28
+ maxUploadBytes: 100 * MB,
29
+ ttlSeconds: 60 * 60,
30
+ maxInlineBytes: 10 * MB,
31
+ maxChunkBytes: 8 * MB,
32
+ maxStreamsPerUser: 32,
33
+ maxStagedBytesPerUser: 512 * MB
34
+ });
35
+
36
+ export const DEFAULT_CHUNK_BYTES = 4 * MB;
37
+
38
+ const MIME = {
39
+ '.csv': 'text/csv', '.json': 'application/json', '.jsonl': 'application/x-ndjson', '.txt': 'text/plain',
40
+ '.html': 'text/html', '.xml': 'application/xml', '.pdf': 'application/pdf', '.png': 'image/png',
41
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.zip': 'application/zip',
42
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
43
+ };
44
+
45
+ export function mimeFor(filename) {
46
+ return MIME[extname(filename || '').toLowerCase()] || null;
47
+ }
48
+
49
+ export function defaultContentType(format) {
50
+ return { csv: 'text/csv', json: 'application/json', jsonl: 'application/x-ndjson' }[format] || null;
51
+ }
52
+
53
+ /** An Error with the HTTP status (and optional data) the prod boom would carry. */
54
+ export function httpError(status, message, data) {
55
+ const err = new Error(message);
56
+ err.statusCode = status;
57
+ if (data !== undefined) err.data = data;
58
+ return err;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Store
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * In-memory stream store. Uploads hold indexed chunks; downloads hold an
67
+ * append-only buffer list. Expiry is enforced lazily on access.
68
+ */
69
+ export function createStreamStore(limits = LIMITS) {
70
+ const items = new Map();
71
+
72
+ function sweep(now = Date.now()) {
73
+ for (const [id, item] of items) {
74
+ if (item.expiresAt <= now) items.delete(id);
75
+ }
76
+ }
77
+
78
+ function touch(item) {
79
+ item.expiresAt = Date.now() + limits.ttlSeconds * 1000;
80
+ return item;
81
+ }
82
+
83
+ function create(type, props) {
84
+ const item = touch({
85
+ id: randomUUID(),
86
+ type,
87
+ filename: props.filename || null,
88
+ fingerprint: props.fingerprint || null,
89
+ contentType: props.contentType || null,
90
+ size: props.size || 0,
91
+ // What this stream counts for while it is staged: an upload's
92
+ // declared size, a download's ceiling until sealed (see reserve).
93
+ reserved: props.reserved || props.size || 0,
94
+ chunkSize: props.chunkSize || null,
95
+ chunks: props.chunks || 0,
96
+ complete: Boolean(props.complete),
97
+ chunkData: new Map(),
98
+ parts: [],
99
+ createdAt: Date.now()
100
+ });
101
+ items.set(item.id, item);
102
+ return item;
103
+ }
104
+
105
+ function get(type, id) {
106
+ sweep();
107
+ const item = items.get(id);
108
+ return item && item.type === type ? item : null;
109
+ }
110
+
111
+ function bytes(item) {
112
+ if (item.type === 'upload') {
113
+ const ordered = [];
114
+ for (let n = 1; n <= item.chunks; n++) ordered.push(item.chunkData.get(n) || Buffer.alloc(0));
115
+ return Buffer.concat(ordered);
116
+ }
117
+ return Buffer.concat(item.parts);
118
+ }
119
+
120
+ function remove(id) {
121
+ return items.delete(id);
122
+ }
123
+
124
+ /**
125
+ * The prod per-user staging ledger (app-streams.js#reserveStream). Dev has
126
+ * a single user, so everything in the store is their share: too many
127
+ * staged streams is 429, too many reserved bytes is 413, same data. Like
128
+ * prod, an open download is counted at the ceiling its meter allows until
129
+ * seal() trades that for what it actually wrote — so an author meets the
130
+ * same 413 here as deployed, rather than only after they ship.
131
+ */
132
+ function reserve(size = 0) {
133
+ sweep();
134
+ const staged = [...items.values()];
135
+ if (staged.length >= limits.maxStreamsPerUser) {
136
+ throw httpError(429, `You already have ${staged.length} streams staged for this app; end, discard or download them before starting another`, { maxStreamsPerUser: limits.maxStreamsPerUser });
137
+ }
138
+ const bytes = staged.reduce((sum, item) => sum + (item.reserved || 0), 0);
139
+ if (bytes + size > limits.maxStagedBytesPerUser) {
140
+ throw httpError(413, `Staging ${size} more bytes would exceed the ${limits.maxStagedBytesPerUser} byte limit for your streams in this app (${bytes} already staged)`, { maxStagedBytesPerUser: limits.maxStagedBytesPerUser, stagedBytes: bytes });
141
+ }
142
+ }
143
+
144
+ /** Seal a download: complete, and holding only the bytes it wrote. */
145
+ function seal(item) {
146
+ item.complete = true;
147
+ item.reserved = item.size;
148
+ return touch(item);
149
+ }
150
+
151
+ return { create, get, bytes, remove, reserve, seal, sweep, touch, items, limits };
152
+ }
153
+
154
+ /** Guest-facing handle: never the store item itself. */
155
+ export function toHandle(item, extra = {}) {
156
+ return {
157
+ __appStream: item.type,
158
+ id: item.id,
159
+ filename: item.filename,
160
+ contentType: item.contentType,
161
+ fingerprint: item.fingerprint || null,
162
+ size: item.size,
163
+ expiresAt: new Date(item.expiresAt).toISOString(),
164
+ ...extra
165
+ };
166
+ }
167
+
168
+ export function isStreamRef(value, type) {
169
+ return Boolean(value) && typeof value === 'object' && value.__appStream === type && typeof value.id === 'string';
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Chunked upload protocol (mirrors app-streams.js)
174
+ // ---------------------------------------------------------------------------
175
+
176
+ export function createUpload(store, { filename, contentType, size, chunkSize, fingerprint }) {
177
+ const { limits } = store;
178
+ if (typeof filename !== 'string' || !filename || /[\\/\x00-\x1F]/.test(filename)) {
179
+ throw httpError(400, 'filename is required and may not contain path separators or control characters');
180
+ }
181
+ if (!Number.isInteger(size) || size < 0) throw httpError(400, 'size must be a non-negative integer');
182
+ if (size > limits.maxUploadBytes) {
183
+ throw httpError(413, `Upload exceeds the ${limits.maxUploadBytes} byte limit`, { maxUploadBytes: limits.maxUploadBytes });
184
+ }
185
+ const effective = Math.min(Math.max(1, chunkSize || DEFAULT_CHUNK_BYTES), limits.maxChunkBytes);
186
+ const chunks = Math.ceil(size / effective);
187
+ store.reserve(size);
188
+ return store.create('upload', {
189
+ filename,
190
+ contentType: contentType || mimeFor(filename) || 'application/octet-stream',
191
+ size,
192
+ fingerprint: fingerprint || null,
193
+ chunkSize: effective,
194
+ chunks,
195
+ complete: chunks === 0
196
+ });
197
+ }
198
+
199
+ export function expectedChunkBytes(item, n) {
200
+ return n < item.chunks ? item.chunkSize : item.size - item.chunkSize * (item.chunks - 1);
201
+ }
202
+
203
+ export function writeUploadChunk(store, item, n, contents) {
204
+ if (!Number.isInteger(n) || n < 1 || n > item.chunks) {
205
+ throw httpError(422, `Chunk ${n} is outside the expected range 1..${item.chunks}`);
206
+ }
207
+ if (item.complete) throw httpError(409, 'Upload is already complete');
208
+ const expected = expectedChunkBytes(item, n);
209
+ if (contents.length !== expected) {
210
+ throw httpError(422, `Chunk ${n}: expected ${expected} bytes, received ${contents.length}`);
211
+ }
212
+ item.chunkData.set(n, Buffer.from(contents));
213
+ store.touch(item);
214
+ }
215
+
216
+ export function missingChunks(item) {
217
+ const received = [...item.chunkData.keys()].sort((a, b) => a - b);
218
+ const missing = [];
219
+ for (let n = 1; n <= item.chunks; n++) if (!item.chunkData.has(n)) missing.push(n);
220
+ return { received, missing };
221
+ }
222
+
223
+ export function completeUpload(store, item) {
224
+ if (item.complete) return item;
225
+ const { missing } = missingChunks(item);
226
+ if (missing.length) {
227
+ throw httpError(412, `Upload is missing ${missing.length} chunk(s)`, { missing: missing.slice(0, 50) });
228
+ }
229
+ item.complete = true;
230
+ store.touch(item);
231
+ return item;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Encoders / parsers (no dependencies — the plugin ships with dotenv + yaml only)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function csvCell(value, delimiter) {
239
+ if (value === null || value === undefined) return '';
240
+ const s = typeof value === 'object' ? JSON.stringify(value) : String(value);
241
+ return /["\r\n]/.test(s) || s.includes(delimiter) ? `"${s.replace(/"/g, '""')}"` : s;
242
+ }
243
+
244
+ /** Rows → CSV text. Header from opts.columns or the first row's keys. */
245
+ export function encodeRows(rows, { format = 'csv', columns, header = true, delimiter = ',' } = {}) {
246
+ if (format === 'json') {
247
+ return JSON.stringify(columns && columns.length ? rows.map(r => Object.fromEntries(columns.map(c => [c, r[c]]))) : rows);
248
+ }
249
+ if (format === 'jsonl') {
250
+ return rows.map(r => `${JSON.stringify(r)}\n`).join('');
251
+ }
252
+ if (format !== 'csv') throw httpError(400, `Unknown stream format "${format}" (expected csv, json or jsonl)`);
253
+ if (!rows.length && !(columns && columns.length)) return '';
254
+ const cols = columns && columns.length ? columns : Object.keys(rows[0]);
255
+ const cell = v => csvCell(v, delimiter);
256
+ const lines = [];
257
+ if (header) lines.push(cols.map(cell).join(delimiter));
258
+ for (const r of rows) lines.push(cols.map(c => cell(r[c])).join(delimiter));
259
+ return `${lines.join('\n')}\n`;
260
+ }
261
+
262
+ /**
263
+ * RFC 4180-ish CSV parser: quoted fields, doubled quotes, CRLF/LF, custom
264
+ * delimiter. Returns string[][] (no header handling — callers decide).
265
+ */
266
+ export function parseCsv(text, { delimiter = ',', quote = '"' } = {}) {
267
+ const rows = [];
268
+ let row = [];
269
+ let field = '';
270
+ let quoted = false;
271
+ let i = 0;
272
+ while (i < text.length) {
273
+ const ch = text[i];
274
+ if (quoted) {
275
+ if (ch === quote) {
276
+ if (text[i + 1] === quote) { field += quote; i += 2; continue; }
277
+ quoted = false; i++; continue;
278
+ }
279
+ field += ch; i++; continue;
280
+ }
281
+ if (ch === quote) { quoted = true; i++; continue; }
282
+ if (ch === delimiter) { row.push(field); field = ''; i++; continue; }
283
+ if (ch === '\r') { i++; continue; }
284
+ if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
285
+ field += ch; i++;
286
+ }
287
+ if (field.length || row.length) { row.push(field); rows.push(row); }
288
+ return rows;
289
+ }
290
+
291
+ /** Postgres text-format bytea literal for a Buffer: '\x' + hex. */
292
+ export function byteaLiteral(buf) {
293
+ return `\\x${Buffer.from(buf).toString('hex')}`;
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Bag services
298
+ // ---------------------------------------------------------------------------
299
+
300
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_$]*$/;
301
+
302
+ function quoteIdent(ident, what) {
303
+ if (typeof ident !== 'string' || !ident) throw httpError(400, `copyInto: ${what} is required`);
304
+ const parts = ident.split('.');
305
+ if (parts.length > 2 || parts.some(p => !IDENT_RE.test(p))) throw httpError(400, `copyInto: invalid ${what} "${ident}"`);
306
+ return parts.map(p => `"${p.replace(/"/g, '""')}"`).join('.');
307
+ }
308
+
309
+ /**
310
+ * Build the per-invocation `uploads` / `downloads` services plus the query()
311
+ * parameter resolver and the finalize hook — the dev twin of
312
+ * app-streams.js#createStreamsHostFn.
313
+ *
314
+ * @param {Object} p
315
+ * @param {Object} p.store - createStreamStore()
316
+ * @param {Function|null} p.query - the invocation's query(sql, params) (null when no dev workspace)
317
+ * @param {string} [p.base=''] - URL prefix for download.url ('' on the dev origin)
318
+ */
319
+ export function createStreamServices({ store, query, base = '' }) {
320
+ const { limits } = store;
321
+ const pending = new Set(); // downloads created and not yet ended/discarded
322
+
323
+ function requireQuery(what) {
324
+ if (!query) throw httpError(400, `${what} needs a dev workspace (INFORMER_DEV_WORKSPACE) — run: npx informer-workspace init`);
325
+ return query;
326
+ }
327
+
328
+ function ownedUpload(id) {
329
+ const item = store.get('upload', id);
330
+ if (!item) throw httpError(404, 'Not Found');
331
+ if (!item.complete) throw httpError(409, 'Upload is not complete yet');
332
+ return item;
333
+ }
334
+
335
+ function ownedDownload(id) {
336
+ const item = store.get('download', id);
337
+ if (!item) throw httpError(404, 'Not Found');
338
+ return item;
339
+ }
340
+
341
+ // Not yet sealed: writing past end() is a 409 in prod, as a chunk PUT on a
342
+ // completed upload is.
343
+ function writableDownload(id) {
344
+ const item = ownedDownload(id);
345
+ if (item.complete) throw httpError(409, 'Download is already complete');
346
+ return item;
347
+ }
348
+
349
+ /** Seal a download by id — what respond(download) and end() both do. */
350
+ async function endDownload(id) {
351
+ const item = ownedDownload(id);
352
+ pending.delete(id);
353
+ store.seal(item);
354
+ return item;
355
+ }
356
+
357
+ function inline(item) {
358
+ if (item.size > limits.maxInlineBytes) {
359
+ throw httpError(413, `Upload is ${item.size} bytes; inline reads are capped at ${limits.maxInlineBytes}. Use copyInto() or pass the upload as a query() parameter instead.`, { maxInlineBytes: limits.maxInlineBytes });
360
+ }
361
+ return store.bytes(item);
362
+ }
363
+
364
+ async function copyInto(item, table, opts = {}) {
365
+ const q = requireQuery('copyInto()');
366
+ const format = opts.format || 'csv';
367
+ if (!['csv', 'text'].includes(format)) throw httpError(400, `copyInto: unsupported format "${format}" (expected csv or text)`);
368
+ const target = quoteIdent(table, 'table');
369
+ const text = store.bytes(item).toString('utf8');
370
+ let rows = format === 'csv'
371
+ ? parseCsv(text, { delimiter: opts.delimiter || ',', quote: opts.quote || '"' })
372
+ : text.split(/\r?\n/).filter(l => l.length).map(l => l.split(opts.delimiter || '\t'));
373
+ let columns = Array.isArray(opts.columns) && opts.columns.length ? opts.columns : null;
374
+ if (format === 'csv' && opts.header !== false) {
375
+ const header = rows.shift() || [];
376
+ if (!columns) columns = header;
377
+ }
378
+ if (!columns) throw httpError(400, 'copyInto (dev): provide `columns` or a CSV header row so the emulated INSERT knows the target columns');
379
+ rows = rows.filter(r => r.length > 1 || (r.length === 1 && r[0] !== ''));
380
+ const nullToken = opts.null !== undefined ? String(opts.null) : (format === 'csv' ? '' : '\\N');
381
+ const cols = columns.map(c => quoteIdent(c, 'column')).join(', ');
382
+ // Production COPY is one atomic statement; this emulation is a loop of
383
+ // INSERTs, so validate every row up front — otherwise a bad row halfway
384
+ // through leaves a partially populated table only dev ever sees.
385
+ const headerOffset = (format === 'csv' && opts.header !== false) ? 2 : 1;
386
+ rows.forEach((r, idx) => {
387
+ if (r.length !== columns.length) {
388
+ throw httpError(422, `copyInto: row ${idx + headerOffset} has ${r.length} fields, expected ${columns.length}`);
389
+ }
390
+ });
391
+ const BATCH = 500;
392
+ let rowCount = 0;
393
+ for (let i = 0; i < rows.length; i += BATCH) {
394
+ const batch = rows.slice(i, i + BATCH);
395
+ const params = [];
396
+ const values = batch.map(r => {
397
+ return `(${r.map(v => { params.push(v === nullToken ? null : v); return `$${params.length}`; }).join(', ')})`;
398
+ });
399
+ await q(`INSERT INTO ${target} (${cols}) VALUES ${values.join(', ')}`, params);
400
+ rowCount += batch.length;
401
+ }
402
+ return { rowCount };
403
+ }
404
+
405
+ const uploads = {
406
+ async get(id) {
407
+ const item = ownedUpload(id);
408
+ return {
409
+ ...toHandle(item),
410
+ text: async (encoding) => {
411
+ if (encoding !== undefined && encoding !== null && !Buffer.isEncoding(encoding)) throw httpError(422, `Unknown text encoding "${encoding}"`);
412
+ return inline(item).toString(encoding || 'utf8');
413
+ },
414
+ json: async () => {
415
+ const text = inline(item).toString('utf8');
416
+ try {
417
+ return JSON.parse(text);
418
+ } catch (err) {
419
+ throw httpError(422, `Upload is not valid JSON: ${err.message}`);
420
+ }
421
+ },
422
+ base64: async () => inline(item).toString('base64'),
423
+ extractText: async () => { throw httpError(501, 'upload.extractText() is not available in dev — the platform text extractor runs on the Informer server; test it against a deployed app'); },
424
+ copyInto: async (table, opts) => await copyInto(item, table, opts || {}),
425
+ discard: async () => { store.remove(item.id); return true; }
426
+ };
427
+ }
428
+ };
429
+
430
+ function downloadHandle(item) {
431
+ const url = `${base}/_downloads/${encodeURIComponent(item.id)}${item.filename ? `/${encodeURIComponent(item.filename)}` : ''}`;
432
+ const handle = {
433
+ ...toHandle(item, { url }),
434
+ async write(chunk) {
435
+ writableDownload(item.id);
436
+ const buf = toBytes(chunk);
437
+ item.parts.push(buf);
438
+ item.size += buf.length;
439
+ return true;
440
+ },
441
+ async writeRows(rows, opts = {}) {
442
+ writableDownload(item.id);
443
+ const format = opts.format || 'csv';
444
+ if (!item.contentType) item.contentType = defaultContentType(format);
445
+ // One header per download, emitted with the first row — like the
446
+ // streaming encoder in prod. An empty page emits nothing and must
447
+ // not count as the first, or the header would never appear.
448
+ const out = encodeRows(rows, { ...opts, format, header: !item.rowsStarted && opts.header !== false });
449
+ if (out) {
450
+ item.rowsStarted = true;
451
+ await handle.write(out);
452
+ }
453
+ return rows.length;
454
+ },
455
+ async fromQuery(sql, params, opts = {}) {
456
+ const q = requireQuery('fromQuery()');
457
+ writableDownload(item.id);
458
+ const rows = await q(sql, params || []);
459
+ const format = opts.format || 'csv';
460
+ if (!item.contentType) item.contentType = defaultContentType(format);
461
+ await handle.write(encodeRows(rows, { ...opts, format }));
462
+ return { rowCount: rows.length };
463
+ },
464
+ async end() {
465
+ return downloadHandle(await endDownload(item.id));
466
+ },
467
+ async discard() {
468
+ pending.delete(item.id);
469
+ store.remove(item.id);
470
+ return true;
471
+ }
472
+ };
473
+ return handle;
474
+ }
475
+
476
+ const downloads = {
477
+ async create({ filename, contentType } = {}) {
478
+ // No size to declare yet: reserve the ceiling the write path holds
479
+ // this download to, as prod does, and let endDownload() true it up.
480
+ store.reserve(limits.maxUploadBytes);
481
+ const item = store.create('download', { filename, contentType: contentType || mimeFor(filename), reserved: limits.maxUploadBytes });
482
+ pending.add(item.id);
483
+ return downloadHandle(item);
484
+ }
485
+ };
486
+
487
+ /** Swap upload references in query() params for `\x…` bytea literals (memoized per invocation). */
488
+ const resolved = new Map();
489
+ async function resolveQueryParams(params) {
490
+ if (!Array.isArray(params) || !params.some(p => isStreamRef(p, 'upload'))) return params;
491
+ return params.map(p => {
492
+ if (!isStreamRef(p, 'upload')) return p;
493
+ if (!resolved.has(p.id)) resolved.set(p.id, byteaLiteral(store.bytes(ownedUpload(p.id))));
494
+ return resolved.get(p.id);
495
+ });
496
+ }
497
+
498
+ /** Seal every download the handler created and neither ended nor discarded. */
499
+ async function finalize() {
500
+ for (const id of [...pending]) {
501
+ const item = store.get('download', id);
502
+ pending.delete(id);
503
+ if (item) store.seal(item);
504
+ }
505
+ }
506
+
507
+ return { uploads, downloads, resolveQueryParams, endDownload, finalize };
508
+ }
509
+
510
+ // What a guest hands write(): a string or bytes. Anything else is a 422 in
511
+ // prod, not a TypeError from Buffer.from.
512
+ function toBytes(chunk) {
513
+ if (typeof chunk === 'string') return Buffer.from(chunk, 'utf8');
514
+ if (ArrayBuffer.isView(chunk)) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
515
+ if (chunk instanceof ArrayBuffer || Array.isArray(chunk)) return Buffer.from(chunk);
516
+ throw httpError(422, `write() expects a string or bytes, got ${chunk === null ? 'null' : typeof chunk}`);
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Serving
521
+ // ---------------------------------------------------------------------------
522
+
523
+ export function contentDisposition(filename, inline = false) {
524
+ const type = inline ? 'inline' : 'attachment';
525
+ if (!filename) return type;
526
+ const ascii = filename.replace(/["\\\r\n]/g, '_').replace(/[^\x20-\x7e]/g, '_');
527
+ return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
528
+ }
529
+
530
+ // MIRROR of app-streams.js (pinned by test/streams-parity.test.js): only types
531
+ // a browser renders inertly may go out inline; everything else is forced to
532
+ // attachment and pinned shut with a CSP, so an author sees the same
533
+ // disposition here that the deployed app will apply.
534
+ export const INLINE_SAFE_TYPE_RE = /^(?:text\/(?:plain|csv|tab-separated-values|markdown)|application\/(?:json|x-ndjson|pdf)|image\/(?:png|jpeg|gif|webp|bmp|avif)|(?:audio|video)\/[\w.+-]+)(?:\s*;.*)?$/;
535
+
536
+ export const SCRIPTABLE_CSP = [
537
+ `default-src 'none'`,
538
+ `script-src 'none'`,
539
+ `style-src 'unsafe-inline'`,
540
+ `img-src data:`,
541
+ `base-uri 'none'`,
542
+ `frame-ancestors 'none'`,
543
+ `sandbox`
544
+ ].join('; ');
545
+
546
+ export function isInlineSafeType(contentType) {
547
+ return INLINE_SAFE_TYPE_RE.test(String(contentType || '').trim().toLowerCase());
548
+ }
549
+
550
+ /** Write a completed download to a Node response. Single-use unless `keep`. */
551
+ export function serveDownload(store, item, res, { keep = false, inline = false } = {}) {
552
+ const body = store.bytes(item);
553
+ const contentType = item.contentType || mimeFor(item.filename) || 'application/octet-stream';
554
+ const inlineSafe = isInlineSafeType(contentType);
555
+ res.statusCode = 200;
556
+ res.setHeader('Content-Type', contentType);
557
+ res.setHeader('Content-Disposition', contentDisposition(item.filename, inline && inlineSafe));
558
+ res.setHeader('Content-Length', String(body.length));
559
+ res.setHeader('X-Content-Type-Options', 'nosniff');
560
+ res.setHeader('Cache-Control', 'no-store');
561
+ if (!inlineSafe) {
562
+ res.setHeader('Content-Security-Policy', SCRIPTABLE_CSP);
563
+ res.setHeader('Referrer-Policy', 'no-referrer');
564
+ }
565
+ res.end(body);
566
+ if (!keep) store.remove(item.id);
567
+ }
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // Middleware: /_uploads and /_downloads on the dev origin
571
+ // ---------------------------------------------------------------------------
572
+
573
+ function sendJson(res, status, body) {
574
+ res.statusCode = status;
575
+ res.setHeader('Content-Type', 'application/json');
576
+ res.end(body === undefined ? '' : JSON.stringify(body));
577
+ }
578
+
579
+ function sendError(res, err) {
580
+ const status = err.statusCode || 500;
581
+ const payload = { statusCode: status, message: err.message };
582
+ if (err.data !== undefined) payload.data = err.data;
583
+ sendJson(res, status, payload);
584
+ }
585
+
586
+ async function readBody(req) {
587
+ const chunks = [];
588
+ for await (const chunk of req) chunks.push(chunk);
589
+ return Buffer.concat(chunks);
590
+ }
591
+
592
+ function uploadSummary(item) {
593
+ const { received, missing } = missingChunks(item);
594
+ return toHandle(item, { chunkSize: item.chunkSize, chunks: item.chunks, complete: item.complete, received, missing });
595
+ }
596
+
597
+ /**
598
+ * Connect middleware for the upload protocol. Mount at '/_uploads' — the
599
+ * mount prefix is already stripped from req.url by the time it runs.
600
+ */
601
+ export function createUploadsMiddleware(store) {
602
+ return async function uploadsMiddleware(req, res, next) {
603
+ try {
604
+ const url = new URL(req.url, 'http://dev.local');
605
+ const parts = url.pathname.split('/').filter(Boolean);
606
+ const method = req.method.toUpperCase();
607
+
608
+ if (parts.length === 0 && method === 'POST') {
609
+ let payload;
610
+ try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}'); } catch { throw httpError(400, 'Invalid JSON body'); }
611
+ const item = createUpload(store, payload);
612
+ res.setHeader('Location', `/_uploads/${item.id}`);
613
+ return sendJson(res, 201, uploadSummary(item));
614
+ }
615
+ if (parts.length === 0) return next();
616
+
617
+ const item = store.get('upload', parts[0]);
618
+ if (!item) throw httpError(404, 'Not Found');
619
+
620
+ if (parts.length === 1 && method === 'GET') return sendJson(res, 200, uploadSummary(item));
621
+ if (parts.length === 1 && method === 'DELETE') { store.remove(item.id); res.statusCode = 204; return res.end(); }
622
+ if (parts.length === 2 && parts[1] === '_complete' && method === 'POST') {
623
+ completeUpload(store, item);
624
+ return sendJson(res, 200, uploadSummary(item));
625
+ }
626
+ if (parts.length === 2 && method === 'PUT') {
627
+ const n = parseInt(parts[1], 10);
628
+ if (!Number.isInteger(n) || n < 1) throw httpError(400, 'Chunk number must be a positive integer');
629
+ const body = await readBody(req);
630
+ if (body.length > store.limits.maxChunkBytes) throw httpError(413, `Chunk exceeds the ${store.limits.maxChunkBytes} byte limit`);
631
+ writeUploadChunk(store, item, n, body);
632
+ res.statusCode = 204;
633
+ return res.end();
634
+ }
635
+ return next();
636
+ } catch (err) {
637
+ return sendError(res, err);
638
+ }
639
+ };
640
+ }
641
+
642
+ /** Connect middleware for GET /_downloads/{id}/{filename?}?keep&inline. */
643
+ export function createDownloadsMiddleware(store) {
644
+ return function downloadsMiddleware(req, res, next) {
645
+ try {
646
+ if (req.method.toUpperCase() !== 'GET') return next();
647
+ const url = new URL(req.url, 'http://dev.local');
648
+ const [id] = url.pathname.split('/').filter(Boolean);
649
+ if (!id) return next();
650
+ const item = store.get('download', id);
651
+ if (!item || !item.complete) throw httpError(404, 'Not Found');
652
+ return serveDownload(store, item, res, {
653
+ keep: url.searchParams.get('keep') === 'true',
654
+ inline: url.searchParams.get('inline') === 'true'
655
+ });
656
+ } catch (err) {
657
+ return sendError(res, err);
658
+ }
659
+ };
660
+ }
package/src/env.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import dotenv from 'dotenv';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { resolve, dirname, parse as parsePath } from 'node:path';
4
4
 
5
5
  /**
@@ -71,6 +71,22 @@ export function envWritePath({ mode, cwd } = {}) {
71
71
  return resolve(dir, useMode ? `.env.${mode}` : '.env');
72
72
  }
73
73
 
74
+ /**
75
+ * A variable as defined in the app's OWN env file (the one envWritePath
76
+ * names), ignoring the shell and any parent .env loadEnv walked up to. Null
77
+ * when the file is missing or leaves the variable unset or empty.
78
+ *
79
+ * @param {string} name
80
+ * @param {{ mode?: string, cwd?: string }} options
81
+ * @returns {string|null}
82
+ */
83
+ export function localEnvValue(name, { mode, cwd } = {}) {
84
+ const path = envWritePath({ mode, cwd });
85
+ if (!existsSync(path)) return null;
86
+ const value = dotenv.parse(readFileSync(path, 'utf8'))[name];
87
+ return value ? value : null;
88
+ }
89
+
74
90
  /**
75
91
  * Parse --mode <name> from a process.argv array.
76
92
  *