Skip to content

Commit 1676d44

Browse files
committed
add seroval json mode
1 parent 4a8d415 commit 1676d44

File tree

4 files changed

+345
-251
lines changed

4 files changed

+345
-251
lines changed

packages/start/src/config/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export interface SolidStartOptions {
2121
routeDir?: string;
2222
extensions?: string[];
2323
middleware?: string;
24+
serialization?: {
25+
// This only matters for server function responses
26+
mode?: 'js' | 'json';
27+
};
2428
}
2529

2630
const absolute = (path: string, root: string) =>
@@ -129,6 +133,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
129133
"import.meta.env.START_APP_ENTRY": `"${appEntryPath}"`,
130134
"import.meta.env.START_CLIENT_ENTRY": `"${handlers.client}"`,
131135
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
136+
"import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || 'json'),
132137
},
133138
builder: {
134139
sharedPlugins: true,
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import {
2+
crossSerializeStream,
3+
deserialize,
4+
Feature,
5+
fromCrossJSON,
6+
getCrossReferenceHeader,
7+
type SerovalNode,
8+
toCrossJSONStream,
9+
} from "seroval";
10+
import {
11+
AbortSignalPlugin,
12+
CustomEventPlugin,
13+
DOMExceptionPlugin,
14+
EventPlugin,
15+
FormDataPlugin,
16+
HeadersPlugin,
17+
ReadableStreamPlugin,
18+
RequestPlugin,
19+
ResponsePlugin,
20+
URLPlugin,
21+
URLSearchParamsPlugin,
22+
} from "seroval-plugins/web";
23+
24+
// TODO(Alexis): if we can, allow providing an option to extend these.
25+
const DEFAULT_PLUGINS = [
26+
AbortSignalPlugin,
27+
CustomEventPlugin,
28+
DOMExceptionPlugin,
29+
EventPlugin,
30+
FormDataPlugin,
31+
HeadersPlugin,
32+
ReadableStreamPlugin,
33+
RequestPlugin,
34+
ResponsePlugin,
35+
URLSearchParamsPlugin,
36+
URLPlugin,
37+
];
38+
const MAX_SERIALIZATION_DEPTH_LIMIT = 64;
39+
const DISABLED_FEATURES = Feature.RegExp;
40+
41+
/**
42+
* Alexis:
43+
*
44+
* A "chunk" is a piece of data emitted by the streaming serializer.
45+
* Each chunk is represented by a 32-bit value (encoded in hexadecimal),
46+
* followed by the encoded string (8-bit representation). This format
47+
* is important so we know how much of the chunk being streamed we
48+
* are expecting before parsing the entire string data.
49+
*
50+
* This is sort of a bootleg "multipart/form-data" except it's bad at
51+
* handling File/Blob LOL
52+
*
53+
* The format is as follows:
54+
* ;0xFFFFFFFF;<string data>
55+
*/
56+
function createChunk(data: string): Uint8Array {
57+
const encodeData = new TextEncoder().encode(data);
58+
const bytes = encodeData.length;
59+
const baseHex = bytes.toString(16);
60+
const totalHex = "00000000".substring(0, 8 - baseHex.length) + baseHex; // 32-bit
61+
const head = new TextEncoder().encode(`;0x${totalHex};`);
62+
63+
const chunk = new Uint8Array(12 + bytes);
64+
chunk.set(head);
65+
chunk.set(encodeData, 12);
66+
return chunk;
67+
}
68+
69+
export function serializeToJSStream(id: string, value: any) {
70+
return new ReadableStream({
71+
start(controller) {
72+
crossSerializeStream(value, {
73+
scopeId: id,
74+
plugins: DEFAULT_PLUGINS,
75+
onSerialize(data: string, initial: boolean) {
76+
controller.enqueue(
77+
createChunk(
78+
initial ? `(${getCrossReferenceHeader(id)},${data})` : data,
79+
),
80+
);
81+
},
82+
onDone() {
83+
controller.close();
84+
},
85+
onError(error: any) {
86+
controller.error(error);
87+
},
88+
});
89+
},
90+
});
91+
}
92+
93+
export function serializeToJSONStream(value: any) {
94+
return new ReadableStream({
95+
start(controller) {
96+
toCrossJSONStream(value, {
97+
disabledFeatures: DISABLED_FEATURES,
98+
depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT,
99+
plugins: DEFAULT_PLUGINS,
100+
onParse(node) {
101+
controller.enqueue(createChunk(JSON.stringify(node)));
102+
},
103+
onDone() {
104+
controller.close();
105+
},
106+
onError(error) {
107+
controller.error(error);
108+
},
109+
});
110+
},
111+
});
112+
}
113+
114+
class SerovalChunkReader {
115+
reader: ReadableStreamDefaultReader<Uint8Array>;
116+
buffer: Uint8Array;
117+
done: boolean;
118+
constructor(stream: ReadableStream<Uint8Array>) {
119+
this.reader = stream.getReader();
120+
this.buffer = new Uint8Array(0);
121+
this.done = false;
122+
}
123+
124+
async readChunk() {
125+
// if there's no chunk, read again
126+
const chunk = await this.reader.read();
127+
if (!chunk.done) {
128+
// repopulate the buffer
129+
const newBuffer = new Uint8Array(this.buffer.length + chunk.value.length);
130+
newBuffer.set(this.buffer);
131+
newBuffer.set(chunk.value, this.buffer.length);
132+
this.buffer = newBuffer;
133+
} else {
134+
this.done = true;
135+
}
136+
}
137+
138+
async next(): Promise<
139+
{ done: true; value: undefined } | { done: false; value: string }
140+
> {
141+
// Check if the buffer is empty
142+
if (this.buffer.length === 0) {
143+
// if we are already done...
144+
if (this.done) {
145+
return {
146+
done: true,
147+
value: undefined,
148+
};
149+
}
150+
// Otherwise, read a new chunk
151+
await this.readChunk();
152+
return await this.next();
153+
}
154+
// Read the "byte header"
155+
// The byte header tells us how big the expected data is
156+
// so we know how much data we should wait before we
157+
// deserialize the data
158+
const head = new TextDecoder().decode(this.buffer.subarray(1, 11));
159+
const bytes = Number.parseInt(head, 16); // ;0x00000000;
160+
// Check if the buffer has enough bytes to be parsed
161+
while (bytes > this.buffer.length - 12) {
162+
// If it's not enough, and the reader is done
163+
// then the chunk is invalid.
164+
if (this.done) {
165+
throw new Error("Malformed server function stream.");
166+
}
167+
// Otherwise, we read more chunks
168+
await this.readChunk();
169+
}
170+
// Extract the exact chunk as defined by the byte header
171+
const partial = new TextDecoder().decode(
172+
this.buffer.subarray(12, 12 + bytes),
173+
);
174+
// The rest goes to the buffer
175+
this.buffer = this.buffer.subarray(12 + bytes);
176+
177+
// Deserialize the chunk
178+
return {
179+
done: false,
180+
value: partial,
181+
};
182+
}
183+
184+
async drain(interpret: (chunk: string) => void) {
185+
while (true) {
186+
const result = await this.next();
187+
if (result.done) {
188+
break;
189+
} else {
190+
interpret(result.value);
191+
}
192+
}
193+
}
194+
}
195+
196+
export async function serializeToJSONString(value: any) {
197+
const response = new Response(serializeToJSONStream(value));
198+
return await response.text();
199+
}
200+
201+
export async function deserializeFromJSONString(json: string) {
202+
const blob = new Response(json);
203+
return await deserializeJSONStream(blob);
204+
}
205+
206+
export async function deserializeJSONStream(response: Response | Request) {
207+
if (!response.body) {
208+
throw new Error("missing body");
209+
}
210+
const reader = new SerovalChunkReader(response.body);
211+
const result = await reader.next();
212+
if (!result.done) {
213+
const refs = new Map();
214+
215+
function interpretChunk(chunk: string): unknown {
216+
const value = fromCrossJSON(JSON.parse(chunk) as SerovalNode, {
217+
refs,
218+
disabledFeatures: DISABLED_FEATURES,
219+
depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT,
220+
plugins: DEFAULT_PLUGINS,
221+
});
222+
return value;
223+
}
224+
225+
void reader.drain(interpretChunk);
226+
227+
return interpretChunk(result.value);
228+
}
229+
return undefined;
230+
}
231+
232+
export async function deserializeJSStream(id: string, response: Response) {
233+
if (!response.body) {
234+
throw new Error("missing body");
235+
}
236+
const reader = new SerovalChunkReader(response.body);
237+
238+
const result = await reader.next();
239+
240+
if (!result.done) {
241+
reader.drain(deserialize).then(
242+
() => {
243+
// @ts-ignore
244+
delete $R[id];
245+
},
246+
() => {
247+
// no-op
248+
},
249+
);
250+
return deserialize(result.value);
251+
}
252+
return undefined;
253+
}

0 commit comments

Comments
 (0)