@vesk/adapter 0.2.69 → 0.2.71

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.
@@ -19,9 +19,6 @@ export interface DevHmrState {
19
19
  }
20
20
  /** Default state when no HMR provider is available (no server). */
21
21
  export declare function defaultDevHmrState(): DevHmrState;
22
- export declare function bodyTooLarge(maxBytes: number): Error & {
23
- status: number;
24
- };
25
22
  export interface DevPanelResponse {
26
23
  status: number;
27
24
  headers: Record<string, string>;
@@ -1 +1 @@
1
- {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../src/dev-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,MAAM,EAA6C,MAAM,WAAW,CAAC;AASjG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AAM7C,OAAO,KAAK,EAAa,gBAAgB,EAAwB,MAAM,yBAAyB,CAAC;AAgBjG;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,IAAI,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;IAC9C,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxD,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB;+EAC2E;IAC3E,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,mEAAmE;AACnE,wBAAgB,kBAAkB,IAAI,WAAW,CAEhD;AAuCD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAIzE;AAkBD,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,mHAAmH;IACnH,QAAQ,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC7B;;8BAE0B;IAC1B,MAAM,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,UAAU,GAAG,YAAY,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,CAAC;IACrE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,WAAW,CAAC;IAChC,8EAA8E;IAC9E,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3E;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,wBAAwB,GAAG;IACvE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CAChH,CAUA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAa3C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIrD;AA8CD,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAorBvG"}
1
+ {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../src/dev-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,MAAM,EAA6C,MAAM,WAAW,CAAC;AASjG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AAO7C,OAAO,KAAK,EAAa,gBAAgB,EAAwB,MAAM,yBAAyB,CAAC;AAgBjG;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,IAAI,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;IAC9C,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxD,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB;+EAC2E;IAC3E,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,mEAAmE;AACnE,wBAAgB,kBAAkB,IAAI,WAAW,CAEhD;AAgDD,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,mHAAmH;IACnH,QAAQ,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC7B;;8BAE0B;IAC1B,MAAM,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,UAAU,GAAG,YAAY,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,CAAC;IACrE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,WAAW,CAAC;IAChC,8EAA8E;IAC9E,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3E;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,wBAAwB,GAAG;IACvE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CAChH,CAUA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAa3C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIrD;AAUD,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAorBvG"}
@@ -14,6 +14,7 @@ import { resolveRuntimeErrorPayload, renderDevErrorPage } from './ssr-error';
14
14
  import { renderDefaultNotFoundPage, renderDefaultErrorPage } from '@vesk/adapter/src/default-pages';
15
15
  import { buildTreeShakenRuntime, runtimeExportNames } from '@vesk/adapter/src/client-bundle';
16
16
  import { resolveWithin, installMdReadHook } from '@vesk/adapter/src/paths';
17
+ import { makeWebRequest, bodyTooLarge } from '@vesk/adapter/src/web-request';
17
18
  import { getPluginRecords } from './plugins';
18
19
  // @vesk/agentic — optional AI plugin; gated by active state if installed
19
20
  import { createAgentRouter } from '@vesk/agentic/src/dev-api';
@@ -58,11 +59,6 @@ async function deliverResponse(res, response) {
58
59
  }
59
60
  res.end(await response.text());
60
61
  }
61
- export function bodyTooLarge(maxBytes) {
62
- const err = new Error(`Request body exceeds limit (${maxBytes} bytes)`);
63
- err.status = 413;
64
- return err;
65
- }
66
62
  function errorStatus(e, fallback) {
67
63
  const s = e?.status ?? e?.statusCode;
68
64
  return typeof s === 'number' && s >= 400 && s < 600 ? s : fallback;
@@ -149,49 +145,6 @@ export function injectDevScripts(html) {
149
145
  const scripts = '\t<script type="module" src="/_vesk/hmr.js"></script>\n' + devBootstrapScript() + '\n';
150
146
  return html.replace('</body>', scripts + '</body>');
151
147
  }
152
- function makeWebRequest(nodeReq, url, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
153
- const parsedUrl = new URL(url, `http://${nodeReq.headers.host || 'localhost'}`);
154
- const method = nodeReq.method || 'GET';
155
- let _bodyBuffer = null;
156
- async function getBody() {
157
- if (_bodyBuffer)
158
- return _bodyBuffer;
159
- const chunks = [];
160
- let total = 0;
161
- for await (const chunk of nodeReq) {
162
- total += chunk.byteLength;
163
- if (total > maxBodyBytes)
164
- throw bodyTooLarge(maxBodyBytes);
165
- chunks.push(Buffer.from(chunk));
166
- }
167
- _bodyBuffer = Buffer.concat(chunks);
168
- return _bodyBuffer;
169
- }
170
- const webRequest = new Request(parsedUrl, { method, headers: nodeReq.headers, body: null });
171
- webRequest.json = async () => { try {
172
- return JSON.parse((await getBody()).toString());
173
- }
174
- catch {
175
- return null;
176
- } };
177
- webRequest.text = async () => (await getBody()).toString('utf-8');
178
- webRequest.formData = async () => {
179
- const body = await getBody();
180
- const ct = String(nodeReq.headers['content-type'] || '');
181
- if (ct.includes('multipart/form-data')) {
182
- const temp = new Request('http://localhost', { method: 'POST', headers: nodeReq.headers, body: body });
183
- return temp.formData();
184
- }
185
- const fd = new FormData();
186
- if (ct.includes('x-www-form-urlencoded')) {
187
- for (const [k, v] of new URLSearchParams(body.toString()).entries())
188
- fd.append(k, v);
189
- }
190
- return fd;
191
- };
192
- webRequest.clone = () => webRequest;
193
- return webRequest;
194
- }
195
148
  const MIME = {
196
149
  '.svg': 'image/svg+xml', '.css': 'text/css', '.js': 'application/javascript',
197
150
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAqE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAiCpK,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,EAAE,EACnB,SAAS,EAAE,SAAS,EAAE,EACtB,SAAS,EAAE,YAAY,EAAE,EACzB,YAAY,EAAE,cAAc,EAAE,EAC9B,iBAAiB,EAAE,OAAO,EAC1B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,SAAS,CAAC,EAAE,MAAM,EAClB,aAAa,UAAQ,GACpB,QAAQ,CAuEV"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAqE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAiCpK,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,EAAE,EACnB,SAAS,EAAE,SAAS,EAAE,EACtB,SAAS,EAAE,YAAY,EAAE,EACzB,YAAY,EAAE,cAAc,EAAE,EAC9B,iBAAiB,EAAE,OAAO,EAC1B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,SAAS,CAAC,EAAE,MAAM,EAClB,aAAa,UAAQ,GACpB,QAAQ,CA8EV"}
package/dist/manifest.js CHANGED
@@ -44,6 +44,13 @@ export function generateManifest(routes, ssrRoutes, apiRoutes, staticRoutes, mid
44
44
  entry.tags = r._isrTags;
45
45
  routeEntries.push(entry);
46
46
  }
47
+ // Same precedence rule as the API entries below, and for the same reason:
48
+ // every consumer takes the FIRST exact-length match, so directory-scan order
49
+ // decides. `app/docs/[slug]/page.vsk` scanned before `app/docs/boom/page.vsk`
50
+ // left `/docs/boom` permanently served by the dynamic route — a static page
51
+ // that 404s or, worse, renders the wrong page's content. The API half of this
52
+ // fix landed earlier; SSR entries had the identical defect.
53
+ routeEntries.sort((a, b) => compareSpecificity(a.path, b.path));
47
54
  const apiEntries = [];
48
55
  for (const r of apiRoutes) {
49
56
  const urlParts = r.fullPath.split('/').filter(Boolean);
@@ -1,8 +1,5 @@
1
1
  import { type Server } from 'node:http';
2
2
  import type { CompressionConfig, ProdServerOptions } from '@vesk/adapter/src/types';
3
- export declare function bodyTooLarge(maxBytes: number): Error & {
4
- status: number;
5
- };
6
3
  export declare function startProdServer(outDir: string, options?: ProdServerOptions & {
7
4
  compression?: CompressionConfig | false;
8
5
  healthCheck?: boolean;
@@ -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;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
+ {"version":3,"file":"prod-server.d.ts","sourceRoot":"","sources":["../src/prod-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAejG,OAAO,KAAK,EAAkB,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAyHpG,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,CAwoBvK"}
@@ -12,6 +12,8 @@ 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
14
  import { installResponsePipeline } from '@vesk/adapter/src/response-pipeline';
15
+ import { resolveBoundaryFileForUrl } from '@vesk/adapter/src/ssr-function';
16
+ import { makeWebRequest, bodyTooLarge } from '@vesk/adapter/src/web-request';
15
17
  import { mimeFor } from '@vesk/adapter/src/platform-output';
16
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
19
  async function readBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) {
@@ -101,69 +103,10 @@ function pumpRemaining(res, reader) {
101
103
  res.end();
102
104
  })();
103
105
  }
104
- export function bodyTooLarge(maxBytes) {
105
- const err = new Error(`Request body exceeds limit (${maxBytes} bytes)`);
106
- err.status = 413;
107
- return err;
108
- }
109
106
  function errorStatus(e, fallback) {
110
107
  const s = e?.status ?? e?.statusCode;
111
108
  return typeof s === 'number' && s >= 400 && s < 600 ? s : fallback;
112
109
  }
113
- function makeWebRequest(nodeReq, url, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
114
- const parsedUrl = new URL(url, `http://${nodeReq.headers.host || 'localhost'}`);
115
- const method = nodeReq.method || 'GET';
116
- let _bodyBuffer = null;
117
- async function getBody() {
118
- if (_bodyBuffer)
119
- return _bodyBuffer;
120
- const chunks = [];
121
- let total = 0;
122
- for await (const chunk of nodeReq) {
123
- total += chunk.byteLength;
124
- if (total > maxBodyBytes)
125
- throw bodyTooLarge(maxBodyBytes);
126
- chunks.push(Buffer.from(chunk));
127
- }
128
- _bodyBuffer = Buffer.concat(chunks);
129
- return _bodyBuffer;
130
- }
131
- const webRequest = new Request(parsedUrl, { method, headers: nodeReq.headers, body: null });
132
- webRequest.json = async () => { try {
133
- return JSON.parse((await getBody()).toString());
134
- }
135
- catch {
136
- return null;
137
- } };
138
- webRequest.text = async () => (await getBody()).toString('utf-8');
139
- webRequest.formData = async () => {
140
- const body = await getBody();
141
- const ct = String(nodeReq.headers['content-type'] || '');
142
- if (ct.includes('multipart/form-data')) {
143
- const temp = new Request('http://localhost', { method: 'POST', headers: nodeReq.headers, body: body });
144
- return temp.formData();
145
- }
146
- const fd = new FormData();
147
- if (ct.includes('x-www-form-urlencoded')) {
148
- for (const [k, v] of new URLSearchParams(body.toString()).entries())
149
- fd.append(k, v);
150
- }
151
- return fd;
152
- };
153
- webRequest.clone = () => webRequest;
154
- const cookies = {};
155
- for (const [k, v] of String(nodeReq.headers.cookie || '').split(';').map(s => s.trim()).filter(Boolean)) {
156
- const eq = k.indexOf('=');
157
- if (eq > -1)
158
- cookies[k.slice(0, eq)] = decodeURIComponent(k.slice(eq + 1));
159
- }
160
- webRequest.cookies = cookies;
161
- const query = {};
162
- for (const [k, v] of parsedUrl.searchParams.entries())
163
- query[k] = v;
164
- webRequest.query = query;
165
- return webRequest;
166
- }
167
110
  export async function startProdServer(outDir, options) {
168
111
  const port = options?.port || 3000;
169
112
  const host = options?.host || '127.0.0.1';
@@ -252,9 +195,26 @@ export async function startProdServer(outDir, options) {
252
195
  }
253
196
  }
254
197
  const functionCache = new Map();
198
+ /** Import failures, kept so the same broken module is not re-imported per request. */
199
+ const functionFailures = new Map();
200
+ /**
201
+ * Load a generated function module.
202
+ *
203
+ * Returns null both for "no such file" and for "the file exists but could not
204
+ * be imported", and the caller cannot tell them apart — which turned a route
205
+ * with a bad import into a **404 with no diagnostic anywhere**. A missing
206
+ * export or an unresolvable dependency is a build bug, and the only evidence
207
+ * was an empty 404. `functionFailure` records what happened so the request
208
+ * path can answer 500 and log it, which is what a broken route is.
209
+ */
210
+ function functionFailure(funcPath) {
211
+ return functionFailures.get(funcPath);
212
+ }
255
213
  async function loadFunction(funcPath) {
256
214
  if (functionCache.has(funcPath))
257
215
  return functionCache.get(funcPath);
216
+ if (functionFailures.has(funcPath))
217
+ return null;
258
218
  const fullPath = resolve(outDir, funcPath);
259
219
  if (!existsSync(fullPath))
260
220
  return null;
@@ -263,7 +223,9 @@ export async function startProdServer(outDir, options) {
263
223
  functionCache.set(funcPath, mod);
264
224
  return mod;
265
225
  }
266
- catch {
226
+ catch (e) {
227
+ console.error(`vesk start: failed to load ${funcPath}:`, e instanceof Error ? e.message : e);
228
+ functionFailures.set(funcPath, e);
267
229
  return null;
268
230
  }
269
231
  }
@@ -578,19 +540,27 @@ export async function startProdServer(outDir, options) {
578
540
  const params = matchPath(route.path, url.pathname);
579
541
  if (params) {
580
542
  const mod = await loadFunction(route.function);
581
- if (mod) {
582
- try {
583
- const webRequest = makeWebRequest(req, url.href, maxBodyBytes);
584
- const response = await withSecurityScope(() => mod.handle(webRequest));
585
- await deliverResponse(res, response);
586
- return;
587
- }
588
- catch (e) {
589
- const status = errorStatus(e, 500);
590
- res.writeHead(status, { 'Content-Type': 'application/json' });
591
- res.end(JSON.stringify({ error: status === 500 ? 'Internal Server Error' : e instanceof Error ? e.message : String(e) }));
543
+ if (!mod) {
544
+ // The route matched, so a 404 would be a lie: the handler is
545
+ // there and could not be loaded.
546
+ if (functionFailure(route.function)) {
547
+ res.writeHead(500, { 'Content-Type': 'application/json' });
548
+ res.end(JSON.stringify({ error: 'Internal Server Error' }));
592
549
  return;
593
550
  }
551
+ continue;
552
+ }
553
+ try {
554
+ const webRequest = makeWebRequest(req, url.href, maxBodyBytes);
555
+ const response = await withSecurityScope(() => mod.handle(webRequest));
556
+ await deliverResponse(res, response);
557
+ return;
558
+ }
559
+ catch (e) {
560
+ const status = errorStatus(e, 500);
561
+ res.writeHead(status, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify({ error: status === 500 ? 'Internal Server Error' : e instanceof Error ? e.message : String(e) }));
563
+ return;
594
564
  }
595
565
  }
596
566
  }
@@ -600,17 +570,42 @@ export async function startProdServer(outDir, options) {
600
570
  const pageCssUrls = resolveCssUrls({
601
571
  enabled: hasBuiltGlobalCss(outDir),
602
572
  });
603
- const nfPath = resolve(appDir, 'not-found.vsk');
604
- let notFoundHtml = null;
605
- if (existsSync(nfPath)) {
606
- try {
607
- const runtimePath = resolve(outDir, 'server', 'runtime.js');
608
- const { renderFullPage, storeDataScriptGlobal } = await import(runtimePath);
609
- const src = readFileSync(nfPath, 'utf-8');
610
- const compName = resolveComponentName(src) || 'NotFound';
611
- notFoundHtml = await renderFullPage(src, compName, { params: {}, url: url.pathname }, new Map(), { hydrate: true, cssUrls: pageCssUrls, security: securityConfig?.security || {}, externalDataScript: storeDataScriptGlobal, sourcePath: nfPath, headExtra: bakedHeadExtra });
573
+ /**
574
+ * Render the boundary that owns `url.pathname`.
575
+ *
576
+ * Deepest-match over the URL's directories, not a hard read of the root
577
+ * file: `app/admin/not-found.vsk` is the boundary for `/admin/*`, and the
578
+ * root one is the fallback. The generated SSR function does the same walk at
579
+ * build time (`resolveNotFoundFile`) and renders the boundary itself inside
580
+ * the layout stack; this path is what remains for an unmatched URL, for a
581
+ * route with no layout (which streams, so its status is already committed),
582
+ * and for an error thrown outside a page's own try block.
583
+ */
584
+ const boundaryCache = new Map();
585
+ async function renderBoundaryFile(file, pathname, props) {
586
+ const boundaryPath = resolveBoundaryFileForUrl(pathname, appDir, file);
587
+ if (!boundaryPath)
588
+ return null;
589
+ const key = `${file}:${boundaryPath}`;
590
+ let html = boundaryCache.has(key) ? boundaryCache.get(key) : null;
591
+ if (html === null) {
592
+ try {
593
+ const runtimePath = resolve(outDir, 'server', 'runtime.js');
594
+ const { renderFullPage, storeDataScriptGlobal } = await import(runtimePath);
595
+ const src = readFileSync(boundaryPath, 'utf-8');
596
+ const compName = resolveComponentName(src) || (file === 'error.vsk' ? 'Error' : 'NotFound');
597
+ html = await renderFullPage(src, compName, props, new Map(), { hydrate: true, cssUrls: pageCssUrls, clientScriptUrl: buildConfig?.assets?.client?.url || '/_vesk/static/client.js', clientScriptIntegrity: buildConfig?.assets?.client?.integrity, security: securityConfig?.security || {}, externalDataScript: storeDataScriptGlobal, sourcePath: boundaryPath, headExtra: bakedHeadExtra });
598
+ }
599
+ catch {
600
+ return null;
601
+ }
612
602
  }
613
- catch { }
603
+ return html;
604
+ }
605
+ let notFoundHtml = null;
606
+ {
607
+ const rendered = await renderBoundaryFile('not-found.vsk', url.pathname, { params: {}, url: url.pathname });
608
+ notFoundHtml = rendered;
614
609
  }
615
610
  for (const route of buildConfig.routes) {
616
611
  if (route.type === 'ssr') {
@@ -686,20 +681,17 @@ export async function startProdServer(outDir, options) {
686
681
  if (eventsMod) {
687
682
  await executeError(err, { url: url.href, routePath: route.path, target: 'node', status: 500 }, eventsMod);
688
683
  }
689
- const errPath = resolve(appDir, 'error.vsk');
690
- let errorHtml = null;
691
- if (existsSync(errPath)) {
692
- try {
693
- const runtimePath = resolve(outDir, 'server', 'runtime.js');
694
- const { renderFullPage, storeDataScriptGlobal } = await import(runtimePath);
695
- const src = readFileSync(errPath, 'utf-8');
696
- const compName = resolveComponentName(src) || 'Error';
697
- // never leak stack traces / internal messages to prod error pages
698
- const expose = process.env.NODE_ENV !== 'production';
699
- 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: pageCssUrls, clientScriptUrl: buildConfig?.assets?.client?.url || '/_vesk/static/client.js', clientScriptIntegrity: buildConfig?.assets?.client?.integrity, security: securityConfig?.security || {}, externalDataScript: storeDataScriptGlobal, sourcePath: errPath, headExtra: bakedHeadExtra });
700
- }
701
- catch { }
702
- }
684
+ // Same nearest-boundary walk as not-found above. This is the last resort for a
685
+ // throw the generated function could not absorb — a layout render
686
+ // failure, or the error boundary itself failing — and it must pick
687
+ // the boundary that owns the URL, not the root one.
688
+ const expose = process.env.NODE_ENV !== 'production';
689
+ const errorHtml = await renderBoundaryFile('error.vsk', url.pathname, {
690
+ error: expose ? err.message : 'Internal Server Error',
691
+ stack: expose ? err.stack : '',
692
+ statusCode: 500,
693
+ url: url.pathname,
694
+ });
703
695
  res.writeHead(500, { 'Content-Type': 'text/html' });
704
696
  res.end(errorHtml || renderDefaultErrorPage({ message: process.env.NODE_ENV !== 'production' ? err.message : undefined, url: url.pathname, headExtra: bakedHeadExtra }));
705
697
  return;
@@ -1,5 +1,25 @@
1
1
  import type { RouteNode, SsrFunctionOptions } from '@vesk/adapter/src/types';
2
+ /**
3
+ * The nearest `<file>` walking up from a route's own directory to the app root.
4
+ *
5
+ * This is the server-side twin of the client router's `findErrorComponent` /
6
+ * `findNotFoundComponent`, which walk the matched chain deepest-first. Same rule,
7
+ * same reason: an `error.vsk` (or `not-found.vsk`) inside `app/admin/` is the
8
+ * boundary for `/admin/*` and nothing else — the root file is the fallback, not
9
+ * the implementation.
10
+ */
11
+ export declare function resolveBoundaryFile(sourceDir: string, appDir: string, file: string): string | null;
2
12
  export declare function resolveErrorFile(sourceDir: string, appDir: string): string | null;
13
+ export declare function resolveNotFoundFile(sourceDir: string, appDir: string): string | null;
14
+ /**
15
+ * The nearest `<file>` for a REQUESTED URL, used by the server when no SSR
16
+ * function ran (an unmatched path) or when one gave up.
17
+ *
18
+ * The walk is over URL segments, so a dynamic segment cannot be mapped back to a
19
+ * directory — `/users/42/edit` stops at `/users`. Static paths, which is what a
20
+ * `not-found.vsk` is written for, resolve exactly.
21
+ */
22
+ export declare function resolveBoundaryFileForUrl(pathname: string, appDir: string, file: string): string | null;
3
23
  /** Shared persisted-store SSR helper block emitted into every generated SSR
4
24
  * function (prod via ssr-function.ts, dev via hmr.ts — keep the two callers
5
25
  * in sync by calling this). Emit it AFTER the function declarations so the
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-function.d.ts","sourceRoot":"","sources":["../src/ssr-function.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAkB,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAI7F,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQjF;AAgED;;;2EAG2E;AAC3E,wBAAgB,qBAAqB,IAAI,MAAM,CA4C9C;AAED,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,CA4btD"}
1
+ {"version":3,"file":"ssr-function.d.ts","sourceRoot":"","sources":["../src/ssr-function.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAkB,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQlG;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEjF;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEpF;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQvG;AAgED;;;2EAG2E;AAC3E,wBAAgB,qBAAqB,IAAI,MAAM,CA4C9C;AAED,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,CA6etD"}
@@ -3,13 +3,44 @@ import { resolve, relative, join } from 'node:path';
3
3
  import { resolveComponentName, precompileFile } from '@vesk/compiler/src/server-codegen';
4
4
  import { resolveCssUrls, hasBuiltGlobalCss } from '@vesk/adapter/src/css';
5
5
  import { renderDefaultErrorFragment } from '@vesk/adapter/src/default-pages';
6
- // Finds the nearest error.vsk walking up from the route's own directory to the
7
- // app root, mirroring the router's findErrorComponent chain semantics.
8
- export function resolveErrorFile(sourceDir, appDir) {
6
+ /**
7
+ * The nearest `<file>` walking up from a route's own directory to the app root.
8
+ *
9
+ * This is the server-side twin of the client router's `findErrorComponent` /
10
+ * `findNotFoundComponent`, which walk the matched chain deepest-first. Same rule,
11
+ * same reason: an `error.vsk` (or `not-found.vsk`) inside `app/admin/` is the
12
+ * boundary for `/admin/*` and nothing else — the root file is the fallback, not
13
+ * the implementation.
14
+ */
15
+ export function resolveBoundaryFile(sourceDir, appDir, file) {
9
16
  const rel = relative(appDir, sourceDir).split('/').filter(Boolean);
10
17
  for (let depth = rel.length; depth >= 0; depth--) {
11
18
  const dir = depth === 0 ? appDir : join(appDir, ...rel.slice(0, depth));
12
- const p = join(dir, 'error.vsk');
19
+ const p = join(dir, file);
20
+ if (existsSync(p))
21
+ return p;
22
+ }
23
+ return null;
24
+ }
25
+ export function resolveErrorFile(sourceDir, appDir) {
26
+ return resolveBoundaryFile(sourceDir, appDir, 'error.vsk');
27
+ }
28
+ export function resolveNotFoundFile(sourceDir, appDir) {
29
+ return resolveBoundaryFile(sourceDir, appDir, 'not-found.vsk');
30
+ }
31
+ /**
32
+ * The nearest `<file>` for a REQUESTED URL, used by the server when no SSR
33
+ * function ran (an unmatched path) or when one gave up.
34
+ *
35
+ * The walk is over URL segments, so a dynamic segment cannot be mapped back to a
36
+ * directory — `/users/42/edit` stops at `/users`. Static paths, which is what a
37
+ * `not-found.vsk` is written for, resolve exactly.
38
+ */
39
+ export function resolveBoundaryFileForUrl(pathname, appDir, file) {
40
+ const segments = pathname.split('/').filter(Boolean);
41
+ for (let depth = segments.length; depth >= 0; depth--) {
42
+ const dir = join(appDir, ...segments.slice(0, depth));
43
+ const p = join(dir, file);
13
44
  if (existsSync(p))
14
45
  return p;
15
46
  }
@@ -168,18 +199,30 @@ export function generateSsrFunction(routeNode, appDir, outDir, componentMap, opt
168
199
  const errorVars = errorPath
169
200
  ? `const _errorComp = ${JSON.stringify(errorComp)};\nconst _errorCompiled = ${hydrateInvoke(precompilePlan(errorPath))};\n`
170
201
  : 'const _errorComp = null;\nconst _errorCompiled = null;\n';
202
+ // Not-found used to have no server-side resolution at all: a NotFoundError was
203
+ // re-thrown out of the function and the prod server answered with the ROOT
204
+ // not-found.vsk. A nested `app/admin/not-found.vsk` therefore did nothing on a
205
+ // hard load, while the client router — which walks the matched chain
206
+ // deepest-first — honoured it on SPA navigation. Same URL, two different
207
+ // pages depending on how you arrived.
208
+ const notFoundPath = resolveNotFoundFile(routeNode.sourceDir, appDir);
209
+ const notFoundSrc = notFoundPath ? readFileSync(notFoundPath, 'utf-8') : null;
210
+ const notFoundComp = notFoundPath ? (extractCompName(notFoundSrc) || 'NotFound') : null;
211
+ const notFoundVars = notFoundPath
212
+ ? `const _notFoundComp = ${JSON.stringify(notFoundComp)};\nconst _notFoundCompiled = ${hydrateInvoke(precompilePlan(notFoundPath))};\n`
213
+ : 'const _notFoundComp = null;\nconst _notFoundCompiled = null;\n';
171
214
  let src = '';
172
215
  if (layoutDecls.length > 0) {
173
216
  src = `const _pageComp = ${JSON.stringify(pageComp)};\n`;
174
217
  src += `const _pageCompiled = (() => { try { return hydratePrecompile(${pagePlan}); } catch { return null; } })();\n`;
175
218
  src += `const _layoutCompList = [${layoutDecls.map((d) => d.comp).join(', ')}];\n`;
176
219
  src += `const _layoutCompiledList = [\n${layoutDecls.map((d) => ' ' + d.compiled + ',').join('\n')}\n];\n`;
177
- src += errorVars;
220
+ src += errorVars + notFoundVars;
178
221
  }
179
222
  else {
180
223
  src = `const _comp = ${JSON.stringify(pageComp)};\n`;
181
224
  src += `const _compiled = (() => { try { return hydratePrecompile(${pagePlan}); } catch { return null; } })();\n`;
182
- src += errorVars;
225
+ src += errorVars + notFoundVars;
183
226
  }
184
227
  const urlParts = routeNode.fullPath.split('/').filter(Boolean);
185
228
  const paramExprs = buildParamExtraction(routeNode, urlParts);
@@ -215,30 +258,66 @@ export function generateSsrFunction(routeNode, appDir, outDir, componentMap, opt
215
258
  ' }',
216
259
  '}',
217
260
  '',
261
+ // Returns null rather than throwing when there is no boundary, so the
262
+ // caller can re-throw the original NotFoundError and let the server answer
263
+ // — a page with no not-found.vsk anywhere must still get a 404.
264
+ 'async function __renderNotFoundBody(props) {',
265
+ ' if (!_notFoundComp) return null;',
266
+ ' try {',
267
+ ' const result = await renderPage(\'\', _notFoundComp, props, __componentRegistry, { hydrate: true, cached: _notFoundCompiled });',
268
+ ' return result.body;',
269
+ ' } catch {',
270
+ ' return null;',
271
+ ' }',
272
+ '}',
273
+ '',
274
+ // The layout wrap, shared by the page / error / not-found paths. Extracted
275
+ // because three call sites that each re-implemented it is three chances to
276
+ // forget the head merge.
277
+ 'async function __wrapLayouts(params, body, head) {',
278
+ ' let _b = body;',
279
+ ' let _h = head || "";',
280
+ ' for (let _i = _layoutCompList.length - 1; _i > 0; _i--) {',
281
+ " const _inner = await renderPage('', _layoutCompList[_i], { params, children: _b }, __componentRegistry, { hydrate: true, cached: _layoutCompiledList[_i] });",
282
+ ' _b = _inner.body;',
283
+ ' if (_inner.head) _h = _inner.head + _h;',
284
+ ' }',
285
+ " return renderFullPage('', _layoutCompList[0], { params, children: _b }, __componentRegistry, { hydrate: true, cached: _layoutCompiledList[0]" + bakedOptions + clientScriptOption + dataScriptOption + ", pageHead: _h });",
286
+ '}',
287
+ '',
218
288
  'async function __renderHtml(params, requestUrl) {',
219
289
  ' return withSsrStore(async () => {',
220
290
  ' resetPersistedStoreKeys();',
221
291
  ' let page;',
222
292
  ' let caughtError = null;',
293
+ ' let notFound = false;',
223
294
  " const __expose = process.env.NODE_ENV !== 'production';",
224
295
  ' try {',
225
296
  ' page = await renderPage(\'\', _pageComp, { params }, __componentRegistry, { hydrate: true, cached: _pageCompiled });',
226
297
  ' } catch (err) {',
227
- ' if (err && (err.name === \'NotFoundError\' || err.name === \'Redirect\')) throw err;',
228
- ' caughtError = err;',
229
- " const message = err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);",
230
- " const stack = err && typeof err === 'object' && 'stack' in err ? String(err.stack) : '';",
231
- " page = { body: await __renderErrorBody({ params, statusCode: 500, error: __expose ? message : 'Internal Server Error', stack: __expose ? stack : '', url: requestUrl || '' }), head: '' };",
232
- ' }',
233
- ' let _body = (caughtError ? \'<!--vesk-ssr-error:\' + (caughtError && typeof caughtError === \'object\' && \'message\' in caughtError ? encodeURIComponent(__expose ? String(caughtError.message) : \'Internal Server Error\') : \'\') + \'-->\' : \'\') + page.body;',
234
- ' let _head = page.head || \'\';',
235
- ' for (let _i = _layoutCompList.length - 1; _i > 0; _i--) {',
236
- " const _inner = await renderPage('', _layoutCompList[_i], { params, children: _body }, __componentRegistry, { hydrate: true, cached: _layoutCompiledList[_i] });",
237
- ' _body = _inner.body;',
238
- ' if (_inner.head) _head = _inner.head + _head;',
298
+ " if (err && err.name === 'Redirect') throw err;",
299
+ " if (err && err.name === 'NotFoundError') {",
300
+ // The boundary is chosen per-route at build time (resolveNotFoundFile walks
301
+ // this route's own directory up to the app root), so a nested
302
+ // app/admin/not-found.vsk answers /admin/* and the root one answers the
303
+ // rest — matching the client router's deepest-first chain walk.
304
+ ' const nfBody = await __renderNotFoundBody({ params, url: requestUrl || \'\' });',
305
+ ' if (nfBody !== null) {',
306
+ ' notFound = true;',
307
+ ' page = { body: nfBody, head: \'\' };',
308
+ ' } else {',
309
+ ' throw err;',
310
+ ' }',
311
+ ' } else {',
312
+ ' caughtError = err;',
313
+ " const message = err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);",
314
+ " const stack = err && typeof err === 'object' && 'stack' in err ? String(err.stack) : '';",
315
+ " page = { body: await __renderErrorBody({ params, statusCode: 500, error: __expose ? message : 'Internal Server Error', stack: __expose ? stack : '', url: requestUrl || '' }), head: '' };",
316
+ ' }',
239
317
  ' }',
240
- " const html = await renderFullPage('', _layoutCompList[0], { params, children: _body }, __componentRegistry, { hydrate: true, cached: _layoutCompiledList[0]" + bakedOptions + clientScriptOption + dataScriptOption + ", pageHead: _head });",
241
- " return new Response(__injectStoreInit(html), { headers: { 'Content-Type': 'text/html' }, status: caughtError ? 500 : 200 });",
318
+ " let _body = (caughtError ? '<!--vesk-ssr-error:' + (caughtError && typeof caughtError === 'object' && 'message' in caughtError ? encodeURIComponent(__expose ? String(caughtError.message) : 'Internal Server Error') : '') + '-->' : '') + page.body;",
319
+ ' const html = await __wrapLayouts(params, _body, page.head || \'\');',
320
+ " return new Response(__injectStoreInit(html), { headers: { 'Content-Type': 'text/html' }, status: notFound ? 404 : (caughtError ? 500 : 200) });",
242
321
  ' });',
243
322
  '}',
244
323
  '',
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The `Request` handed to API routes and server actions.
3
+ *
4
+ * One implementation, used by both the dev and production servers. They each
5
+ * carried an identical copy until this existed, which is how the same defect sat
6
+ * in both: the shim built its `Request` with `body: null` and supplied only
7
+ * whole-body readers (`text()`, `json()`, `formData()`). Anything reading
8
+ * `request.body` — which is every streaming parser, including
9
+ * `@vesk/plugin-multipart` — saw no body at all and failed with `NO_BODY`.
10
+ * That is the difference between an upload handler that works and one that
11
+ * 400s on every request, so the body is now a real stream.
12
+ *
13
+ * The stream is created LAZILY, on first access to `request.body`. Attaching
14
+ * `data` listeners eagerly would start draining every GET body in the process,
15
+ * and a GET that never asked for its body would still be read.
16
+ *
17
+ * Reading is single-pass by definition, so both access patterns have to work on
18
+ * one socket: every byte that flows through the stream is cached, and
19
+ * `text()`/`json()`/`formData()` serve from that cache if the stream has already
20
+ * been taken. A handler that reads `request.body` and then calls
21
+ * `request.text()` gets the body once, not a hang.
22
+ */
23
+ import type { IncomingMessage } from 'node:http';
24
+ export interface ExtendedRequest extends Request {
25
+ json(): Promise<unknown>;
26
+ text(): Promise<string>;
27
+ formData(): Promise<FormData>;
28
+ clone(): ExtendedRequest;
29
+ cookies: Record<string, string>;
30
+ query: Record<string, string>;
31
+ }
32
+ export declare function bodyTooLarge(maxBytes: number): Error & {
33
+ status: number;
34
+ };
35
+ export declare function makeWebRequest(nodeReq: IncomingMessage, url: string, maxBodyBytes?: number): ExtendedRequest;
36
+ //# sourceMappingURL=web-request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-request.d.ts","sourceRoot":"","sources":["../src/web-request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAIjD,MAAM,WAAW,eAAgB,SAAQ,OAAO;IAC9C,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACzB,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACxB,QAAQ,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC9B,KAAK,IAAI,eAAe,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAIzE;AAED,wBAAgB,cAAc,CAC5B,OAAO,EAAE,eAAe,EACxB,GAAG,EAAE,MAAM,EACX,YAAY,GAAE,MAA+B,GAC5C,eAAe,CAwIjB"}
@@ -0,0 +1,165 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { DEFAULT_MAX_BODY_BYTES } from '@vesk/compiler/src/server-codegen';
3
+ export function bodyTooLarge(maxBytes) {
4
+ const err = new Error(`Request body exceeds limit (${maxBytes} bytes)`);
5
+ err.status = 413;
6
+ return err;
7
+ }
8
+ export function makeWebRequest(nodeReq, url, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
9
+ const parsedUrl = new URL(url, `http://${nodeReq.headers.host || 'localhost'}`);
10
+ const method = nodeReq.method || 'GET';
11
+ let cachedBody = null;
12
+ let streamTaken = false;
13
+ let streamEnded = false;
14
+ const endWaiters = [];
15
+ const settleEnd = (body) => {
16
+ if (body !== null)
17
+ cachedBody = body;
18
+ streamEnded = true;
19
+ for (const resolve of endWaiters.splice(0))
20
+ resolve();
21
+ };
22
+ function createBodyStream() {
23
+ streamTaken = true;
24
+ const cache = [];
25
+ let total = 0;
26
+ return new ReadableStream({
27
+ start(controller) {
28
+ nodeReq.on('data', (chunk) => {
29
+ total += chunk.length;
30
+ if (total > maxBodyBytes) {
31
+ // Same cap the buffered readers enforce, checked as the bytes arrive
32
+ // so an oversized upload is refused mid-body rather than after the
33
+ // last byte has been read and held in memory.
34
+ settleEnd(Buffer.concat(cache));
35
+ cache.length = 0;
36
+ try {
37
+ controller.error(bodyTooLarge(maxBodyBytes));
38
+ }
39
+ catch { /* already settled */ }
40
+ nodeReq.destroy();
41
+ return;
42
+ }
43
+ cache.push(chunk);
44
+ try {
45
+ controller.enqueue(new Uint8Array(chunk));
46
+ }
47
+ catch { /* consumer cancelled */ }
48
+ });
49
+ nodeReq.on('end', () => {
50
+ settleEnd(Buffer.concat(cache));
51
+ cache.length = 0;
52
+ try {
53
+ controller.close();
54
+ }
55
+ catch { /* errored */ }
56
+ });
57
+ nodeReq.on('error', (e) => {
58
+ settleEnd(Buffer.concat(cache));
59
+ cache.length = 0;
60
+ try {
61
+ controller.error(e);
62
+ }
63
+ catch { /* already settled */ }
64
+ });
65
+ },
66
+ });
67
+ }
68
+ async function getBody() {
69
+ if (cachedBody)
70
+ return cachedBody;
71
+ if (streamTaken) {
72
+ // The stream is the reader now. Everything it has seen is in the cache,
73
+ // and it settles on 'end' — so wait rather than racing an empty read.
74
+ while (!streamEnded)
75
+ await new Promise(r => endWaiters.push(r));
76
+ return cachedBody || Buffer.alloc(0);
77
+ }
78
+ const chunks = [];
79
+ let total = 0;
80
+ for await (const chunk of nodeReq) {
81
+ total += chunk.byteLength;
82
+ if (total > maxBodyBytes)
83
+ throw bodyTooLarge(maxBodyBytes);
84
+ chunks.push(Buffer.from(chunk));
85
+ }
86
+ cachedBody = Buffer.concat(chunks);
87
+ return cachedBody;
88
+ }
89
+ const webRequest = new Request(parsedUrl, {
90
+ method,
91
+ headers: nodeReq.headers,
92
+ body: null,
93
+ });
94
+ // Shadow the prototype's `body` getter with a lazily-created stream over the
95
+ // Node request. An own accessor on the instance wins over the prototype one,
96
+ // which is the only reason this works without a Node-specific Request class.
97
+ let bodyStream = null;
98
+ Object.defineProperty(webRequest, 'body', {
99
+ configurable: true,
100
+ enumerable: false,
101
+ get: () => {
102
+ if (bodyStream)
103
+ return bodyStream;
104
+ // `new Request(..., { body: null })` reports null for GET/HEAD, which is
105
+ // correct; only a request that can carry a body needs a stream.
106
+ if (method === 'GET' || method === 'HEAD')
107
+ return null;
108
+ bodyStream = createBodyStream();
109
+ return bodyStream;
110
+ },
111
+ });
112
+ webRequest.json = async () => { try {
113
+ return JSON.parse((await getBody()).toString());
114
+ }
115
+ catch {
116
+ return null;
117
+ } };
118
+ webRequest.text = async () => (await getBody()).toString('utf-8');
119
+ webRequest.formData = async () => {
120
+ const body = await getBody();
121
+ const ct = String(nodeReq.headers['content-type'] || '');
122
+ if (ct.includes('multipart/form-data')) {
123
+ const temp = new Request('http://localhost', { method: 'POST', headers: nodeReq.headers, body: body });
124
+ return temp.formData();
125
+ }
126
+ const fd = new FormData();
127
+ if (ct.includes('x-www-form-urlencoded')) {
128
+ for (const [k, v] of new URLSearchParams(body.toString()).entries())
129
+ fd.append(k, v);
130
+ }
131
+ return fd;
132
+ };
133
+ webRequest.clone = () => webRequest;
134
+ const cookies = {};
135
+ // Iterate the pieces, do NOT destructure them. `cookie.split(';')` yields an
136
+ // array of STRINGS, and a string is iterable, so `for (const [k, v] of …)`
137
+ // bound `k` to the first CHARACTER of the pair ('s' of "session=…"),
138
+ // `indexOf('=')` was always -1, and the loop never populated anything.
139
+ // `request.cookies` has therefore been `{}` in every production request that
140
+ // ever read it. The `[k, v]` form is right for `searchParams.entries()`,
141
+ // which really does yield pairs — which is why the bug survived next to
142
+ // working code.
143
+ for (const part of String(nodeReq.headers.cookie || '').split(';')) {
144
+ const eq = part.indexOf('=');
145
+ if (eq === -1)
146
+ continue;
147
+ const name = part.slice(0, eq).trim();
148
+ if (!name)
149
+ continue;
150
+ const raw = part.slice(eq + 1).trim();
151
+ try {
152
+ cookies[name] = decodeURIComponent(raw);
153
+ }
154
+ catch {
155
+ // A malformed percent-escape must not lose the whole cookie jar.
156
+ cookies[name] = raw;
157
+ }
158
+ }
159
+ webRequest.cookies = cookies;
160
+ const query = {};
161
+ for (const [k, v] of parsedUrl.searchParams.entries())
162
+ query[k] = v;
163
+ webRequest.query = query;
164
+ return webRequest;
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/adapter",
3
- "version": "0.2.69",
3
+ "version": "0.2.71",
4
4
  "type": "module",
5
5
  "description": "Vesk adapter — builds deployable .vesk/ output for Deno and Node serverless",
6
6
  "main": "./dist/index.js",
@@ -23,9 +23,9 @@
23
23
  "sharp": "^0.35.3"
24
24
  },
25
25
  "dependencies": {
26
- "@vesk/compiler": "^0.2.69",
27
- "@vesk/runtime": "^0.2.69",
28
- "@vesk/types": "^0.2.69"
26
+ "@vesk/compiler": "^0.2.71",
27
+ "@vesk/runtime": "^0.2.71",
28
+ "@vesk/types": "^0.2.71"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/ws": "^8.18.1",