@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.
- package/CHANGELOG.md +48 -0
- package/dist/config/boundary-modules.d.ts +15 -9
- package/dist/config/boundary-modules.js +24 -21
- package/dist/directives/compile.d.ts +3 -1
- package/dist/directives/compile.js +5 -2
- package/dist/directives/get-hierarchical-name.d.ts +2 -0
- package/dist/directives/get-hierarchical-name.js +104 -0
- package/dist/directives/index.js +6 -2
- package/dist/directives/plugin.d.ts +3 -0
- package/dist/directives/plugin.js +173 -138
- package/dist/directives/validate.d.ts +17 -0
- package/dist/directives/validate.js +176 -0
- package/dist/fns/handler.js +128 -10
- package/dist/fns/serialization.d.ts +25 -4
- package/dist/fns/serialization.js +258 -93
- package/dist/fns/shared.js +3 -2
- package/package.json +3 -3
- package/dist/directives/get-descriptive-name.d.ts +0 -2
- package/dist/directives/get-descriptive-name.js +0 -37
package/dist/fns/handler.js
CHANGED
|
@@ -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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
121
|
-
this.done = false;
|
|
171
|
+
this.maxChunkSize = options.maxChunkSize ?? Infinity;
|
|
122
172
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
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
|
|
404
|
+
return value;
|
|
240
405
|
}
|
|
241
406
|
return undefined;
|
|
242
407
|
}
|
package/dist/fns/shared.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
53
|
-
"seroval-plugins": "^1.6.
|
|
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",
|