rwsdk 1.5.4 → 1.5.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/dist/runtime/lib/stitchDocumentAndAppStreams.js +31 -5
- package/dist/runtime/lib/stitchDocumentAndAppStreams.test.js +54 -0
- package/dist/use-synced-state/hibernation/__tests__/client-core.test.js +28 -0
- package/dist/use-synced-state/hibernation/protocol.d.mts +2 -2
- package/dist/use-synced-state/hibernation/protocol.mjs +5 -2
- package/package.json +1 -1
|
@@ -19,6 +19,24 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
|
|
|
19
19
|
const decoder = new TextDecoder();
|
|
20
20
|
const encoder = new TextEncoder();
|
|
21
21
|
const nonHoistedTagPattern = /<(?!(?:\/)?(?:title|meta|link|style|base)[\s>\/])(?![!?])/i;
|
|
22
|
+
// Longest hoistable name is 5 chars (title/style); a match candidate at
|
|
23
|
+
// index i needs buffer[i..] to have at least 1 (optional "/") + 5 (name)
|
|
24
|
+
// + 1 (terminating char) = 7 chars of context past "<" before it can be
|
|
25
|
+
// trusted as final. Otherwise the tag name may be mid-stream-truncated,
|
|
26
|
+
// causing a false-positive match on e.g. "<t" and severing <title>.
|
|
27
|
+
const MIN_TRAILING_CONTEXT = 7;
|
|
28
|
+
function findTrustedMatch(buf, streamDone) {
|
|
29
|
+
const match = buf.match(nonHoistedTagPattern);
|
|
30
|
+
if (!match || typeof match.index !== "number")
|
|
31
|
+
return null;
|
|
32
|
+
if (streamDone)
|
|
33
|
+
return match; // no more data coming; trust it
|
|
34
|
+
// buf.length - match.index counts the "<" itself, so subtract 1 to
|
|
35
|
+
// get the number of confirmed characters *after* it.
|
|
36
|
+
if (buf.length - match.index - 1 < MIN_TRAILING_CONTEXT)
|
|
37
|
+
return null; // ambiguous, wait for more data
|
|
38
|
+
return match;
|
|
39
|
+
}
|
|
22
40
|
let sourceReader;
|
|
23
41
|
let appBodyController = null;
|
|
24
42
|
let buffer = "";
|
|
@@ -35,7 +53,7 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
|
|
|
35
53
|
const { done, value } = await sourceReader.read();
|
|
36
54
|
if (done) {
|
|
37
55
|
if (buffer) {
|
|
38
|
-
const match = buffer
|
|
56
|
+
const match = findTrustedMatch(buffer, true);
|
|
39
57
|
if (match && typeof match.index === "number") {
|
|
40
58
|
const hoistedPart = buffer.slice(0, match.index);
|
|
41
59
|
controller.enqueue(encoder.encode(hoistedPart));
|
|
@@ -52,7 +70,7 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
|
|
|
52
70
|
return;
|
|
53
71
|
}
|
|
54
72
|
buffer += decoder.decode(value, { stream: true });
|
|
55
|
-
const match = buffer
|
|
73
|
+
const match = findTrustedMatch(buffer, false);
|
|
56
74
|
if (match && typeof match.index === "number") {
|
|
57
75
|
const hoistedPart = buffer.slice(0, match.index);
|
|
58
76
|
const appPart = buffer.slice(match.index);
|
|
@@ -76,9 +94,17 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
|
|
|
76
94
|
}
|
|
77
95
|
else {
|
|
78
96
|
const flushIndex = buffer.lastIndexOf("\n");
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
// Never flush past a point that could still be part of
|
|
98
|
+
// an ambiguous, not-yet-trusted candidate tag. Only
|
|
99
|
+
// flush content strictly before the last unresolved
|
|
100
|
+
// "<" in the buffer, so a truncated tag name can never
|
|
101
|
+
// be split across a flush boundary.
|
|
102
|
+
const earliestOpenBracket = buffer.lastIndexOf("<");
|
|
103
|
+
const safeFlushLimit = earliestOpenBracket === -1 ? buffer.length : earliestOpenBracket;
|
|
104
|
+
const effectiveFlushIndex = Math.min(flushIndex, safeFlushLimit - 1);
|
|
105
|
+
if (flushIndex !== -1 && effectiveFlushIndex >= 0) {
|
|
106
|
+
controller.enqueue(encoder.encode(buffer.slice(0, effectiveFlushIndex + 1)));
|
|
107
|
+
buffer = buffer.slice(effectiveFlushIndex + 1);
|
|
82
108
|
}
|
|
83
109
|
await pump();
|
|
84
110
|
}
|
|
@@ -155,6 +155,60 @@ describe("stitchDocumentAndAppStreams", () => {
|
|
|
155
155
|
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
156
156
|
expect(result.trim().startsWith(`<!DOCTYPE html>`)).toBe(true);
|
|
157
157
|
});
|
|
158
|
+
it("does not split an unclosed title tag across a chunk boundary", async () => {
|
|
159
|
+
const outerHtml = `<!DOCTYPE html>
|
|
160
|
+
<html>
|
|
161
|
+
<head>
|
|
162
|
+
<meta charset="utf-8" />
|
|
163
|
+
</head>
|
|
164
|
+
<body>
|
|
165
|
+
${startMarker}
|
|
166
|
+
<script src="/client.js"></script>
|
|
167
|
+
</body>
|
|
168
|
+
</html>`;
|
|
169
|
+
// The ">" of the closing </title> arrives in the next chunk. A naive
|
|
170
|
+
// regex scan of the first chunk can falsely treat the "</title" as a
|
|
171
|
+
// non-hoisted tag boundary, leaving the <title> unclosed in <head>.
|
|
172
|
+
const innerHtmlChunks = [
|
|
173
|
+
`<title>Page Title</title`,
|
|
174
|
+
`><div>App content</div>${endMarker}`,
|
|
175
|
+
];
|
|
176
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
|
|
177
|
+
expect(result).toContain(`<title>Page Title</title>`);
|
|
178
|
+
expect(result).toMatch(/<head>[\s\S]*<title>Page Title<\/title>[\s\S]*<\/head>/);
|
|
179
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
180
|
+
const titleIndex = result.indexOf(`<title>Page Title</title>`);
|
|
181
|
+
const headCloseIndex = result.indexOf(`</head>`);
|
|
182
|
+
const bodyIndex = result.indexOf(`<body>`);
|
|
183
|
+
expect(titleIndex).toBeLessThan(headCloseIndex);
|
|
184
|
+
expect(titleIndex).toBeLessThan(bodyIndex);
|
|
185
|
+
});
|
|
186
|
+
it("does not truncate a hoisted tag name across a chunk boundary", async () => {
|
|
187
|
+
const outerHtml = `<!DOCTYPE html>
|
|
188
|
+
<html>
|
|
189
|
+
<head>
|
|
190
|
+
<meta charset="utf-8" />
|
|
191
|
+
</head>
|
|
192
|
+
<body>
|
|
193
|
+
${startMarker}
|
|
194
|
+
<script src="/client.js"></script>
|
|
195
|
+
</body>
|
|
196
|
+
</html>`;
|
|
197
|
+
// The "<title" tag name is split between chunks. A naive regex scan of
|
|
198
|
+
// the first chunk (ending at "<t") can falsely treat that "<" as a
|
|
199
|
+
// non-hoisted tag boundary and fail to hoist the title.
|
|
200
|
+
const innerHtmlChunks = [
|
|
201
|
+
`<t`,
|
|
202
|
+
`itle>Page Title</title><div>App content</div>${endMarker}`,
|
|
203
|
+
];
|
|
204
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
|
|
205
|
+
expect(result).toContain(`<title>Page Title</title>`);
|
|
206
|
+
expect(result).toMatch(/<head>[\s\S]*<title>Page Title<\/title>[\s\S]*<\/head>/);
|
|
207
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
208
|
+
const titleIndex = result.indexOf(`<title>Page Title</title>`);
|
|
209
|
+
const headCloseIndex = result.indexOf(`</head>`);
|
|
210
|
+
expect(titleIndex).toBeLessThan(headCloseIndex);
|
|
211
|
+
});
|
|
158
212
|
});
|
|
159
213
|
describe("basic stitching flow", () => {
|
|
160
214
|
it("stitches document head, app shell, and document tail", async () => {
|
|
@@ -201,6 +201,34 @@ describe("client-core", () => {
|
|
|
201
201
|
serverSockets[0].close();
|
|
202
202
|
await expect(getStatePromise).rejects.toThrow("WebSocket closed");
|
|
203
203
|
});
|
|
204
|
+
it("resolves getState with undefined when the server has no state", async () => {
|
|
205
|
+
const client = createClient();
|
|
206
|
+
const getStatePromise = client.getState("counter");
|
|
207
|
+
await waitForOpen(clients[0]);
|
|
208
|
+
const serverSocket = await waitForCondition(() => serverSockets[0]);
|
|
209
|
+
await waitForCondition(() => serverMessages[0].length >= 1 ? serverMessages[0] : undefined);
|
|
210
|
+
expect(serverMessages[0][0]).toMatchObject({
|
|
211
|
+
v: 1,
|
|
212
|
+
kind: "getState",
|
|
213
|
+
key: "counter",
|
|
214
|
+
});
|
|
215
|
+
// When the server has no state it sends a getState response with no value
|
|
216
|
+
// property (because JSON.stringify omits undefined values).
|
|
217
|
+
send(serverSocket, {
|
|
218
|
+
kind: "getState",
|
|
219
|
+
key: "counter",
|
|
220
|
+
id: serverMessages[0][0].id,
|
|
221
|
+
});
|
|
222
|
+
await wait(0);
|
|
223
|
+
await vi.advanceTimersByTimeAsync(PENDING_REQUEST_TIMEOUT_MS + 1000);
|
|
224
|
+
await expect(getStatePromise).resolves.toBeUndefined();
|
|
225
|
+
// The pending timeout should not fire; the socket should stay open.
|
|
226
|
+
const firstSocket = clients[0];
|
|
227
|
+
const closeSpy = vi.fn();
|
|
228
|
+
firstSocket.once("close", closeSpy);
|
|
229
|
+
await vi.advanceTimersByTimeAsync(PENDING_REQUEST_TIMEOUT_MS + 1000);
|
|
230
|
+
expect(closeSpy).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
204
232
|
it("queues messages sent before the socket opens", async () => {
|
|
205
233
|
const client = createClient();
|
|
206
234
|
const getStatePromise = client.getState("counter");
|
|
@@ -25,7 +25,7 @@ export type ClientMessage = {
|
|
|
25
25
|
export type ServerMessage = {
|
|
26
26
|
kind: "getState";
|
|
27
27
|
key: string;
|
|
28
|
-
value
|
|
28
|
+
value?: SyncedStateValue | undefined;
|
|
29
29
|
id: string;
|
|
30
30
|
} | {
|
|
31
31
|
kind: "setState";
|
|
@@ -42,7 +42,7 @@ export type ServerMessage = {
|
|
|
42
42
|
} | {
|
|
43
43
|
kind: "update";
|
|
44
44
|
key: string;
|
|
45
|
-
value
|
|
45
|
+
value?: SyncedStateValue;
|
|
46
46
|
} | {
|
|
47
47
|
kind: "error";
|
|
48
48
|
message: string;
|
|
@@ -26,13 +26,16 @@ function isClientMessage(message) {
|
|
|
26
26
|
function isServerMessage(message) {
|
|
27
27
|
switch (message.kind) {
|
|
28
28
|
case "getState":
|
|
29
|
-
return "id" in message &&
|
|
29
|
+
return ("id" in message &&
|
|
30
|
+
typeof message.id === "string" &&
|
|
31
|
+
"key" in message &&
|
|
32
|
+
typeof message.key === "string");
|
|
30
33
|
case "setState":
|
|
31
34
|
case "subscribe":
|
|
32
35
|
case "unsubscribe":
|
|
33
36
|
return "id" in message && typeof message.id === "string";
|
|
34
37
|
case "update":
|
|
35
|
-
return "
|
|
38
|
+
return "key" in message && typeof message.key === "string";
|
|
36
39
|
case "error":
|
|
37
40
|
return "message" in message && typeof message.message === "string";
|
|
38
41
|
default:
|