@vesk/adapter 0.2.67 → 0.2.68

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.
@@ -1,10 +1,10 @@
1
1
  import { type Server } from 'node:http';
2
+ import type { CompressionConfig, ProdServerOptions } from '@vesk/adapter/src/types';
2
3
  export declare function bodyTooLarge(maxBytes: number): Error & {
3
4
  status: number;
4
5
  };
5
- export declare function startProdServer(outDir: string, options?: {
6
- port?: number;
7
- host?: string;
8
- maxBodyBytes?: number;
6
+ export declare function startProdServer(outDir: string, options?: ProdServerOptions & {
7
+ compression?: CompressionConfig | false;
8
+ healthCheck?: boolean;
9
9
  }): Promise<Server>;
10
10
  //# sourceMappingURL=prod-server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"prod-server.d.ts","sourceRoot":"","sources":["../src/prod-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAkDjG,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAIzE;AAkGD,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAkiBxI"}
1
+ {"version":3,"file":"prod-server.d.ts","sourceRoot":"","sources":["../src/prod-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAajG,OAAO,KAAK,EAAkB,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAuFpG,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAIzE;AA0FD,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG;IAAE,WAAW,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqlBvK"}
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs';
2
- import { resolve, extname, dirname, basename } from 'node:path';
2
+ import { resolve, dirname, basename } from 'node:path';
3
3
  import { createServer } from 'node:http';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createRateLimiter, resolveComponentName, safeJsonForScript, getClientProtocol, DEFAULT_MAX_BODY_BYTES } from '@vesk/compiler/src/server-codegen';
@@ -11,6 +11,8 @@ import { resolveWithin, installMdReadHook } from '@vesk/adapter/src/paths';
11
11
  import { resolveCssUrls, hasBuiltGlobalCss } from '@vesk/adapter/src/css';
12
12
  import { loadVeskConfig } from '@vesk/adapter/src/load-config';
13
13
  import { renderDefaultNotFoundPage, renderDefaultErrorPage } from '@vesk/adapter/src/default-pages';
14
+ import { installResponsePipeline } from '@vesk/adapter/src/response-pipeline';
15
+ import { mimeFor } from '@vesk/adapter/src/platform-output';
14
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
17
  async function readBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) {
16
18
  const chunks = [];
@@ -23,15 +25,68 @@ async function readBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) {
23
25
  }
24
26
  return Buffer.concat(chunks);
25
27
  }
28
+ /** Open-ended content types. Buffering one would hold the response until the
29
+ * stream closed — which, for a never-ending event stream, is never. */
30
+ const STREAMING_TYPES = new Set(['text/event-stream', 'application/x-ndjson']);
31
+ /** Above this, a "buffered" body is streamed instead rather than held whole. */
32
+ const DELIVER_BUFFER_LIMIT = 1024 * 1024;
26
33
  /**
27
- * Writes a handler Response to the socket, piping a streaming body
28
- * chunk-by-chunk (SSE / text streams) instead of buffering everything.
34
+ * Writes a handler Response to the socket.
35
+ *
36
+ * A finite body is delivered as ONE `end(buffer)`, not chunk-by-chunk, and that
37
+ * is load-bearing: the response pipeline can only tag a body and revalidate it
38
+ * against `If-None-Match` — and only apply content-encoding — once it can see
39
+ * the whole body. Streaming a 2 KB JSON body would silently opt every API
40
+ * response out of both. A response that turns out to exceed the buffer limit,
41
+ * or that declares an open-ended content type, falls back to streaming so the
42
+ * memory and time-to-first-byte cost of a genuinely large body is unchanged.
29
43
  */
30
44
  async function deliverResponse(res, response) {
31
- res.writeHead(response.status, Object.fromEntries(response.headers));
45
+ const headers = Object.fromEntries(response.headers);
32
46
  const body = response.body;
33
- if (body && typeof body.getReader === 'function') {
34
- const reader = body.getReader();
47
+ if (!body || typeof body.getReader !== 'function') {
48
+ res.writeHead(response.status, headers);
49
+ res.end(await response.text());
50
+ return;
51
+ }
52
+ const contentType = String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
53
+ const reader = body.getReader();
54
+ if (STREAMING_TYPES.has(contentType)) {
55
+ res.writeHead(response.status, headers);
56
+ pumpRemaining(res, reader);
57
+ return;
58
+ }
59
+ const chunks = [];
60
+ let size = 0;
61
+ let overflow = false;
62
+ try {
63
+ for (;;) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ chunks.push(Buffer.from(value));
68
+ size += value.byteLength;
69
+ if (size > DELIVER_BUFFER_LIMIT) {
70
+ overflow = true;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ catch {
76
+ /* stream aborted by the client */
77
+ }
78
+ res.writeHead(response.status, headers);
79
+ if (overflow) {
80
+ for (const chunk of chunks)
81
+ res.write(chunk);
82
+ pumpRemaining(res, reader);
83
+ return;
84
+ }
85
+ res.end(Buffer.concat(chunks));
86
+ }
87
+ /** Drain an already-started reader onto the socket, then end it. */
88
+ function pumpRemaining(res, reader) {
89
+ void (async () => {
35
90
  try {
36
91
  for (;;) {
37
92
  const { done, value } = await reader.read();
@@ -44,9 +99,7 @@ async function deliverResponse(res, response) {
44
99
  /* stream aborted by the client */
45
100
  }
46
101
  res.end();
47
- return;
48
- }
49
- res.end(await response.text());
102
+ })();
50
103
  }
51
104
  export function bodyTooLarge(maxBytes) {
52
105
  const err = new Error(`Request body exceeds limit (${maxBytes} bytes)`);
@@ -111,13 +164,6 @@ function makeWebRequest(nodeReq, url, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
111
164
  webRequest.query = query;
112
165
  return webRequest;
113
166
  }
114
- const MIME = {
115
- '.svg': 'image/svg+xml', '.css': 'text/css', '.js': 'application/javascript',
116
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
117
- '.ico': 'image/x-icon', '.html': 'text/html', '.json': 'application/json',
118
- '.webmanifest': 'application/manifest+json',
119
- '.woff': 'font/woff', '.woff2': 'font/woff2', '.wasm': 'application/wasm',
120
- };
121
167
  export async function startProdServer(outDir, options) {
122
168
  const port = options?.port || 3000;
123
169
  const host = options?.host || '127.0.0.1';
@@ -142,11 +188,16 @@ export async function startProdServer(outDir, options) {
142
188
  const projectDir = resolve(outDir, '..');
143
189
  let securityConfig = {};
144
190
  let mdConfig;
191
+ let compressionConfig = {};
192
+ let healthCheckEnabled = true;
145
193
  try {
146
194
  const config = await loadVeskConfig(projectDir);
147
195
  if (config) {
148
196
  securityConfig = { security: config.security };
149
197
  mdConfig = config.md;
198
+ // `compression: false` disables encoding entirely; an object tunes it.
199
+ compressionConfig = config.compression === false ? { enabled: false } : (config.compression || {});
200
+ healthCheckEnabled = config.healthCheck !== false;
150
201
  }
151
202
  }
152
203
  catch (e) {
@@ -154,6 +205,13 @@ export async function startProdServer(outDir, options) {
154
205
  // config must never silently disable the app's chosen security policy.
155
206
  console.error('vesk start: failed to load vesk.config — falling back to secure defaults:', e instanceof Error ? e.message : e);
156
207
  }
208
+ // Explicit server options win over vesk.config (the CLI passes nothing here,
209
+ // so this is the programmatic path — how tests and embedders configure it).
210
+ if (options?.compression !== undefined) {
211
+ compressionConfig = options.compression === false ? { enabled: false } : options.compression;
212
+ }
213
+ if (options?.healthCheck !== undefined)
214
+ healthCheckEnabled = options.healthCheck !== false;
157
215
  if (mdConfig) {
158
216
  try {
159
217
  const { configureMd } = await import('@vesk/runtime/src/md');
@@ -262,6 +320,23 @@ export async function startProdServer(outDir, options) {
262
320
  ? 'https'
263
321
  : getClientProtocol({ headers: req.headers }, !!securityConfig?.trustProxy);
264
322
  globalThis.__vesk_ssr_base_url = `${proto}://${reqHost}`;
323
+ // Liveness probe. Deliberately the FIRST thing in the handler, ahead of the
324
+ // rate limiter, middleware and the pipeline below:
325
+ // - a probe that can be answered 429 is indistinguishable from a dead
326
+ // server to an orchestrator, which then restarts a healthy pod;
327
+ // - it must not be rewritten or authed by user middleware;
328
+ // - it must never be encoded or tagged — a load balancer revalidating a
329
+ // cached "ok" is testing its own cache, not this process.
330
+ if (healthCheckEnabled && (url.pathname === '/healthz' || url.pathname === '/_vesk/healthz')) {
331
+ const probe = JSON.stringify({ status: 'ok', uptime: Math.floor(process.uptime()) });
332
+ res.writeHead(200, {
333
+ 'Content-Type': 'application/json; charset=utf-8',
334
+ 'Content-Length': String(Buffer.byteLength(probe)),
335
+ 'Cache-Control': 'no-store',
336
+ });
337
+ res.end(probe);
338
+ return;
339
+ }
265
340
  if (rateLimiter) {
266
341
  const clientIp = getClientIpFromReq(req);
267
342
  if (!rateLimiter.check(clientIp)) {
@@ -327,11 +402,22 @@ export async function startProdServer(outDir, options) {
327
402
  }
328
403
  return origWriteHead2(statusCode, headers);
329
404
  });
405
+ // Installed LAST so it wraps the two header decorators above: its captured
406
+ // `writeHead` is their wrapper, so security headers and per-request
407
+ // overrides are still merged in — once, at flush time. One install covers
408
+ // every response the handler produces (static files, SSR HTML, API JSON,
409
+ // middleware short-circuits), so no call site can be missed.
410
+ installResponsePipeline(res, req, {
411
+ compress: compressionConfig.enabled !== false,
412
+ compressThreshold: compressionConfig.threshold,
413
+ brotliQuality: compressionConfig.brotliQuality,
414
+ gzipLevel: compressionConfig.gzipLevel,
415
+ etag: compressionConfig.etag !== false,
416
+ });
330
417
  const publicDir = resolve(staticDir, 'public');
331
418
  const rootFile = url.pathname.length > 1 ? resolveWithin(publicDir, url.pathname.slice(1)) : null;
332
419
  if (rootFile && existsSync(rootFile) && statSync(rootFile).isFile()) {
333
- const ext = extname(rootFile);
334
- res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
420
+ res.writeHead(200, { 'Content-Type': mimeFor(rootFile) });
335
421
  res.end(readFileSync(rootFile));
336
422
  return;
337
423
  }
@@ -387,9 +473,8 @@ export async function startProdServer(outDir, options) {
387
473
  return;
388
474
  }
389
475
  if (existsSync(staticPath) && statSync(staticPath).isFile()) {
390
- const ext = extname(staticPath);
391
476
  res.writeHead(200, {
392
- 'Content-Type': MIME[ext] || 'application/octet-stream',
477
+ 'Content-Type': mimeFor(staticPath),
393
478
  // A content-hashed name can never change meaning, so it is immutable
394
479
  // for a year; anything unhashed must be revalidated or a deploy would
395
480
  // serve stale JS forever.
@@ -651,8 +736,19 @@ export async function startProdServer(outDir, options) {
651
736
  server.close(() => process.exit(0));
652
737
  setTimeout(() => process.exit(0), 3000).unref();
653
738
  }
654
- process.on('SIGINT', () => shutdown('SIGINT'));
655
- process.on('SIGTERM', () => shutdown('SIGTERM'));
739
+ // Named handlers so they can be detached on close. Without this every
740
+ // start/stop cycle leaks two process listeners, which is a hard crash
741
+ // (MaxListenersExceededWarning → throw) once a host restarts the server more
742
+ // than five times — the shape every test harness and hot-restarting dev
743
+ // wrapper uses.
744
+ const onSigint = () => shutdown('SIGINT');
745
+ const onSigterm = () => shutdown('SIGTERM');
746
+ process.on('SIGINT', onSigint);
747
+ process.on('SIGTERM', onSigterm);
748
+ server.once('close', () => {
749
+ process.off('SIGINT', onSigint);
750
+ process.off('SIGTERM', onSigterm);
751
+ });
656
752
  server.listen(port, host, () => {
657
753
  console.error(`vesk production server at http://localhost:${port} (listening on ${host})`);
658
754
  });
@@ -0,0 +1,54 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export type ContentEncoding = 'br' | 'gzip';
3
+ /**
4
+ * Pick a content coding from `Accept-Encoding`.
5
+ *
6
+ * Quality values are honoured because a client is entitled to say "I accept
7
+ * gzip but not brotli" (`br;q=0`) or "gzip only at half priority". `*` applies
8
+ * to codings the header does not name. Ties go to brotli: every browser that
9
+ * sends both lists them equally, and brotli is smaller.
10
+ */
11
+ export declare function negotiateEncoding(header: string | string[] | undefined): ContentEncoding | null;
12
+ /**
13
+ * Would compressing this content type actually pay?
14
+ *
15
+ * The rule is "text-like, not already compressed". Images, fonts, video and
16
+ * archives are already compressed — a second pass costs CPU and usually makes
17
+ * the body *larger*, which is why the size check in `installResponsePipeline`
18
+ * is kept as a second line of defence rather than the only one.
19
+ */
20
+ export declare function isCompressibleType(contentType: string | undefined): boolean;
21
+ /**
22
+ * A validator for these exact bytes.
23
+ *
24
+ * sha1 rather than sha256 because this is a cache validator, not a security
25
+ * primitive (RFC 9110 requires "a function of the representation", and nginx
26
+ * ships md5 here), and every request hashes its body. Truncated to 20 bytes of
27
+ * base64 — 27 characters — which is far beyond collision range for a validator.
28
+ */
29
+ export declare function etagFor(body: Buffer): string;
30
+ /**
31
+ * `If-None-Match` satisfaction, weak comparison as RFC 9110 §13.1.2 requires
32
+ * for GET. Weak comparison ignores the `W/` prefix on either side, which is what
33
+ * makes a `W/"x"` from a proxy match our strong `"x"`.
34
+ */
35
+ export declare function ifNoneMatchSatisfied(header: string | string[] | undefined, etag: string): boolean;
36
+ export interface ResponsePipelineOptions {
37
+ /** Set false to disable content-coding negotiation. Default true. */
38
+ compress?: boolean;
39
+ /** Smallest body worth compressing, in bytes. Default 1024. */
40
+ compressThreshold?: number;
41
+ /** Set false to disable ETag/304. Default true. */
42
+ etag?: boolean;
43
+ /** brotli quality 0-11. Default 5 — 11 is several hundred ms per megabyte. */
44
+ brotliQuality?: number;
45
+ /** gzip level 1-9. Default 6. */
46
+ gzipLevel?: number;
47
+ }
48
+ /**
49
+ * Wrap `res` so every buffered response is tagged with an ETag, revalidated
50
+ * against `If-None-Match`, and encoded — in that order, because a 304 must be
51
+ * decided on the *unencoded* bytes.
52
+ */
53
+ export declare function installResponsePipeline(res: ServerResponse, req: IncomingMessage, options?: ResponsePipelineOptions): void;
54
+ //# sourceMappingURL=response-pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response-pipeline.d.ts","sourceRoot":"","sources":["../src/response-pipeline.ts"],"names":[],"mappings":"AAyCA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAKjE,MAAM,MAAM,eAAe,GAAG,IAAI,GAAG,MAAM,CAAC;AAE5C;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,eAAe,GAAG,IAAI,CAgC/F;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAwB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAWjG;AAED,MAAM,WAAW,uBAAuB;IACvC,qEAAqE;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+DAA+D;IAC/D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mDAAmD;IACnD,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,8EAA8E;IAC9E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iCAAiC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AA8BD;;;;GAIG;AACH,wBAAgB,uBAAuB,CACtC,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,eAAe,EACpB,OAAO,GAAE,uBAA4B,GACnC,IAAI,CA2ON"}
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Transport-level response pipeline: conditional GET (ETag → 304) and
3
+ * content-encoding negotiation (brotli / gzip).
4
+ *
5
+ * Both live in one wrapper because they share the same constraint: the decision
6
+ * can only be made once the whole body is known. `res.writeHead` sends headers
7
+ * to the socket immediately, so a wrapper that compressed or tagged at
8
+ * `writeHead` time would either have to buffer every response in the server (a
9
+ * second copy of every page) or get the answer wrong for streamed bodies.
10
+ * Instead this captures `writeHead` and defers it until the body is either
11
+ * complete (`end`) or discovered to be a stream (`write`).
12
+ *
13
+ * Consequences worth knowing:
14
+ *
15
+ * - **One install covers every call site.** The prod server writes headers in
16
+ * ~15 places; wrapping `res` once means a new one cannot be forgotten. A
17
+ * static file, an SSR page, a JSON API body and a middleware short-circuit
18
+ * all pass through the same path.
19
+ * - **The first `write()` opts the response out of everything.** A stream
20
+ * (SSE, a chunked `renderPageStream`) cannot be re-encoded after the first
21
+ * bytes are out, so `write()` flushes the captured headers verbatim and every
22
+ * later `write`/`end` is a passthrough. No `Content-Encoding` is ever
23
+ * announced for a body that was not encoded.
24
+ * - **The wrapper composes with header decorators.** Installing *after* another
25
+ * `writeHead` wrapper means this layer's captured reference IS that wrapper,
26
+ * so security headers and per-request overrides are still applied — at flush
27
+ * time, exactly once.
28
+ *
29
+ * Deliberately not here: range requests, `Last-Modified`/`If-Modified-Since`
30
+ * (the ETag covers the same revalidation need and cannot disagree with itself),
31
+ * and compression of already-encoded bodies.
32
+ */
33
+ import { createHash } from 'node:crypto';
34
+ import { brotliCompress, constants as zlibConstants, createBrotliCompress, createGzip, gzip, } from 'node:zlib';
35
+ import { promisify } from 'node:util';
36
+ const brotliCompressAsync = promisify(brotliCompress);
37
+ const gzipAsync = promisify(gzip);
38
+ /**
39
+ * Pick a content coding from `Accept-Encoding`.
40
+ *
41
+ * Quality values are honoured because a client is entitled to say "I accept
42
+ * gzip but not brotli" (`br;q=0`) or "gzip only at half priority". `*` applies
43
+ * to codings the header does not name. Ties go to brotli: every browser that
44
+ * sends both lists them equally, and brotli is smaller.
45
+ */
46
+ export function negotiateEncoding(header) {
47
+ if (!header)
48
+ return null;
49
+ // `header` is `string | string[]` per Node's typings, but a duplicated
50
+ // header arrives joined by ", " in practice; both shapes fold to one list.
51
+ const value = Array.isArray(header) ? header.join(',') : header;
52
+ const q = {};
53
+ for (const part of value.split(',')) {
54
+ const seg = part.trim();
55
+ if (!seg)
56
+ continue;
57
+ const semi = seg.indexOf(';');
58
+ const token = (semi === -1 ? seg : seg.slice(0, semi)).trim().toLowerCase();
59
+ if (!token)
60
+ continue;
61
+ let weight = 1;
62
+ if (semi !== -1) {
63
+ for (const param of seg.slice(semi + 1).split(';')) {
64
+ const eq = param.indexOf('=');
65
+ if (eq === -1)
66
+ continue;
67
+ if (param.slice(0, eq).trim().toLowerCase() !== 'q')
68
+ continue;
69
+ const parsed = Number.parseFloat(param.slice(eq + 1).trim());
70
+ if (Number.isFinite(parsed))
71
+ weight = parsed;
72
+ }
73
+ }
74
+ // First occurrence wins, per RFC 9110 — a client cannot raise its own
75
+ // preference by listing a coding twice.
76
+ if (!(token in q))
77
+ q[token] = weight;
78
+ }
79
+ const star = q['*'];
80
+ const br = q['br'] !== undefined ? q['br'] : star;
81
+ const gz = q['gzip'] !== undefined ? q['gzip'] : star;
82
+ if (br !== undefined && br > 0 && (gz === undefined || gz <= 0 || br >= gz))
83
+ return 'br';
84
+ if (gz !== undefined && gz > 0)
85
+ return 'gzip';
86
+ return null;
87
+ }
88
+ /**
89
+ * Would compressing this content type actually pay?
90
+ *
91
+ * The rule is "text-like, not already compressed". Images, fonts, video and
92
+ * archives are already compressed — a second pass costs CPU and usually makes
93
+ * the body *larger*, which is why the size check in `installResponsePipeline`
94
+ * is kept as a second line of defence rather than the only one.
95
+ */
96
+ export function isCompressibleType(contentType) {
97
+ if (!contentType)
98
+ return false;
99
+ const type = (contentType.split(';')[0] || '').trim().toLowerCase();
100
+ if (!type)
101
+ return false;
102
+ if (type.startsWith('text/'))
103
+ return true;
104
+ if (type === 'image/svg+xml' || type === 'image/x-icon' || type === 'image/bmp')
105
+ return true;
106
+ if (type.endsWith('+json') || type.endsWith('+xml'))
107
+ return true;
108
+ switch (type) {
109
+ case 'application/javascript':
110
+ case 'application/x-javascript':
111
+ case 'application/ecmascript':
112
+ case 'application/json':
113
+ case 'application/manifest+json':
114
+ case 'application/ld+json':
115
+ case 'application/graphql':
116
+ case 'application/x-ndjson':
117
+ case 'application/xml':
118
+ case 'application/xhtml+xml':
119
+ case 'application/rss+xml':
120
+ case 'application/atom+xml':
121
+ return true;
122
+ default:
123
+ return false;
124
+ }
125
+ }
126
+ /**
127
+ * A validator for these exact bytes.
128
+ *
129
+ * sha1 rather than sha256 because this is a cache validator, not a security
130
+ * primitive (RFC 9110 requires "a function of the representation", and nginx
131
+ * ships md5 here), and every request hashes its body. Truncated to 20 bytes of
132
+ * base64 — 27 characters — which is far beyond collision range for a validator.
133
+ */
134
+ export function etagFor(body) {
135
+ return `"${createHash('sha1').update(body).digest('base64').slice(0, 27)}"`;
136
+ }
137
+ /**
138
+ * `If-None-Match` satisfaction, weak comparison as RFC 9110 §13.1.2 requires
139
+ * for GET. Weak comparison ignores the `W/` prefix on either side, which is what
140
+ * makes a `W/"x"` from a proxy match our strong `"x"`.
141
+ */
142
+ export function ifNoneMatchSatisfied(header, etag) {
143
+ if (!header)
144
+ return false;
145
+ const value = Array.isArray(header) ? header.join(',') : header;
146
+ const candidates = value.split(',').map(s => s.trim()).filter(Boolean);
147
+ if (candidates.includes('*'))
148
+ return true;
149
+ const strip = (s) => (s.startsWith('W/') ? s.slice(2) : s).trim();
150
+ const target = strip(etag);
151
+ for (const candidate of candidates) {
152
+ if (strip(candidate) === target)
153
+ return true;
154
+ }
155
+ return false;
156
+ }
157
+ function getHeader(headers, name) {
158
+ const wanted = name.toLowerCase();
159
+ for (const key of Object.keys(headers)) {
160
+ if (key.toLowerCase() === wanted) {
161
+ const v = headers[key];
162
+ if (v === undefined)
163
+ return undefined;
164
+ return Array.isArray(v) ? v.join(', ') : String(v);
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+ function hasNoStore(cacheControl) {
170
+ if (!cacheControl)
171
+ return false;
172
+ return cacheControl.split(',').some(part => part.trim().toLowerCase() === 'no-store');
173
+ }
174
+ /** Add `value` to `Vary` without duplicating an entry already there. */
175
+ function withVary(current, value) {
176
+ if (!current)
177
+ return value;
178
+ const existing = current.split(',').map(s => s.trim().toLowerCase());
179
+ if (existing.includes(value.toLowerCase()))
180
+ return current;
181
+ if (existing.includes('*'))
182
+ return current;
183
+ return `${current}, ${value}`;
184
+ }
185
+ /**
186
+ * Wrap `res` so every buffered response is tagged with an ETag, revalidated
187
+ * against `If-None-Match`, and encoded — in that order, because a 304 must be
188
+ * decided on the *unencoded* bytes.
189
+ */
190
+ export function installResponsePipeline(res, req, options = {}) {
191
+ const compressEnabled = options.compress !== false;
192
+ const etagEnabled = options.etag !== false;
193
+ const threshold = options.compressThreshold ?? 1024;
194
+ const brotliQuality = options.brotliQuality ?? 5;
195
+ const gzipLevel = options.gzipLevel ?? 6;
196
+ const realWriteHead = res.writeHead.bind(res);
197
+ const realWrite = res.write.bind(res);
198
+ const realEnd = res.end.bind(res);
199
+ const realFlushHeaders = res.flushHeaders.bind(res);
200
+ let pendingStatus = 200;
201
+ let pendingHeaders = {};
202
+ let flushed = false;
203
+ let streaming = false;
204
+ let streamCompressor = null;
205
+ /** Emit the captured status+headers exactly once, merging in `extra`. */
206
+ function flush(extra) {
207
+ if (flushed)
208
+ return;
209
+ flushed = true;
210
+ const headers = {};
211
+ for (const [key, value] of Object.entries(pendingHeaders)) {
212
+ if (value !== undefined)
213
+ headers[key] = value;
214
+ }
215
+ for (const [key, value] of Object.entries(extra || {})) {
216
+ if (value === undefined)
217
+ delete headers[key];
218
+ else
219
+ headers[key] = value;
220
+ }
221
+ realWriteHead(pendingStatus, headers);
222
+ }
223
+ res.writeHead = ((statusCode, headers) => {
224
+ if (flushed || streaming)
225
+ return realWriteHead(statusCode, headers);
226
+ pendingStatus = statusCode;
227
+ pendingHeaders = { ...(headers || {}) };
228
+ return res;
229
+ });
230
+ /**
231
+ * Would this response's bytes differ if the client sent a different
232
+ * `Accept-Encoding`? If yes it must be announced in `Vary` whether or not
233
+ * an encoding was actually applied — a cache that stored the identity copy
234
+ * without seeing `Vary` would then serve it to a client that can decode br.
235
+ */
236
+ function encodingIsVariable() {
237
+ return compressEnabled
238
+ && !getHeader(pendingHeaders, 'content-encoding')
239
+ && isCompressibleType(getHeader(pendingHeaders, 'content-type'));
240
+ }
241
+ function startStreamCompression(encoding) {
242
+ const compressor = encoding === 'br'
243
+ ? createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: brotliQuality } })
244
+ : createGzip({ level: gzipLevel });
245
+ // The compressor owns the socket from here: its output is what the client
246
+ // receives, and its `end` is what actually finishes the response. This is
247
+ // what lets a *streamed* body still be encoded — buffering it to compute a
248
+ // validator would defeat the streaming in the first place.
249
+ compressor.on('data', (chunk) => { realWrite(chunk); });
250
+ compressor.on('end', () => { realEnd(); });
251
+ compressor.on('error', () => {
252
+ // A compressor failure must still produce a complete response object;
253
+ // the bytes already written are the unencoded prefix, which is why the
254
+ // socket has to be closed rather than left open.
255
+ try {
256
+ realEnd();
257
+ }
258
+ catch { /* already finished */ }
259
+ });
260
+ streamCompressor = compressor;
261
+ }
262
+ res.write = ((chunk, encoding) => {
263
+ if (!flushed && !streaming) {
264
+ streaming = true;
265
+ const vary = withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding');
266
+ const encodingChosen = encodingIsVariable() ? negotiateEncoding(req.headers['accept-encoding']) : null;
267
+ if (encodingChosen) {
268
+ startStreamCompression(encodingChosen);
269
+ // Content-Length cannot survive: the encoded stream's length is
270
+ // unknown until it ends, and a wrong one truncates the response.
271
+ flush({ 'Content-Encoding': encodingChosen, Vary: vary, 'Content-Length': undefined });
272
+ }
273
+ else {
274
+ flush({ Vary: encodingIsVariable() ? vary : undefined });
275
+ }
276
+ }
277
+ if (streamCompressor) {
278
+ streamCompressor.write(toBuffer(chunk, encoding) || Buffer.alloc(0));
279
+ return true;
280
+ }
281
+ return typeof encoding === 'undefined'
282
+ ? realWrite(chunk)
283
+ : realWrite(chunk, encoding);
284
+ });
285
+ res.flushHeaders = (() => {
286
+ if (!flushed) {
287
+ streaming = true;
288
+ flush();
289
+ }
290
+ realFlushHeaders.call(res);
291
+ });
292
+ function toBuffer(chunk, encoding) {
293
+ if (chunk === undefined || chunk === null)
294
+ return null;
295
+ if (Buffer.isBuffer(chunk))
296
+ return chunk;
297
+ if (typeof chunk === 'string') {
298
+ return Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8');
299
+ }
300
+ if (chunk instanceof Uint8Array)
301
+ return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
302
+ return null;
303
+ }
304
+ function finish(body, endArgs) {
305
+ const method = (req.method || 'GET').toUpperCase();
306
+ // Nothing to compress, tag or revalidate. Empty bodies include 204 and
307
+ // 304 responses, which must not carry a Content-Length anyway.
308
+ if (!body || body.length === 0 || streaming || flushed) {
309
+ flush();
310
+ realEnd(...endArgs);
311
+ return;
312
+ }
313
+ const cacheable = method === 'GET' || method === 'HEAD';
314
+ const existingEtag = getHeader(pendingHeaders, 'etag');
315
+ // `no-store` means the bytes are not reusable, so a validator would only
316
+ // produce a 304 that hands the client back a body the server has already
317
+ // consumed. This is load-bearing for the single-use `/ssr-data.js` and
318
+ // `/store-init.js` payloads, which are deleted as they are read.
319
+ const canValidate = cacheable && pendingStatus === 200 && !hasNoStore(getHeader(pendingHeaders, 'cache-control'));
320
+ if (!canValidate) {
321
+ maybeCompress(body, endArgs);
322
+ return;
323
+ }
324
+ const etag = existingEtag || (etagEnabled ? etagFor(body) : undefined);
325
+ if (etag && ifNoneMatchSatisfied(req.headers['if-none-match'], etag)) {
326
+ // A 304 carries the validator and the caching directives and nothing
327
+ // else — no body, and no Content-Type/Content-Length describing one.
328
+ const headers304 = { ETag: etag };
329
+ const cc = getHeader(pendingHeaders, 'cache-control');
330
+ if (cc)
331
+ headers304['Cache-Control'] = cc;
332
+ headers304.Vary = withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding');
333
+ flushed = true;
334
+ realWriteHead(304, headers304);
335
+ realEnd();
336
+ return;
337
+ }
338
+ if (etag && !existingEtag) {
339
+ pendingHeaders = { ...pendingHeaders, ETag: etag };
340
+ }
341
+ maybeCompress(body, endArgs);
342
+ }
343
+ function maybeCompress(body, endArgs) {
344
+ // Below the threshold the bytes are identical whatever the client asks
345
+ // for, so no `Vary` is owed — announcing one would fragment caches for
346
+ // nothing. At or above it, the representation does vary by encoding and
347
+ // `Vary` is announced whether or not a coding was chosen.
348
+ if (compressEnabled
349
+ && body.length < threshold
350
+ || !encodingIsVariable()) {
351
+ flush();
352
+ realEnd(...endArgs);
353
+ return;
354
+ }
355
+ const encoding = negotiateEncoding(req.headers['accept-encoding']);
356
+ if (!encoding) {
357
+ flush({ Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding') });
358
+ realEnd(...endArgs);
359
+ return;
360
+ }
361
+ const compress = encoding === 'br' ? brotliCompressAsync : gzipAsync;
362
+ const params = encoding === 'br'
363
+ ? { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: brotliQuality } }
364
+ : { level: gzipLevel };
365
+ void Promise.resolve()
366
+ .then(() => compress(body, params))
367
+ .then((encoded) => {
368
+ // A body that did not get smaller is not worth the encoding: it
369
+ // costs the client CPU on every fetch for nothing.
370
+ if (flushed || streaming) {
371
+ flush();
372
+ realEnd(...endArgs);
373
+ return;
374
+ }
375
+ if (encoded.length >= body.length) {
376
+ flush({ Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding') });
377
+ realEnd(...endArgs);
378
+ return;
379
+ }
380
+ flush({
381
+ 'Content-Encoding': encoding,
382
+ Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding'),
383
+ 'Content-Length': String(encoded.length),
384
+ });
385
+ realEnd(encoded);
386
+ })
387
+ .catch(() => {
388
+ // Compression must never be the reason a response is lost.
389
+ flush({ Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding') });
390
+ realEnd(...endArgs);
391
+ });
392
+ }
393
+ res.end = ((chunk, encoding, callback) => {
394
+ if (streamCompressor) {
395
+ // Ending the compressor is what ends the response — it flushes its
396
+ // own buffered tail first, so the client gets a complete stream.
397
+ if (typeof callback === 'function')
398
+ res.once('finish', callback);
399
+ const tail = toBuffer(chunk, encoding);
400
+ if (tail)
401
+ streamCompressor.end(tail);
402
+ else
403
+ streamCompressor.end();
404
+ return res;
405
+ }
406
+ if (streaming || flushed) {
407
+ return realEnd(chunk, encoding, callback);
408
+ }
409
+ // Node's end() has four shapes — (), (cb), (data), (data, enc),
410
+ // (data, cb), (data, enc, cb). Forward only the arguments that were
411
+ // actually supplied: `end(body, undefined, undefined)` is NOT the same
412
+ // as `end(body)` for a callback-taking overload.
413
+ const endArgs = chunk === undefined
414
+ ? (callback === undefined ? [] : [callback])
415
+ : encoding === undefined
416
+ ? (callback === undefined ? [chunk] : [chunk, callback])
417
+ : (callback === undefined ? [chunk, encoding] : [chunk, encoding, callback]);
418
+ const body = toBuffer(chunk, encoding);
419
+ finish(body, endArgs);
420
+ return res;
421
+ });
422
+ }
package/dist/types.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export type { RouteNode, ApiRouteNode, VeskPlugin, ServerEventContext, VeskEventHandlers, VeskSecurity, VeskCors, VeskRateLimit, VeskSecurityPreset, SecurityConfig, MiddlewareChainItem, MiddlewareExtractResult, AncestorLayout, BuildOptions, DevServerOptions, ProdServerOptions, SsrFunctionOptions, ApiFunctionOptions, ClientBundleOptions, ClientBundleFileEntry, ClientBundleChunkSpec, ClientBundleCache, ChunkEntry, ClientBundleResult, MonolithicBundleParts, Manifest, ManifestRouteEntry, ManifestActionEntry, ManifestPrerenderedEntry, SsgRouteResult, BuildResult, ImageRef, ImageResult, SeoCheckIssue, SeoAuditResult, CombinedPageInfo, Tracked, Resource, UseFetchOptions, UseFetchStreamOptions, VeskRequest, VeskResponse, SetCookieOptions, CookieStore, ServerRequest, SsrDataSink, MdProps, MdConfig, MdHtmlMode, MarkdownOptions, MdHtmlWarning, VeskLeafComponent, VeskPrimitiveComponent, VeskTaggedLeafComponent, VeskComponentRegistry, VeskScope, } from '@vesk/types';
1
+ export type { RouteNode, ApiRouteNode, VeskPlugin, ServerEventContext, VeskEventHandlers, VeskSecurity, VeskCors, VeskRateLimit, VeskSecurityPreset, SecurityConfig, MiddlewareChainItem, MiddlewareExtractResult, AncestorLayout, BuildOptions, DevServerOptions, ProdServerOptions, SsrFunctionOptions, ApiFunctionOptions, ClientBundleOptions, ClientBundleFileEntry, ClientBundleChunkSpec, ClientBundleCache, ChunkEntry, ClientBundleResult, MonolithicBundleParts, Manifest, ManifestRouteEntry, ManifestActionEntry, ManifestPrerenderedEntry, SsgRouteResult, BuildResult, ImageRef, ImageResult, SeoCheckIssue, SeoAuditResult, CombinedPageInfo, Tracked, Resource, UseFetchOptions, UseFetchStreamOptions, VeskRequest, VeskResponse, SetCookieOptions, CookieStore, ServerRequest, SsrDataSink, MdProps, MdConfig, MdHtmlMode, MarkdownOptions, CompressionConfig, MdHtmlWarning, VeskLeafComponent, VeskPrimitiveComponent, VeskTaggedLeafComponent, VeskComponentRegistry, VeskScope, } from '@vesk/types';
2
2
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,YAAY,EACV,SAAS,EACT,YAAY,EACZ,UAAU,EACV,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,EACZ,QAAQ,EACR,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,mBAAmB,EACnB,uBAAuB,EACvB,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,EACnB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,UAAU,EACV,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,cAAc,EACd,WAAW,EACX,QAAQ,EACR,WAAW,EACX,aAAa,EACb,cAAc,EACd,gBAAgB,EAChB,OAAO,EACP,QAAQ,EACR,eAAe,EACf,qBAAqB,EACrB,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACX,aAAa,EACb,WAAW,EACX,OAAO,EACP,QAAQ,EACR,UAAU,EACV,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,sBAAsB,EACtB,uBAAuB,EACvB,qBAAqB,EACrB,SAAS,GACV,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,YAAY,EACV,SAAS,EACT,YAAY,EACZ,UAAU,EACV,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,EACZ,QAAQ,EACR,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,mBAAmB,EACnB,uBAAuB,EACvB,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,EACnB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,UAAU,EACV,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,cAAc,EACd,WAAW,EACX,QAAQ,EACR,WAAW,EACX,aAAa,EACb,cAAc,EACd,gBAAgB,EAChB,OAAO,EACP,QAAQ,EACR,eAAe,EACf,qBAAqB,EACrB,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACX,aAAa,EACb,WAAW,EACX,OAAO,EACP,QAAQ,EACR,UAAU,EACV,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EACjB,sBAAsB,EACtB,uBAAuB,EACvB,qBAAqB,EACrB,SAAS,GACV,MAAM,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/adapter",
3
- "version": "0.2.67",
3
+ "version": "0.2.68",
4
4
  "type": "module",
5
5
  "description": "Vesk adapter — builds deployable .vesk/ output for Deno and Node serverless",
6
6
  "main": "./dist/index.js",
@@ -23,9 +23,9 @@
23
23
  "sharp": "^0.35.3"
24
24
  },
25
25
  "dependencies": {
26
- "@vesk/compiler": "^0.2.67",
27
- "@vesk/runtime": "^0.2.67",
28
- "@vesk/types": "^0.2.67"
26
+ "@vesk/compiler": "^0.2.68",
27
+ "@vesk/runtime": "^0.2.68",
28
+ "@vesk/types": "^0.2.68"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/ws": "^8.18.1",