@solidjs/start 2.0.5 → 2.0.6

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,13 +19,115 @@ import "solid-start:server-fn-manifest";
19
19
  import { getServerFunction, hasServerFunction } from "./registration.js";
20
20
  import { applyServerFunctionErrorHandler } from "./error-handler.js";
21
21
  import { getExpectedRedirectStatus } from "../server/util.js";
22
+ /**
23
+ * Server functions are same-origin RPC. A cross-site page must not be able to
24
+ * invoke one with the visitor's cookies, so reject cross-site requests before
25
+ * the function runs. This is the token-less origin check used by other
26
+ * frameworks: trust `Sec-Fetch-Site` when the browser sends it, and fall back
27
+ * to comparing `Origin` against the request host.
28
+ *
29
+ * `same-origin` and `same-site` are allowed, matching the reach of a
30
+ * `SameSite=Lax`/`Strict` cookie. `none` is a user-initiated navigation
31
+ * (typed URL, bookmark), not a request made by another site.
32
+ */
33
+ function isCrossSiteRequest(request, url) {
34
+ const secFetchSite = request.headers.get("sec-fetch-site");
35
+ if (secFetchSite) {
36
+ return secFetchSite === "cross-site";
37
+ }
38
+ // Older browsers omit Sec-Fetch-Site. They still send Origin on the
39
+ // cross-site requests that matter (form and fetch POSTs), so compare it.
40
+ const origin = request.headers.get("origin");
41
+ // `null` is an opaque origin (a sandboxed iframe, a cross-origin redirect
42
+ // chain): the browser declines to name the caller, so it cannot be matched.
43
+ if (origin === "null") {
44
+ return true;
45
+ }
46
+ if (origin) {
47
+ try {
48
+ return new URL(origin).host !== url.host;
49
+ }
50
+ catch {
51
+ return true;
52
+ }
53
+ }
54
+ // No Origin either (a same-origin GET, or a non-browser client): nothing to
55
+ // reject on.
56
+ return false;
57
+ }
58
+ const CACHE_CONTROL = "Cache-Control";
59
+ const DEFAULT_CACHE_CONTROL = "no-store";
60
+ /**
61
+ * A server function answers one caller, so a shared cache must not store the
62
+ * response unless the function opted in with its own `Cache-Control`, either
63
+ * on a returned Response or on the event's response headers. A 304 is left
64
+ * alone: it updates a stored response rather than being one, and `no-store`
65
+ * on it would drop the entry the conditional request was keeping.
66
+ *
67
+ * h3 copies `res.headers` onto a returned Response below 400 and
68
+ * `res.errHeaders` onto one at or above it, and builds every other return
69
+ * value from `res`, so those are the headers that reach the wire.
70
+ */
71
+ function withDefaultCacheControl(h3Event, value) {
72
+ if (!(value instanceof Response)) {
73
+ if (h3Event.res.status !== 304 && !h3Event.res.headers.has(CACHE_CONTROL)) {
74
+ h3Event.res.headers.set(CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
75
+ }
76
+ return value;
77
+ }
78
+ const eventHeaders = value.status >= 400 ? h3Event.res.errHeaders : h3Event.res.headers;
79
+ if (value.status === 304 || value.headers.has(CACHE_CONTROL) || eventHeaders.has(CACHE_CONTROL)) {
80
+ return value;
81
+ }
82
+ try {
83
+ value.headers.set(CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
84
+ return value;
85
+ }
86
+ catch {
87
+ // a fetch() Response passed through has immutable headers
88
+ const headers = new Headers(value.headers);
89
+ headers.set(CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
90
+ return new Response(value.body, {
91
+ status: value.status,
92
+ statusText: value.statusText,
93
+ headers,
94
+ });
95
+ }
96
+ }
97
+ function thrownCacheControl(error) {
98
+ try {
99
+ const headers = error?.headers;
100
+ return headers ? new Headers(headers).has(CACHE_CONTROL) : false;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
22
106
  export async function handleServerFunction(h3Event) {
107
+ let response;
108
+ try {
109
+ response = await runServerFunction(h3Event);
110
+ }
111
+ catch (error) {
112
+ // h3 answers a thrown value itself, with the error's headers and then
113
+ // `res.errHeaders`
114
+ if (!thrownCacheControl(error) && !h3Event.res.errHeaders.has(CACHE_CONTROL)) {
115
+ h3Event.res.errHeaders.set(CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
116
+ }
117
+ throw error;
118
+ }
119
+ return withDefaultCacheControl(h3Event, response);
120
+ }
121
+ async function runServerFunction(h3Event) {
23
122
  const event = getFetchEvent(h3Event);
24
123
  const request = event.request;
124
+ const url = new URL(request.url);
125
+ if (isCrossSiteRequest(request, url)) {
126
+ return new Response(import.meta.env.DEV ? "Cross-site server function requests are not allowed" : null, { status: 403 });
127
+ }
25
128
  const serverReference = request.headers.get("X-Server-Id");
26
129
  const instance = request.headers.get("X-Server-Instance");
27
130
  const singleFlight = request.headers.has("X-Single-Flight");
28
- const url = new URL(request.url);
29
131
  let functionId;
30
132
  if (serverReference) {
31
133
  // invariant(typeof serverReference === "string", "Invalid server function");
@@ -199,6 +301,18 @@ function getRefererLocation(request, url) {
199
301
  // leaving the browser sitting on the server function endpoint
200
302
  return new URL(import.meta.env.BASE_URL, url.origin).toString();
201
303
  }
304
+ // The no-JS form path passes the submitted FormData as the last argument, and
305
+ // its entries are echoed back through the flash cookie. Any other no-JS POST
306
+ // (empty body, a non-form content type) leaves a value here that is not a
307
+ // FormData, so guard the entries() call instead of assuming it.
308
+ function buildFlashInput(parsed) {
309
+ if (parsed.length === 0) {
310
+ return [];
311
+ }
312
+ const last = parsed[parsed.length - 1];
313
+ const entries = typeof last?.entries === "function" ? [...last.entries()] : last;
314
+ return [...parsed.slice(0, -1), entries];
315
+ }
202
316
  async function handleNoJS(result, request, parsed, thrown) {
203
317
  const url = new URL(request.url);
204
318
  const isError = result instanceof Error;
@@ -225,15 +339,19 @@ async function handleNoJS(result, request, parsed, thrown) {
225
339
  Location: getRefererLocation(request, url),
226
340
  });
227
341
  if (result) {
228
- headers.append("Set-Cookie", `flash=${encodeURIComponent(JSON.stringify({
229
- url: url.pathname + url.search,
230
- result: isError ? result.message : result,
231
- thrown: thrown,
232
- error: isError,
233
- input: parsed.length
234
- ? [...parsed.slice(0, -1), [...parsed[parsed.length - 1].entries()]]
235
- : [],
236
- }))}; Secure; HttpOnly;`);
342
+ try {
343
+ headers.append("Set-Cookie", `flash=${encodeURIComponent(JSON.stringify({
344
+ url: url.pathname + url.search,
345
+ result: isError ? result.message : result,
346
+ thrown: thrown,
347
+ error: isError,
348
+ input: buildFlashInput(parsed),
349
+ }))}; Secure; HttpOnly;`);
350
+ }
351
+ catch {
352
+ // The flash cookie is best effort. A value that cannot be serialized must
353
+ // not take down the redirect, which is also this request's error handler.
354
+ }
237
355
  }
238
356
  return new Response(null, {
239
357
  status: statusCode,
@@ -1,11 +1,29 @@
1
+ /**
2
+ * The largest chunk a server accepts from a client. The header can declare up
3
+ * to 4GB, and the reader buffers that much before it parses anything.
4
+ */
5
+ export declare const MAX_REQUEST_CHUNK_SIZE: number;
1
6
  export declare function serializeToJSStream(id: string, value: any): ReadableStream<any>;
2
7
  export declare function serializeToJSONStream(value: any): ReadableStream<any>;
8
+ export interface SerovalChunkReaderOptions {
9
+ /** Rejects any chunk larger than this many bytes. */
10
+ maxChunkSize?: number;
11
+ }
3
12
  export declare class SerovalChunkReader {
4
13
  reader: ReadableStreamDefaultReader<Uint8Array>;
5
- buffer: Uint8Array;
6
14
  done: boolean;
7
- constructor(stream: ReadableStream<Uint8Array>);
8
- readChunk(): Promise<void>;
15
+ private maxChunkSize;
16
+ /** Pieces read from the stream and not yet consumed, in order. */
17
+ private pieces;
18
+ private length;
19
+ constructor(stream: ReadableStream<Uint8Array>, options?: SerovalChunkReaderOptions);
20
+ /** Reads until `size` bytes are buffered or the stream ends. */
21
+ private fill;
22
+ /**
23
+ * Removes the first `size` bytes from the buffer. Pieces are only joined
24
+ * when the bytes span more than one, so each byte is copied at most once.
25
+ */
26
+ private take;
9
27
  next(): Promise<{
10
28
  done: true;
11
29
  value: undefined;
@@ -13,9 +31,12 @@ export declare class SerovalChunkReader {
13
31
  done: false;
14
32
  value: string;
15
33
  }>;
34
+ /** Stops reading and releases the stream, such as after a parse error. */
35
+ cancel(reason?: unknown): Promise<void>;
36
+ /** Interprets every remaining chunk. On an error, the stream is cancelled. */
16
37
  drain(interpret: (chunk: string) => void): Promise<void>;
17
38
  }
18
39
  export declare function serializeToJSONString(value: any): Promise<string>;
19
40
  export declare function deserializeFromJSONString(json: string): Promise<unknown>;
20
- export declare function deserializeJSONStream(response: Response | Request): Promise<unknown>;
41
+ export declare function deserializeJSONStream(response: Response | Request, options?: SerovalChunkReaderOptions): Promise<unknown>;
21
42
  export declare function deserializeJSStream(id: string, response: Request | Response): Promise<unknown>;
@@ -1,4 +1,4 @@
1
- import { crossSerializeStream, deserialize, Feature, fromCrossJSON, getCrossReferenceHeader, toCrossJSONStream, } from "seroval";
1
+ import { crossSerializeStream, deserialize, Feature, fromCrossJSON, getCrossReferenceHeader, isStream, toCrossJSONStream, } from "seroval";
2
2
  import { AbortSignalPlugin, CustomEventPlugin, DOMExceptionPlugin, EventPlugin, FormDataPlugin, HeadersPlugin, ReadableStreamPlugin, RequestPlugin, ResponsePlugin, URLPlugin, URLSearchParamsPlugin, } from "seroval-plugins/web";
3
3
  import userPlugins from "solid-start:seroval-plugins";
4
4
  const DEFAULT_PLUGINS = [
@@ -60,16 +60,37 @@ const JS_SERIALIZE_DISABLED_FEATURES = import.meta.env.PROD ? Feature.ErrorProto
60
60
  * The format is as follows:
61
61
  * ;0xFFFFFFFF;<string data>
62
62
  */
63
- function createChunk(data) {
64
- const encodeData = new TextEncoder().encode(data);
65
- const bytes = encodeData.length;
66
- const baseHex = bytes.toString(16);
67
- const totalHex = "00000000".substring(0, 8 - baseHex.length) + baseHex; // 32-bit
68
- const head = new TextEncoder().encode(`;0x${totalHex};`);
69
- const chunk = new Uint8Array(12 + bytes);
70
- chunk.set(head);
71
- chunk.set(encodeData, 12);
72
- return chunk;
63
+ const encoder = new TextEncoder();
64
+ const decoder = new TextDecoder();
65
+ const HEADER_SIZE = 12;
66
+ const SEMICOLON = 0x3b;
67
+ const ZERO = 0x30;
68
+ const LOWER_X = 0x78;
69
+ /**
70
+ * The largest chunk a server accepts from a client. The header can declare up
71
+ * to 4GB, and the reader buffers that much before it parses anything.
72
+ */
73
+ export const MAX_REQUEST_CHUNK_SIZE = 64 * 1024 * 1024;
74
+ function createChunkHeader(bytes) {
75
+ const hex = bytes.toString(16).padStart(8, "0");
76
+ const head = new Uint8Array(HEADER_SIZE);
77
+ head[0] = SEMICOLON;
78
+ head[1] = ZERO;
79
+ head[2] = LOWER_X;
80
+ for (let i = 0; i < 8; i++) {
81
+ head[3 + i] = hex.charCodeAt(i);
82
+ }
83
+ head[11] = SEMICOLON;
84
+ return head;
85
+ }
86
+ /**
87
+ * The header and the data are sent as separate pieces, so the encoded data is
88
+ * not copied into a second buffer.
89
+ */
90
+ function enqueueChunk(controller, data) {
91
+ const encoded = encoder.encode(data);
92
+ controller.enqueue(createChunkHeader(encoded.length));
93
+ controller.enqueue(encoded);
73
94
  }
74
95
  export function serializeToJSStream(id, value) {
75
96
  return new ReadableStream({
@@ -79,7 +100,7 @@ export function serializeToJSStream(id, value) {
79
100
  disabledFeatures: JS_SERIALIZE_DISABLED_FEATURES,
80
101
  plugins: PLUGINS,
81
102
  onSerialize(data, initial) {
82
- controller.enqueue(createChunk(initial ? `(${getCrossReferenceHeader(id)},${data})` : data));
103
+ enqueueChunk(controller, initial ? `(${getCrossReferenceHeader(id)},${data})` : data);
83
104
  },
84
105
  onDone() {
85
106
  controller.close();
@@ -99,7 +120,7 @@ export function serializeToJSONStream(value) {
99
120
  depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT,
100
121
  plugins: PLUGINS,
101
122
  onParse(node) {
102
- controller.enqueue(createChunk(JSON.stringify(node)));
123
+ enqueueChunk(controller, JSON.stringify(node));
103
124
  },
104
125
  onDone() {
105
126
  controller.close();
@@ -111,88 +132,196 @@ export function serializeToJSONStream(value) {
111
132
  },
112
133
  });
113
134
  }
135
+ function hexValue(code) {
136
+ if (code >= 0x30 && code <= 0x39)
137
+ return code - 0x30;
138
+ if (code >= 0x61 && code <= 0x66)
139
+ return code - 0x61 + 10;
140
+ if (code >= 0x41 && code <= 0x46)
141
+ return code - 0x41 + 10;
142
+ return -1;
143
+ }
144
+ /** Returns the data size the header declares, or -1 when it is malformed. */
145
+ function parseChunkHeader(head) {
146
+ if (head[0] !== SEMICOLON || head[1] !== ZERO || head[2] !== LOWER_X || head[11] !== SEMICOLON) {
147
+ return -1;
148
+ }
149
+ let size = 0;
150
+ for (let i = 3; i < 11; i++) {
151
+ const digit = hexValue(head[i]);
152
+ if (digit === -1) {
153
+ return -1;
154
+ }
155
+ size = size * 16 + digit;
156
+ }
157
+ return size;
158
+ }
159
+ function malformed() {
160
+ return new Error("Malformed server function stream.");
161
+ }
114
162
  export class SerovalChunkReader {
115
163
  reader;
116
- buffer;
117
- done;
118
- constructor(stream) {
164
+ done = false;
165
+ maxChunkSize;
166
+ /** Pieces read from the stream and not yet consumed, in order. */
167
+ pieces = [];
168
+ length = 0;
169
+ constructor(stream, options = {}) {
119
170
  this.reader = stream.getReader();
120
- this.buffer = new Uint8Array(0);
121
- this.done = false;
171
+ this.maxChunkSize = options.maxChunkSize ?? Infinity;
122
172
  }
123
- async readChunk() {
124
- // if there's no chunk, read again
125
- const chunk = await this.reader.read();
126
- if (!chunk.done) {
127
- // repopulate the buffer
128
- const newBuffer = new Uint8Array(this.buffer.length + chunk.value.length);
129
- newBuffer.set(this.buffer);
130
- newBuffer.set(chunk.value, this.buffer.length);
131
- this.buffer = newBuffer;
132
- }
133
- else {
134
- this.done = true;
173
+ /** Reads until `size` bytes are buffered or the stream ends. */
174
+ async fill(size) {
175
+ while (this.length < size && !this.done) {
176
+ const chunk = await this.reader.read();
177
+ if (chunk.done) {
178
+ this.done = true;
179
+ }
180
+ else if (chunk.value.length > 0) {
181
+ this.pieces.push(chunk.value);
182
+ this.length += chunk.value.length;
183
+ }
135
184
  }
136
185
  }
137
- async next() {
138
- // Check if the buffer is empty or incomplete
139
- if (this.buffer.length < 12) {
140
- // if we are already done...
141
- if (this.done) {
142
- // incomplete stream
143
- if (this.buffer.length !== 0) {
144
- throw new Error("Malformed server function stream.");
145
- }
146
- return {
147
- done: true,
148
- value: undefined,
149
- };
186
+ /**
187
+ * Removes the first `size` bytes from the buffer. Pieces are only joined
188
+ * when the bytes span more than one, so each byte is copied at most once.
189
+ */
190
+ take(size) {
191
+ const first = this.pieces[0];
192
+ if (first && first.length >= size) {
193
+ if (first.length === size) {
194
+ this.pieces.shift();
150
195
  }
151
- // Otherwise, read a new chunk
152
- await this.readChunk();
153
- return await this.next();
154
- }
155
- // Read the "byte header"
156
- // The byte header tells us how big the expected data is
157
- // so we know how much data we should wait before we
158
- // deserialize the data
159
- const head = new TextDecoder().decode(this.buffer.subarray(1, 11));
160
- const bytes = Number.parseInt(head, 16); // ;0x00000000;
161
- if (Number.isNaN(bytes)) {
162
- throw new Error("Malformed server function stream.");
163
- }
164
- // Check if the buffer has enough bytes to be parsed
165
- while (bytes > this.buffer.length - 12) {
166
- // If it's not enough, and the reader is done
167
- // then the chunk is invalid.
168
- if (this.done) {
169
- throw new Error("Malformed server function stream.");
196
+ else {
197
+ this.pieces[0] = first.subarray(size);
170
198
  }
171
- // Otherwise, we read more chunks
172
- await this.readChunk();
173
- }
174
- // Extract the exact chunk as defined by the byte header
175
- const partial = new TextDecoder().decode(this.buffer.subarray(12, 12 + bytes));
176
- // The rest goes to the buffer
177
- this.buffer = this.buffer.subarray(12 + bytes);
178
- // Deserialize the chunk
179
- return {
180
- done: false,
181
- value: partial,
182
- };
183
- }
184
- async drain(interpret) {
185
- while (true) {
186
- const result = await this.next();
187
- if (result.done) {
188
- break;
199
+ this.length -= size;
200
+ return first.subarray(0, size);
201
+ }
202
+ const result = new Uint8Array(size);
203
+ let offset = 0;
204
+ while (offset < size) {
205
+ const piece = this.pieces[0];
206
+ const count = Math.min(piece.length, size - offset);
207
+ result.set(piece.subarray(0, count), offset);
208
+ offset += count;
209
+ if (count === piece.length) {
210
+ this.pieces.shift();
189
211
  }
190
212
  else {
213
+ this.pieces[0] = piece.subarray(count);
214
+ }
215
+ }
216
+ this.length -= size;
217
+ return result;
218
+ }
219
+ async next() {
220
+ await this.fill(HEADER_SIZE);
221
+ if (this.length === 0) {
222
+ return { done: true, value: undefined };
223
+ }
224
+ if (this.length < HEADER_SIZE) {
225
+ throw malformed();
226
+ }
227
+ // The header gives the size of the data, so we know how much to wait for
228
+ // before decoding it.
229
+ const size = parseChunkHeader(this.take(HEADER_SIZE));
230
+ if (size === -1) {
231
+ throw malformed();
232
+ }
233
+ if (size > this.maxChunkSize) {
234
+ throw new Error(`Server function stream chunk of ${size} bytes is larger than the limit of ${this.maxChunkSize} bytes.`);
235
+ }
236
+ await this.fill(size);
237
+ if (this.length < size) {
238
+ throw malformed();
239
+ }
240
+ return { done: false, value: decoder.decode(this.take(size)) };
241
+ }
242
+ /** Stops reading and releases the stream, such as after a parse error. */
243
+ async cancel(reason) {
244
+ this.pieces = [];
245
+ this.length = 0;
246
+ this.done = true;
247
+ await this.reader.cancel(reason).catch(() => { });
248
+ }
249
+ /** Interprets every remaining chunk. On an error, the stream is cancelled. */
250
+ async drain(interpret) {
251
+ try {
252
+ while (true) {
253
+ const result = await this.next();
254
+ if (result.done) {
255
+ break;
256
+ }
191
257
  interpret(result.value);
192
258
  }
193
259
  }
260
+ catch (error) {
261
+ await this.cancel(error);
262
+ throw error;
263
+ }
194
264
  }
195
265
  }
266
+ function reportDrainError(error) {
267
+ console.error("[solid-start] failed to read the rest of a server function stream:", error);
268
+ }
269
+ const ignoreRejection = () => { };
270
+ /**
271
+ * A decoded payload is the peer's bytes, so a promise it decodes to can reject
272
+ * with nobody holding it: an argument the function never awaits, a field of a
273
+ * result the caller ignores. Seroval stores every promise it decodes in the
274
+ * refs map, including ones that reject synchronously inside `fromCrossJSON`,
275
+ * so claim them as they are stored. Code that awaits the promise still sees
276
+ * the rejection.
277
+ */
278
+ class DecodedRefs extends Map {
279
+ set(id, value) {
280
+ if (value instanceof Promise)
281
+ value.catch(ignoreRejection);
282
+ return super.set(id, value);
283
+ }
284
+ }
285
+ /**
286
+ * Fails every value still waiting on a frame that will not arrive. Seroval
287
+ * keeps these in the refs between frames: open streams and pending-promise
288
+ * resolvers (`{ p, s, f }`). `fromCrossJSON` builds streams from seroval's
289
+ * internal `Stream` class, which only `isStream` recognizes; the eval-based
290
+ * `deserialize` rebuilds them as plain `__SEROVAL_STREAM__` objects. Throwing
291
+ * into a closed stream and rejecting a settled promise are no-ops, so this is
292
+ * safe to run after a body that ended normally.
293
+ *
294
+ * A ref can be any value a plugin decoded, so nothing is read off it unguarded.
295
+ */
296
+ function settlePendingRefs(refs, error) {
297
+ for (const value of refs) {
298
+ if (value === null || typeof value !== "object")
299
+ continue;
300
+ try {
301
+ const ref = value;
302
+ if (isStream(ref)) {
303
+ ref.throw(error);
304
+ }
305
+ else if (ref.__SEROVAL_STREAM__ === true && typeof ref.throw === "function") {
306
+ ref.throw(error);
307
+ }
308
+ else if (typeof ref.s === "function" &&
309
+ typeof ref.f === "function" &&
310
+ ref.p instanceof Promise) {
311
+ // seroval stores the resolver before its promise, so a failed second
312
+ // store leaves a promise that `DecodedRefs` never saw
313
+ ref.p.catch(ignoreRejection);
314
+ ref.f(error);
315
+ }
316
+ }
317
+ catch {
318
+ // a stream listener threw, or a plugin value refused the read
319
+ }
320
+ }
321
+ }
322
+ function createEndOfStreamError() {
323
+ return new Error("Server function stream ended unexpectedly.");
324
+ }
196
325
  export async function serializeToJSONString(value) {
197
326
  const response = new Response(serializeToJSONStream(value));
198
327
  return await response.text();
@@ -201,14 +330,17 @@ export async function deserializeFromJSONString(json) {
201
330
  const blob = new Response(json);
202
331
  return await deserializeJSONStream(blob);
203
332
  }
204
- export async function deserializeJSONStream(response) {
333
+ export async function deserializeJSONStream(response, options) {
205
334
  if (!response.body) {
206
335
  throw new Error("missing body");
207
336
  }
208
- const reader = new SerovalChunkReader(response.body);
209
- const result = await reader.next();
337
+ const reader = new SerovalChunkReader(response.body, options);
338
+ const result = await reader.next().catch(async (error) => {
339
+ await reader.cancel(error);
340
+ throw error;
341
+ });
210
342
  if (!result.done) {
211
- const refs = new Map();
343
+ const refs = new DecodedRefs();
212
344
  function interpretChunk(chunk) {
213
345
  const value = fromCrossJSON(JSON.parse(chunk), {
214
346
  refs,
@@ -218,25 +350,58 @@ export async function deserializeJSONStream(response) {
218
350
  });
219
351
  return value;
220
352
  }
221
- void reader.drain(interpretChunk);
222
- return interpretChunk(result.value);
353
+ let value;
354
+ try {
355
+ value = interpretChunk(result.value);
356
+ }
357
+ catch (error) {
358
+ await reader.cancel(error);
359
+ throw error;
360
+ }
361
+ // Later chunks settle the promises and streams inside `value`. Once the
362
+ // body is done, whether it ended or failed (a malformed chunk, a dropped
363
+ // connection), nothing can settle what is still waiting on it. A bad chunk
364
+ // is reported here, because nothing else awaits this.
365
+ reader.drain(interpretChunk).then(() => settlePendingRefs(refs.values(), createEndOfStreamError()), error => {
366
+ reportDrainError(error);
367
+ settlePendingRefs(refs.values(), error);
368
+ });
369
+ return value;
223
370
  }
224
371
  return undefined;
225
372
  }
373
+ function releaseJSScope(id, error) {
374
+ const scopes = globalThis.$R;
375
+ const refs = scopes?.[id];
376
+ if (!refs)
377
+ return;
378
+ delete scopes[id];
379
+ settlePendingRefs(refs, error);
380
+ }
226
381
  export async function deserializeJSStream(id, response) {
227
382
  if (!response.body) {
228
383
  throw new Error("missing body");
229
384
  }
230
385
  const reader = new SerovalChunkReader(response.body);
231
- const result = await reader.next();
386
+ const result = await reader.next().catch(async (error) => {
387
+ await reader.cancel(error);
388
+ throw error;
389
+ });
232
390
  if (!result.done) {
233
- reader.drain(deserialize).then(() => {
234
- // @ts-ignore
235
- delete $R[id];
236
- }, () => {
237
- // no-op
391
+ let value;
392
+ try {
393
+ value = deserialize(result.value);
394
+ }
395
+ catch (error) {
396
+ await reader.cancel(error);
397
+ releaseJSScope(id, error);
398
+ throw error;
399
+ }
400
+ reader.drain(deserialize).then(() => releaseJSScope(id, createEndOfStreamError()), error => {
401
+ reportDrainError(error);
402
+ releaseJSScope(id, error);
238
403
  });
239
- return deserialize(result.value);
404
+ return value;
240
405
  }
241
406
  return undefined;
242
407
  }
@@ -1,4 +1,4 @@
1
- import { deserializeJSONStream, deserializeJSStream } from "./serialization.js";
1
+ import { deserializeJSONStream, deserializeJSStream, MAX_REQUEST_CHUNK_SIZE, } from "./serialization.js";
2
2
  export const BODY_FORMAT_KEY = "X-Start-Type";
3
3
  export const BODY_FORMAL_FILE = "__START__";
4
4
  export var BodyFormat;
@@ -81,7 +81,8 @@ export async function extractBody(instance, client, source) {
81
81
  if (client && import.meta.env.SEROVAL_MODE === "js") {
82
82
  return await deserializeJSStream(instance, clone);
83
83
  }
84
- return await deserializeJSONStream(clone);
84
+ // A server reads chunks sent by any client, so their size is limited.
85
+ return await deserializeJSONStream(clone, client ? undefined : { maxChunkSize: MAX_REQUEST_CHUNK_SIZE });
85
86
  case startType === BodyFormat.String:
86
87
  return await clone.text();
87
88
  case startType === BodyFormat.File: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidjs/start",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/solidjs/solid-start.git",
@@ -49,8 +49,8 @@
49
49
  "path-to-regexp": "^8.4.2",
50
50
  "pathe": "^2.0.3",
51
51
  "radix3": "^1.1.2",
52
- "seroval": "^1.6.0",
53
- "seroval-plugins": "^1.6.0",
52
+ "seroval": "^1.6.8",
53
+ "seroval-plugins": "^1.6.8",
54
54
  "shiki": "^4.3.1",
55
55
  "solid-js": "^1.9.15",
56
56
  "srvx": "^0.12.4",
@@ -1,2 +0,0 @@
1
- import type { NodePath } from "@babel/core";
2
- export declare function getDescriptiveName(path: NodePath, defaultName: string): string;