@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.
- package/dist/client-bundle.d.ts.map +1 -1
- package/dist/client-bundle.js +68 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/prod-server.d.ts +4 -4
- package/dist/prod-server.d.ts.map +1 -1
- package/dist/prod-server.js +118 -22
- package/dist/response-pipeline.d.ts +54 -0
- package/dist/response-pipeline.d.ts.map +1 -0
- package/dist/response-pipeline.js +422 -0
- package/dist/server-only-plugin.d.ts +77 -0
- package/dist/server-only-plugin.d.ts.map +1 -0
- package/dist/server-only-plugin.js +233 -0
- package/dist/server-only.d.ts +99 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/server-only.js +283 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client-bundle.d.ts","sourceRoot":"","sources":["../src/client-bundle.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"client-bundle.d.ts","sourceRoot":"","sources":["../src/client-bundle.ts"],"names":[],"mappings":"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"}
|
package/dist/client-bundle.js
CHANGED
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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'),
|
package/dist/prod-server.d.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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;
|
|
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"}
|
package/dist/prod-server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
-
import { resolve,
|
|
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
|
|
28
|
-
*
|
|
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
|
-
|
|
45
|
+
const headers = Object.fromEntries(response.headers);
|
|
32
46
|
const body = response.body;
|
|
33
|
-
if (body
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
|
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
|
-
|
|
655
|
-
process
|
|
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"}
|