forked from solidjs/solid-start
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver-runtime.ts
More file actions
249 lines (230 loc) · 7.06 KB
/
server-runtime.ts
File metadata and controls
249 lines (230 loc) · 7.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import { deserialize, toJSONAsync } from "seroval";
import {
CustomEventPlugin,
DOMExceptionPlugin,
EventPlugin,
FormDataPlugin,
HeadersPlugin,
ReadableStreamPlugin,
RequestPlugin,
ResponsePlugin,
URLPlugin,
URLSearchParamsPlugin
} from "seroval-plugins/web";
import { type Component } from "solid-js";
import { createIslandReference } from "../server/islands/index";
class SerovalChunkReader {
reader: ReadableStreamDefaultReader<Uint8Array>;
buffer: Uint8Array;
done: boolean;
constructor(stream: ReadableStream<Uint8Array>) {
this.reader = stream.getReader();
this.buffer = new Uint8Array(0);
this.done = false;
}
async readChunk() {
// if there's no chunk, read again
const chunk = await this.reader.read();
if (!chunk.done) {
// repopulate the buffer
let newBuffer = new Uint8Array(this.buffer.length + chunk.value.length);
newBuffer.set(this.buffer);
newBuffer.set(chunk.value, this.buffer.length);
this.buffer = newBuffer;
} else {
this.done = true;
}
}
async next(): Promise<any> {
// Check if the buffer is empty
if (this.buffer.length === 0) {
// if we are already done...
if (this.done) {
return {
done: true,
value: undefined
};
}
// Otherwise, read a new chunk
await this.readChunk();
return await this.next();
}
// Read the "byte header"
// The byte header tells us how big the expected data is
// so we know how much data we should wait before we
// deserialize the data
const head = new TextDecoder().decode(this.buffer.subarray(1, 11));
const bytes = Number.parseInt(head, 16); // ;0x00000000;
// Check if the buffer has enough bytes to be parsed
while (bytes > this.buffer.length - 12) {
// If it's not enough, and the reader is done
// then the chunk is invalid.
if (this.done) {
throw new Error("Malformed server function stream.");
}
// Otherwise, we read more chunks
await this.readChunk();
}
// Extract the exact chunk as defined by the byte header
const partial = new TextDecoder().decode(this.buffer.subarray(12, 12 + bytes));
// The rest goes to the buffer
this.buffer = this.buffer.subarray(12 + bytes);
// Deserialize the chunk
return {
done: false,
value: deserialize(partial)
};
}
async drain() {
while (true) {
const result = await this.next();
if (result.done) {
break;
}
}
}
}
async function deserializeStream(id: string, response: Response) {
if (!response.body) {
throw new Error("missing body");
}
const reader = new SerovalChunkReader(response.body);
const result = await reader.next();
if (!result.done) {
reader.drain().then(
() => {
// @ts-ignore
delete $R[id];
},
() => {
// no-op
}
);
}
return result.value;
}
let INSTANCE = 0;
function createRequest(base: string, id: string, instance: string, options: RequestInit) {
return fetch(base, {
method: "POST",
...options,
headers: {
...options.headers,
"X-Server-Id": id,
"X-Server-Instance": instance
}
});
}
const plugins = [
CustomEventPlugin,
DOMExceptionPlugin,
EventPlugin,
FormDataPlugin,
HeadersPlugin,
ReadableStreamPlugin,
RequestPlugin,
ResponsePlugin,
URLSearchParamsPlugin,
URLPlugin
];
async function fetchServerFunction(
base: string,
id: string,
options: Omit<RequestInit, "body">,
args: any[]
) {
const instance = `server-fn:${INSTANCE++}`;
const response = await (args.length === 0
? createRequest(base, id, instance, options)
: args.length === 1 && args[0] instanceof FormData
? createRequest(base, id, instance, { ...options, body: args[0] })
: args.length === 1 && args[0] instanceof URLSearchParams
? createRequest(base, id, instance, {
...options,
body: args[0],
headers: { ...options.headers, "Content-Type": "application/x-www-form-urlencoded" }
})
: createRequest(base, id, instance, {
...options,
body: JSON.stringify(await Promise.resolve(toJSONAsync(args, { plugins }))),
headers: { ...options.headers, "Content-Type": "application/json" }
}));
if (
response.headers.has("Location") ||
response.headers.has("X-Revalidate") ||
response.headers.has("X-Single-Flight")
) {
if (response.body) {
/* @ts-ignore-next-line */
response.customBody = () => {
return deserializeStream(instance, response);
};
}
return response;
}
const contentType = response.headers.get("Content-Type");
let result;
if (contentType && contentType.startsWith("text/plain")) {
result = await response.text();
} else if (contentType && contentType.startsWith("application/json")) {
result = await response.json();
} else {
result = await deserializeStream(instance, response);
}
if (response.headers.has("X-Error")) {
throw result;
}
return result;
}
export function createServerReference(fn: Function, id: string, name: string) {
const baseURL = import.meta.env.SERVER_BASE_URL;
// @tanstack/server-functions-plugin contructs the id from the filename + function name eg src_lib_api_ts--getStory_query
// So we extract the name by splitting on --
// This feels flaky and we should rather try and get the function name directly from the plugin
// but this requires a change in the plugin
const functionName = id.split("--").pop() || id;
const functionPath = `${baseURL}/_server/${encodeURIComponent(functionName)}/`;
return new Proxy(fn, {
get(target, prop, receiver) {
if (prop === "url") {
return `${functionPath}?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`;
}
if (prop === "GET") {
return receiver.withOptions({ method: "GET" });
}
if (prop === "withOptions") {
const url = `${functionPath}?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`;
return (options: RequestInit) => {
const fn = async (...args: any[]) => {
const encodeArgs = options.method && options.method.toUpperCase() === "GET";
return fetchServerFunction(
encodeArgs
? url +
(args.length
? `&args=${encodeURIComponent(
JSON.stringify(await Promise.resolve(toJSONAsync(args, { plugins })))
)}`
: "")
: functionPath,
`${id}#${name}`,
options,
encodeArgs ? [] : args
);
};
fn.url = url;
return fn;
};
}
return (target as any)[prop];
},
apply(target, thisArg, args) {
return fetchServerFunction(functionPath, `${id}#${name}`, {}, args);
}
});
}
export function createClientReference(Component: Component<any>, id: string, name: string) {
if (typeof Component === "function") {
return createIslandReference(Component, id, name);
}
return Component;
}