@vesk/adapter 0.1.1 → 0.2.8

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/hmr.js CHANGED
@@ -3,8 +3,9 @@ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
3
3
  import { resolve, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { compileClient } from '@vesk/compiler/src/client-codegen';
6
- import { resolveComponentName } from '@vesk/compiler/src/server-codegen';
6
+ import { resolveComponentName, randomToken } from '@vesk/compiler/src/server-codegen';
7
7
  import { resolveErrorFile } from '@vesk/adapter/src/ssr-function';
8
+ import { isAllowedWsUpgrade } from '@vesk/adapter/src/paths';
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
10
  function findRouteForSource(routeTree, sourceDir) {
10
11
  for (const node of routeTree) {
@@ -297,15 +298,30 @@ function regenerateSsrFunction(routeNode, appDir, outDir, componentMap, options)
297
298
  writeFileSync(funcPath, funcCode, 'utf-8');
298
299
  }
299
300
  export function createHmrServer(httpServer, appDir, devDir, componentMap) {
300
- const wss = new WebSocketServer({ server: httpServer, path: '/_vesk/hmr' });
301
+ // Per-session nonce gating the client-side HMR eval hook (see
302
+ // appendHmrGlobals in client-bundle.ts). Broadcast with every update.
303
+ const hmrNonce = randomToken(16);
304
+ globalThis.__vesk_hmr_nonce = hmrNonce;
305
+ const wss = new WebSocketServer({ noServer: true });
301
306
  const clients = new Set();
307
+ // Origin-checked upgrade: cross-site pages always attach an Origin header
308
+ // to WebSocket handshakes, so a mismatch means a CSWS attempt — destroy it.
309
+ httpServer.on('upgrade', (req, socket, head) => {
310
+ if (req.url !== '/_vesk/hmr' || !isAllowedWsUpgrade(req.headers)) {
311
+ socket.destroy();
312
+ return;
313
+ }
314
+ wss.handleUpgrade(req, socket, head, (ws) => {
315
+ wss.emit('connection', ws, req);
316
+ });
317
+ });
302
318
  wss.on('connection', (ws) => {
303
319
  clients.add(ws);
304
320
  ws.on('close', () => clients.delete(ws));
305
321
  ws.on('error', () => clients.delete(ws));
306
322
  });
307
323
  function broadcast(type, data) {
308
- const msg = JSON.stringify({ type, ...data });
324
+ const msg = JSON.stringify({ type, nonce: hmrNonce, ...data });
309
325
  for (const ws of clients) {
310
326
  try {
311
327
  ws.send(msg);
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACe,YAAY,EAAE,WAAW,EAEnD,MAAM,yBAAyB,CAAC;AAgBjC,wBAAsB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAwSpG;AAED,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACe,YAAY,EAAE,WAAW,EAEnD,MAAM,yBAAyB,CAAC;AAgBjC,wBAAsB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CA+SpG;AAED,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC"}
package/dist/index.js CHANGED
@@ -27,6 +27,12 @@ export async function build(appDir, options) {
27
27
  const outDir = resolve(options?.outDir || resolve(appDir, '..', '.vesk'));
28
28
  const publicDir = options?.publicDir || resolve(appDir, '..', 'public');
29
29
  const plugins = options?.plugins || [];
30
+ // Apply the Markdown policy for this build so SSG prerendering renders
31
+ // (and warns about) raw HTML exactly like the running server will.
32
+ if (options?.md) {
33
+ const { configureMd } = await import('@vesk/runtime/src/md');
34
+ configureMd(options.md);
35
+ }
30
36
  for (const plugin of plugins) {
31
37
  if (typeof plugin.onBuildStart === 'function') {
32
38
  await plugin.onBuildStart();
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAA2B,MAAM,yBAAyB,CAAC;AAS5F,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA6ChG;AAED,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CA2C5E"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAA2B,MAAM,yBAAyB,CAAC;AAS5F,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgDhG;AAED,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CA8C5E"}
@@ -33,14 +33,17 @@ export function compileMiddleware(mwChain, _appDir) {
33
33
  ' if (index >= chain.length) return null;',
34
34
  ' const fn = chain[index];',
35
35
  ' let nextCalled = false;',
36
+ ' let nextResult = null;',
36
37
  ' async function next(rewrite) {',
37
38
  ' if (nextCalled) return null;',
38
39
  ' nextCalled = true;',
39
40
  ' if (rewrite) rewriteUrl = rewrite;',
40
- ' return run(index + 1);',
41
+ ' nextResult = await run(index + 1);',
42
+ ' return nextResult;',
41
43
  ' }',
42
44
  ' const result = await fn(ctx, next);',
43
45
  ' if (result instanceof Response) return result;',
46
+ ' if (nextResult instanceof Response) return nextResult;',
44
47
  ' if (!nextCalled) return run(index + 1);',
45
48
  ' return null;',
46
49
  ' }',
@@ -76,14 +79,17 @@ export function compileMiddlewareCode(mwSourceTexts) {
76
79
  ' if (index >= __mwChain.length) return null;',
77
80
  ' const fn = __mwChain[index];',
78
81
  ' let nc = false;',
82
+ ' let ncResult = null;',
79
83
  ' async function next(rewrite) {',
80
84
  ' if (nc) return null;',
81
85
  ' nc = true;',
82
86
  ' if (rewrite) rewriteUrl = rewrite;',
83
- ' return run(index + 1);',
87
+ ' ncResult = await run(index + 1);',
88
+ ' return ncResult;',
84
89
  ' }',
85
90
  ' const result = await fn(ctx, next);',
86
91
  ' if (result instanceof Response) return result;',
92
+ ' if (ncResult instanceof Response) return ncResult;',
87
93
  ' if (!nc) return run(index + 1);',
88
94
  ' return null;',
89
95
  ' }',
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Resolves `relPath` against `baseDir` and returns the absolute path ONLY if
3
+ * it stays strictly inside `baseDir` (never the directory itself, never a
4
+ * sibling). Returns null otherwise. Single source of truth for static file
5
+ * serving / prerender writes so traversal defenses cannot drift.
6
+ */
7
+ export declare function resolveWithin(baseDir: string, relPath: string): string | null;
8
+ /**
9
+ * True when an incoming WebSocket upgrade may connect: same-origin when the
10
+ * client sends an Origin header, always allowed for non-browser clients that
11
+ * omit it (matching the HMR threat model — cross-site pages always attach
12
+ * Origin to WS handshakes). Loopback aliases (localhost / 127.0.0.1 / ::1)
13
+ * are treated as equivalent so local dev works over either name.
14
+ */
15
+ export declare function isAllowedWsUpgrade(headers: Record<string, unknown>): boolean;
16
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM7E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAU5E"}
package/dist/paths.js ADDED
@@ -0,0 +1,81 @@
1
+ import { resolve, sep } from 'node:path';
2
+ /**
3
+ * Resolves `relPath` against `baseDir` and returns the absolute path ONLY if
4
+ * it stays strictly inside `baseDir` (never the directory itself, never a
5
+ * sibling). Returns null otherwise. Single source of truth for static file
6
+ * serving / prerender writes so traversal defenses cannot drift.
7
+ */
8
+ export function resolveWithin(baseDir, relPath) {
9
+ const base = resolve(baseDir);
10
+ const target = resolve(baseDir, relPath);
11
+ const prefix = base + sep;
12
+ if (!target.startsWith(prefix))
13
+ return null;
14
+ return target;
15
+ }
16
+ /**
17
+ * True when an incoming WebSocket upgrade may connect: same-origin when the
18
+ * client sends an Origin header, always allowed for non-browser clients that
19
+ * omit it (matching the HMR threat model — cross-site pages always attach
20
+ * Origin to WS handshakes). Loopback aliases (localhost / 127.0.0.1 / ::1)
21
+ * are treated as equivalent so local dev works over either name.
22
+ */
23
+ export function isAllowedWsUpgrade(headers) {
24
+ const origin = typeof headers['origin'] === 'string' ? headers['origin'] : '';
25
+ if (!origin)
26
+ return true;
27
+ const host = typeof headers['host'] === 'string' ? headers['host'] : '';
28
+ if (!host)
29
+ return false;
30
+ const originAuth = urlAuthority(origin);
31
+ if (originAuth === host.toLowerCase().trim())
32
+ return true;
33
+ const a = splitAuthority(originAuth);
34
+ const b = splitAuthority(host.toLowerCase().trim());
35
+ return a.port === b.port && isLoopback(a.hostname) && isLoopback(b.hostname);
36
+ }
37
+ function splitAuthority(authority) {
38
+ // bracketed IPv6 [::1]:3000
39
+ if (authority.startsWith('[')) {
40
+ const close = authority.indexOf(']');
41
+ if (close !== -1) {
42
+ return { hostname: authority.slice(0, close + 1), port: authority.slice(close + 2) };
43
+ }
44
+ }
45
+ const colon = authority.lastIndexOf(':');
46
+ if (colon === -1)
47
+ return { hostname: authority, port: '' };
48
+ let multiColon = false;
49
+ for (let i = 0; i < colon; i++) {
50
+ if (authority[i] === ':') {
51
+ multiColon = true;
52
+ break;
53
+ }
54
+ }
55
+ if (multiColon)
56
+ return { hostname: authority, port: '' };
57
+ return { hostname: authority.slice(0, colon), port: authority.slice(colon + 1) };
58
+ }
59
+ function isLoopback(hostname) {
60
+ const h = hostname.replace(/^\[|\]$/g, '');
61
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
62
+ }
63
+ function urlAuthority(url) {
64
+ const schemeIdx = url.indexOf('://');
65
+ if (schemeIdx === -1)
66
+ return '';
67
+ let rest = url.slice(schemeIdx + 3);
68
+ let end = rest.length;
69
+ for (let i = 0; i < rest.length; i++) {
70
+ const c = rest[i];
71
+ if (c === '/' || c === '?' || c === '#') {
72
+ end = i;
73
+ break;
74
+ }
75
+ }
76
+ rest = rest.slice(0, end);
77
+ const at = rest.lastIndexOf('@');
78
+ if (at !== -1)
79
+ rest = rest.slice(at + 1);
80
+ return rest.toLowerCase();
81
+ }
@@ -265,7 +265,8 @@ function shellFor(platform) {
265
265
  staticServe: 'disk-deno',
266
266
  bootstrap: [
267
267
  'if (typeof Deno !== "undefined" && typeof Deno.serve === "function") {',
268
- ' Deno.serve(async (request) => {',
268
+ ' const __veskPort = Number(Deno.env.get("PORT") || 8000);',
269
+ ' Deno.serve({ port: __veskPort }, async (request) => {',
269
270
  ' const staticResponse = await serveEmbeddedStatic(request);',
270
271
  ' if (staticResponse) return staticResponse;',
271
272
  ' return handleRequest(request);',
@@ -1 +1 @@
1
- {"version":3,"file":"platform-handler.d.ts","sourceRoot":"","sources":["../src/platform-handler.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAIvE,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,SAAS,EAAE,CAAC;IACvB,SAAS,EAAE,YAAY,EAAE,CAAC;IAC1B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAMpD;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED,wBAAgB,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEtC;AAQD;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CA4HjF;AAED,MAAM,WAAW,qBAAqB;IACpC,kEAAkE;IAClE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8DzF;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,SAAS,EAAE,CAAC;IACvB,SAAS,EAAE,YAAY,EAAE,CAAC;IAC1B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,aAAa,EAAE,OAAO,CAAC;CACxB"}
1
+ {"version":3,"file":"platform-handler.d.ts","sourceRoot":"","sources":["../src/platform-handler.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAIvE,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,SAAS,EAAE,CAAC;IACvB,SAAS,EAAE,YAAY,EAAE,CAAC;IAC1B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAMpD;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED,wBAAgB,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEtC;AAQD;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CAgIjF;AAED,MAAM,WAAW,qBAAqB;IACpC,kEAAkE;IAClE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiFzF;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,SAAS,EAAE,CAAC;IACvB,SAAS,EAAE,YAAY,EAAE,CAAC;IAC1B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,aAAa,EAAE,OAAO,CAAC;CACxB"}
@@ -96,8 +96,9 @@ export async function handleRequest(request) {
96
96
  return new Response(null, { status: 308, headers: { Location: '/_vesk/static/public' + (pathname.endsWith('/') ? pathname + 'index.html' : pathname + '.html') } });
97
97
  }
98
98
 
99
+ let mwCtx = { params: {}, url, locals: {}, cookies: {}, request, set() {}, get() { return undefined; } };
99
100
  if (${hasMwLiteral}) {
100
- const mwCtx = {
101
+ mwCtx = {
101
102
  request,
102
103
  params: {},
103
104
  url,
@@ -110,6 +111,9 @@ export async function handleRequest(request) {
110
111
  if (mwResult.response) return mwResult.response;
111
112
  if (mwResult.rewriteUrl) url.pathname = mwResult.rewriteUrl;
112
113
  }
114
+ // Expose root middleware locals to API/SSR handlers (which seed their own
115
+ // ctx.locals from this before running route-level middleware).
116
+ globalThis.__vesk_request = mwCtx;
113
117
 
114
118
  for (const route of __routes) {
115
119
  const params = __matchPath(route.path, url.pathname);
@@ -154,12 +158,28 @@ export async function bundlePlatformHandler(options) {
154
158
  plugins.push({
155
159
  name: 'empty-node-builtins',
156
160
  setup(build) {
157
- const builtins = /^(fs|path|node:fs|node:path|child_process|os|crypto|net|stream|buffer|events|util|url|querystring|http|https|zlib|tty)$/;
158
- build.onResolve({ filter: builtins }, () => {
159
- return { path: 'node-builtin-empty', namespace: 'empty-node' };
161
+ const builtins = /^(node:)?(fs|path|child_process|os|crypto|net|stream|buffer|events|util|url|querystring|http|https|zlib|tty|async_hooks)$/;
162
+ build.onResolve({ filter: builtins }, (args) => {
163
+ return { path: args.path, namespace: 'empty-node' };
160
164
  });
161
- build.onLoad({ filter: /.*/, namespace: 'empty-node' }, () => ({
162
- contents: `
165
+ build.onLoad({ filter: /.*/, namespace: 'empty-node' }, (args) => {
166
+ // async_hooks needs a usable AsyncLocalStorage shape (constructor +
167
+ // run/getStore), not a callable-Proxy stub — the runtime's
168
+ // per-request SSR data isolation instantiates it.
169
+ if (/async_hooks$/.test(args.path)) {
170
+ return {
171
+ contents: `
172
+ export class AsyncLocalStorage {
173
+ run(_store, fn) { return fn(); }
174
+ getStore() { return null; }
175
+ enterWith() {}
176
+ exit(fn) { return fn(); }
177
+ }
178
+ `,
179
+ loader: 'js',
180
+ };
181
+ }
182
+ const contents = `
163
183
  const __m = typeof Proxy !== 'undefined' ? new Proxy({}, {
164
184
  get(_, key) { return typeof key === 'string' ? () => {} : undefined; },
165
185
  has() { return true; },
@@ -187,9 +207,12 @@ export const spawnSync = () => ({ status: 0 });
187
207
  export const execSync = () => '';
188
208
  export const randomBytes = () => ({});
189
209
  export const createHash = () => ({ update: () => {}, digest: () => '' });
190
- `,
191
- loader: 'js',
192
- }));
210
+ `;
211
+ return {
212
+ contents,
213
+ loader: 'js',
214
+ };
215
+ });
193
216
  },
194
217
  });
195
218
  }
@@ -1,5 +1,10 @@
1
1
  import { type Server } from 'node:http';
2
+ export declare function bodyTooLarge(maxBytes: number): Error & {
3
+ status: number;
4
+ };
2
5
  export declare function startProdServer(outDir: string, options?: {
3
6
  port?: number;
7
+ host?: string;
8
+ maxBodyBytes?: number;
4
9
  }): Promise<Server>;
5
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;AAgGjG,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAyWlG"}
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;AAsBjG,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAIzE;AA4FD,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,CA0YxI"}
@@ -3,17 +3,32 @@ import { resolve, extname, dirname } from 'node:path';
3
3
  import { createServer } from 'node:http';
4
4
  import { createRequire } from 'node:module';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { createRateLimiter, resolveComponentName } from '@vesk/compiler/src/server-codegen';
7
- import { securityHeaders, getClientProtocol } from '@vesk/compiler/src/server-utils';
6
+ import { createRateLimiter, resolveComponentName, safeJsonForScript, getClientProtocol, DEFAULT_MAX_BODY_BYTES } from '@vesk/compiler/src/server-codegen';
7
+ import { securityHeaders } from '@vesk/compiler/src/server-utils';
8
+ import { resolveWithin } from '@vesk/adapter/src/paths';
8
9
  const _require = createRequire(import.meta.url);
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
- async function readBody(req) {
11
+ async function readBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) {
11
12
  const chunks = [];
12
- for await (const chunk of req)
13
+ let total = 0;
14
+ for await (const chunk of req) {
15
+ total += chunk.byteLength;
16
+ if (total > maxBytes)
17
+ throw bodyTooLarge(maxBytes);
13
18
  chunks.push(chunk);
19
+ }
14
20
  return Buffer.concat(chunks);
15
21
  }
16
- function makeWebRequest(nodeReq, url) {
22
+ export function bodyTooLarge(maxBytes) {
23
+ const err = new Error(`Request body exceeds limit (${maxBytes} bytes)`);
24
+ err.status = 413;
25
+ return err;
26
+ }
27
+ function errorStatus(e, fallback) {
28
+ const s = e?.status ?? e?.statusCode;
29
+ return typeof s === 'number' && s >= 400 && s < 600 ? s : fallback;
30
+ }
31
+ function makeWebRequest(nodeReq, url, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
17
32
  const parsedUrl = new URL(url, `http://${nodeReq.headers.host || 'localhost'}`);
18
33
  const method = nodeReq.method || 'GET';
19
34
  let _bodyBuffer = null;
@@ -21,8 +36,13 @@ function makeWebRequest(nodeReq, url) {
21
36
  if (_bodyBuffer)
22
37
  return _bodyBuffer;
23
38
  const chunks = [];
24
- for await (const chunk of nodeReq)
39
+ let total = 0;
40
+ for await (const chunk of nodeReq) {
41
+ total += chunk.byteLength;
42
+ if (total > maxBodyBytes)
43
+ throw bodyTooLarge(maxBodyBytes);
25
44
  chunks.push(Buffer.from(chunk));
45
+ }
26
46
  _bodyBuffer = Buffer.concat(chunks);
27
47
  return _bodyBuffer;
28
48
  }
@@ -70,6 +90,11 @@ const MIME = {
70
90
  };
71
91
  export async function startProdServer(outDir, options) {
72
92
  const port = options?.port || 3000;
93
+ const host = options?.host || '127.0.0.1';
94
+ const maxBodyBytes = options?.maxBodyBytes || DEFAULT_MAX_BODY_BYTES;
95
+ // Production default: generated handlers consult this to gate error details.
96
+ if (!process.env.NODE_ENV)
97
+ process.env.NODE_ENV = 'production';
73
98
  const staticDir = resolve(outDir, 'static');
74
99
  const configPath = resolve(outDir, 'config.json');
75
100
  if (!existsSync(configPath)) {
@@ -81,6 +106,7 @@ export async function startProdServer(outDir, options) {
81
106
  console.error(`vesk start: serving from ${outDir}`);
82
107
  const projectDir = resolve(outDir, '..');
83
108
  let securityConfig = {};
109
+ let mdConfig;
84
110
  try {
85
111
  const veskConfigPath = resolve(projectDir, 'vesk.config.js');
86
112
  const veskConfigTsPath = resolve(projectDir, 'vesk.config.ts');
@@ -98,14 +124,24 @@ export async function startProdServer(outDir, options) {
98
124
  rawConfig = rawConfig();
99
125
  const configObj = rawConfig;
100
126
  securityConfig = { security: configObj.security };
127
+ mdConfig = configObj.md;
101
128
  }
102
- catch {
103
- // ignore config load errors
129
+ catch (e) {
130
+ // Fail closed: keep the secure defaults above and warn loudly — a broken
131
+ // config must never silently disable the app's chosen security policy.
132
+ console.error('vesk start: failed to load vesk.config — falling back to secure defaults:', e instanceof Error ? e.message : e);
133
+ }
134
+ if (mdConfig) {
135
+ try {
136
+ const { configureMd } = await import('@vesk/runtime/src/md');
137
+ configureMd(mdConfig);
138
+ }
139
+ catch { /* runtime md module unavailable — keep default escape policy */ }
104
140
  }
105
141
  let rateLimiter = null;
106
142
  if (securityConfig?.rateLimit) {
107
143
  const rlConfig = securityConfig.rateLimit;
108
- rateLimiter = createRateLimiter({ windowMs: rlConfig.windowMs || 60000, max: rlConfig.max || 100 });
144
+ rateLimiter = createRateLimiter({ windowMs: rlConfig.windowMs || 60000, max: rlConfig.max || 100, trustProxy: !!securityConfig?.trustProxy });
109
145
  }
110
146
  let securityHeadersFn = null;
111
147
  try {
@@ -199,9 +235,8 @@ export async function startProdServer(outDir, options) {
199
235
  return origWriteHead(statusCode, headers);
200
236
  });
201
237
  const publicDir = resolve(staticDir, 'public');
202
- const sanitized = url.pathname.replace(/\.\./g, '');
203
- const rootFile = resolve(publicDir, sanitized.slice(1));
204
- if (rootFile.startsWith(publicDir) && existsSync(rootFile) && statSync(rootFile).isFile()) {
238
+ const rootFile = url.pathname.length > 1 ? resolveWithin(publicDir, url.pathname.slice(1)) : null;
239
+ if (rootFile && existsSync(rootFile) && statSync(rootFile).isFile()) {
205
240
  const ext = extname(rootFile);
206
241
  res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
207
242
  res.end(readFileSync(rootFile));
@@ -214,10 +249,11 @@ export async function startProdServer(outDir, options) {
214
249
  if (payload)
215
250
  delete store[token];
216
251
  const lines = [];
252
+ // script-safe serialization: props may contain user data
217
253
  if (payload?.props)
218
- lines.push(`globalThis.__vesk_props = ${JSON.stringify(payload.props)};`);
254
+ lines.push(`globalThis.__vesk_props = ${safeJsonForScript(JSON.stringify(payload.props))};`);
219
255
  if (payload?.ssrData)
220
- lines.push(`globalThis.__vsk_ssr_data = ${JSON.stringify(payload.ssrData)};`);
256
+ lines.push(`globalThis.__vsk_ssr_data = ${safeJsonForScript(JSON.stringify(payload.ssrData))};`);
221
257
  res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' });
222
258
  res.end(lines.join('\n') || '// no ssr data');
223
259
  return;
@@ -231,9 +267,9 @@ export async function startProdServer(outDir, options) {
231
267
  }
232
268
  }
233
269
  if (url.pathname.startsWith('/_vesk/static/')) {
234
- const relPath = url.pathname.replace('/_vesk/static/', '').replace(/\.\./g, '');
235
- const staticPath = resolve(staticDir, relPath);
236
- if (!staticPath.startsWith(staticDir)) {
270
+ const relPath = url.pathname.slice('/_vesk/static/'.length);
271
+ const staticPath = resolveWithin(staticDir, relPath);
272
+ if (!staticPath) {
237
273
  res.writeHead(403);
238
274
  res.end('Forbidden');
239
275
  return;
@@ -256,8 +292,9 @@ export async function startProdServer(outDir, options) {
256
292
  }
257
293
  }
258
294
  }
295
+ let mwCtx;
259
296
  if (middlewareMod) {
260
- const mwCtx = {
297
+ mwCtx = {
261
298
  request: new Request(url.href, { headers: req.headers, method: req.method || 'GET' }),
262
299
  params: {},
263
300
  url,
@@ -277,6 +314,20 @@ export async function startProdServer(outDir, options) {
277
314
  url.pathname = mwResult.rewriteUrl;
278
315
  }
279
316
  }
317
+ else {
318
+ mwCtx = {
319
+ params: {},
320
+ url,
321
+ locals: {},
322
+ cookies: {},
323
+ request: null,
324
+ set() { },
325
+ get() { return undefined; },
326
+ };
327
+ }
328
+ // Expose root middleware locals to API/SSR handlers (which seed their own
329
+ // ctx.locals from this before running route-level middleware).
330
+ globalThis.__vesk_request = mwCtx;
280
331
  if (url.pathname.startsWith('/_vesk/action/')) {
281
332
  const actionId = url.pathname.replace('/_vesk/action/', '');
282
333
  const actionEntry = buildConfig.actions && buildConfig.actions.find(a => a.id === actionId);
@@ -292,15 +343,18 @@ export async function startProdServer(outDir, options) {
292
343
  return;
293
344
  }
294
345
  try {
295
- const webRequest = makeWebRequest(req, url.href);
346
+ const webRequest = makeWebRequest(req, url.href, maxBodyBytes);
296
347
  const response = await mod.handleAction(webRequest, actionId);
297
348
  const body = await response.text();
298
349
  res.writeHead(response.status, Object.fromEntries(response.headers));
299
350
  res.end(body);
300
351
  }
301
352
  catch (e) {
302
- const message = e instanceof Error ? e.message : String(e);
303
- res.writeHead(500, { 'Content-Type': 'application/json' });
353
+ const status = errorStatus(e, 500);
354
+ const message = status === 500 && process.env.NODE_ENV === 'production'
355
+ ? 'Internal Server Error'
356
+ : e instanceof Error ? e.message : String(e);
357
+ res.writeHead(status, { 'Content-Type': 'application/json' });
304
358
  res.end(JSON.stringify({ ok: false, error: message }));
305
359
  }
306
360
  return;
@@ -313,7 +367,7 @@ export async function startProdServer(outDir, options) {
313
367
  const mod = await loadFunction(route.function);
314
368
  if (mod) {
315
369
  try {
316
- const webRequest = makeWebRequest(req, url.href);
370
+ const webRequest = makeWebRequest(req, url.href, maxBodyBytes);
317
371
  const response = await mod.handle(webRequest);
318
372
  const body = await response.text();
319
373
  res.writeHead(response.status, Object.fromEntries(response.headers));
@@ -321,9 +375,9 @@ export async function startProdServer(outDir, options) {
321
375
  return;
322
376
  }
323
377
  catch (e) {
324
- const message = e instanceof Error ? e.message : String(e);
325
- res.writeHead(500, { 'Content-Type': 'application/json' });
326
- res.end(JSON.stringify({ error: message }));
378
+ const status = errorStatus(e, 500);
379
+ res.writeHead(status, { 'Content-Type': 'application/json' });
380
+ res.end(JSON.stringify({ error: status === 500 ? 'Internal Server Error' : e instanceof Error ? e.message : String(e) }));
327
381
  return;
328
382
  }
329
383
  }
@@ -337,10 +391,10 @@ export async function startProdServer(outDir, options) {
337
391
  if (existsSync(nfPath)) {
338
392
  try {
339
393
  const runtimePath = resolve(outDir, 'server', 'runtime.js');
340
- const { renderFullPage } = await import(runtimePath);
394
+ const { renderFullPage, storeDataScriptGlobal } = await import(runtimePath);
341
395
  const src = readFileSync(nfPath, 'utf-8');
342
396
  const compName = resolveComponentName(src) || 'NotFound';
343
- notFoundHtml = await renderFullPage(src, compName, { params: {}, url: url.pathname }, new Map(), { hydrate: true, cssUrls: ['/_vesk/static/_tailwind.css', '/_vesk/static/global.css'], security: securityConfig?.security || {}, sourcePath: nfPath });
397
+ notFoundHtml = await renderFullPage(src, compName, { params: {}, url: url.pathname }, new Map(), { hydrate: true, cssUrls: ['/_vesk/static/_tailwind.css', '/_vesk/static/global.css'], security: securityConfig?.security || {}, externalDataScript: storeDataScriptGlobal, sourcePath: nfPath });
344
398
  }
345
399
  catch { }
346
400
  }
@@ -351,7 +405,7 @@ export async function startProdServer(outDir, options) {
351
405
  const mod = await loadFunction(route.function);
352
406
  if (mod) {
353
407
  try {
354
- const webRequest = makeWebRequest(req, url.href);
408
+ const webRequest = makeWebRequest(req, url.href, maxBodyBytes);
355
409
  let cachedResult = null;
356
410
  if (route.revalidate && route.revalidate > 0) {
357
411
  const { pageIsr } = await import('@vesk/runtime/src/index-server');
@@ -404,10 +458,12 @@ export async function startProdServer(outDir, options) {
404
458
  if (existsSync(errPath)) {
405
459
  try {
406
460
  const runtimePath = resolve(outDir, 'server', 'runtime.js');
407
- const { renderFullPage } = await import(runtimePath);
461
+ const { renderFullPage, storeDataScriptGlobal } = await import(runtimePath);
408
462
  const src = readFileSync(errPath, 'utf-8');
409
463
  const compName = resolveComponentName(src) || 'Error';
410
- errorHtml = await renderFullPage(src, compName, { error: err.message, stack: err.stack, statusCode: 500, url: url.pathname }, new Map(), { hydrate: true, cssUrls: ['/_vesk/static/_tailwind.css', '/_vesk/static/global.css'], security: securityConfig?.security || {}, sourcePath: errPath });
464
+ // never leak stack traces / internal messages to prod error pages
465
+ const expose = process.env.NODE_ENV !== 'production';
466
+ errorHtml = await renderFullPage(src, compName, { error: expose ? err.message : 'Internal Server Error', stack: expose ? err.stack : '', statusCode: 500, url: url.pathname }, new Map(), { hydrate: true, cssUrls: ['/_vesk/static/_tailwind.css', '/_vesk/static/global.css'], clientScriptUrl: '/_vesk/static/client.js', security: securityConfig?.security || {}, externalDataScript: storeDataScriptGlobal, sourcePath: errPath });
411
467
  }
412
468
  catch { }
413
469
  }
@@ -422,8 +478,8 @@ export async function startProdServer(outDir, options) {
422
478
  res.writeHead(404, { 'Content-Type': 'text/html' });
423
479
  res.end(notFoundHtml || '<!DOCTYPE html><html><body><h1>404</h1><p>Not Found</p></body></html>');
424
480
  });
425
- server.listen(port, () => {
426
- console.error(`vesk production server at http://localhost:${port}`);
481
+ server.listen(port, host, () => {
482
+ console.error(`vesk production server at http://localhost:${port} (listening on ${host})`);
427
483
  });
428
484
  return server;
429
485
  }
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-bundle.d.ts","sourceRoot":"","sources":["../src/runtime-bundle.ts"],"names":[],"mappings":"AAuCA,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAsGnF"}
1
+ {"version":3,"file":"runtime-bundle.d.ts","sourceRoot":"","sources":["../src/runtime-bundle.ts"],"names":[],"mappings":"AAuCA,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2GnF"}
@@ -39,7 +39,7 @@ export async function bundleRuntime(appDir, outDir) {
39
39
  const runtimeRoot = findRuntimeSrc(appDir);
40
40
  const entryFile = resolve(outDir, 'server', `.runtime-entry-${buildId++}.mjs`);
41
41
  const entryContent = [
42
- `import { renderPage, renderFullPage, renderPageStream, compileFile, setRuntimeModule, setVskHydrate } from ${JSON.stringify(resolve(compilerRoot, 'server-codegen.js'))};`,
42
+ `import { renderPage, renderFullPage, renderPageStream, compileFile, setRuntimeModule, setVskHydrate, assertSameOrigin } from ${JSON.stringify(resolve(compilerRoot, 'server-codegen.js'))};`,
43
43
  `import { parseCookies } from ${JSON.stringify(resolve(compilerRoot, 'server-cookies.js'))};`,
44
44
  `import * as __veskRuntime from ${JSON.stringify(resolve(runtimeRoot, 'index-server.js'))};`,
45
45
  '',
@@ -77,14 +77,19 @@ export async function bundleRuntime(appDir, outDir) {
77
77
  ' return req.locals || {};',
78
78
  '}',
79
79
  '',
80
- 'export { renderPage, renderFullPage, renderPageStream, compileFile, setVskHydrate, parseCookies };',
80
+ 'export { renderPage, renderFullPage, renderPageStream, compileFile, setVskHydrate, parseCookies, assertSameOrigin };',
81
81
  'export { withSsrStore } from "@vesk/compiler/src/ssr-store";',
82
82
  '',
83
83
  '// Deliver hydration data as an origin-served script so strict CSP (no unsafe-inline)',
84
84
  '// does not block it. The prod server serves /ssr-data.js from the global store.',
85
85
  'export function storeDataScriptGlobal(payload) {',
86
86
  " if (!payload || (!payload.props && !payload.ssrData)) return null;",
87
- " const token = Math.random().toString(36).slice(2) + Date.now().toString(36);",
87
+ ' // CSPRNG token this URL gates access to the page\'s hydration payload',
88
+ ' const bytes = new Uint8Array(12);',
89
+ ' crypto.getRandomValues(bytes);',
90
+ " let tk = '';",
91
+ " for (const b of bytes) tk += b.toString(16).padStart(2, '0');",
92
+ ' const token = tk;',
88
93
  " const store = (globalThis.__vsk_ssr_data_store ||= {});",
89
94
  " store[token] = payload;",
90
95
  ' // Bound the store: if the browser never fetches /ssr-data.js the entry',
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-function.d.ts","sourceRoot":"","sources":["../src/ssr-function.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAkB,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAW7F,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQjF;AAiDD,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,OAAO,CAAC,EAAE,kBAAkB,GAC3B;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CA8VtD"}
1
+ {"version":3,"file":"ssr-function.d.ts","sourceRoot":"","sources":["../src/ssr-function.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAkB,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAW7F,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQjF;AAiDD,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,OAAO,CAAC,EAAE,kBAAkB,GAC3B;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAyWtD"}