@solidjs/start 1.3.1 → 1.3.5
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/config/index.js +8 -3
- package/config/xxhash32.js +214 -0
- package/dist/runtime/cache-control.d.ts +17 -0
- package/dist/runtime/cache-control.js +38 -0
- package/dist/runtime/cross-site.d.ts +11 -0
- package/dist/runtime/cross-site.js +35 -0
- package/dist/runtime/cross-site.test.d.ts +1 -0
- package/dist/runtime/cross-site.test.js +52 -0
- package/dist/runtime/serialization.d.ts +7 -0
- package/dist/runtime/serialization.js +84 -7
- package/dist/runtime/serialization.test.d.ts +1 -0
- package/dist/runtime/serialization.test.js +313 -0
- package/dist/runtime/server-handler.js +27 -1
- package/dist/runtime/server-handler.test.d.ts +1 -0
- package/dist/runtime/server-handler.test.js +207 -0
- package/dist/runtime/server-runtime.js +9 -9
- package/package.json +3 -3
package/config/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { config } from "vinxi/plugins/config";
|
|
|
9
9
|
import solid from "vite-plugin-solid";
|
|
10
10
|
import { SolidStartClientFileRouter, SolidStartServerFileRouter } from "./fs-router.js";
|
|
11
11
|
import { serverComponents } from "./server-components.js";
|
|
12
|
+
import xxHash32 from "./xxhash32.js";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_EXTENSIONS = ["js", "jsx", "ts", "tsx"];
|
|
14
15
|
|
|
@@ -37,6 +38,10 @@ function solidStartServerFsRouter(config) {
|
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function getFunctionId(id) {
|
|
42
|
+
return xxHash32(id).toString(16);
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
const SolidStartServerFnsPlugin = createTanStackServerFnPlugin({
|
|
41
46
|
// This is the ID that will be available to look up and import
|
|
42
47
|
// our server function manifest and resolve its module
|
|
@@ -47,7 +52,7 @@ const SolidStartServerFnsPlugin = createTanStackServerFnPlugin({
|
|
|
47
52
|
fileURLToPath(new URL("../dist/runtime/server-runtime.js", import.meta.url))
|
|
48
53
|
)}"`,
|
|
49
54
|
replacer: opts =>
|
|
50
|
-
`createServerReference(${() => {}}, '${opts.functionId}', '${opts.extractedFilename}')`
|
|
55
|
+
`createServerReference(${() => {}}, '${(opts.functionId)}', '${getFunctionId(opts.extractedFilename)}')`
|
|
51
56
|
},
|
|
52
57
|
ssr: {
|
|
53
58
|
getRuntimeCode: () =>
|
|
@@ -55,7 +60,7 @@ const SolidStartServerFnsPlugin = createTanStackServerFnPlugin({
|
|
|
55
60
|
fileURLToPath(new URL("../dist/runtime/server-fns-runtime.js", import.meta.url))
|
|
56
61
|
)}'`,
|
|
57
62
|
replacer: opts =>
|
|
58
|
-
`createServerReference(${opts.fn}, '${opts.functionId}', '${opts.extractedFilename}')`
|
|
63
|
+
`createServerReference(${opts.fn}, '${(opts.functionId)}', '${getFunctionId(opts.extractedFilename)}')`
|
|
59
64
|
},
|
|
60
65
|
server: {
|
|
61
66
|
getRuntimeCode: () =>
|
|
@@ -63,7 +68,7 @@ const SolidStartServerFnsPlugin = createTanStackServerFnPlugin({
|
|
|
63
68
|
fileURLToPath(new URL("../dist/runtime/server-fns-runtime.js", import.meta.url))
|
|
64
69
|
)}'`,
|
|
65
70
|
replacer: opts =>
|
|
66
|
-
`createServerReference(${opts.fn}, '${opts.functionId}', '${opts.extractedFilename}')`
|
|
71
|
+
`createServerReference(${opts.fn}, '${(opts.functionId)}', '${getFunctionId(opts.extractedFilename)}')`
|
|
67
72
|
}
|
|
68
73
|
});
|
|
69
74
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2019 Jason Dent
|
|
4
|
+
* https://github.com/Jason3S/xxhash
|
|
5
|
+
*/
|
|
6
|
+
const PRIME32_1 = 2654435761;
|
|
7
|
+
const PRIME32_2 = 2246822519;
|
|
8
|
+
const PRIME32_3 = 3266489917;
|
|
9
|
+
const PRIME32_4 = 668265263;
|
|
10
|
+
const PRIME32_5 = 374761393;
|
|
11
|
+
|
|
12
|
+
function toUtf8(text) {
|
|
13
|
+
const bytes = [];
|
|
14
|
+
for (let i = 0, n = text.length; i < n; ++i) {
|
|
15
|
+
const c = text.charCodeAt(i);
|
|
16
|
+
if (c < 0x80) {
|
|
17
|
+
bytes.push(c);
|
|
18
|
+
} else if (c < 0x800) {
|
|
19
|
+
bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
|
20
|
+
} else if (c < 0xd800 || c >= 0xe000) {
|
|
21
|
+
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
|
22
|
+
} else {
|
|
23
|
+
const cp =
|
|
24
|
+
0x10000 + (((c & 0x3ff) << 10) | (text.charCodeAt(++i) & 0x3ff));
|
|
25
|
+
bytes.push(
|
|
26
|
+
0xf0 | ((cp >> 18) & 0x7),
|
|
27
|
+
0x80 | ((cp >> 12) & 0x3f),
|
|
28
|
+
0x80 | ((cp >> 6) & 0x3f),
|
|
29
|
+
0x80 | (cp & 0x3f),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return new Uint8Array(bytes);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
*
|
|
37
|
+
* @param buffer - byte array or string
|
|
38
|
+
* @param seed - optional seed (32-bit unsigned);
|
|
39
|
+
*/
|
|
40
|
+
export default function xxHash32(
|
|
41
|
+
buffer,
|
|
42
|
+
seed = 0,
|
|
43
|
+
) {
|
|
44
|
+
buffer = typeof buffer === 'string' ? toUtf8(buffer) : buffer;
|
|
45
|
+
const b = buffer;
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
Step 1. Initialize internal accumulators
|
|
49
|
+
Each accumulator gets an initial value based on optional seed input.
|
|
50
|
+
Since the seed is optional, it can be 0.
|
|
51
|
+
```
|
|
52
|
+
u32 acc1 = seed + PRIME32_1 + PRIME32_2;
|
|
53
|
+
u32 acc2 = seed + PRIME32_2;
|
|
54
|
+
u32 acc3 = seed + 0;
|
|
55
|
+
u32 acc4 = seed - PRIME32_1;
|
|
56
|
+
```
|
|
57
|
+
Special case : input is less than 16 bytes
|
|
58
|
+
When input is too small (< 16 bytes), the algorithm will not process any stripe.
|
|
59
|
+
Consequently, it will not make use of parallel accumulators.
|
|
60
|
+
In which case, a simplified initialization is performed, using a single accumulator :
|
|
61
|
+
u32 acc = seed + PRIME32_5;
|
|
62
|
+
The algorithm then proceeds directly to step 4.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
let acc = (seed + PRIME32_5) & 0xffffffff;
|
|
66
|
+
let offset = 0;
|
|
67
|
+
|
|
68
|
+
if (b.length >= 16) {
|
|
69
|
+
const accN = [
|
|
70
|
+
(seed + PRIME32_1 + PRIME32_2) & 0xffffffff,
|
|
71
|
+
(seed + PRIME32_2) & 0xffffffff,
|
|
72
|
+
(seed + 0) & 0xffffffff,
|
|
73
|
+
(seed - PRIME32_1) & 0xffffffff,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
Step 2. Process stripes
|
|
78
|
+
A stripe is a contiguous segment of 16 bytes. It is evenly divided into 4 lanes,
|
|
79
|
+
of 4 bytes each. The first lane is used to update accumulator 1, the second lane
|
|
80
|
+
is used to update accumulator 2, and so on. Each lane read its associated 32-bit
|
|
81
|
+
value using little-endian convention. For each {lane, accumulator}, the update
|
|
82
|
+
process is called a round, and applies the following formula :
|
|
83
|
+
```
|
|
84
|
+
accN = accN + (laneN * PRIME32_2);
|
|
85
|
+
accN = accN <<< 13;
|
|
86
|
+
accN = accN * PRIME32_1;
|
|
87
|
+
```
|
|
88
|
+
This shuffles the bits so that any bit from input lane impacts several bits in
|
|
89
|
+
output accumulator. All operations are performed modulo 2^32.
|
|
90
|
+
Input is consumed one full stripe at a time. Step 2 is looped as many times as
|
|
91
|
+
necessary to consume the whole input, except the last remaining bytes which cannot
|
|
92
|
+
form a stripe (< 16 bytes). When that happens, move to step 3.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
const b = buffer;
|
|
96
|
+
const limit = b.length - 16;
|
|
97
|
+
let lane = 0;
|
|
98
|
+
for (offset = 0; (offset & 0xfffffff0) <= limit; offset += 4) {
|
|
99
|
+
const i = offset;
|
|
100
|
+
const laneN0 = b[i + 0] + (b[i + 1] << 8);
|
|
101
|
+
const laneN1 = b[i + 2] + (b[i + 3] << 8);
|
|
102
|
+
const laneNP = laneN0 * PRIME32_2 + ((laneN1 * PRIME32_2) << 16);
|
|
103
|
+
let acc = (accN[lane] + laneNP) & 0xffffffff;
|
|
104
|
+
acc = (acc << 13) | (acc >>> 19);
|
|
105
|
+
const acc0 = acc & 0xffff;
|
|
106
|
+
const acc1 = acc >>> 16;
|
|
107
|
+
accN[lane] = (acc0 * PRIME32_1 + ((acc1 * PRIME32_1) << 16)) & 0xffffffff;
|
|
108
|
+
lane = (lane + 1) & 0x3;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/*
|
|
112
|
+
Step 3. Accumulator convergence
|
|
113
|
+
All 4 lane accumulators from previous steps are merged to produce a
|
|
114
|
+
single remaining accumulator
|
|
115
|
+
of same width (32-bit). The associated formula is as follows :
|
|
116
|
+
```
|
|
117
|
+
acc = (acc1 <<< 1) + (acc2 <<< 7) + (acc3 <<< 12) + (acc4 <<< 18);
|
|
118
|
+
```
|
|
119
|
+
*/
|
|
120
|
+
acc =
|
|
121
|
+
(((accN[0] << 1) | (accN[0] >>> 31)) +
|
|
122
|
+
((accN[1] << 7) | (accN[1] >>> 25)) +
|
|
123
|
+
((accN[2] << 12) | (accN[2] >>> 20)) +
|
|
124
|
+
((accN[3] << 18) | (accN[3] >>> 14))) &
|
|
125
|
+
0xffffffff;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
Step 4. Add input length
|
|
130
|
+
The input total length is presumed known at this stage.
|
|
131
|
+
This step is just about adding the length to
|
|
132
|
+
accumulator, so that it participates to final mixing.
|
|
133
|
+
```
|
|
134
|
+
acc = acc + (u32)inputLength;
|
|
135
|
+
```
|
|
136
|
+
*/
|
|
137
|
+
acc = (acc + buffer.length) & 0xffffffff;
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
Step 5. Consume remaining input
|
|
141
|
+
There may be up to 15 bytes remaining to consume from the input.
|
|
142
|
+
The final stage will digest them according
|
|
143
|
+
to following pseudo-code :
|
|
144
|
+
```
|
|
145
|
+
while (remainingLength >= 4) {
|
|
146
|
+
lane = read_32bit_little_endian(input_ptr);
|
|
147
|
+
acc = acc + lane * PRIME32_3;
|
|
148
|
+
acc = (acc <<< 17) * PRIME32_4;
|
|
149
|
+
input_ptr += 4; remainingLength -= 4;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
This process ensures that all input bytes are present in the final mix.
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
const limit = buffer.length - 4;
|
|
156
|
+
for (; offset <= limit; offset += 4) {
|
|
157
|
+
const i = offset;
|
|
158
|
+
const laneN0 = b[i + 0] + (b[i + 1] << 8);
|
|
159
|
+
const laneN1 = b[i + 2] + (b[i + 3] << 8);
|
|
160
|
+
const laneP = laneN0 * PRIME32_3 + ((laneN1 * PRIME32_3) << 16);
|
|
161
|
+
acc = (acc + laneP) & 0xffffffff;
|
|
162
|
+
acc = (acc << 17) | (acc >>> 15);
|
|
163
|
+
acc =
|
|
164
|
+
((acc & 0xffff) * PRIME32_4 + (((acc >>> 16) * PRIME32_4) << 16)) &
|
|
165
|
+
0xffffffff;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/*
|
|
169
|
+
```
|
|
170
|
+
while (remainingLength >= 1) {
|
|
171
|
+
lane = read_byte(input_ptr);
|
|
172
|
+
acc = acc + lane * PRIME32_5;
|
|
173
|
+
acc = (acc <<< 11) * PRIME32_1;
|
|
174
|
+
input_ptr += 1; remainingLength -= 1;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
*/
|
|
178
|
+
|
|
179
|
+
for (; offset < b.length; ++offset) {
|
|
180
|
+
const lane = b[offset];
|
|
181
|
+
acc += lane * PRIME32_5;
|
|
182
|
+
acc = (acc << 11) | (acc >>> 21);
|
|
183
|
+
acc =
|
|
184
|
+
((acc & 0xffff) * PRIME32_1 + (((acc >>> 16) * PRIME32_1) << 16)) &
|
|
185
|
+
0xffffffff;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
Step 6. Final mix (avalanche)
|
|
190
|
+
The final mix ensures that all input bits have a chance to impact any bit in
|
|
191
|
+
the output digest, resulting in an unbiased distribution. This is also called
|
|
192
|
+
avalanche effect.
|
|
193
|
+
```
|
|
194
|
+
acc = acc xor (acc >> 15);
|
|
195
|
+
acc = acc * PRIME32_2;
|
|
196
|
+
acc = acc xor (acc >> 13);
|
|
197
|
+
acc = acc * PRIME32_3;
|
|
198
|
+
acc = acc xor (acc >> 16);
|
|
199
|
+
```
|
|
200
|
+
*/
|
|
201
|
+
|
|
202
|
+
acc ^= acc >>> 15;
|
|
203
|
+
acc =
|
|
204
|
+
(((acc & 0xffff) * PRIME32_2) & 0xffffffff) +
|
|
205
|
+
(((acc >>> 16) * PRIME32_2) << 16);
|
|
206
|
+
acc ^= acc >>> 13;
|
|
207
|
+
acc =
|
|
208
|
+
(((acc & 0xffff) * PRIME32_3) & 0xffffffff) +
|
|
209
|
+
(((acc >>> 16) * PRIME32_3) << 16);
|
|
210
|
+
acc ^= acc >>> 16;
|
|
211
|
+
|
|
212
|
+
// turn any negatives back into a positive number;
|
|
213
|
+
return acc < 0 ? acc + 4294967296 : acc;
|
|
214
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type HTTPEvent } from "vinxi/http";
|
|
2
|
+
/**
|
|
3
|
+
* A server function answers one caller, so a shared cache must not store the
|
|
4
|
+
* response unless the function opted in with its own `Cache-Control`, either
|
|
5
|
+
* on a returned Response or on the event's response headers. A 304 is left
|
|
6
|
+
* alone: it updates a stored response rather than being one, and `no-store`
|
|
7
|
+
* on it would drop the entry the conditional request was keeping.
|
|
8
|
+
*
|
|
9
|
+
* This runs once the handler has finished rather than up front: forwarded
|
|
10
|
+
* response headers are appended to the event's, so a default already present
|
|
11
|
+
* would be joined with the function's value instead of being replaced by it.
|
|
12
|
+
* h3 writes a returned Response's headers over the event's, so the event's
|
|
13
|
+
* header only reaches the wire when the Response has none of its own.
|
|
14
|
+
*/
|
|
15
|
+
export declare function withDefaultCacheControl<T>(h3Event: HTTPEvent, value: T): T;
|
|
16
|
+
/** h3 answers a thrown value itself, keeping the event's response headers. */
|
|
17
|
+
export declare function setThrownCacheControl(h3Event: HTTPEvent): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getResponseHeader, getResponseStatus, setResponseHeader } from "vinxi/http";
|
|
2
|
+
const CACHE_CONTROL = "Cache-Control";
|
|
3
|
+
const DEFAULT_CACHE_CONTROL = "no-store";
|
|
4
|
+
function hasEventCacheControl(h3Event) {
|
|
5
|
+
return getResponseHeader(h3Event, CACHE_CONTROL) !== undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A server function answers one caller, so a shared cache must not store the
|
|
9
|
+
* response unless the function opted in with its own `Cache-Control`, either
|
|
10
|
+
* on a returned Response or on the event's response headers. A 304 is left
|
|
11
|
+
* alone: it updates a stored response rather than being one, and `no-store`
|
|
12
|
+
* on it would drop the entry the conditional request was keeping.
|
|
13
|
+
*
|
|
14
|
+
* This runs once the handler has finished rather than up front: forwarded
|
|
15
|
+
* response headers are appended to the event's, so a default already present
|
|
16
|
+
* would be joined with the function's value instead of being replaced by it.
|
|
17
|
+
* h3 writes a returned Response's headers over the event's, so the event's
|
|
18
|
+
* header only reaches the wire when the Response has none of its own.
|
|
19
|
+
*/
|
|
20
|
+
export function withDefaultCacheControl(h3Event, value) {
|
|
21
|
+
if (value instanceof Response) {
|
|
22
|
+
if (value.status === 304 || value.headers.has(CACHE_CONTROL))
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
else if (getResponseStatus(h3Event) === 304) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
if (!hasEventCacheControl(h3Event)) {
|
|
29
|
+
setResponseHeader(h3Event, CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/** h3 answers a thrown value itself, keeping the event's response headers. */
|
|
34
|
+
export function setThrownCacheControl(h3Event) {
|
|
35
|
+
if (!hasEventCacheControl(h3Event)) {
|
|
36
|
+
setResponseHeader(h3Event, CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server functions are same-origin RPC: a page on another site must not be
|
|
3
|
+
* able to invoke one with the visitor's cookies. Trust `Sec-Fetch-Site` when
|
|
4
|
+
* the browser sends it, and fall back to comparing `Origin` against the
|
|
5
|
+
* request host.
|
|
6
|
+
*
|
|
7
|
+
* `same-origin` and `same-site` are allowed, matching the reach of a
|
|
8
|
+
* `SameSite=Lax`/`Strict` cookie. `none` is a user-initiated navigation
|
|
9
|
+
* (typed URL, bookmark), not a request issued by another site.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isCrossSiteRequest(request: Request, url: URL): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server functions are same-origin RPC: a page on another site must not be
|
|
3
|
+
* able to invoke one with the visitor's cookies. Trust `Sec-Fetch-Site` when
|
|
4
|
+
* the browser sends it, and fall back to comparing `Origin` against the
|
|
5
|
+
* request host.
|
|
6
|
+
*
|
|
7
|
+
* `same-origin` and `same-site` are allowed, matching the reach of a
|
|
8
|
+
* `SameSite=Lax`/`Strict` cookie. `none` is a user-initiated navigation
|
|
9
|
+
* (typed URL, bookmark), not a request issued by another site.
|
|
10
|
+
*/
|
|
11
|
+
export function isCrossSiteRequest(request, url) {
|
|
12
|
+
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
13
|
+
if (secFetchSite) {
|
|
14
|
+
return secFetchSite === "cross-site";
|
|
15
|
+
}
|
|
16
|
+
// Older browsers omit Sec-Fetch-Site. They still send Origin on the
|
|
17
|
+
// cross-site requests that matter (form and fetch POSTs), so compare it.
|
|
18
|
+
const origin = request.headers.get("origin");
|
|
19
|
+
// `null` is an opaque origin (a sandboxed iframe, a cross-origin redirect
|
|
20
|
+
// chain): the browser declines to name the caller, so it cannot be matched.
|
|
21
|
+
if (origin === "null") {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (origin) {
|
|
25
|
+
try {
|
|
26
|
+
return new URL(origin).host !== url.host;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// No Origin either (a same-origin GET, or a non-browser client): nothing to
|
|
33
|
+
// reject on.
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isCrossSiteRequest } from "./cross-site";
|
|
3
|
+
const URL_STRING = "http://localhost:3000/_server";
|
|
4
|
+
function check(headers) {
|
|
5
|
+
const request = new Request(URL_STRING, { method: "POST", headers });
|
|
6
|
+
return isCrossSiteRequest(request, new URL(request.url));
|
|
7
|
+
}
|
|
8
|
+
describe("isCrossSiteRequest", () => {
|
|
9
|
+
describe("with Sec-Fetch-Site", () => {
|
|
10
|
+
it("rejects cross-site", () => {
|
|
11
|
+
expect(check({ "sec-fetch-site": "cross-site" })).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("allows same-origin", () => {
|
|
14
|
+
expect(check({ "sec-fetch-site": "same-origin" })).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
it("allows same-site", () => {
|
|
17
|
+
expect(check({ "sec-fetch-site": "same-site" })).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it("allows none (a user-initiated navigation)", () => {
|
|
20
|
+
expect(check({ "sec-fetch-site": "none" })).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
it("takes precedence over a mismatched Origin", () => {
|
|
23
|
+
expect(check({ "sec-fetch-site": "same-origin", origin: "https://other.example" })).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it("takes precedence over a matching Origin", () => {
|
|
26
|
+
expect(check({ "sec-fetch-site": "cross-site", origin: "http://localhost:3000" })).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("takes precedence over Origin: null", () => {
|
|
29
|
+
expect(check({ "sec-fetch-site": "same-origin", origin: "null" })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("without Sec-Fetch-Site", () => {
|
|
33
|
+
it("allows an Origin matching the request host", () => {
|
|
34
|
+
expect(check({ origin: "http://localhost:3000" })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it("rejects an Origin on another host", () => {
|
|
37
|
+
expect(check({ origin: "https://other.example" })).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it("rejects an Origin on the same hostname but another port", () => {
|
|
40
|
+
expect(check({ origin: "http://localhost:4000" })).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
it("rejects an opaque Origin (null)", () => {
|
|
43
|
+
expect(check({ origin: "null" })).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
it("rejects an Origin that cannot be parsed", () => {
|
|
46
|
+
expect(check({ origin: "not a url" })).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it("allows a request with neither header", () => {
|
|
49
|
+
expect(check({})).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export declare function serializeToJSStream(id: string, value: any): ReadableStream<any>;
|
|
2
2
|
export declare function serializeToJSONStream(value: any): ReadableStream<any>;
|
|
3
3
|
export declare function serializeToJSONString(value: any): Promise<string>;
|
|
4
|
+
/**
|
|
5
|
+
* Decodes the body written by `serializeToJSONString`. The body is the whole
|
|
6
|
+
* payload, so once it is decoded nothing can settle a promise or stream it
|
|
7
|
+
* left open; those are failed rather than left waiting. The cross deserializer
|
|
8
|
+
* is used because it exposes its refs, and it reads the same nodes `toJSONAsync`
|
|
9
|
+
* emits.
|
|
10
|
+
*/
|
|
4
11
|
export declare function deserializeFromJSONString(json: string): Promise<unknown>;
|
|
5
12
|
export declare function deserializeJSONStream(response: Response | Request): Promise<unknown>;
|
|
6
13
|
export declare function deserializeJSStream(id: string, response: Response): Promise<unknown>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { crossSerializeStream, deserialize, Feature, fromCrossJSON,
|
|
1
|
+
import { crossSerializeStream, deserialize, Feature, fromCrossJSON, getCrossReferenceHeader, isStream, toCrossJSONStream, toJSONAsync, } from "seroval";
|
|
2
2
|
import { AbortSignalPlugin, CustomEventPlugin, DOMExceptionPlugin, EventPlugin, FormDataPlugin, HeadersPlugin, ReadableStreamPlugin, RequestPlugin, ResponsePlugin, URLPlugin, URLSearchParamsPlugin, } from "seroval-plugins/web";
|
|
3
3
|
// TODO(Alexis): if we can, allow providing an option to extend these.
|
|
4
4
|
const DEFAULT_PLUGINS = [
|
|
@@ -105,10 +105,14 @@ class SerovalChunkReader {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
async next() {
|
|
108
|
-
// Check if the buffer is empty
|
|
109
|
-
if (this.buffer.length
|
|
108
|
+
// Check if the buffer is empty or incomplete
|
|
109
|
+
if (this.buffer.length < 12) {
|
|
110
110
|
// if we are already done...
|
|
111
111
|
if (this.done) {
|
|
112
|
+
// incomplete stream
|
|
113
|
+
if (this.buffer.length !== 0) {
|
|
114
|
+
throw new Error("Malformed server function stream.");
|
|
115
|
+
}
|
|
112
116
|
return {
|
|
113
117
|
done: true,
|
|
114
118
|
value: undefined,
|
|
@@ -124,6 +128,9 @@ class SerovalChunkReader {
|
|
|
124
128
|
// deserialize the data
|
|
125
129
|
const head = new TextDecoder().decode(this.buffer.subarray(1, 11));
|
|
126
130
|
const bytes = Number.parseInt(head, 16); // ;0x00000000;
|
|
131
|
+
if (Number.isNaN(bytes)) {
|
|
132
|
+
throw new Error("Malformed server function stream header.");
|
|
133
|
+
}
|
|
127
134
|
// Check if the buffer has enough bytes to be parsed
|
|
128
135
|
while (bytes > this.buffer.length - 12) {
|
|
129
136
|
// If it's not enough, and the reader is done
|
|
@@ -165,11 +172,81 @@ export async function serializeToJSONString(value) {
|
|
|
165
172
|
disabledFeatures: DISABLED_FEATURES,
|
|
166
173
|
}));
|
|
167
174
|
}
|
|
175
|
+
const ignoreRejection = () => { };
|
|
176
|
+
/**
|
|
177
|
+
* A decoded payload is the peer's bytes, so a promise it decodes to can reject
|
|
178
|
+
* with nobody holding it: an argument the function never awaits. Seroval
|
|
179
|
+
* stores every promise it decodes in the refs map, including ones that reject
|
|
180
|
+
* synchronously inside `fromCrossJSON`, so claim them as they are stored. Code
|
|
181
|
+
* that awaits the promise still sees the rejection.
|
|
182
|
+
*/
|
|
183
|
+
class DecodedRefs extends Map {
|
|
184
|
+
set(id, value) {
|
|
185
|
+
if (value instanceof Promise)
|
|
186
|
+
value.catch(ignoreRejection);
|
|
187
|
+
return super.set(id, value);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Fails every value still waiting on a node that will not arrive. Seroval
|
|
192
|
+
* keeps these in the refs: open streams (`isStream`) and pending-promise
|
|
193
|
+
* resolvers (`{ p, s, f }`). Throwing into a closed stream and
|
|
194
|
+
* rejecting a settled promise are no-ops, so this is safe to run after a body
|
|
195
|
+
* that decoded completely.
|
|
196
|
+
*
|
|
197
|
+
* A ref can be any value a plugin decoded, so nothing is read off it unguarded.
|
|
198
|
+
*/
|
|
199
|
+
function settlePendingRefs(refs, error) {
|
|
200
|
+
for (const value of refs) {
|
|
201
|
+
if (value === null || typeof value !== "object")
|
|
202
|
+
continue;
|
|
203
|
+
try {
|
|
204
|
+
if (isStream(value)) {
|
|
205
|
+
value.throw(error);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const ref = value;
|
|
209
|
+
if (typeof ref.s === "function" &&
|
|
210
|
+
typeof ref.f === "function" &&
|
|
211
|
+
ref.p instanceof Promise) {
|
|
212
|
+
// seroval stores the resolver before its promise, so a failed second
|
|
213
|
+
// store leaves a promise that `DecodedRefs` never saw
|
|
214
|
+
ref.p.catch(ignoreRejection);
|
|
215
|
+
ref.f(error);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// a stream listener threw, or a plugin value refused the read
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function createEndOfStreamError() {
|
|
224
|
+
return new Error("Server function stream ended unexpectedly.");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Decodes the body written by `serializeToJSONString`. The body is the whole
|
|
228
|
+
* payload, so once it is decoded nothing can settle a promise or stream it
|
|
229
|
+
* left open; those are failed rather than left waiting. The cross deserializer
|
|
230
|
+
* is used because it exposes its refs, and it reads the same nodes `toJSONAsync`
|
|
231
|
+
* emits.
|
|
232
|
+
*/
|
|
168
233
|
export async function deserializeFromJSONString(json) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
234
|
+
const source = JSON.parse(json);
|
|
235
|
+
const refs = new DecodedRefs();
|
|
236
|
+
let value;
|
|
237
|
+
try {
|
|
238
|
+
value = fromCrossJSON(source.t, {
|
|
239
|
+
refs,
|
|
240
|
+
plugins: DEFAULT_PLUGINS,
|
|
241
|
+
disabledFeatures: DISABLED_FEATURES,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
settlePendingRefs(refs.values(), error);
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
settlePendingRefs(refs.values(), createEndOfStreamError());
|
|
249
|
+
return value;
|
|
173
250
|
}
|
|
174
251
|
export async function deserializeJSONStream(response) {
|
|
175
252
|
if (!response.body) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { deserializeFromJSONString, deserializeJSONStream, deserializeJSStream, serializeToJSONString, } from "./serialization";
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
function makeChunk(dataStr, declaredBytes) {
|
|
5
|
+
const data = encoder.encode(dataStr);
|
|
6
|
+
const bytes = declaredBytes ?? data.length;
|
|
7
|
+
const baseHex = bytes.toString(16).padStart(8, "0");
|
|
8
|
+
const head = encoder.encode(`;0x${baseHex};`);
|
|
9
|
+
const chunk = new Uint8Array(head.length + data.length);
|
|
10
|
+
chunk.set(head);
|
|
11
|
+
chunk.set(data, head.length);
|
|
12
|
+
return chunk;
|
|
13
|
+
}
|
|
14
|
+
function streamFromChunks(chunks) {
|
|
15
|
+
return new ReadableStream({
|
|
16
|
+
start(controller) {
|
|
17
|
+
for (const c of chunks)
|
|
18
|
+
controller.enqueue(c);
|
|
19
|
+
controller.close();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function responseWithChunks(chunks) {
|
|
24
|
+
if (chunks === null)
|
|
25
|
+
return new Response(null);
|
|
26
|
+
return new Response(streamFromChunks(chunks));
|
|
27
|
+
}
|
|
28
|
+
const cases = [
|
|
29
|
+
{ name: "deserializeJSONStream", call: (r) => deserializeJSONStream(r) },
|
|
30
|
+
{ name: "deserializeJSStream", call: (r) => deserializeJSStream("server-fn:0", r) },
|
|
31
|
+
];
|
|
32
|
+
describe("Serialization negative testing (unhappy paths)", () => {
|
|
33
|
+
// TODO: Serialization drains remaining chunks in the background for performance and
|
|
34
|
+
// its async errors aren't propagated to a designated error boundary.
|
|
35
|
+
// This is a temporary catch-all to avoid unhandled rejections in this test suite until
|
|
36
|
+
// we have a better solution for handling async errors in serialization.
|
|
37
|
+
const _unhandledRejectionHandler = (reason, promise) => {
|
|
38
|
+
// eslint-disable-next-line no-console
|
|
39
|
+
console.error("Unhandled rejection (ignored) in serialization.test:", reason, promise);
|
|
40
|
+
};
|
|
41
|
+
// Install immediately and retain for the duration of this test file.
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
process.on("unhandledRejection", _unhandledRejectionHandler);
|
|
44
|
+
});
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
// Wait for any pending microtasks to allow background processes to complete
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
48
|
+
process.off("unhandledRejection", _unhandledRejectionHandler);
|
|
49
|
+
});
|
|
50
|
+
for (const fn of cases) {
|
|
51
|
+
it(`${fn.name} throws on missing body`, async () => {
|
|
52
|
+
await expect(fn.call(responseWithChunks(null))).rejects.toThrow("missing body");
|
|
53
|
+
});
|
|
54
|
+
it(`${fn.name} throws on plain XML response`, async () => {
|
|
55
|
+
const xml = '<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>';
|
|
56
|
+
const chunk = encoder.encode(xml);
|
|
57
|
+
const resp = new Response(new ReadableStream({
|
|
58
|
+
start(controller) {
|
|
59
|
+
controller.enqueue(chunk);
|
|
60
|
+
controller.close();
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
await expect(fn.call(resp)).rejects.toThrow();
|
|
64
|
+
});
|
|
65
|
+
it(`${fn.name} throws Malformed server function stream when header larger than provided bytes`, async () => {
|
|
66
|
+
const chunk = makeChunk("bad", 16); // declare more than actual
|
|
67
|
+
await expect(fn.call(responseWithChunks([chunk]))).rejects.toThrow("Malformed server function stream.");
|
|
68
|
+
});
|
|
69
|
+
it(`${fn.name} throws Malformed server function stream when header smaller than provided bytes`, async () => {
|
|
70
|
+
const chunk = makeChunk("bad", 2); // declare less than actual
|
|
71
|
+
await expect(fn.call(responseWithChunks([chunk]))).rejects.toThrow();
|
|
72
|
+
});
|
|
73
|
+
it(`${fn.name} throws on valid header but invalid JSON body`, async () => {
|
|
74
|
+
const chunk = makeChunk("not-a-json");
|
|
75
|
+
await expect(fn.call(responseWithChunks([chunk]))).rejects.toThrow();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const roundTrip = async (value) => (await deserializeFromJSONString(await serializeToJSONString(value)));
|
|
80
|
+
const ENDED = "Server function stream ended unexpectedly.";
|
|
81
|
+
/** Fails the test instead of hanging when a decoded value never settles. */
|
|
82
|
+
const settleWithin = (promise, ms = 200) => Promise.race([
|
|
83
|
+
promise,
|
|
84
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("still pending")), ms)),
|
|
85
|
+
]);
|
|
86
|
+
async function readAll(stream) {
|
|
87
|
+
const reader = stream.getReader();
|
|
88
|
+
const chunks = [];
|
|
89
|
+
while (true) {
|
|
90
|
+
const { done, value } = await reader.read();
|
|
91
|
+
if (done)
|
|
92
|
+
return chunks;
|
|
93
|
+
chunks.push(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// PromiseSuccess, PromiseFailure, StreamThrow, StreamReturn
|
|
97
|
+
const SETTLING_NODES = new Set([23, 24, 33, 34]);
|
|
98
|
+
/** Drops the nodes that settle promises and end streams, as if the body was cut short. */
|
|
99
|
+
function withoutSettlingNodes(json) {
|
|
100
|
+
return JSON.stringify(JSON.parse(json), (_key, value) => Array.isArray(value)
|
|
101
|
+
? value.filter(node => !(node && SETTLING_NODES.has(node.t)))
|
|
102
|
+
: value);
|
|
103
|
+
}
|
|
104
|
+
describe("deserializeFromJSONString", () => {
|
|
105
|
+
describe("round-trips argument payloads", () => {
|
|
106
|
+
it("plain values, collections and shared references", async () => {
|
|
107
|
+
const shared = { x: 1 };
|
|
108
|
+
const cyclic = { name: "cyclic" };
|
|
109
|
+
cyclic.self = cyclic;
|
|
110
|
+
const [value] = await roundTrip([
|
|
111
|
+
{
|
|
112
|
+
string: "text",
|
|
113
|
+
number: 1.5,
|
|
114
|
+
bigint: 10n,
|
|
115
|
+
nan: NaN,
|
|
116
|
+
negativeZero: -0,
|
|
117
|
+
infinity: -Infinity,
|
|
118
|
+
nil: null,
|
|
119
|
+
undef: undefined,
|
|
120
|
+
bool: true,
|
|
121
|
+
date: new Date(0),
|
|
122
|
+
map: new Map([["k", shared]]),
|
|
123
|
+
set: new Set([1, 2]),
|
|
124
|
+
array: [shared, shared],
|
|
125
|
+
bytes: new Uint8Array([1, 2, 3]),
|
|
126
|
+
cyclic,
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
expect(value.string).toBe("text");
|
|
130
|
+
expect(value.number).toBe(1.5);
|
|
131
|
+
expect(value.bigint).toBe(10n);
|
|
132
|
+
expect(value.nan).toBeNaN();
|
|
133
|
+
expect(Object.is(value.negativeZero, -0)).toBe(true);
|
|
134
|
+
expect(value.infinity).toBe(-Infinity);
|
|
135
|
+
expect(value.nil).toBeNull();
|
|
136
|
+
expect("undef" in value && value.undef === undefined).toBe(true);
|
|
137
|
+
expect(value.bool).toBe(true);
|
|
138
|
+
expect(value.date).toEqual(new Date(0));
|
|
139
|
+
expect(value.map.get("k")).toEqual({ x: 1 });
|
|
140
|
+
expect([...value.set]).toEqual([1, 2]);
|
|
141
|
+
expect(value.array[0]).toBe(value.array[1]);
|
|
142
|
+
expect(value.map.get("k")).toBe(value.array[0]);
|
|
143
|
+
expect([...value.bytes]).toEqual([1, 2, 3]);
|
|
144
|
+
expect(value.cyclic.self).toBe(value.cyclic);
|
|
145
|
+
});
|
|
146
|
+
it("resolved and rejected promises", async () => {
|
|
147
|
+
const [resolved, rejected] = await roundTrip([
|
|
148
|
+
Promise.resolve(42),
|
|
149
|
+
Promise.reject(new TypeError("boom")),
|
|
150
|
+
]);
|
|
151
|
+
await expect(resolved).resolves.toBe(42);
|
|
152
|
+
await expect(rejected).rejects.toThrow(TypeError);
|
|
153
|
+
await expect(rejected).rejects.toThrow("boom");
|
|
154
|
+
});
|
|
155
|
+
it("errors", async () => {
|
|
156
|
+
const [error, aggregate] = await roundTrip([
|
|
157
|
+
new RangeError("out of range"),
|
|
158
|
+
new AggregateError([new Error("inner")], "outer"),
|
|
159
|
+
]);
|
|
160
|
+
expect(error).toBeInstanceOf(RangeError);
|
|
161
|
+
expect(error.message).toBe("out of range");
|
|
162
|
+
expect(aggregate).toBeInstanceOf(AggregateError);
|
|
163
|
+
expect(aggregate.errors[0].message).toBe("inner");
|
|
164
|
+
});
|
|
165
|
+
it("async iterators", async () => {
|
|
166
|
+
async function* numbers() {
|
|
167
|
+
yield 1;
|
|
168
|
+
yield 2;
|
|
169
|
+
}
|
|
170
|
+
const [iterable] = await roundTrip([numbers()]);
|
|
171
|
+
const values = [];
|
|
172
|
+
for await (const value of iterable)
|
|
173
|
+
values.push(value);
|
|
174
|
+
expect(values).toEqual([1, 2]);
|
|
175
|
+
});
|
|
176
|
+
it("readable streams", async () => {
|
|
177
|
+
const source = new ReadableStream({
|
|
178
|
+
start(controller) {
|
|
179
|
+
controller.enqueue("a");
|
|
180
|
+
controller.enqueue("b");
|
|
181
|
+
controller.close();
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const [stream] = await roundTrip([source]);
|
|
185
|
+
expect(stream).toBeInstanceOf(ReadableStream);
|
|
186
|
+
expect(await settleWithin(readAll(stream))).toEqual(["a", "b"]);
|
|
187
|
+
});
|
|
188
|
+
it("form data", async () => {
|
|
189
|
+
const form = new FormData();
|
|
190
|
+
form.append("name", "value");
|
|
191
|
+
form.append("name", "second");
|
|
192
|
+
form.append("other", "x");
|
|
193
|
+
const [value] = await roundTrip([form]);
|
|
194
|
+
expect(value).toBeInstanceOf(FormData);
|
|
195
|
+
expect(value.getAll("name")).toEqual(["value", "second"]);
|
|
196
|
+
expect(value.get("other")).toBe("x");
|
|
197
|
+
});
|
|
198
|
+
it("requests and responses", async () => {
|
|
199
|
+
const [request, response] = await roundTrip([
|
|
200
|
+
new Request("http://localhost/path?q=1", {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "x-test": "1" },
|
|
203
|
+
body: "request body",
|
|
204
|
+
}),
|
|
205
|
+
new Response("response body", { status: 201, headers: { "x-test": "2" } }),
|
|
206
|
+
]);
|
|
207
|
+
expect(request).toBeInstanceOf(Request);
|
|
208
|
+
expect(request.method).toBe("POST");
|
|
209
|
+
expect(request.url).toBe("http://localhost/path?q=1");
|
|
210
|
+
expect(request.headers.get("x-test")).toBe("1");
|
|
211
|
+
expect(await request.text()).toBe("request body");
|
|
212
|
+
expect(response).toBeInstanceOf(Response);
|
|
213
|
+
expect(response.status).toBe(201);
|
|
214
|
+
expect(response.headers.get("x-test")).toBe("2");
|
|
215
|
+
expect(await response.text()).toBe("response body");
|
|
216
|
+
});
|
|
217
|
+
it("headers, URLs and search params", async () => {
|
|
218
|
+
const [headers, url, params] = await roundTrip([
|
|
219
|
+
new Headers({ a: "1" }),
|
|
220
|
+
new URL("http://localhost/a?b=c"),
|
|
221
|
+
new URLSearchParams("x=1&x=2"),
|
|
222
|
+
]);
|
|
223
|
+
expect(headers).toBeInstanceOf(Headers);
|
|
224
|
+
expect(headers.get("a")).toBe("1");
|
|
225
|
+
expect(url).toBeInstanceOf(URL);
|
|
226
|
+
expect(url.href).toBe("http://localhost/a?b=c");
|
|
227
|
+
expect(params).toBeInstanceOf(URLSearchParams);
|
|
228
|
+
expect(params.getAll("x")).toEqual(["1", "2"]);
|
|
229
|
+
});
|
|
230
|
+
it("abort signals, events and DOM exceptions", async () => {
|
|
231
|
+
const [signal, event, custom, exception] = await roundTrip([
|
|
232
|
+
AbortSignal.abort("reason"),
|
|
233
|
+
new Event("ping"),
|
|
234
|
+
new CustomEvent("custom", { detail: { a: 1 } }),
|
|
235
|
+
new DOMException("message", "AbortError"),
|
|
236
|
+
]);
|
|
237
|
+
expect(signal).toBeInstanceOf(AbortSignal);
|
|
238
|
+
expect(signal.aborted).toBe(true);
|
|
239
|
+
expect(signal.reason).toBe("reason");
|
|
240
|
+
expect(event).toBeInstanceOf(Event);
|
|
241
|
+
expect(event.type).toBe("ping");
|
|
242
|
+
expect(custom).toBeInstanceOf(CustomEvent);
|
|
243
|
+
expect(custom.detail).toEqual({ a: 1 });
|
|
244
|
+
expect(exception).toBeInstanceOf(DOMException);
|
|
245
|
+
expect(exception.name).toBe("AbortError");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
describe("values left pending when the body ends", () => {
|
|
249
|
+
it("rejects a promise that the body never settles", async () => {
|
|
250
|
+
const [promise] = (await deserializeFromJSONString(JSON.stringify({ t: { t: 9, i: 0, a: [{ t: 22, i: 1, s: 2 }], o: 0 }, f: 31, m: [] })));
|
|
251
|
+
await expect(settleWithin(promise)).rejects.toThrow(ENDED);
|
|
252
|
+
});
|
|
253
|
+
it("keeps a promise that the body settles", async () => {
|
|
254
|
+
const [promise] = (await deserializeFromJSONString(JSON.stringify({
|
|
255
|
+
t: {
|
|
256
|
+
t: 9,
|
|
257
|
+
i: 0,
|
|
258
|
+
a: [
|
|
259
|
+
{ t: 22, i: 1, s: 2 },
|
|
260
|
+
{ t: 23, i: 2, a: [{ t: 2, s: 1 }, { t: 0, s: 7 }] },
|
|
261
|
+
],
|
|
262
|
+
o: 0,
|
|
263
|
+
},
|
|
264
|
+
f: 31,
|
|
265
|
+
m: [],
|
|
266
|
+
})));
|
|
267
|
+
await expect(settleWithin(promise)).resolves.toBe(7);
|
|
268
|
+
});
|
|
269
|
+
it("errors a readable stream that the body never ends", async () => {
|
|
270
|
+
const source = new ReadableStream({
|
|
271
|
+
start(controller) {
|
|
272
|
+
controller.enqueue("a");
|
|
273
|
+
controller.close();
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const json = withoutSettlingNodes(await serializeToJSONString([source]));
|
|
277
|
+
const [stream] = (await deserializeFromJSONString(json));
|
|
278
|
+
// erroring a ReadableStream discards the chunks still queued in it
|
|
279
|
+
await expect(settleWithin(readAll(stream))).rejects.toThrow(ENDED);
|
|
280
|
+
});
|
|
281
|
+
it("errors an async iterator that the body never ends", async () => {
|
|
282
|
+
async function* numbers() {
|
|
283
|
+
yield 1;
|
|
284
|
+
}
|
|
285
|
+
const json = withoutSettlingNodes(await serializeToJSONString([numbers()]));
|
|
286
|
+
const [iterable] = (await deserializeFromJSONString(json));
|
|
287
|
+
const values = [];
|
|
288
|
+
const consume = async () => {
|
|
289
|
+
for await (const value of iterable)
|
|
290
|
+
values.push(value);
|
|
291
|
+
};
|
|
292
|
+
await expect(settleWithin(consume())).rejects.toThrow(ENDED);
|
|
293
|
+
expect(values).toEqual([1]);
|
|
294
|
+
});
|
|
295
|
+
it("does not report rejections for decoded promises nobody awaits", async () => {
|
|
296
|
+
const unhandled = [];
|
|
297
|
+
const record = (reason) => unhandled.push(reason);
|
|
298
|
+
process.on("unhandledRejection", record);
|
|
299
|
+
try {
|
|
300
|
+
await deserializeFromJSONString(JSON.stringify({ t: { t: 9, i: 0, a: [{ t: 22, i: 1, s: 2 }], o: 0 }, f: 31, m: [] }));
|
|
301
|
+
await roundTrip([Promise.reject(new Error("never awaited"))]);
|
|
302
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
process.off("unhandledRejection", record);
|
|
306
|
+
}
|
|
307
|
+
expect(unhandled).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
it("throws when a node is malformed", async () => {
|
|
310
|
+
await expect(deserializeFromJSONString(JSON.stringify({ t: { t: 9, i: 0, a: [{ t: 22, i: 1, s: 2 }, { t: 999 }], o: 0 }, f: 31, m: [] }))).rejects.toThrow();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -10,14 +10,32 @@ import { getExpectedRedirectStatus } from "../server/handler";
|
|
|
10
10
|
import { createPageEvent } from "../server/pageEvent";
|
|
11
11
|
// @ts-ignore
|
|
12
12
|
import serverFnManifest from "solidstart:server-fn-manifest";
|
|
13
|
+
import { setThrownCacheControl, withDefaultCacheControl } from "./cache-control";
|
|
14
|
+
import { isCrossSiteRequest } from "./cross-site";
|
|
13
15
|
import { deserializeFromJSONString, serializeToJSONStream, serializeToJSStream } from "./serialization";
|
|
14
16
|
async function handleServerFunction(h3Event) {
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await runServerFunction(h3Event);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
setThrownCacheControl(h3Event);
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return withDefaultCacheControl(h3Event, response);
|
|
26
|
+
}
|
|
27
|
+
async function runServerFunction(h3Event) {
|
|
15
28
|
const event = getFetchEvent(h3Event);
|
|
16
29
|
const request = event.request;
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
if (isCrossSiteRequest(request, url)) {
|
|
32
|
+
return process.env.NODE_ENV === "development"
|
|
33
|
+
? new Response("Cross-site server function requests are not allowed", { status: 403 })
|
|
34
|
+
: new Response(null, { status: 403 });
|
|
35
|
+
}
|
|
17
36
|
const serverReference = request.headers.get("X-Server-Id");
|
|
18
37
|
const instance = request.headers.get("X-Server-Instance");
|
|
19
38
|
const singleFlight = request.headers.has("X-Single-Flight");
|
|
20
|
-
const url = new URL(request.url);
|
|
21
39
|
let functionId, name;
|
|
22
40
|
if (serverReference) {
|
|
23
41
|
invariant(typeof serverReference === "string", "Invalid server function");
|
|
@@ -133,6 +151,10 @@ async function handleServerFunction(h3Event) {
|
|
|
133
151
|
setHeader(h3Event, "content-type", "text/javascript");
|
|
134
152
|
return serializeToJSStream(instance, result);
|
|
135
153
|
}
|
|
154
|
+
// Explicitly set the Content-Type to avoid runtimes (e.g., AWS Lambda)
|
|
155
|
+
// that default to `application/json`, which can break serialization
|
|
156
|
+
// when the SEROVAL output is not valid JSON.
|
|
157
|
+
setHeader(h3Event, "content-type", "text/plain");
|
|
136
158
|
return serializeToJSONStream(result);
|
|
137
159
|
}
|
|
138
160
|
catch (x) {
|
|
@@ -166,6 +188,10 @@ async function handleServerFunction(h3Event) {
|
|
|
166
188
|
setHeader(h3Event, "content-type", "text/javascript");
|
|
167
189
|
return serializeToJSStream(instance, x);
|
|
168
190
|
}
|
|
191
|
+
// Explicitly set the Content-Type to avoid runtimes (e.g., AWS Lambda)
|
|
192
|
+
// that default to `application/json`, which can break serialization
|
|
193
|
+
// when the SEROVAL output is not valid JSON.
|
|
194
|
+
setHeader(h3Event, "content-type", "text/plain");
|
|
169
195
|
return serializeToJSONStream(x);
|
|
170
196
|
}
|
|
171
197
|
return x;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { getRequestEvent } from "solid-js/web";
|
|
2
|
+
import { createApp, toWebHandler } from "vinxi/http";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
const serverFn = vi.hoisted(() => ({
|
|
5
|
+
calls: 0,
|
|
6
|
+
current: (..._args) => undefined
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("solidstart:server-fn-manifest", () => ({
|
|
9
|
+
default: {
|
|
10
|
+
fn: {
|
|
11
|
+
functionName: "fn",
|
|
12
|
+
importer: async () => ({
|
|
13
|
+
fn: (...args) => {
|
|
14
|
+
serverFn.calls++;
|
|
15
|
+
return serverFn.current(...args);
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../server/handler", () => ({ getExpectedRedirectStatus: () => 302 }));
|
|
22
|
+
vi.mock("../server/pageEvent", () => ({ createPageEvent: async () => { } }));
|
|
23
|
+
const { default: serverHandler } = await import("./server-handler");
|
|
24
|
+
const handle = toWebHandler(createApp().use(serverHandler));
|
|
25
|
+
const SERVER_URL = "http://localhost:3000/_server";
|
|
26
|
+
function call(init = {}) {
|
|
27
|
+
const { method = "POST", headers = {}, body, search = "" } = init;
|
|
28
|
+
return handle(new Request(SERVER_URL + search, {
|
|
29
|
+
method,
|
|
30
|
+
headers: { "x-server-id": "fn#fn", "x-server-instance": "server-fn:0", ...headers },
|
|
31
|
+
body
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
const responseHeaders = () => getRequestEvent().response.headers;
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
serverFn.calls = 0;
|
|
37
|
+
serverFn.current = () => "result";
|
|
38
|
+
});
|
|
39
|
+
describe("cross-site requests", () => {
|
|
40
|
+
it("rejects a cross-site POST with 403 before the function runs", async () => {
|
|
41
|
+
const response = await call({ headers: { "sec-fetch-site": "cross-site" } });
|
|
42
|
+
expect(response.status).toBe(403);
|
|
43
|
+
expect(serverFn.calls).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
it("rejects a cross-site GET with 403 before the function runs", async () => {
|
|
46
|
+
const response = await call({
|
|
47
|
+
method: "GET",
|
|
48
|
+
headers: { origin: "https://other.example" }
|
|
49
|
+
});
|
|
50
|
+
expect(response.status).toBe(403);
|
|
51
|
+
expect(serverFn.calls).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
it("rejects Origin: null without Sec-Fetch-Site", async () => {
|
|
54
|
+
const response = await call({ headers: { origin: "null" } });
|
|
55
|
+
expect(response.status).toBe(403);
|
|
56
|
+
expect(serverFn.calls).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
it("rejects before decoding the arguments", async () => {
|
|
59
|
+
const response = await call({
|
|
60
|
+
headers: { "sec-fetch-site": "cross-site", "x-serialized": "true" },
|
|
61
|
+
body: "not json"
|
|
62
|
+
});
|
|
63
|
+
expect(response.status).toBe(403);
|
|
64
|
+
});
|
|
65
|
+
it("omits the message outside development", async () => {
|
|
66
|
+
const response = await call({ headers: { "sec-fetch-site": "cross-site" } });
|
|
67
|
+
expect(await response.text()).toBe("");
|
|
68
|
+
});
|
|
69
|
+
it.each([
|
|
70
|
+
{ "sec-fetch-site": "same-origin" },
|
|
71
|
+
{ "sec-fetch-site": "same-site" },
|
|
72
|
+
{ "sec-fetch-site": "none" },
|
|
73
|
+
{ origin: "http://localhost:3000" },
|
|
74
|
+
{}
|
|
75
|
+
])("allows %o", async (headers) => {
|
|
76
|
+
const response = await call({ headers });
|
|
77
|
+
expect(response.status).toBe(200);
|
|
78
|
+
expect(serverFn.calls).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("Cache-Control", () => {
|
|
82
|
+
it("defaults a result to no-store", async () => {
|
|
83
|
+
const response = await call();
|
|
84
|
+
expect(response.status).toBe(200);
|
|
85
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
86
|
+
});
|
|
87
|
+
it("defaults a GET result to no-store", async () => {
|
|
88
|
+
const response = await call({ method: "GET" });
|
|
89
|
+
expect(serverFn.calls).toBe(1);
|
|
90
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
91
|
+
});
|
|
92
|
+
it("defaults a thrown error to no-store", async () => {
|
|
93
|
+
serverFn.current = () => {
|
|
94
|
+
throw new Error("failed");
|
|
95
|
+
};
|
|
96
|
+
const response = await call();
|
|
97
|
+
expect(response.headers.get("x-error")).toBe("failed");
|
|
98
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
99
|
+
});
|
|
100
|
+
it("defaults a 403 refusal to no-store", async () => {
|
|
101
|
+
const response = await call({ headers: { "sec-fetch-site": "cross-site" } });
|
|
102
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
103
|
+
});
|
|
104
|
+
it("defaults a 404 for an unknown function to no-store", async () => {
|
|
105
|
+
const response = await call({ headers: { "x-server-id": "missing#fn" } });
|
|
106
|
+
expect(response.status).toBe(404);
|
|
107
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
108
|
+
});
|
|
109
|
+
it("defaults an error that escapes the handler to no-store", async () => {
|
|
110
|
+
const logged = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
111
|
+
try {
|
|
112
|
+
const response = await call({ headers: { "x-serialized": "true" }, body: "not json" });
|
|
113
|
+
expect(response.status).toBe(500);
|
|
114
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
logged.mockRestore();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
it("defaults a no-JS redirect to no-store", async () => {
|
|
121
|
+
const form = new FormData();
|
|
122
|
+
form.append("field", "value");
|
|
123
|
+
serverFn.current = () => undefined;
|
|
124
|
+
const response = await call({
|
|
125
|
+
headers: { "x-server-instance": "", referer: "http://localhost:3000/page" },
|
|
126
|
+
search: "?id=fn&name=fn",
|
|
127
|
+
body: form
|
|
128
|
+
});
|
|
129
|
+
expect(response.status).toBe(302);
|
|
130
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
131
|
+
});
|
|
132
|
+
it("keeps a value set on the event's response headers", async () => {
|
|
133
|
+
serverFn.current = () => {
|
|
134
|
+
responseHeaders().set("Cache-Control", "max-age=60");
|
|
135
|
+
return "result";
|
|
136
|
+
};
|
|
137
|
+
const response = await call();
|
|
138
|
+
expect(response.headers.get("cache-control")).toBe("max-age=60");
|
|
139
|
+
});
|
|
140
|
+
it("keeps a value appended to the event's response headers without joining the default", async () => {
|
|
141
|
+
serverFn.current = () => {
|
|
142
|
+
responseHeaders().append("Cache-Control", "max-age=60");
|
|
143
|
+
return "result";
|
|
144
|
+
};
|
|
145
|
+
const response = await call();
|
|
146
|
+
expect(response.headers.get("cache-control")).toBe("max-age=60");
|
|
147
|
+
});
|
|
148
|
+
it("keeps a value on a returned Response without joining the default", async () => {
|
|
149
|
+
serverFn.current = () => new Response(null, { headers: { "Cache-Control": "private, max-age=60" } });
|
|
150
|
+
const response = await call();
|
|
151
|
+
expect(response.headers.get("cache-control")).toBe("private, max-age=60");
|
|
152
|
+
});
|
|
153
|
+
it("keeps a value on a thrown Response", async () => {
|
|
154
|
+
serverFn.current = () => {
|
|
155
|
+
throw new Response(null, { status: 400, headers: { "Cache-Control": "max-age=5" } });
|
|
156
|
+
};
|
|
157
|
+
const response = await call();
|
|
158
|
+
expect(response.status).toBe(400);
|
|
159
|
+
expect(response.headers.get("cache-control")).toBe("max-age=5");
|
|
160
|
+
});
|
|
161
|
+
it("defaults a returned Response without its own value to no-store", async () => {
|
|
162
|
+
serverFn.current = () => new Response(null, { headers: { "x-other": "1" } });
|
|
163
|
+
const response = await call();
|
|
164
|
+
expect(response.headers.get("x-other")).toBe("1");
|
|
165
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
166
|
+
});
|
|
167
|
+
it("keeps a value on a raw Response passthrough", async () => {
|
|
168
|
+
serverFn.current = () => new Response("raw", { headers: { "X-Content-Raw": "true", "Cache-Control": "max-age=60" } });
|
|
169
|
+
const response = await call();
|
|
170
|
+
expect(await response.text()).toBe("raw");
|
|
171
|
+
expect(response.headers.get("cache-control")).toBe("max-age=60");
|
|
172
|
+
});
|
|
173
|
+
it("defaults a raw Response passthrough without its own value to no-store", async () => {
|
|
174
|
+
serverFn.current = () => new Response("raw", { headers: { "X-Content-Raw": "true" } });
|
|
175
|
+
const response = await call();
|
|
176
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
177
|
+
});
|
|
178
|
+
it("does not set a value on a 304 Response", async () => {
|
|
179
|
+
serverFn.current = () => new Response(null, { status: 304, headers: { "X-Content-Raw": "true" } });
|
|
180
|
+
const response = await call();
|
|
181
|
+
expect(response.status).toBe(304);
|
|
182
|
+
expect(response.headers.has("cache-control")).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
it("does not set a value when the function answers 304 on the event", async () => {
|
|
185
|
+
serverFn.current = () => {
|
|
186
|
+
getRequestEvent().response.status = 304;
|
|
187
|
+
return null;
|
|
188
|
+
};
|
|
189
|
+
const response = await call();
|
|
190
|
+
expect(response.status).toBe(304);
|
|
191
|
+
expect(response.headers.has("cache-control")).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe("serialized arguments", () => {
|
|
195
|
+
it("answers with an error when an awaited argument is still pending at the end of the body", async () => {
|
|
196
|
+
serverFn.current = async (arg) => await arg;
|
|
197
|
+
const outcome = await Promise.race([
|
|
198
|
+
call({
|
|
199
|
+
headers: { "x-serialized": "true", "content-type": "text/plain" },
|
|
200
|
+
body: JSON.stringify({ t: { t: 9, i: 0, a: [{ t: 22, i: 1, s: 2 }], o: 0 }, f: 31, m: [] })
|
|
201
|
+
}),
|
|
202
|
+
new Promise(resolve => setTimeout(() => resolve("still waiting"), 200))
|
|
203
|
+
]);
|
|
204
|
+
expect(outcome).not.toBe("still waiting");
|
|
205
|
+
expect(outcome.headers.get("x-error")).toMatch(/ended unexpectedly/);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -55,13 +55,7 @@ async function fetchServerFunction(base, id, options, args) {
|
|
|
55
55
|
const contentType = response.headers.get("Content-Type");
|
|
56
56
|
const cloned = response.clone();
|
|
57
57
|
let result;
|
|
58
|
-
if (
|
|
59
|
-
result = await cloned.text();
|
|
60
|
-
}
|
|
61
|
-
else if (contentType && contentType.startsWith("application/json")) {
|
|
62
|
-
result = await cloned.json();
|
|
63
|
-
}
|
|
64
|
-
else if (response.headers.get("x-serialized")) {
|
|
58
|
+
if (response.headers.get("x-serialized")) {
|
|
65
59
|
if (import.meta.env.SEROVAL_MODE === "js") {
|
|
66
60
|
result = await deserializeJSStream(instance, cloned);
|
|
67
61
|
}
|
|
@@ -69,8 +63,14 @@ async function fetchServerFunction(base, id, options, args) {
|
|
|
69
63
|
result = await deserializeJSONStream(cloned);
|
|
70
64
|
}
|
|
71
65
|
}
|
|
72
|
-
if (
|
|
73
|
-
|
|
66
|
+
else if (contentType && contentType.startsWith("text/plain")) {
|
|
67
|
+
result = await cloned.text();
|
|
68
|
+
}
|
|
69
|
+
else if (contentType && contentType.startsWith("application/json")) {
|
|
70
|
+
result = await cloned.json();
|
|
71
|
+
}
|
|
72
|
+
if (response.headers.has("X-Error") || response.status >= 500) {
|
|
73
|
+
throw result ?? new Error(`Server function call failed with status ${response.status}`);
|
|
74
74
|
}
|
|
75
75
|
return result;
|
|
76
76
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidjs/start",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"author": "Ryan Carniato",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"error-stack-parser": "^2.1.4",
|
|
69
69
|
"html-to-image": "^1.11.11",
|
|
70
70
|
"radix3": "^1.1.0",
|
|
71
|
-
"seroval": "^1.
|
|
72
|
-
"seroval-plugins": "^1.
|
|
71
|
+
"seroval": "^1.6.8",
|
|
72
|
+
"seroval-plugins": "^1.6.8",
|
|
73
73
|
"shiki": "^1.26.1",
|
|
74
74
|
"source-map-js": "^1.0.2",
|
|
75
75
|
"terracotta": "^1.0.4",
|