@tanstack/router-core
Version:
Modern and scalable routing for React applications
580 lines (579 loc) • 17.8 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, opts) {
return transformStreamWithRouter(router, routerStream, opts);
}
function transformPipeableStreamWithRouter(router, routerStream, opts) {
return Readable.fromWeb(transformStreamWithRouter(router, Readable.toWeb(routerStream), opts));
}
const MIN_CLOSING_TAG_LENGTH = 4;
const DEFAULT_SERIALIZATION_TIMEOUT_MS = 6e4;
const DEFAULT_LIFETIME_TIMEOUT_MS = DEFAULT_SERIALIZATION_TIMEOUT_MS * 2;
const MAX_LEFTOVER_CHARS = 2048;
const MAX_TAIL_CHARS = 64 * 1024;
const MAX_ROUTER_HTML_CHARS = 16 * 1024 * 1024;
const MAX_PENDING_WRITE_CHARS = 16 * 1024 * 1024;
const MergeState = {
ReadingBody: 0,
HoldingTail: 1,
AppDone: 2,
Draining: 3,
Done: 4
};
const textEncoder = new TextEncoder();
const noop = () => {};
const resolvedPromise = Promise.resolve();
function findHtmlBoundary(str) {
let lastClosingTagEnd = -1;
let searchFrom = str.length - MIN_CLOSING_TAG_LENGTH;
while (searchFrom >= 0) {
const openSlash = str.lastIndexOf("</", searchFrom);
if (openSlash === -1) break;
if ((str.charCodeAt(openSlash + 2) | 32) === 98 && (str.charCodeAt(openSlash + 3) | 32) === 111 && (str.charCodeAt(openSlash + 4) | 32) === 100 && (str.charCodeAt(openSlash + 5) | 32) === 121 && str.charCodeAt(openSlash + 6) === 62) return -openSlash - 2;
if (lastClosingTagEnd === -1) {
let i = openSlash + 2;
const startCode = str.charCodeAt(i);
if (startCode >= 97 && startCode <= 122 || startCode >= 65 && startCode <= 90) {
i++;
while (i < str.length) {
const code = str.charCodeAt(i);
if (code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 95 || code === 58 || code === 46 || code === 45) i++;
else break;
}
if (str.charCodeAt(i) === 62) lastClosingTagEnd = i + 1;
}
}
searchFrom = openSlash - 1;
}
return lastClosingTagEnd;
}
function safeReleaseReader(reader) {
try {
reader.releaseLock();
return true;
} catch {
return false;
}
}
/**
* Cancel a reader without producing an unhandled rejection. `reader.cancel()`
* can reject (e.g. when the underlying source's cancel() throws), and
* downstream cancel() should still wait for upstream teardown when possible.
*/
function safeCancelReader(reader, reason) {
let cancelPromise;
try {
cancelPromise = reader.cancel(reason);
} catch {}
if (!safeReleaseReader(reader) && cancelPromise) return cancelPromise.then(noop, noop).then(() => {
safeReleaseReader(reader);
});
return cancelPromise ? cancelPromise.then(noop, noop) : resolvedPromise;
}
function createReaderState(appStream) {
const reader = appStream.getReader();
let released = false;
return {
reader,
cancel: (reason) => {
if (released) return resolvedPromise;
released = true;
return safeCancelReader(reader, reason);
},
release: () => {
if (released) return;
released = true;
safeReleaseReader(reader);
}
};
}
function createAbortNotifier(opts) {
let abortNotified = false;
return (reason) => {
if (abortNotified) return;
abortNotified = true;
try {
opts?.onAbort?.(reason);
} catch {}
};
}
function transformStreamWithRouter(router, appStream, opts) {
const serverSsr = router.serverSsr;
if (!serverSsr) throw new Error("Invariant failed: router.serverSsr is required");
if (serverSsr.reserveStreamFastPath()) return makeFastPathStream(appStream, opts, serverSsr);
return makeMainStream(serverSsr, appStream, opts);
}
function makeFastPathStream(appStream, opts, serverSsr) {
let cleanedUp = false;
let controller;
let state = MergeState.ReadingBody;
let lifetimeTimeoutHandle;
let stopListeningToInjectedHtml;
const readerState = createReaderState(appStream);
const notifyAbort = createAbortNotifier(opts);
const isDone = () => state === MergeState.Done;
let renderFinished = false;
const finishSsrRendering = () => {
if (!serverSsr || renderFinished) return true;
renderFinished = true;
try {
serverSsr.setRenderFinished();
return true;
} catch (error) {
safeError(error);
cleanup(error);
return false;
}
};
const cleanup = (reason, cancelReader = true) => {
if (cleanedUp) return resolvedPromise;
cleanedUp = true;
if (lifetimeTimeoutHandle !== void 0) {
clearTimeout(lifetimeTimeoutHandle);
lifetimeTimeoutHandle = void 0;
}
try {
stopListeningToInjectedHtml?.();
} catch {}
stopListeningToInjectedHtml = void 0;
if (cancelReader) notifyAbort(reason);
const readerDone = cancelReader ? readerState.cancel(reason) : (readerState.release(), resolvedPromise);
if (serverSsr) try {
serverSsr.cleanup();
} catch (error) {
console.error("Error in SSR cleanup:", error);
}
return readerDone;
};
const safeClose = () => {
if (isDone()) return;
state = MergeState.Done;
try {
controller?.close();
} catch {}
};
const safeError = (error) => {
if (isDone()) return;
state = MergeState.Done;
try {
controller?.error(error);
} catch {}
};
if (serverSsr) stopListeningToInjectedHtml = serverSsr.onInjectedHtml(() => {
const err = /* @__PURE__ */ new Error("SSR router HTML injected during fast path");
safeError(err);
cleanup(err);
});
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS;
lifetimeTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isDone()) {
const err = /* @__PURE__ */ new Error("Stream lifetime exceeded");
console.warn(`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`);
safeError(err);
cleanup(err);
}
}, lifetimeMs);
return new ReadableStream({
start(c) {
controller = c;
},
async pull(c) {
if (cleanedUp || isDone()) return;
try {
const { done, value } = await readerState.reader.read();
if (!done) {
if (!cleanedUp && !isDone()) c.enqueue(value);
return;
}
if (cleanedUp || isDone()) return;
if (!finishSsrRendering()) return;
safeClose();
return cleanup(void 0, false);
} catch (error) {
if (cleanedUp) return;
console.error("Error reading appStream:", error);
if (state < MergeState.AppDone) try {
serverSsr?.setRenderFinished();
} catch {}
safeError(error);
return cleanup(error);
} finally {
if (cleanedUp || isDone()) readerState.release();
}
},
cancel(reason) {
state = MergeState.Done;
return cleanup(reason);
}
});
}
function makeMainStream(serverSsr, appStream, opts) {
let stopListeningToInjectedHtml;
let stopListeningToSerializationFinished;
let serializationTimeoutHandle;
let lifetimeTimeoutHandle;
let cleanedUp = false;
let controller;
let closeWhenDrained = false;
let state = MergeState.ReadingBody;
const readerState = createReaderState(appStream);
const notifyAbort = createAbortNotifier(opts);
const pendingWrites = [];
let pendingWriteHead = 0;
let pendingWriteChars = 0;
function clearPending() {
pendingWrites.length = 0;
pendingWriteHead = 0;
pendingWriteChars = 0;
}
let drainResolve = null;
const waitForDrain = () => new Promise((r) => {
drainResolve = r;
});
const signalDrain = () => {
if (drainResolve) {
const r = drainResolve;
drainResolve = null;
r();
}
};
const isDone = () => state === MergeState.Done;
function drainPending() {
if (!controller || isDone()) return;
while (pendingWriteHead < pendingWrites.length) {
const ds = controller.desiredSize;
if (ds !== null && ds <= 0) return;
const next = pendingWrites[pendingWriteHead];
pendingWrites[pendingWriteHead] = "";
pendingWriteHead++;
pendingWriteChars -= next.length;
try {
controller.enqueue(textEncoder.encode(next));
} catch (error) {
safeError(error);
cleanup(error);
return;
}
}
if (pendingWriteHead >= pendingWrites.length) {
pendingWrites.length = 0;
pendingWriteHead = 0;
}
if (closeWhenDrained && pendingWriteHead >= pendingWrites.length) {
closeWhenDrained = false;
safeClose();
cleanup(void 0, false);
}
}
/**
* Enqueue a string chunk through the backpressure queue. Stored as a
* string and encoded only when the downstream actually accepts the chunk
* — keeps native-memory pressure inside the controller's queue (which
* honors desiredSize) rather than ours.
*/
function writeChunk(chunk) {
if (cleanedUp || isDone()) return;
if (!chunk.length) return;
if (pendingWriteChars + chunk.length > MAX_PENDING_WRITE_CHARS) {
const err = /* @__PURE__ */ new Error("SSR stream pending output exceeded maximum buffer");
safeError(err);
cleanup(err);
return;
}
pendingWrites.push(chunk);
pendingWriteChars += chunk.length;
drainPending();
}
function safeClose() {
if (isDone()) return;
state = MergeState.Done;
try {
controller?.close();
} catch {}
}
function safeError(error) {
if (isDone()) return;
state = MergeState.Done;
try {
controller?.error(error);
} catch {}
}
/**
* Cleanup with guards; must be idempotent.
*/
function cleanup(reason, cancelReader = true) {
if (cleanedUp) return resolvedPromise;
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;
}
clearPendingRouterHtml();
leftover = "";
pendingTail = "";
clearPending();
if (cancelReader) notifyAbort(reason);
const readerDone = cancelReader ? readerState.cancel(reason) : (readerState.release(), resolvedPromise);
signalDrain();
try {
serverSsr.cleanup();
} catch (error) {
console.error("Error in SSR cleanup:", error);
}
return readerDone;
}
const textDecoder = new TextDecoder();
const pendingRouterHtml = [];
let pendingRouterHtmlChars = 0;
let leftover = "";
let pendingTail = "";
let streamBarrierLifted = false;
let streamBarrierMarkerSeen = false;
let serializationFinished = false;
function noteBarrierMarker(chunk) {
if (streamBarrierMarkerSeen) return;
if (chunk.includes("$tsr-stream-barrier")) streamBarrierMarkerSeen = true;
}
function liftBarrierAfterBoundary() {
if (streamBarrierLifted) return;
if (!streamBarrierMarkerSeen) return;
streamBarrierLifted = true;
serverSsr.liftScriptBarrier();
}
const stream = new ReadableStream({
start(c) {
controller = c;
drainPending();
},
pull() {
drainPending();
signalDrain();
},
cancel(reason) {
state = MergeState.Done;
return cleanup(reason);
}
});
function drainRouterHtml() {
if (cleanedUp || isDone()) return;
let html;
try {
html = serverSsr.takeBufferedHtml();
} catch (error) {
safeError(error);
cleanup(error);
return;
}
if (!html) return;
if (state >= MergeState.Draining) {
const err = /* @__PURE__ */ new Error("SSR router HTML injected after stream finalization");
safeError(err);
cleanup(err);
return;
}
if (state === MergeState.HoldingTail) {
flushPendingRouterHtml();
writeChunk(html);
} else {
if (pendingRouterHtmlChars + html.length > MAX_ROUTER_HTML_CHARS) {
const err = /* @__PURE__ */ new Error("SSR router HTML exceeded maximum buffer");
safeError(err);
cleanup(err);
return;
}
pendingRouterHtml.push(html);
pendingRouterHtmlChars += html.length;
}
}
function flushPendingRouterHtml() {
if (!pendingRouterHtml.length) return;
for (const html of pendingRouterHtml) writeChunk(html);
clearPendingRouterHtml();
}
function clearPendingRouterHtml() {
pendingRouterHtml.length = 0;
pendingRouterHtmlChars = 0;
}
function appendTail(chunk) {
pendingTail += chunk;
if (pendingTail.length > MAX_TAIL_CHARS) throw new Error("SSR stream tail exceeded maximum buffer");
}
function waitForBackpressure() {
return !!(controller && controller.desiredSize !== null && controller.desiredSize <= 0);
}
function startSerializationTimeout() {
if (cleanedUp || isDone()) return;
if (serializationTimeoutHandle !== void 0) return;
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS;
serializationTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isDone()) {
const err = /* @__PURE__ */ new Error("Serialization timeout after app render finished");
console.error("Serialization timeout after app render finished");
safeError(err);
cleanup(err);
}
}, timeoutMs);
}
/**
* Finish only when app done and serialization complete. Queues final
* output and requests close-when-drained so we don't close ahead of
* pending writes still waiting on downstream capacity.
*/
function tryFinish() {
if (state !== MergeState.AppDone || !serializationFinished) return;
if (cleanedUp || isDone()) return;
if (serializationTimeoutHandle !== void 0) {
clearTimeout(serializationTimeoutHandle);
serializationTimeoutHandle = void 0;
}
drainRouterHtml();
if (cleanedUp || isDone()) return;
const decoderRemainder = textDecoder.decode();
if (leftover) writeChunk(leftover);
if (cleanedUp || isDone()) return;
if (decoderRemainder) writeChunk(decoderRemainder);
if (cleanedUp || isDone()) return;
flushPendingRouterHtml();
if (cleanedUp || isDone()) return;
if (pendingTail) writeChunk(pendingTail);
if (cleanedUp || isDone()) return;
leftover = "";
pendingTail = "";
state = MergeState.Draining;
closeWhenDrained = true;
drainPending();
}
function finishAppRendering() {
if (state >= MergeState.AppDone) return;
state = MergeState.AppDone;
try {
serverSsr.setRenderFinished();
} catch (error) {
safeError(error);
cleanup(error);
return;
}
drainRouterHtml();
if (cleanedUp || isDone()) return;
serializationFinished = serializationFinished || serverSsr.isSerializationFinished();
if (serializationFinished) tryFinish();
else startSerializationTimeout();
}
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS;
const lifetimeMs = opts?.lifetimeMs ?? timeoutMs * 2;
lifetimeTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isDone()) {
const err = /* @__PURE__ */ new Error("Stream lifetime exceeded");
console.warn(`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`);
safeError(err);
cleanup(err);
}
}, lifetimeMs);
stopListeningToInjectedHtml = serverSsr.onInjectedHtml(() => {
drainRouterHtml();
});
stopListeningToSerializationFinished = serverSsr.onSerializationFinished(() => {
serializationFinished = true;
drainRouterHtml();
tryFinish();
});
drainRouterHtml();
if (cleanedUp || isDone()) return stream;
serializationFinished = serializationFinished || serverSsr.isSerializationFinished();
if (serializationFinished) {
drainRouterHtml();
if (cleanedUp || isDone()) return stream;
}
(async () => {
try {
while (true) {
if (waitForBackpressure()) {
await waitForDrain();
if (cleanedUp || isDone()) return;
}
const { done, value } = await readerState.reader.read();
if (done) break;
if (cleanedUp || isDone()) return;
const text = typeof value === "string" ? value : textDecoder.decode(value, { stream: true });
const chunkString = leftover ? leftover + text : text;
if (state >= MergeState.HoldingTail) {
appendTail(chunkString);
leftover = "";
continue;
}
const boundary = findHtmlBoundary(chunkString);
if (boundary < -1) {
const bodyEndIndex = -boundary - 2;
state = MergeState.HoldingTail;
appendTail(chunkString.slice(bodyEndIndex));
const bodyChunk = chunkString.slice(0, bodyEndIndex);
writeChunk(bodyChunk);
if (cleanedUp || isDone()) return;
noteBarrierMarker(bodyChunk);
liftBarrierAfterBoundary();
if (cleanedUp || isDone()) return;
flushPendingRouterHtml();
leftover = "";
continue;
}
const lastClosingTagEnd = boundary;
if (lastClosingTagEnd > 0) {
const safeChunk = chunkString.slice(0, lastClosingTagEnd);
writeChunk(safeChunk);
if (cleanedUp || isDone()) return;
noteBarrierMarker(safeChunk);
liftBarrierAfterBoundary();
if (cleanedUp || isDone()) return;
flushPendingRouterHtml();
leftover = chunkString.slice(lastClosingTagEnd);
if (leftover.length > MAX_LEFTOVER_CHARS) {
noteBarrierMarker(leftover);
writeChunk(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS));
leftover = leftover.slice(-2048);
}
} else {
const combined = chunkString;
if (combined.length > MAX_LEFTOVER_CHARS) {
noteBarrierMarker(combined);
const flushUpto = combined.length - MAX_LEFTOVER_CHARS;
writeChunk(combined.slice(0, flushUpto));
leftover = combined.slice(flushUpto);
} else leftover = combined;
}
}
if (cleanedUp || isDone()) return;
finishAppRendering();
} catch (error) {
if (cleanedUp) return;
console.error("Error reading appStream:", error);
if (state < MergeState.AppDone) try {
serverSsr.setRenderFinished();
} catch {}
safeError(error);
cleanup(error);
} finally {
readerState.release();
}
})().catch((error) => {
if (cleanedUp) return;
console.error("Error in stream transform:", error);
safeError(error);
cleanup(error);
});
return stream;
}
//#endregion
export { transformPipeableStreamWithRouter, transformReadableStreamWithRouter, transformStreamWithRouter };
//# sourceMappingURL=transformStreamWithRouter.js.map