@tanstack/router-core
Version:
Modern and scalable routing for React applications
325 lines (324 loc) • 10.2 kB
JavaScript
import "./constants.js";
import { ReadableStream } from "node:stream/web";
import { Readable } from "node:stream";
//#region src/ssr/transformStreamWithRouter.ts
function transformReadableStreamWithRouter(router, routerStream) {
return transformStreamWithRouter(router, routerStream);
}
function transformPipeableStreamWithRouter(router, routerStream) {
return Readable.fromWeb(transformStreamWithRouter(router, Readable.toWeb(routerStream)));
}
var BODY_END_TAG = "</body>";
var HTML_END_TAG = "</html>";
var MIN_CLOSING_TAG_LENGTH = 4;
var DEFAULT_SERIALIZATION_TIMEOUT_MS = 6e4;
var DEFAULT_LIFETIME_TIMEOUT_MS = 6e4;
var textEncoder = new TextEncoder();
/**
* Finds the position just after the last valid HTML closing tag in the string.
*
* Valid closing tags match the pattern: </[a-zA-Z][\w:.-]*>
* Examples: </div>, </my-component>, </slot:name.nested>
*
* @returns Position after the last closing tag, or -1 if none found
*/
function findLastClosingTagEnd(str) {
const len = str.length;
if (len < MIN_CLOSING_TAG_LENGTH) return -1;
let i = len - 1;
while (i >= MIN_CLOSING_TAG_LENGTH - 1) {
if (str.charCodeAt(i) === 62) {
let j = i - 1;
while (j >= 1) {
const code = str.charCodeAt(j);
if (code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 95 || code === 58 || code === 46 || code === 45) j--;
else break;
}
const tagNameStart = j + 1;
if (tagNameStart < i) {
const startCode = str.charCodeAt(tagNameStart);
if (startCode >= 97 && startCode <= 122 || startCode >= 65 && startCode <= 90) {
if (j >= 1 && str.charCodeAt(j) === 47 && str.charCodeAt(j - 1) === 60) return i + 1;
}
}
}
i--;
}
return -1;
}
function transformStreamWithRouter(router, appStream, opts) {
const serializationAlreadyFinished = router.serverSsr?.isSerializationFinished() ?? false;
const initialBufferedHtml = router.serverSsr?.takeBufferedHtml();
if (serializationAlreadyFinished && !initialBufferedHtml) {
let cleanedUp = false;
let controller;
let isStreamClosed = false;
let lifetimeTimeoutHandle;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
if (lifetimeTimeoutHandle !== void 0) {
clearTimeout(lifetimeTimeoutHandle);
lifetimeTimeoutHandle = void 0;
}
router.serverSsr?.cleanup();
};
const safeClose = () => {
if (isStreamClosed) return;
isStreamClosed = true;
try {
controller?.close();
} catch {}
};
const safeError = (error) => {
if (isStreamClosed) return;
isStreamClosed = true;
try {
controller?.error(error);
} catch {}
};
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS;
lifetimeTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isStreamClosed) {
console.warn(`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`);
safeError(/* @__PURE__ */ new Error("Stream lifetime exceeded"));
cleanup();
}
}, lifetimeMs);
const stream = new ReadableStream({
start(c) {
controller = c;
},
cancel() {
isStreamClosed = true;
cleanup();
}
});
(async () => {
const reader = appStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (cleanedUp || isStreamClosed) return;
controller?.enqueue(value);
}
if (cleanedUp || isStreamClosed) return;
router.serverSsr?.setRenderFinished();
safeClose();
cleanup();
} catch (error) {
if (cleanedUp) return;
console.error("Error reading appStream:", error);
router.serverSsr?.setRenderFinished();
safeError(error);
cleanup();
} finally {
reader.releaseLock();
}
})().catch((error) => {
if (cleanedUp) return;
console.error("Error in stream transform:", error);
safeError(error);
cleanup();
});
return stream;
}
let stopListeningToInjectedHtml;
let stopListeningToSerializationFinished;
let serializationTimeoutHandle;
let lifetimeTimeoutHandle;
let cleanedUp = false;
let controller;
let isStreamClosed = false;
const textDecoder = new TextDecoder();
let pendingRouterHtml = initialBufferedHtml ?? "";
let leftover = "";
let pendingClosingTags = "";
const MAX_LEFTOVER_CHARS = 2048;
let isAppRendering = true;
let streamBarrierLifted = false;
let serializationFinished = serializationAlreadyFinished;
function safeEnqueue(chunk) {
if (isStreamClosed) return;
if (typeof chunk === "string") controller.enqueue(textEncoder.encode(chunk));
else controller.enqueue(chunk);
}
function safeClose() {
if (isStreamClosed) return;
isStreamClosed = true;
try {
controller.close();
} catch {}
}
function safeError(error) {
if (isStreamClosed) return;
isStreamClosed = true;
try {
controller.error(error);
} catch {}
}
/**
* Cleanup with guards; must be idempotent.
*/
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try {
stopListeningToInjectedHtml?.();
stopListeningToSerializationFinished?.();
} catch {}
stopListeningToInjectedHtml = void 0;
stopListeningToSerializationFinished = void 0;
if (serializationTimeoutHandle !== void 0) {
clearTimeout(serializationTimeoutHandle);
serializationTimeoutHandle = void 0;
}
if (lifetimeTimeoutHandle !== void 0) {
clearTimeout(lifetimeTimeoutHandle);
lifetimeTimeoutHandle = void 0;
}
pendingRouterHtml = "";
leftover = "";
pendingClosingTags = "";
router.serverSsr?.cleanup();
}
const stream = new ReadableStream({
start(c) {
controller = c;
},
cancel() {
isStreamClosed = true;
cleanup();
}
});
function flushPendingRouterHtml() {
if (!pendingRouterHtml) return;
safeEnqueue(pendingRouterHtml);
pendingRouterHtml = "";
}
function appendRouterHtml(html) {
if (!html) return;
pendingRouterHtml += html;
}
/**
* Finish only when app done and serialization complete.
*/
function tryFinish() {
if (isAppRendering || !serializationFinished) return;
if (cleanedUp || isStreamClosed) return;
if (serializationTimeoutHandle !== void 0) {
clearTimeout(serializationTimeoutHandle);
serializationTimeoutHandle = void 0;
}
const decoderRemainder = textDecoder.decode();
if (leftover) safeEnqueue(leftover);
if (decoderRemainder) safeEnqueue(decoderRemainder);
flushPendingRouterHtml();
if (pendingClosingTags) safeEnqueue(pendingClosingTags);
safeClose();
cleanup();
}
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS;
lifetimeTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isStreamClosed) {
console.warn(`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`);
safeError(/* @__PURE__ */ new Error("Stream lifetime exceeded"));
cleanup();
}
}, lifetimeMs);
if (!serializationAlreadyFinished) {
stopListeningToInjectedHtml = router.subscribe("onInjectedHtml", () => {
if (cleanedUp || isStreamClosed) return;
const html = router.serverSsr?.takeBufferedHtml();
if (!html) return;
if (isAppRendering || leftover || pendingClosingTags) appendRouterHtml(html);
else safeEnqueue(html);
});
stopListeningToSerializationFinished = router.subscribe("onSerializationFinished", () => {
serializationFinished = true;
tryFinish();
});
}
(async () => {
const reader = appStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (cleanedUp || isStreamClosed) return;
const text = value instanceof Uint8Array ? textDecoder.decode(value, { stream: true }) : String(value);
const chunkString = leftover ? leftover + text : text;
if (!streamBarrierLifted) {
if (chunkString.includes("$tsr-stream-barrier")) {
streamBarrierLifted = true;
router.serverSsr?.liftScriptBarrier();
}
}
if (pendingClosingTags) {
pendingClosingTags += chunkString;
leftover = "";
continue;
}
const bodyEndIndex = chunkString.indexOf(BODY_END_TAG);
const htmlEndIndex = chunkString.indexOf(HTML_END_TAG);
if (bodyEndIndex !== -1 && htmlEndIndex !== -1 && bodyEndIndex < htmlEndIndex) {
pendingClosingTags = chunkString.slice(bodyEndIndex);
safeEnqueue(chunkString.slice(0, bodyEndIndex));
flushPendingRouterHtml();
leftover = "";
continue;
}
const lastClosingTagEnd = findLastClosingTagEnd(chunkString);
if (lastClosingTagEnd > 0) {
safeEnqueue(chunkString.slice(0, lastClosingTagEnd));
flushPendingRouterHtml();
leftover = chunkString.slice(lastClosingTagEnd);
if (leftover.length > MAX_LEFTOVER_CHARS) {
safeEnqueue(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS));
leftover = leftover.slice(-MAX_LEFTOVER_CHARS);
}
} else {
const combined = chunkString;
if (combined.length > MAX_LEFTOVER_CHARS) {
const flushUpto = combined.length - MAX_LEFTOVER_CHARS;
safeEnqueue(combined.slice(0, flushUpto));
leftover = combined.slice(flushUpto);
} else leftover = combined;
}
}
if (cleanedUp || isStreamClosed) return;
isAppRendering = false;
router.serverSsr?.setRenderFinished();
if (serializationFinished) tryFinish();
else {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS;
serializationTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isStreamClosed) {
console.error("Serialization timeout after app render finished");
safeError(/* @__PURE__ */ new Error("Serialization timeout after app render finished"));
cleanup();
}
}, timeoutMs);
}
} catch (error) {
if (cleanedUp) return;
console.error("Error reading appStream:", error);
isAppRendering = false;
router.serverSsr?.setRenderFinished();
safeError(error);
cleanup();
} finally {
reader.releaseLock();
}
})().catch((error) => {
if (cleanedUp) return;
console.error("Error in stream transform:", error);
safeError(error);
cleanup();
});
return stream;
}
//#endregion
export { transformPipeableStreamWithRouter, transformReadableStreamWithRouter, transformStreamWithRouter };
//# sourceMappingURL=transformStreamWithRouter.js.map