Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 3 additions & 23 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", impor
const appPageResponsePath = resolveEntryPath("../server/app-page-response.js", import.meta.url);
const cspPath = resolveEntryPath("../server/csp.js", import.meta.url);
const appPageRequestPath = resolveEntryPath("../server/app-page-request.js", import.meta.url);
const flightHintsPath = resolveEntryPath("../server/flight-hints.js", import.meta.url);
const appRouteHandlerResponsePath = resolveEntryPath(
"../server/app-route-handler-response.js",
import.meta.url,
Expand Down Expand Up @@ -330,35 +331,14 @@ import {
createTemporaryReferenceSet,
} from "@vitejs/plugin-rsc/rsc";
import { AsyncLocalStorage } from "node:async_hooks";
import { createFlightHintFixTransform as __createFlightHintFixTransform } from ${JSON.stringify(flightHintsPath)};

// React Flight emits HL hints with "stylesheet" for CSS, but the HTML spec
// requires "style" for <link rel="preload">. Fix at the source so every
// consumer (SSR embed, client-side navigation, server actions) gets clean data.
//
// Flight lines are newline-delimited, so we buffer partial lines across chunks
// to guarantee the regex never sees a split hint.
function renderToReadableStream(model, options) {
const _hlFixRe = /(\\d*:HL\\[.*?),"stylesheet"(\\]|,)/g;
const stream = _renderToReadableStream(model, options);
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let carry = "";
return stream.pipeThrough(new TransformStream({
transform(chunk, controller) {
const text = carry + decoder.decode(chunk, { stream: true });
const lastNl = text.lastIndexOf("\\n");
if (lastNl === -1) {
carry = text;
return;
}
carry = text.slice(lastNl + 1);
controller.enqueue(encoder.encode(text.slice(0, lastNl + 1).replace(_hlFixRe, '$1,"style"$2')));
},
flush(controller) {
const text = carry + decoder.decode();
if (text) controller.enqueue(encoder.encode(text.replace(_hlFixRe, '$1,"style"$2')));
}
}));
return stream.pipeThrough(__createFlightHintFixTransform());
}
import { createElement } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
Expand Down
4 changes: 2 additions & 2 deletions packages/vinext/src/server/app-router-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// @ts-expect-error — virtual module resolved by vinext
import rscHandler from "virtual:vinext-rsc-entry";
import { runWithExecutionContext, type ExecutionContextLike } from "../shims/request-context.js";
import { resolveStaticAssetSignal } from "./worker-utils.js";
import { applyLocalDevStreamingHeaders, resolveStaticAssetSignal } from "./worker-utils.js";

type WorkerAssetEnv = {
ASSETS?: {
Expand Down Expand Up @@ -70,7 +70,7 @@ export default {
});
if (assetResponse) return assetResponse;
}
return result;
return applyLocalDevStreamingHeaders(result, request);
}

if (result === null || result === undefined) {
Expand Down
67 changes: 40 additions & 27 deletions packages/vinext/src/server/app-ssr-stream.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { fixFlightHints } from "./flight-hints.js";
import { createInlineScriptTag, safeJsonStringify } from "./html.js";

export type RscEmbedTransform = {
flush(): string;
finalize(): Promise<string>;
};

/**
* Fix invalid preload "as" values in RSC Flight hint lines before they reach
* the client. React Flight emits HL hints with as="stylesheet" for CSS, but
* the HTML spec requires as="style" for <link rel="preload">.
*/
export function fixFlightHints(text: string): string {
return text.replace(/(\d*:HL\[.*?),"stylesheet"(\]|,)/g, '$1,"style"$2');
}
export { fixFlightHints };

/**
* Create a helper that progressively embeds RSC chunks as inline <script> tags.
Expand Down Expand Up @@ -90,6 +84,21 @@ export function fixPreloadAs(html: string): string {
);
}

function queueTask(callback: () => void): void {
if (typeof MessageChannel === "undefined") {
queueMicrotask(callback);
return;
}

const channel = new MessageChannel();
channel.port1.onmessage = () => {
channel.port1.close();
channel.port2.close();
callback();
};
channel.port2.postMessage(undefined);
}

/**
* Create the tick-buffered HTML transform that injects RSC scripts between
* React Fizz flush cycles without corrupting split HTML chunks.
Expand All @@ -102,7 +111,7 @@ export function createTickBufferedTransform(
const encoder = new TextEncoder();
let injected = false;
let buffered: string[] = [];
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let flushScheduled = false;

const flushBuffered = (controller: TransformStreamDefaultController<Uint8Array>): void => {
for (const chunk of buffered) {
Expand All @@ -121,37 +130,41 @@ export function createTickBufferedTransform(
buffered = [];
};

const flushHtmlAndRsc = (controller: TransformStreamDefaultController<Uint8Array>): void => {
flushBuffered(controller);

const rscScripts = rscEmbed.flush();
if (rscScripts) {
controller.enqueue(encoder.encode(rscScripts));
}
};

return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
buffered.push(fixPreloadAs(decoder.decode(chunk, { stream: true })));

if (timeoutId !== null) return;
if (flushScheduled) return;

timeoutId = setTimeout(() => {
flushScheduled = true;
queueTask(() => {
if (!flushScheduled) return;
flushScheduled = false;
try {
flushBuffered(controller);

const rscScripts = rscEmbed.flush();
if (rscScripts) {
controller.enqueue(encoder.encode(rscScripts));
}
flushHtmlAndRsc(controller);
} catch {
// Stream was cancelled between when the timeout was registered and
// when it fired (e.g. client disconnected, health-check cancelled
// the response body). Ignore — the stream is already closed.
// Stream was cancelled between when the flush was queued and when it
// ran (e.g. client disconnected, health-check cancelled the response
// body). Ignore — the stream is already closed.
}

timeoutId = null;
}, 0);
});
},

async flush(controller) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
if (flushScheduled) {
flushScheduled = false;
}

flushBuffered(controller);
flushHtmlAndRsc(controller);

if (!injected && injectHTML) {
controller.enqueue(encoder.encode(injectHTML));
Expand Down
134 changes: 134 additions & 0 deletions packages/vinext/src/server/flight-hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Fix invalid preload "as" values in RSC Flight hint lines before they reach
* the client. React Flight emits HL hints with as="stylesheet" for CSS, but
* the HTML spec requires as="style" for <link rel="preload">.
*/
export function fixFlightHints(text: string): string {
return text.replace(/(\d*:HL\[.*?),"stylesheet"(\]|,)/g, '$1,"style"$2');
}

function isPotentialHintPrefix(value: string): boolean {
return /^\d*(?::(?:H(?:L(?:\[)?)?)?)?$/.test(value);
}

function isCompleteHintPrefix(value: string): boolean {
return /^\d*:HL\[$/.test(value);
}

/**
* Streaming version of fixFlightHints().
*
* Flight records are newline-delimited, but buffering an entire partial line
* can block the App Router shell when workerd splits an early model row before
* its trailing newline. This parser only buffers the tiny line prefix needed to
* identify HL records, plus the `,"stylesheet"` token when it is split across
* chunks.
*/
export function createFlightHintFixTransform(): TransformStream<Uint8Array, Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const token = ',"stylesheet"';
const replacement = ',"style"';
let mode: "prefix" | "hint" | "passthrough" = "prefix";
let prefix = "";
let tokenMatch = "";

function processText(text: string): string {
let output = "";

const emit = (value: string): void => {
output += value;
};

const processHintChar = (char: string): void => {
if (char === "\n") {
emit(tokenMatch);
tokenMatch = "";
emit(char);
mode = "prefix";
return;
}

const nextMatch = tokenMatch + char;
if (token.startsWith(nextMatch)) {
tokenMatch = nextMatch;
return;
}

if (tokenMatch === token && (char === "]" || char === ",")) {
emit(replacement);
tokenMatch = "";
emit(char);
return;
}

if (tokenMatch) {
emit(tokenMatch);
tokenMatch = "";
processHintChar(char);
return;
}

emit(char);
};

for (const char of text) {
if (mode === "prefix") {
prefix += char;

if (char === "\n") {
emit(prefix);
prefix = "";
continue;
}

if (isCompleteHintPrefix(prefix)) {
emit(prefix);
prefix = "";
mode = "hint";
continue;
}

if (isPotentialHintPrefix(prefix)) {
continue;
}

emit(prefix);
prefix = "";
mode = "passthrough";
continue;
}

if (mode === "hint") {
processHintChar(char);
continue;
}

emit(char);
if (char === "\n") {
mode = "prefix";
}
}

return output;
}

function flushCarry(): string {
let output = prefix;
prefix = "";
output += tokenMatch;
tokenMatch = "";
return output;
}

return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
const output = processText(decoder.decode(chunk, { stream: true }));
if (output) controller.enqueue(encoder.encode(output));
},
flush(controller) {
const output = processText(decoder.decode()) + flushCarry();
if (output) controller.enqueue(encoder.encode(output));
},
});
}
36 changes: 36 additions & 0 deletions packages/vinext/src/server/worker-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,42 @@ function buildHeaderRecord(
return headers;
}

function isLocalDevHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return (
normalized === "localhost" ||
normalized.endsWith(".localhost") ||
normalized === "127.0.0.1" ||
normalized === "0.0.0.0" ||
normalized === "::1" ||
normalized === "[::1]"
);
}

export function applyLocalDevStreamingHeaders(response: Response, request: Request): Response {
if (
!response.body ||
response.headers.has("content-encoding") ||
response.headers.has("content-length")
) {
return response;
}

if (!isLocalDevHostname(new URL(request.url).hostname)) {
return response;
}

// Miniflare/Wrangler dev can buffer streamed non-SSE responses when it
// infers compression. Explicit identity encoding preserves chunk delivery.
const headers = new Headers(response.headers);
headers.set("content-encoding", "identity");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

export function mergeHeaders(
response: Response,
extraHeaders: Record<string, string | string[]>,
Expand Down
Loading
Loading