Skip to content
Merged
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
3 changes: 3 additions & 0 deletions examples/11_fs-router/src/pages/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const HomeLayout = ({ children }: { children: ReactNode }) => (
<li>
<Link to="/slice-page">Slice Page</Link>
</li>
<li>
<Link to="/debug">Debug</Link>
</li>
</ul>
{children}
</div>
Expand Down
9 changes: 9 additions & 0 deletions examples/11_fs-router/src/pages/debug.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Suspense } from 'react';
import { getRouterConfigs } from '../waku.server.js';

const SlowServerInfo = async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 500));
return <p>Server delay complete at {new Date().toLocaleTimeString()}</p>;
};

export default async function Debug() {
const configs = await getRouterConfigs();
return (
<div>
<h4>Route inspection</h4>
<Suspense fallback={<p>Loading server info...</p>}>
<SlowServerInfo />
</Suspense>
<pre>{JSON.stringify(configs, null, 2)}</pre>
</div>
);
Expand Down
5 changes: 5 additions & 0 deletions packages/waku/src/lib/react-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ declare module 'react-server-dom-webpack/server.edge' {
interface TemporaryReferenceSet {}

type Options = {
debugChannel?: { readable?: ReadableStream; writable?: WritableStream };
environmentName?: string;
identifierPrefix?: string;
signal?: AbortSignal;
Expand Down Expand Up @@ -75,6 +76,10 @@ declare module 'react-server-dom-webpack/client' {

type Options<T> = {
callServer?: CallServerCallback<T>;
debugChannel?:
| { writable?: WritableStream; readable?: ReadableStream }
| undefined;
findSourceMapURL?: (filename: string, environmentName: string) => string;
temporaryReferences?: TemporaryReferenceSet;
};

Expand Down
6 changes: 6 additions & 0 deletions packages/waku/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export type Unstable_RenderRsc = (
},
) => Promise<ReadableStream>;

// Experimental: render RSC only for parse/copy flows without a debug channel.
export type Unstable_RenderRscForParse = (
elements: Elements,
) => Promise<ReadableStream>;

export type Unstable_ParseRsc = (
rscStream: ReadableStream,
) => Promise<Elements>;
Expand Down Expand Up @@ -50,6 +55,7 @@ export type Unstable_HandleRequest = (
},
utils: {
renderRsc: Unstable_RenderRsc;
renderRscForParse: Unstable_RenderRscForParse;
parseRsc: Unstable_ParseRsc;
renderHtml: Unstable_RenderHtml;
loadBuildMetadata: (key: string) => Promise<string | undefined>;
Expand Down
149 changes: 149 additions & 0 deletions packages/waku/src/lib/utils/react-debug-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// This file should not include Node specific code.

export const DEBUG_ID_HEADER = 'X-Waku-Debug-Id';
export const DEBUG_CMD_EVENT = 'waku:debug-cmd';
export const DEBUG_DATA_EVENT = 'waku:debug-data';

const bytesToBase64 = (bytes: Uint8Array) => {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
};

const base64ToBytes = (base64: string) =>
Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));

type DebugCmdEventReadyPayload = {
i: string; // debugId
};
type DebugCmdEventChunkPayload = {
i: string; // debugId
b: string; // base64 encoded chunk
};
type DebugCmdEventDonePayload = {
i: string; // debugId
d: true; // done flag
};
type DebugDataEventChunkPayload = {
i: string; // debugId
b: string; // base64 encoded chunk
};
type DebugDataEventDonePayload = {
i: string; // debugId
d: true; // done flag
};
export type DebugEventPayload =
| DebugCmdEventReadyPayload
| DebugCmdEventChunkPayload
| DebugCmdEventDonePayload
| DebugDataEventChunkPayload
| DebugDataEventDonePayload;

export function assertIsDebugEventPayload(
payload: unknown,
): asserts payload is DebugEventPayload {
if (
!payload ||
typeof payload !== 'object' ||
typeof (payload as { i?: unknown }).i !== 'string' ||
('b' in payload && typeof (payload as { b?: unknown }).b !== 'string') ||
('d' in payload && (payload as { d?: unknown }).d !== true)
) {
throw new Error('Invalid debug event payload');
}
}

const createWsDebugChannel = (debugId: string) => {
const hot = import.meta.hot!;
let closed = false;
let onDataEvent: ((payload: unknown) => void) | undefined;

const cleanup = (notify?: boolean) => {
if (closed) {
return;
}
closed = true;
if (onDataEvent) {
hot.off(DEBUG_DATA_EVENT, onDataEvent);
}
if (notify) {
hot.send(DEBUG_CMD_EVENT, {
i: debugId,
d: true,
} satisfies DebugEventPayload);
}
};

const readable = new ReadableStream<Uint8Array>({
start(controller) {
onDataEvent = (payload: unknown) => {
assertIsDebugEventPayload(payload);
if (closed || payload.i !== debugId) {
return;
}
if ('b' in payload) {
// chunk
controller.enqueue(base64ToBytes(payload.b));
}
if ('d' in payload) {
// done
cleanup();
controller.close();
}
};
hot.on(DEBUG_DATA_EVENT, onDataEvent);
hot.send(DEBUG_CMD_EVENT, { i: debugId } satisfies DebugEventPayload);
},
cancel() {
cleanup(true);
},
});

const writable = new WritableStream<Uint8Array>({
write(chunk) {
if (closed) {
throw new TypeError('Channel is closed');
}
hot.send(DEBUG_CMD_EVENT, {
i: debugId,
b: bytesToBase64(chunk),
} satisfies DebugEventPayload);
},
close() {
cleanup(true);
},
abort() {
cleanup(true);
},
});

return { readable, writable };
};

export const setupDebugChannel = (
baseFetchFn: typeof fetch,
prefetchedEntry: { d?: string } | undefined,
) => {
if (prefetchedEntry) {
const debugId = prefetchedEntry.d;
if (debugId) {
const debugChannel = createWsDebugChannel(debugId);
return { debugChannel };
}
return {};
}

const debugId = crypto.randomUUID();
const debugChannel = createWsDebugChannel(debugId);
const fetchFn = ((input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set(DEBUG_ID_HEADER, debugId);
return baseFetchFn(input, {
...init,
headers,
});
}) as typeof fetch;
return { fetchFn, debugChannel };
};
12 changes: 12 additions & 0 deletions packages/waku/src/lib/utils/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Unstable_ParseRsc,
Unstable_RenderHtml,
Unstable_RenderRsc,
Unstable_RenderRscForParse,
} from '../types.js';

export function createRenderUtils(
Expand All @@ -18,8 +19,11 @@ export function createRenderUtils(
loadSsrEntryModule: () => Promise<
typeof import('../vite-entries/entry.ssr.js')
>,
debugChannel?: { readable?: ReadableStream; writable?: WritableStream },
debugId?: string,
): {
renderRsc: Unstable_RenderRsc;
renderRscForParse: Unstable_RenderRscForParse;
parseRsc: Unstable_ParseRsc;
renderHtml: Unstable_RenderHtml;
} {
Expand All @@ -42,6 +46,7 @@ export function createRenderUtils(
{
temporaryReferences,
onError,
debugChannel,
},
{
onClientReference(metadata: {
Expand All @@ -54,6 +59,12 @@ export function createRenderUtils(
},
);
},
async renderRscForParse(elements) {
return renderToReadableStream(elements, {
temporaryReferences,
onError,
});
},
async parseRsc(stream) {
return createFromReadableStream(stream, {}) as Promise<
Record<string, unknown>
Expand All @@ -71,6 +82,7 @@ export function createRenderUtils(
formState: options.formState as never,
nonce: options.nonce,
extraScriptContent: options.unstable_extraScriptContent,
debugId,
});
return new Response(htmlResult.stream, {
status: htmlResult.status || options.status || 200,
Expand Down
11 changes: 10 additions & 1 deletion packages/waku/src/lib/utils/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,23 @@ Promise.resolve(new Response(new ReadableStream({
.map((line) => line.trim())
.join('');

// These constants are defined in packages/waku/src/minimal/client.tsx.
// TODO(daishi): We should avoid duplicating definitions.
const KEY_RESPONSE = 'r';
const KEY_DEBUG_ID = 'd';
Comment thread
dai-shi marked this conversation as resolved.

export function getBootstrapPreamble(options: {
rscPath: string;
hydrate: boolean;
debugId?: string | undefined;
}) {
return `
${options.hydrate ? 'globalThis.__WAKU_HYDRATE__ = true;' : ''}
globalThis.__WAKU_PREFETCHED__ = {
${JSON.stringify(options.rscPath)}: ${fakeFetchCode}
${JSON.stringify(options.rscPath)}: {
${KEY_RESPONSE}: ${fakeFetchCode},
${options.debugId ? `${KEY_DEBUG_ID}: ${JSON.stringify(options.debugId)},` : ''}
},
};
`;
}
2 changes: 2 additions & 0 deletions packages/waku/src/lib/vite-plugins/combined-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { mainPlugin } from './main.js';
import { notFoundPlugin } from './not-found.js';
import { patchRsdwPlugin } from './patch-rsdw.js';
import { privateDirPlugin } from './private-dir.js';
import { reactDebugPlugin } from './react-debug.js';
import { userEntriesPlugin } from './user-entries.js';
import { virtualConfigPlugin } from './virtual-config.js';

Expand All @@ -25,6 +26,7 @@ export function combinedPlugins(config: Required<Config>): PluginOption {
useBuildAppHook: true,
clientChunks: (meta) => meta.serverChunk,
}),
reactDebugPlugin(),
mainPlugin(config),
userEntriesPlugin(config),
virtualConfigPlugin(config),
Expand Down
35 changes: 35 additions & 0 deletions packages/waku/src/lib/vite-plugins/patch-rsdw.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import type { Plugin } from 'vite';

// Waku sends RSC payloads as plain objects (Record<string, unknown>).
// In React's flushComponentPerformance, moveDebugInfoFromChunkToInnerValue
// uses splice(0) which empties chunk._debugInfo after resolution. React main
// has a fallback that recovers _debugInfo from the resolved value, but only
// for arrays, async iterables, React elements, and lazy types — not plain
// objects. This transform relaxes that restriction so _debugInfo is recovered
// from any object, which is needed for Waku's plain-object payloads to show
// the "Server Components" track in Chrome DevTools Performance tab.
const SEARCH = 'debugInfo = root._debugInfo;';
const REPLACE = `
${SEARCH}
if (debugInfo && 0 === debugInfo.length && "fulfilled" === root.status) {
var _resolved = typeof resolveLazy === "function" ? resolveLazy(root.value) : root.value;
if ("object" === typeof _resolved && null !== _resolved && isArrayImpl(_resolved._debugInfo)) {
debugInfo = _resolved._debugInfo;
}
}
`;

export function patchRsdwPlugin(): Plugin {
return {
// rewrite `react-server-dom-webpack` in `waku/minimal/client`
Expand All @@ -21,5 +40,21 @@ export function patchRsdwPlugin(): Plugin {
return `export default {}`;
}
},
transform(code, id) {
const [file] = id.split('?');
if (
![
'/react-server-dom-webpack-client.browser.development.js',
'/react-server-dom-webpack_client__browser.js',
].some((suffix) => file!.endsWith(suffix))
) {
return;
}
const patched = code.replace(SEARCH, REPLACE);
if (patched === code) {
return;
}
return patched;
},
};
}
Loading
Loading