rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
354 lines (353 loc) • 16.3 kB
JavaScript
import debug from "debug";
import { Node, Project, SyntaxKind, } from "ts-morph";
import { normalizeModulePath } from "../lib/normalizeModulePath.mjs";
const log = debug("rwsdk:vite:transform-jsx-script-tags");
function transformAssetPath(importPath, projectRootDir) {
if (process.env.VITE_IS_DEV_SERVER === "1") {
return importPath;
}
const normalizedImportPath = normalizeModulePath(importPath, projectRootDir);
return `rwsdk_asset:${normalizedImportPath}`;
}
// Note: This plugin only runs during discovery phase (Phase 1)
// Manifest reading and asset linking happens later in Phase 5
function hasJsxFunctions(text) {
return (text.includes('jsx("script"') ||
text.includes("jsx('script'") ||
text.includes('jsx("link"') ||
text.includes("jsx('link'") ||
text.includes('jsxs("script"') ||
text.includes("jsxs('script'") ||
text.includes('jsxs("link"') ||
text.includes("jsxs('link'") ||
text.includes('jsxDEV("script"') ||
text.includes("jsxDEV('script'") ||
text.includes('jsxDEV("link"') ||
text.includes("jsxDEV('link'"));
}
function transformScriptImports(scriptContent, clientEntryPoints, manifest, projectRootDir) {
const scriptProject = new Project({ useInMemoryFileSystem: true });
try {
const wrappedContent = `function __wrapper() {${scriptContent}}`;
const scriptFile = scriptProject.createSourceFile("script.js", wrappedContent);
let hasChanges = false;
const entryPoints = [];
scriptFile
.getDescendantsOfKind(SyntaxKind.CallExpression)
.forEach((callExpr) => {
const expr = callExpr.getExpression();
const isImport = expr.getText() === "import";
const isAwaitImport = expr.getKind() === SyntaxKind.PropertyAccessExpression &&
expr.getText().endsWith(".import");
if (isImport || isAwaitImport) {
const args = callExpr.getArguments();
if (args.length > 0 && Node.isStringLiteral(args[0])) {
const importPath = args[0].getLiteralValue();
if (importPath.startsWith("/")) {
log("Found dynamic import with root-relative path: %s", importPath);
entryPoints.push(importPath);
clientEntryPoints.add(importPath);
const transformedImportPath = transformAssetPath(importPath, projectRootDir);
args[0].setLiteralValue(transformedImportPath);
hasChanges = true;
}
}
}
});
if (hasChanges) {
const fullText = scriptFile.getFullText();
const startPos = fullText.indexOf("{") + 1;
const endPos = fullText.lastIndexOf("}");
const transformedContent = fullText.substring(startPos, endPos);
return { content: transformedContent, hasChanges: true, entryPoints };
}
return { content: scriptContent, hasChanges: false, entryPoints };
}
catch (error) {
console.warn("Failed to parse inline script content:", error);
return { content: undefined, hasChanges: false, entryPoints: [] };
}
}
export async function transformJsxScriptTagsCode(code, clientEntryPoints, manifest = {}, projectRootDir) {
// context(justinvdm, 15 Jun 2025): Optimization to exit early
// to avoidunnecessary ts-morph parsing
if (!hasJsxFunctions(code)) {
return;
}
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile("temp.tsx", code);
const modifications = [];
const needsRequestInfoImportRef = { value: false };
const entryPointsPerCallExpr = new Map();
let hasRequestInfoImport = false;
let sdkWorkerImportDecl;
sourceFile.getImportDeclarations().forEach((importDecl) => {
const moduleSpecifier = importDecl.getModuleSpecifierValue();
if (moduleSpecifier === "rwsdk/worker") {
sdkWorkerImportDecl = importDecl;
if (importDecl
.getNamedImports()
.some((namedImport) => namedImport.getName() === "requestInfo")) {
hasRequestInfoImport = true;
}
}
});
sourceFile
.getDescendantsOfKind(SyntaxKind.CallExpression)
.forEach((callExpr) => {
const expression = callExpr.getExpression();
const expressionText = expression.getText();
if (expressionText !== "jsx" &&
expressionText !== "jsxs" &&
expressionText !== "jsxDEV") {
return;
}
const args = callExpr.getArguments();
if (args.length < 2)
return;
const elementType = args[0];
if (!Node.isStringLiteral(elementType))
return;
const tagName = elementType.getLiteralValue();
const entryPoints = [];
if (tagName === "script" || tagName === "link") {
const propsArg = args[1];
if (Node.isObjectLiteralExpression(propsArg)) {
const properties = propsArg.getProperties();
let hasDangerouslySetInnerHTML = false;
let hasNonce = false;
let hasStringLiteralChildren = false;
let hasSrc = false;
let isPreload = false;
let hrefProp;
for (const prop of properties) {
if (Node.isPropertyAssignment(prop)) {
const propName = prop.getName();
const initializer = prop.getInitializer();
if (propName === "nonce") {
hasNonce = true;
}
if (propName === "dangerouslySetInnerHTML") {
hasDangerouslySetInnerHTML = true;
}
if (tagName === "script" && propName === "src") {
hasSrc = true;
if (Node.isStringLiteral(initializer) ||
Node.isNoSubstitutionTemplateLiteral(initializer)) {
const srcValue = initializer.getLiteralValue();
if (srcValue.startsWith("/")) {
entryPoints.push(srcValue);
clientEntryPoints.add(srcValue);
const transformedSrc = transformAssetPath(srcValue, projectRootDir);
modifications.push({
type: "literalValue",
node: initializer,
value: transformedSrc,
});
}
}
}
if (tagName === "script" &&
propName === "children" &&
(Node.isStringLiteral(initializer) ||
Node.isNoSubstitutionTemplateLiteral(initializer))) {
hasStringLiteralChildren = true;
const scriptContent = initializer.getLiteralValue();
const { content: transformedContent, hasChanges: contentHasChanges, entryPoints: dynamicEntryPoints, } = transformScriptImports(scriptContent, clientEntryPoints, manifest, projectRootDir);
entryPoints.push(...dynamicEntryPoints);
if (contentHasChanges && transformedContent) {
const isTemplateLiteral = Node.isNoSubstitutionTemplateLiteral(initializer);
const replacementText = isTemplateLiteral
? "`" + transformedContent + "`"
: JSON.stringify(transformedContent);
modifications.push({
type: "replaceText",
node: initializer,
text: replacementText,
});
}
}
if (tagName === "link" &&
propName === "rel" &&
initializer &&
(Node.isStringLiteral(initializer) ||
Node.isNoSubstitutionTemplateLiteral(initializer))) {
const relValue = initializer.getLiteralValue();
if (relValue === "preload" || relValue === "modulepreload") {
isPreload = true;
}
}
if (tagName === "link" && propName === "href") {
hrefProp = prop;
}
}
}
if (isPreload && hrefProp) {
const initializer = hrefProp.getInitializer();
if (initializer &&
(Node.isStringLiteral(initializer) ||
Node.isNoSubstitutionTemplateLiteral(initializer))) {
const hrefValue = initializer.getLiteralValue();
if (hrefValue.startsWith("/")) {
const transformedHref = transformAssetPath(hrefValue, projectRootDir);
modifications.push({
type: "literalValue",
node: initializer,
value: transformedHref,
});
}
}
}
if (tagName === "script" &&
!hasNonce &&
!hasDangerouslySetInnerHTML &&
(hasStringLiteralChildren || hasSrc)) {
modifications.push({
type: "addProperty",
node: propsArg,
name: "nonce",
initializer: "requestInfo.rw.nonce",
});
if (!hasRequestInfoImport) {
needsRequestInfoImportRef.value = true;
}
}
// Note: Link preload href transformations happen in Phase 5 (Asset Linking)
// During discovery phase, we only transform script tags
}
}
if (entryPoints.length > 0) {
log("Found %d script entry points, adding to scripts to be loaded: %o", entryPoints.length, entryPoints);
const sideEffects = entryPoints
.map((p) => `(requestInfo.rw.scriptsToBeLoaded.add("${p}"))`)
.join(",\n");
const leadingCommentRanges = callExpr.getLeadingCommentRanges();
const pureComment = leadingCommentRanges.find((r) => r.getText().includes("@__PURE__"));
const wrapInfo = {
callExpr: callExpr,
sideEffects: sideEffects,
pureCommentText: pureComment?.getText(),
};
if (!entryPointsPerCallExpr.has(callExpr)) {
entryPointsPerCallExpr.set(callExpr, wrapInfo);
}
needsRequestInfoImportRef.value = true;
}
});
if (modifications.length > 0 || entryPointsPerCallExpr.size > 0) {
for (const mod of modifications) {
if (mod.type === "literalValue") {
mod.node.setLiteralValue(mod.value);
}
else if (mod.type === "replaceText") {
mod.node.replaceWithText(mod.text);
}
else if (mod.type === "addProperty") {
mod.node.addPropertyAssignment({
name: mod.name,
initializer: mod.initializer,
});
}
}
const wrapModifications = [];
for (const [callExpr, wrapInfo] of entryPointsPerCallExpr) {
const fullStart = callExpr.getFullStart();
const end = callExpr.getEnd();
const callExprText = callExpr.getText();
const fullText = callExpr.getFullText();
const leadingWhitespace = fullText.substring(0, fullText.length - callExprText.length);
let pureCommentText;
let leadingTriviaText;
if (wrapInfo.pureCommentText) {
pureCommentText = wrapInfo.pureCommentText;
leadingTriviaText = leadingWhitespace;
}
wrapModifications.push({
type: "wrapCallExpr",
sideEffects: wrapInfo.sideEffects,
pureCommentText: pureCommentText,
leadingTriviaText: leadingTriviaText,
fullStart: fullStart,
end: end,
callExprText: callExprText,
leadingWhitespace: leadingWhitespace,
});
}
wrapModifications.sort((a, b) => b.fullStart - a.fullStart);
for (const mod of wrapModifications) {
if (mod.pureCommentText && mod.leadingTriviaText) {
const newText = `(
${mod.sideEffects},
${mod.pureCommentText} ${mod.callExprText}
)`;
const newLeadingTriviaText = mod.leadingTriviaText.replace(mod.pureCommentText, "");
sourceFile.replaceText([mod.fullStart, mod.end], newLeadingTriviaText + newText);
}
else {
const leadingNewlines = mod.leadingWhitespace.match(/\n\s*/)?.[0] || "";
sourceFile.replaceText([mod.fullStart, mod.end], `${leadingNewlines}(
${mod.sideEffects},
${mod.callExprText}
)`);
}
}
if (needsRequestInfoImportRef.value) {
if (sdkWorkerImportDecl) {
if (!hasRequestInfoImport) {
sdkWorkerImportDecl.addNamedImport("requestInfo");
}
}
else {
sourceFile.addImportDeclaration({
moduleSpecifier: "rwsdk/worker",
namedImports: ["requestInfo"],
});
}
}
return {
code: sourceFile.getFullText(),
map: null,
};
}
return;
}
export const transformJsxScriptTagsPlugin = ({ clientEntryPoints, projectRootDir, }) => {
let isBuild = false;
return {
name: "rwsdk:vite:transform-jsx-script-tags",
configResolved(config) {
isBuild = config.command === "build";
},
async transform(code, id) {
// Skip during directive scanning to avoid performance issues
if (process.env.RWSDK_DIRECTIVE_SCAN_ACTIVE) {
return;
}
if (isBuild &&
this.environment?.name === "worker" &&
process.env.RWSDK_BUILD_PASS !== "worker") {
return null;
}
if (this.environment?.name === "worker" &&
id.endsWith(".tsx") &&
!id.includes("node_modules") &&
hasJsxFunctions(code)) {
log("Transforming JSX script tags in %s", id);
process.env.VERBOSE && log("Code:\n%s", code);
// During discovery phase, never use manifest - it doesn't exist yet
const result = await transformJsxScriptTagsCode(code, clientEntryPoints, {}, // Empty manifest during discovery
projectRootDir);
if (result) {
log("Transformed JSX script tags in %s", id);
process.env.VERBOSE &&
log("New Document code for %s:\n%s", id, result.code);
return {
code: result.code,
map: null,
};
}
}
return null;
},
};
};