@vesk/adapter 0.2.67 → 0.2.69

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 +1 @@
1
- {"version":3,"file":"client-bundle.d.ts","sourceRoot":"","sources":["../src/client-bundle.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,SAAS,EAAE,mBAAmB,EAAE,kBAAkB,EAAmF,MAAM,yBAAyB,CAAC;AAuEnL;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BhE;AAED;;;;;GAKG;AACH,UAAU,YAAY;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAC7B,EAAE,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvB,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,IAAI,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE;gBAAE,IAAI,CAAC,EAAE,MAAM,CAAC;gBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,CAAA;KAAE,CAAC,CAAC;IACtH,WAAW,CAAC,EAAE;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,MAAM,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAC1C,YAAY,CAAC,EAAE,KAAK,CAAC;YAAE,IAAI,CAAC,EAAE;gBAAE,IAAI,CAAC,EAAE,MAAM,CAAC;gBAAC,MAAM,CAAC,EAAE;oBAAE,IAAI,CAAC,EAAE,MAAM,CAAC;oBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;iBAAE,CAAA;aAAE,CAAA;SAAE,CAAC,CAAC;KAC/F,CAAC;IACF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAmDD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAKzD;AAkCD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA+B7D;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAyBlD;AAuQD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA+B5D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CASxD;AA4CD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,OAAO,GAAG,MAAM,CAoBnG;AAED,yEAAyE;AACzE,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,MAAM,CAKtE;AAED,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,SAAS,EAAE,EACtB,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,kBAAkB,CAAC,CA2pB7B;AAMD,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CA+B3D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAUlE;AAoCD;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAI/F"}
1
+ {"version":3,"file":"client-bundle.d.ts","sourceRoot":"","sources":["../src/client-bundle.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,7 @@ import { resolveComponentName } from '@vesk/compiler/src/server-codegen';
12
12
  import { collectVskImportPaths, vskImportLines } from '@vesk/compiler/src/vsk-imports';
13
13
  import { inlineMdContentAttrs, guessProjectRoots } from '@vesk/compiler/src/md-inline';
14
14
  import { resolveImportPath, findTsconfigPath } from '@vesk/compiler/src/module-imports';
15
+ import { serverOnlyPlugin } from '@vesk/adapter/src/server-only-plugin';
15
16
  import { availableParallelism } from 'node:os';
16
17
  /**
17
18
  * Run `fn` over `items` with at most `limit` in flight, preserving input
@@ -256,6 +257,28 @@ export function nativeLibraryImportStubs(code) {
256
257
  });
257
258
  return lines.join('\n') + '\n\n';
258
259
  }
260
+ /**
261
+ * Report, and under `--strict` fail on, a server-only package that reached a
262
+ * client chunk.
263
+ *
264
+ * Not an error by default: importing `../db.ts` from a page is the normal way
265
+ * to write a server-rendered page, and the import is harmless as long as the
266
+ * binding is only touched during SSR — which is the whole reason the bundler
267
+ * substitutes a throwing stub instead of breaking the module. `--strict` is the
268
+ * gate for authors who want the build to refuse it outright, the same bargain
269
+ * `--strict` already makes for unresolvable chunk imports.
270
+ */
271
+ function reportServerOnlyImports(chunkName, hits, strict) {
272
+ const detail = [...new Set(hits.map(h => `"${h.binding}" from "${h.specifier}"`))].join(', ');
273
+ if (strict) {
274
+ throw new Error(`[vesk] ${chunkName} imports a server-only package in client code: ${detail}. ` +
275
+ 'Move that work into an API route (app/api/**/route.ts) or a server action. ' +
276
+ 'Pass without --strict to allow it (the binding is replaced by a throwing stub).');
277
+ }
278
+ console.error(`[vesk] ${chunkName}: removed server-only import from client code (${detail}). ` +
279
+ 'It is replaced by a stub that throws if the component calls it at runtime — ' +
280
+ 'server-only work belongs in an API route or a server action.');
281
+ }
259
282
  /**
260
283
  * Demotes surviving value exports to plain declarations (`export const x`
261
284
  * becomes `const x`) and drops bare re-export lists (`export { x }`,
@@ -1008,7 +1031,7 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
1008
1031
  console.error('[vesk] chunk import merge aborted for', chunkName + ':', chunkImports.reason);
1009
1032
  const head = chunkImports.render();
1010
1033
  const code = (head ? head + '\n\n' : '') + chunkCode.join('\n\n');
1011
- chunkEntries.push({ name: chunkName, code, node, importsTable: chunkImports, imports: chunkHasImports(head.length, !chunkImports.ok) });
1034
+ chunkEntries.push({ name: chunkName, code, node, importsTable: chunkImports, imports: chunkHasImports(head.length, !chunkImports.ok), });
1012
1035
  }
1013
1036
  }
1014
1037
  walkSplit(node.children || [], [..._chain, node]);
@@ -1027,7 +1050,7 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
1027
1050
  console.error('[vesk] chunk import merge aborted for shared.js:', sharedImports.reason);
1028
1051
  const head = sharedImports.render();
1029
1052
  const code = (head ? head + '\n\n' : '') + sharedCode.join('\n\n');
1030
- chunkEntries.push({ name: SHARED_CHUNK_NAME, code, node: null, importsTable: sharedImports, imports: chunkHasImports(head.length, !sharedImports.ok) });
1053
+ chunkEntries.push({ name: SHARED_CHUNK_NAME, code, node: null, importsTable: sharedImports, imports: chunkHasImports(head.length, !sharedImports.ok), });
1031
1054
  }
1032
1055
  const chunkIIFE = (body) => `(()=>{\nconst __components = globalThis.__components || (globalThis.__components = {});\nconst __hydrators = globalThis.__hydrators || (globalThis.__hydrators = {});\n${body}\n})();\n`;
1033
1056
  /**
@@ -1187,6 +1210,19 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
1187
1210
  const tmpFile = join(tmpBase, 'entry.js');
1188
1211
  const toBundle = `const __components = globalThis.__components || (globalThis.__components = {});\nconst __hydrators = globalThis.__hydrators || (globalThis.__hydrators = {});\n${entry.code}\n`;
1189
1212
  writeFileSync(tmpFile, toBundle);
1213
+ // Keeps a database driver out of a browser bundle. The interception has
1214
+ // to be here, inside the bundler, because that is the only place the
1215
+ // transitive import `db.ts → slintorm → pg` is ever visible: by now the
1216
+ // chunk's own import list is merged, and the offender is almost never
1217
+ // in it.
1218
+ const serverOnlyHits = [];
1219
+ const driverHits = [];
1220
+ const eagerHits = [];
1221
+ const serverOnlyGuard = serverOnlyPlugin({
1222
+ onStub: hit => serverOnlyHits.push(hit),
1223
+ onDriver: hit => driverHits.push(hit),
1224
+ onEager: hit => eagerHits.push(hit),
1225
+ });
1190
1226
  try {
1191
1227
  // Pass the app tsconfig so esbuild resolves tsconfig `paths` aliases
1192
1228
  // (`@/x`, `@app/x`) inside transitively bundled modules too.
@@ -1212,7 +1248,7 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
1212
1248
  // shared-modules.js serves every route chunk. (esbuild's `alias`
1213
1249
  // option only accepts bare package names, so this is a resolver
1214
1250
  // rather than a map.)
1215
- ...(sharedRedirectPlugin ? { plugins: [sharedRedirectPlugin] } : {}),
1251
+ ...(sharedRedirectPlugin ? { plugins: [sharedRedirectPlugin, serverOnlyGuard] } : { plugins: [serverOnlyGuard] }),
1216
1252
  ...(appTsconfig ? { tsconfig: appTsconfig } : {}),
1217
1253
  });
1218
1254
  finalCode = result.outputFiles[0].text;
@@ -1247,6 +1283,35 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
1247
1283
  }
1248
1284
  catch { }
1249
1285
  }
1286
+ // Checked OUTSIDE the try above, deliberately. A TCP driver cannot be
1287
+ // stubbed into a browser bundle — a page that looked fine and failed on
1288
+ // its first query would be worse than a build error — and the catch
1289
+ // block degrades every failure into "strip the imports and carry on",
1290
+ // which is exactly the wrong outcome for this one.
1291
+ if (eagerHits.length > 0) {
1292
+ // A stub would throw while the chunk is still evaluating, so the page
1293
+ // would die before a component existed. That is the worst place to
1294
+ // learn about it: refuse the build instead.
1295
+ const detail = eagerHits
1296
+ .map(h => `${h.importer} uses ${h.bindings.map(b => `"${b}"`).join(', ')} from "${h.specifier}" at module scope`)
1297
+ .join('; ');
1298
+ console.error('[vesk] server-only package used at module scope in client code', entry.name + ':', detail);
1299
+ throw new Error(`[vesk] ${entry.name}: ${detail}.\n` +
1300
+ 'A module-scope statement runs the moment the browser evaluates the chunk, so a ' +
1301
+ 'server-only package cannot be substituted there. Move that work into an API route ' +
1302
+ '(app/api/**/route.ts) or a server action — if the binding is only used from inside ' +
1303
+ 'a function, this build already handles it.');
1304
+ }
1305
+ if (driverHits.length > 0) {
1306
+ const detail = [...new Set(driverHits.map(h => `${h.specifier} (from ${h.importer})`))].join(', ');
1307
+ console.error('[vesk] chunk bundle contains a TCP-only driver', entry.name + ':', detail);
1308
+ throw new Error(`[vesk] ${entry.name} pulls a TCP-only driver into the browser bundle: ${detail}. ` +
1309
+ "A browser cannot open a socket. Import the package's edge/browser-safe entry if it " +
1310
+ 'ships one, or keep database access in an API route (app/api/**/route.ts) or a server action.');
1311
+ }
1312
+ if (serverOnlyHits.length > 0) {
1313
+ reportServerOnlyImports(entry.name, serverOnlyHits, options?.strict);
1314
+ }
1250
1315
  }
1251
1316
  else {
1252
1317
  // No-import chunk: wrap the compiled source in the registry IIFE. The
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACe,YAAY,EAAE,WAAW,EAC7B,UAAU,EAChC,MAAM,yBAAyB,CAAC;AAoDjC;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;CACjB;AACD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,iBAAiB,EAAE,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,UAAU,EAAE,EACrB,OAAO,EAAE,iBAAiB,EAAE,GAAG,IAAI,GAAG,SAAS,GAC9C,UAAU,EAAE,CAQd;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,UAAU,EAAE,EACrB,KAAK,EAAE,eAAe,GAAG,IAAI,GAAG,SAAS,GACxC,UAAU,EAAE,CAGd;AAkDD,wBAAsB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAiXpG;AAED,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAIhE,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,EACzB,eAAe,GAChB,MAAM,2BAA2B,CAAC;AACnC,YAAY,EACV,mBAAmB,EACnB,YAAY,EACZ,kBAAkB,EAClB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,2BAA2B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EACe,YAAY,EAAE,WAAW,EAC7B,UAAU,EAChC,MAAM,yBAAyB,CAAC;AAoDjC;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;CACjB;AACD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,iBAAiB,EAAE,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,UAAU,EAAE,EACrB,OAAO,EAAE,iBAAiB,EAAE,GAAG,IAAI,GAAG,SAAS,GAC9C,UAAU,EAAE,CAQd;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,UAAU,EAAE,EACrB,KAAK,EAAE,eAAe,GAAG,IAAI,GAAG,SAAS,GACxC,UAAU,EAAE,CAGd;AAkDD,wBAAsB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CA0XpG;AAED,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAIhE,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,EACzB,eAAe,GAChB,MAAM,2BAA2B,CAAC;AACnC,YAAY,EACV,mBAAmB,EACnB,YAAY,EACZ,kBAAkB,EAClB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,2BAA2B,CAAC"}
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { compileMiddleware, compileMiddlewareCode } from '@vesk/adapter/src/midd
15
15
  import { compileEvents } from '@vesk/adapter/src/events';
16
16
  import { generateClientBundle } from '@vesk/adapter/src/client-bundle';
17
17
  import { generateManifest } from '@vesk/adapter/src/manifest';
18
+ import { isSocketlessTarget, tcpDriverViolations, edgeDriverBuildError } from '@vesk/adapter/src/server-only';
18
19
  import { copyStaticAssets } from '@vesk/adapter/src/static';
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  async function resolveCompilerApi(name) {
@@ -204,6 +205,15 @@ export async function build(appDir, options) {
204
205
  if (pluginHeadExtra)
205
206
  console.error('vesk build: baked plugin head → config.json + SSR functions');
206
207
  console.error(`vesk build: output → ${outDir}`);
208
+ // Socketless-target gate, before anything is compiled or bundled. An edge
209
+ // runtime has no TCP socket, so a build that imports a socket driver produces
210
+ // a function that fails on its first query — in production, from a log. Fail
211
+ // here instead, naming the file, the line and the driver.
212
+ if (isSocketlessTarget(options?.platform, options?.target)) {
213
+ const driverHits = tcpDriverViolations(appDir);
214
+ if (driverHits.length > 0)
215
+ throw edgeDriverBuildError(driverHits);
216
+ }
207
217
  const dirs = [
208
218
  resolve(outDir, 'server', 'functions'),
209
219
  resolve(outDir, 'server', 'api'),
@@ -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"}