Skip to content

Commit

Permalink
Merge pull request #82 from unstubbable/server-action-source-maps
Browse files Browse the repository at this point in the history
Source map server actions to their server location
  • Loading branch information
unstubbable authored Sep 26, 2024
2 parents 59cee12 + 4e683b7 commit c708f51
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 89 deletions.
26 changes: 26 additions & 0 deletions apps/aws-app/dev-server/run.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from 'fs/promises';
import path from 'path';
import url from 'url';
import {serve} from '@hono/node-server';
import {serveStatic} from '@hono/node-server/serve-static';
import {Hono} from 'hono';
Expand All @@ -11,6 +14,29 @@ const app = new Hono();

app.use(authMiddleware);
app.use(`/client/*`, serveStatic({root: `dist/static`}));

app.get(`/source-maps`, async (context) => {
const filenameQueryParam = context.req.query(`filename`);

if (!filenameQueryParam) {
return context.newResponse(`Missing query parameter "filename"`, 400);
}

const filename = filenameQueryParam.startsWith(`file://`)
? url.fileURLToPath(filenameQueryParam)
: path.join(import.meta.dirname, `../dist/static`, filenameQueryParam);

try {
const sourceMapFilename = `${filename}.map`;
const sourceMapContents = await fs.readFile(sourceMapFilename);

return context.newResponse(sourceMapContents);
} catch (error) {
console.error(error);
return context.newResponse(null, 404);
}
});

app.route(`/`, handlerApp);

const server = serve({fetch: app.fetch, port: 3002}, ({address, port}) => {
Expand Down
8 changes: 6 additions & 2 deletions apps/shared-app/src/client/button.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
'use client';

import * as React from 'react';
import {trackClick} from '../server/track-click.js';

export type ButtonProps = React.PropsWithChildren<{
readonly disabled?: boolean;
readonly trackClick: () => Promise<number>;
}>;

export function Button({children, disabled}: ButtonProps): React.ReactNode {
export function Button({
children,
disabled,
trackClick,
}: ButtonProps): React.ReactNode {
return (
<button
onClick={() => void trackClick()}
Expand Down
5 changes: 4 additions & 1 deletion apps/shared-app/src/client/product.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {clsx} from 'clsx';
import * as React from 'react';
import type {BuyResult} from '../server/buy.js';
import {trackClick} from '../server/track-click.js';
import {Notification} from '../shared/notification.js';
import {Button} from './button.js';

Expand Down Expand Up @@ -43,7 +44,9 @@ export function Product({buy}: ProductProps): React.ReactNode {
)}
/>
{` `}
<Button disabled={isPending}>Buy now</Button>
<Button disabled={isPending} trackClick={trackClick}>
Buy now
</Button>
{result && (
<Notification status={result.status}>
{result.status === `success` ? (
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/client/hydrate-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,19 @@ export interface RscAppResult {
readonly formState?: ReactFormState;
}

const originRegExp = new RegExp(`^${document.location.origin}`);

export function findSourceMapUrl(filename: string): string | null {
return `${document.location.origin}/source-maps?filename=${encodeURIComponent(
filename.replace(originRegExp, ``),
)}`;
}

export async function hydrateApp(): Promise<void> {
const {root: initialRoot, formState} =
await ReactServerDOMClient.createFromReadableStream<RscAppResult>(
self.initialRscResponseStream,
{callServer},
{callServer, findSourceMapURL: findSourceMapUrl},
);

const initialUrlPath = createUrlPath(document.location);
Expand All @@ -25,7 +33,7 @@ export async function hydrateApp(): Promise<void> {
async function fetchRoot(urlPath: string): Promise<React.ReactElement> {
const {root} = await ReactServerDOMClient.createFromFetch<RscAppResult>(
fetch(urlPath, {headers: {accept: `text/x-component`}}),
{callServer},
{callServer, findSourceMapURL: findSourceMapUrl},
);

return root;
Expand Down
201 changes: 144 additions & 57 deletions packages/webpack-rsc/src/webpack-rsc-client-loader.cts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ namespace webpackRscClientLoader {

type SourceMap = Parameters<LoaderDefinitionFunction>[1];

interface FunctionInfo {
readonly exportName: string;
readonly loc: t.SourceLocation | null | undefined;
}

function webpackRscClientLoader(
this: LoaderContext<webpackRscClientLoader.WebpackRscClientLoaderOptions>,
source: string,
Expand All @@ -35,80 +40,101 @@ function webpackRscClientLoader(
plugins: [`importAssertions`],
});

let moduleId: string | number | undefined;
let hasUseServerDirective = false;
let addedRegisterServerReferenceCall = false;
const importNodes = new Set<t.Node>();

traverse.default(ast, {
Program(path) {
enter(path) {
const {node} = path;

if (!node.directives.some(isUseServerDirective)) {
return;
}
if (t.isProgram(node)) {
if (node.directives.some(isUseServerDirective)) {
hasUseServerDirective = true;

hasUseServerDirective = true;
const moduleInfo = serverReferencesMap.get(resourcePath);

const moduleInfo = serverReferencesMap.get(resourcePath);
if (!moduleInfo) {
loaderContext.emitError(
new Error(
`Could not find server references module info in \`serverReferencesMap\` for ${resourcePath}.`,
),
);

if (!moduleInfo) {
loaderContext.emitError(
new Error(
`Could not find server references module info in \`serverReferencesMap\` for ${resourcePath}.`,
),
);
path.replaceWith(t.program([]));
} else if (!moduleInfo.moduleId) {
loaderContext.emitError(
new Error(
`Could not find server references module ID in \`serverReferencesMap\` for ${resourcePath}.`,
),
);

path.replaceWith(t.program([]));
path.replaceWith(t.program([]));
} else {
moduleId = moduleInfo.moduleId;
}
} else {
path.skip();
}

return;
}

const {moduleId, exportNames} = moduleInfo;
if (importNodes.has(node)) {
return path.skip();
}

if (!moduleId) {
loaderContext.emitError(
new Error(
`Could not find server references module ID in \`serverReferencesMap\` for ${resourcePath}.`,
),
);
const functionInfo = getFunctionInfo(node);

path.replaceWith(t.program([]));
if (moduleId && functionInfo) {
path.replaceWith(
createNamedExportedServerReference(functionInfo, moduleId),
);
path.skip();
addedRegisterServerReferenceCall = true;
} else {
path.remove();
}
},
exit(path) {
if (!t.isProgram(path.node) || !addedRegisterServerReferenceCall) {
path.skip();

return;
}

path.replaceWith(
t.program([
t.importDeclaration(
[
t.importSpecifier(
t.identifier(`createServerReference`),
t.identifier(`createServerReference`),
),
],
t.stringLiteral(`react-server-dom-webpack/client`),
),
t.importDeclaration(
[
t.importSpecifier(
t.identifier(`callServer`),
t.identifier(`callServer`),
),
],
t.stringLiteral(callServerImportSource),
),
...exportNames.map((exportName) =>
t.exportNamedDeclaration(
t.variableDeclaration(`const`, [
t.variableDeclarator(
t.identifier(exportName),
t.callExpression(t.identifier(`createServerReference`), [
t.stringLiteral(`${moduleId}#${exportName}`),
t.identifier(`callServer`),
]),
),
]),
importNodes.add(
t.importDeclaration(
[
t.importSpecifier(
t.identifier(`createServerReference`),
t.identifier(`createServerReference`),
),
),
]),
],
t.stringLiteral(`react-server-dom-webpack/client`),
),
);

importNodes.add(
t.importDeclaration(
[
t.importSpecifier(
t.identifier(`callServer`),
t.identifier(`callServer`),
),
t.importSpecifier(
t.identifier(`findSourceMapUrl`),
t.identifier(`findSourceMapUrl`),
),
],
t.stringLiteral(callServerImportSource),
),
);

(path as traverse.NodePath<t.Program>).unshiftContainer(
`body`,
Array.from(importNodes),
);
},
});
Expand All @@ -117,11 +143,43 @@ function webpackRscClientLoader(
return this.callback(null, source, sourceMap);
}

// TODO: Handle source maps.
const {code, map} = generate.default(
ast,
{
sourceFileName: this.resourcePath,
sourceMaps: this.sourceMap,
// @ts-expect-error
inputSourceMap: sourceMap,
},
source,
);

const {code} = generate.default(ast, {sourceFileName: this.resourcePath});
this.callback(null, code, map ?? sourceMap);
}

this.callback(null, code);
function createNamedExportedServerReference(
functionInfo: FunctionInfo,
moduleId: string | number,
) {
const {exportName, loc} = functionInfo;
const exportIdentifier = t.identifier(exportName);

exportIdentifier.loc = loc;

return t.exportNamedDeclaration(
t.variableDeclaration(`const`, [
t.variableDeclarator(
exportIdentifier,
t.callExpression(t.identifier(`createServerReference`), [
t.stringLiteral(`${moduleId}#${exportName}`),
t.identifier(`callServer`),
t.identifier(`undefined`), // encodeFormAction
t.identifier(`findSourceMapUrl`),
t.stringLiteral(exportName),
]),
),
]),
);
}

function isUseServerDirective(directive: t.Directive): boolean {
Expand All @@ -131,4 +189,33 @@ function isUseServerDirective(directive: t.Directive): boolean {
);
}

function getFunctionInfo(node: t.Node): FunctionInfo | undefined {
let exportName: string | undefined;
let loc: t.SourceLocation | null | undefined;

if (t.isExportNamedDeclaration(node)) {
if (t.isFunctionDeclaration(node.declaration)) {
exportName = node.declaration.id?.name;
loc = node.declaration.id?.loc;
} else if (t.isVariableDeclaration(node.declaration)) {
const declarator = node.declaration.declarations[0];

if (!declarator) {
return undefined;
}

if (
(t.isFunctionExpression(declarator.init) ||
t.isArrowFunctionExpression(declarator.init)) &&
t.isIdentifier(declarator.id)
) {
exportName = declarator.id.name;
loc = declarator.id.loc;
}
}
}

return exportName ? {exportName, loc} : undefined;
}

export = webpackRscClientLoader;
17 changes: 7 additions & 10 deletions packages/webpack-rsc/src/webpack-rsc-client-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,18 @@ describe(`webpackRscClientLoader`, () => {
);

const serverReferencesMap: ServerReferencesMap = new Map([
[resourcePath, {moduleId: `test`, exportNames: [`foo`, `bar`]}],
[resourcePath, {moduleId: `test`, exportNames: []}],
]);

const output = await callLoader(resourcePath, {serverReferencesMap});

expect(output).toEqual(
`
import { createServerReference } from "react-server-dom-webpack/client";
import { callServer } from "@mfng/core/client/browser";
export const foo = createServerReference("test#foo", callServer);
export const bar = createServerReference("test#bar", callServer);
import { callServer, findSourceMapUrl } from "@mfng/core/client/browser";
export const foo = createServerReference("test#foo", callServer, undefined, findSourceMapUrl, "foo");
export const bar = createServerReference("test#bar", callServer, undefined, findSourceMapUrl, "bar");
export const baz = createServerReference("test#baz", callServer, undefined, findSourceMapUrl, "baz");
`.trim(),
);
});
Expand All @@ -86,12 +87,8 @@ export const bar = createServerReference("test#bar", callServer);
callServerImportSource,
});

expect(output).toEqual(
`
import { createServerReference } from "react-server-dom-webpack/client";
import { callServer } from "some-router/call-server";
export const foo = createServerReference("test#foo", callServer);
`.trim(),
expect(output).toMatch(
`import { callServer, findSourceMapUrl } from "some-router/call-server";`,
);
});

Expand Down
Loading

0 comments on commit c708f51

Please sign in to comment.