Skip to content

Commit

Permalink
feat: redesign RSC protocol without URLSearchParams (#832)
Browse files Browse the repository at this point in the history
Related previous PR: #799

- Previously, it distinguish whether it's RSC or RSF (server actions)
with HTTP method. Now, it's distinguished with path.
- Both RSC and RSF accept GET and POST.
- RSC no longer uses `URLSearchParams` but just any `params`.
  • Loading branch information
dai-shi authored Aug 16, 2024
1 parent d0fae28 commit 18ed66f
Show file tree
Hide file tree
Showing 11 changed files with 113 additions and 125 deletions.
78 changes: 45 additions & 33 deletions packages/waku/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const SET_ELEMENTS = 's';
const ON_FETCH_DATA = 'o';

type FetchCache = {
[ENTRY]?: [input: string, searchParamsString: string, elements: Elements];
[ENTRY]?: [input: string, params: unknown, elements: Elements];
[SET_ELEMENTS]?: SetElements;
[ON_FETCH_DATA]?: OnFetchData | undefined;
};
Expand All @@ -98,13 +98,14 @@ const defaultFetchCache: FetchCache = {};
*/
export const callServerRSC = async (
actionId: string,
args: unknown[],
args?: unknown[],
fetchCache = defaultFetchCache,
) => {
const response = fetch(BASE_PATH + encodeInput(encodeActionId(actionId)), {
method: 'POST',
body: await encodeReply(args),
});
const url = BASE_PATH + encodeInput(encodeActionId(actionId));
const response =
args === undefined
? fetch(url)
: encodeReply(args).then((body) => fetch(url, { method: 'POST', body }));
const data = createFromFetch<Awaited<Elements>>(checkStatus(response), {
callServer: (actionId: string, args: unknown[]) =>
callServerRSC(actionId, args, fetchCache),
Expand All @@ -117,78 +118,89 @@ export const callServerRSC = async (
return (await data)._value;
};

const prefetchedParams = new WeakMap<Promise<unknown>, unknown>();

export const fetchRSC = (
input: string,
searchParamsString: string,
params?: unknown,
fetchCache = defaultFetchCache,
): Elements => {
const entry = fetchCache[ENTRY];
if (entry && entry[0] === input && entry[1] === searchParamsString) {
if (entry && entry[0] === input && entry[1] === params) {
return entry[2];
}
const prefetched = ((globalThis as any).__WAKU_PREFETCHED__ ||= {});
const url =
BASE_PATH +
encodeInput(input) +
(searchParamsString ? '?' + searchParamsString : '');
const response = prefetched[url] || fetch(url);
const url = BASE_PATH + encodeInput(input);
const hasValidPrefetchedResponse =
!!prefetched[url] &&
// HACK .has() is for the initial hydration
// It's limited and may result in a wrong result. FIXME
(!prefetchedParams.has(prefetched[url]) ||
prefetchedParams.get(prefetched[url]) === params);
const response = hasValidPrefetchedResponse
? prefetched[url]
: params === undefined
? fetch(url)
: encodeReply(params).then((body) =>
fetch(url, { method: 'POST', body }),
);
delete prefetched[url];
const data = createFromFetch<Awaited<Elements>>(checkStatus(response), {
callServer: (actionId: string, args: unknown[]) =>
callServerRSC(actionId, args, fetchCache),
});
fetchCache[ON_FETCH_DATA]?.(data);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
fetchCache[ENTRY] = [input, searchParamsString, data];
fetchCache[ENTRY] = [input, params, data];
return data;
};

export const prefetchRSC = (
input: string,
searchParamsString: string,
): void => {
export const prefetchRSC = (input: string, params?: unknown): void => {
const prefetched = ((globalThis as any).__WAKU_PREFETCHED__ ||= {});
const url =
BASE_PATH +
encodeInput(input) +
(searchParamsString ? '?' + searchParamsString : '');
const url = BASE_PATH + encodeInput(input);
if (!(url in prefetched)) {
prefetched[url] = fetch(url);
prefetched[url] =
params === undefined
? fetch(url)
: encodeReply(params).then((body) =>
fetch(url, { method: 'POST', body }),
);
prefetchedParams.set(prefetched[url], params);
}
};

const RefetchContext = createContext<
(input: string, searchParams?: URLSearchParams) => void
>(() => {
throw new Error('Missing Root component');
});
const RefetchContext = createContext<(input: string, params?: unknown) => void>(
() => {
throw new Error('Missing Root component');
},
);
const ElementsContext = createContext<Elements | null>(null);

export const Root = ({
initialInput,
initialSearchParamsString,
initialParams,
fetchCache = defaultFetchCache,
unstable_onFetchData,
children,
}: {
initialInput?: string;
initialSearchParamsString?: string;
initialParams?: unknown;
fetchCache?: FetchCache;
unstable_onFetchData?: (data: unknown) => void;
children: ReactNode;
}) => {
fetchCache[ON_FETCH_DATA] = unstable_onFetchData;
const [elements, setElements] = useState(() =>
fetchRSC(initialInput || '', initialSearchParamsString || '', fetchCache),
fetchRSC(initialInput || '', initialParams, fetchCache),
);
useEffect(() => {
fetchCache[SET_ELEMENTS] = setElements;
}, [fetchCache, setElements]);
const refetch = useCallback(
(input: string, searchParams?: URLSearchParams) => {
(input: string, params?: unknown) => {
// clear cache entry before fetching
delete fetchCache[ENTRY];
const data = fetchRSC(input, searchParams?.toString() || '', fetchCache);
const data = fetchRSC(input, params, fetchCache);
startTransition(() => {
setElements((prev) => mergeElements(prev, data));
});
Expand Down
7 changes: 2 additions & 5 deletions packages/waku/src/lib/builder/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,6 @@ const emitRscFiles = async (
const readable = await renderRsc(
{
input,
searchParams: new URLSearchParams(),
method: 'GET',
config,
context,
moduleIdCallback: (id) => addClientModule(input, id),
Expand Down Expand Up @@ -603,14 +601,13 @@ const emitHtmlFiles = async (
pathname,
searchParams: new URLSearchParams(),
htmlHead,
renderRscForHtml: (input, searchParams) =>
renderRscForHtml: (input, params) =>
renderRsc(
{
config,
input,
searchParams,
method: 'GET',
context,
decodedBody: params,
},
{
isDev: false,
Expand Down
7 changes: 1 addition & 6 deletions packages/waku/src/lib/middleware/rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,14 @@ export const rsc: Middleware = (options) => {
]);
const basePrefix = config.basePath + config.rscPath + '/';
if (ctx.req.url.pathname.startsWith(basePrefix)) {
const { method, headers } = ctx.req;
if (method !== 'GET' && method !== 'POST') {
throw new Error(`Unsupported method '${method}'`);
}
const { headers } = ctx.req;
try {
const input = decodeInput(
ctx.req.url.pathname.slice(basePrefix.length),
);
const args: RenderRscArgs = {
config,
input,
searchParams: ctx.req.url.searchParams,
method,
context: ctx.context,
body: ctx.req.body,
contentType: headers['content-type'] || '',
Expand Down
11 changes: 3 additions & 8 deletions packages/waku/src/lib/middleware/ssr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { resolveConfig } from '../config.js';
import { getPathMapping } from '../utils/path.js';
import { renderHtml } from '../renderers/html-renderer.js';
import { hasStatusCode, encodeInput } from '../renderers/utils.js';
import { hasStatusCode } from '../renderers/utils.js';
import { getSsrConfig, renderRsc } from '../renderers/rsc-renderer.js';
import type { RenderRscArgs } from '../renderers/rsc-renderer.js';
import type { Middleware } from './types.js';
Expand Down Expand Up @@ -39,17 +39,12 @@ export const ssr: Middleware = (options) => {
pathname: ctx.req.url.pathname,
searchParams: ctx.req.url.searchParams,
htmlHead,
renderRscForHtml: async (input, searchParams) => {
ctx.req.url.pathname =
config.basePath + config.rscPath + '/' + encodeInput(input);
ctx.req.url.search = searchParams.toString();
renderRscForHtml: async (input, params) => {
const args: RenderRscArgs = {
config,
input,
searchParams: ctx.req.url.searchParams,
method: 'GET',
context: ctx.context,
body: ctx.req.body,
decodedBody: params,
contentType: '',
};
const readable = await (devServer
Expand Down
9 changes: 3 additions & 6 deletions packages/waku/src/lib/renderers/html-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,14 @@ export const renderHtml = async (
htmlHead: string;
renderRscForHtml: (
input: string,
searchParams: URLSearchParams,
params?: unknown,
) => Promise<ReadableStream>;
getSsrConfigForHtml: (
pathname: string,
searchParams: URLSearchParams,
) => Promise<{
input: string;
searchParams?: URLSearchParams;
params?: unknown;
html: ReadableStream;
} | null>;
} & (
Expand Down Expand Up @@ -237,10 +237,7 @@ export const renderHtml = async (
}
let stream: ReadableStream;
try {
stream = await renderRscForHtml(
ssrConfig.input,
ssrConfig.searchParams || searchParams,
);
stream = await renderRscForHtml(ssrConfig.input, ssrConfig.params);
} catch (e) {
if (hasStatusCode(e) && e.statusCode === 404) {
return null;
Expand Down
40 changes: 18 additions & 22 deletions packages/waku/src/lib/renderers/rsc-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ const resolveClientEntryForPrd = (id: string, config: { basePath: string }) => {
export type RenderRscArgs = {
config: Omit<ResolvedConfig, 'middleware'>;
input: string;
searchParams: URLSearchParams;
method: 'GET' | 'POST';
context: Record<string, unknown> | undefined;
// TODO we hope to get only decoded one
decodedBody?: unknown;
body?: ReadableStream | undefined;
contentType?: string | undefined;
moduleIdCallback?: ((id: string) => void) | undefined;
Expand All @@ -52,8 +52,6 @@ export async function renderRsc(
const {
config,
input,
searchParams,
method,
contentType,
context,
body,
Expand Down Expand Up @@ -122,7 +120,7 @@ export async function renderRsc(
const renderWithContext = async (
context: Record<string, unknown> | undefined,
input: string,
searchParams: URLSearchParams,
params: unknown,
) => {
const renderStore = {
context: context || {},
Expand All @@ -132,7 +130,7 @@ export async function renderRsc(
};
return runWithRenderStore(renderStore, async () => {
const elements = await renderEntries(input, {
searchParams,
params,
buildConfig,
});
if (elements === null) {
Expand Down Expand Up @@ -160,13 +158,13 @@ export async function renderRsc(
let rendered = false;
const renderStore = {
context: context || {},
rerender: async (input: string, searchParams = new URLSearchParams()) => {
rerender: async (input: string, params?: unknown) => {
if (rendered) {
throw new Error('already rendered');
}
elementsPromise = Promise.all([
elementsPromise,
renderEntries(input, { searchParams, buildConfig }),
renderEntries(input, { params, buildConfig }),
]).then(([oldElements, newElements]) => ({
...oldElements,
// FIXME we should actually check if newElements is null and send an error
Expand All @@ -191,24 +189,25 @@ export async function renderRsc(
});
};

if (method === 'POST') {
const rsfId = decodeActionId(input);
let args: unknown[] = [];
let bodyStr = '';
if (body) {
bodyStr = await streamToString(body);
}
let decodedBody: unknown | undefined = args.decodedBody;
if (body) {
const bodyStr = await streamToString(body);
if (
typeof contentType === 'string' &&
contentType.startsWith('multipart/form-data')
) {
// XXX This doesn't support streaming unlike busboy
const formData = parseFormData(bodyStr, contentType);
args = await decodeReply(formData, serverBundlerConfig);
decodedBody = await decodeReply(formData, serverBundlerConfig);
} else if (bodyStr) {
args = await decodeReply(bodyStr, serverBundlerConfig);
decodedBody = await decodeReply(bodyStr, serverBundlerConfig);
}
const [fileId, name] = rsfId.split('#') as [string, string];
}

const actionId = decodeActionId(input);
if (actionId) {
const args = Array.isArray(decodedBody) ? decodedBody : [];
const [fileId, name] = actionId.split('#') as [string, string];
let mod: any;
if (isDev) {
mod = await opts.loadServerModuleRsc(filePathToFileURL(fileId));
Expand All @@ -222,8 +221,7 @@ export async function renderRsc(
return renderWithContextWithAction(context, fn, args);
}

// method === 'GET'
return renderWithContext(context, input, searchParams);
return renderWithContext(context, input, decodedBody);
}

export async function getBuildConfig(opts: {
Expand All @@ -250,8 +248,6 @@ export async function getBuildConfig(opts: {
{
config,
input,
searchParams: new URLSearchParams(),
method: 'GET',
context: undefined,
moduleIdCallback: (id) => idSet.add(id),
},
Expand Down
11 changes: 9 additions & 2 deletions packages/waku/src/lib/renderers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,24 @@ export const decodeInput = (encodedInput: string) => {
throw err;
};

const ACTION_PREFIX = 'ACTION_';

export const encodeActionId = (actionId: string) => {
const [file, name] = actionId.split('#') as [string, string];
if (name.includes('/')) {
throw new Error('Unsupported action name');
}
return '_' + file + '/' + name;
return ACTION_PREFIX + file + '/' + name;
};

export const decodeActionId = (encoded: string) => {
if (!encoded.startsWith(ACTION_PREFIX)) {
return null;
}
const index = encoded.lastIndexOf('/');
return encoded.slice(1, index) + '#' + encoded.slice(index + 1);
return (
encoded.slice(ACTION_PREFIX.length, index) + '#' + encoded.slice(index + 1)
);
};

export const hasStatusCode = (x: unknown): x is { statusCode: number } =>
Expand Down
Loading

0 comments on commit 18ed66f

Please sign in to comment.