UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

316 lines (315 loc) 15.4 kB
import { Project, Node, SyntaxKind } from "ts-morph"; import { readFile } from "node:fs/promises"; import { pathExists } from "fs-extra"; let manifestCache; const readManifest = async (manifestPath) => { if (manifestCache === undefined) { const exists = await pathExists(manifestPath); if (!exists) { throw new Error(`RedwoodSDK expected client manifest to exist at ${manifestPath}. This is likely a bug. Please report it at https://github.com/redwoodjs/sdk/issues/new`); } manifestCache = JSON.parse(await readFile(manifestPath, "utf-8")); } return manifestCache; }; 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'")); } // Transform import statements in script content using ts-morph function transformScriptImports(scriptContent, manifest) { const scriptProject = new Project({ useInMemoryFileSystem: true }); try { // Wrap in a function to make it valid JavaScript const wrappedContent = `function __wrapper() {${scriptContent}}`; const scriptFile = scriptProject.createSourceFile("script.js", wrappedContent); let hasChanges = false; // Find all CallExpressions that look like import("path") scriptFile .getDescendantsOfKind(SyntaxKind.CallExpression) .forEach((callExpr) => { const expr = callExpr.getExpression(); // Check for both "import()" and "await import()" patterns const isImport = expr.getText() === "import"; // Check for await import pattern 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("/")) { const path = importPath.slice(1); // Remove leading slash if (manifest[path]) { const transformedPath = manifest[path].file; args[0].replaceWithText(`"/${transformedPath}"`); hasChanges = true; } } } } }); if (hasChanges) { // Extract the transformed content from inside the wrapper function const fullText = scriptFile.getFullText(); // Find content between the first { and the last } const startPos = fullText.indexOf("{") + 1; const endPos = fullText.lastIndexOf("}"); const transformedContent = fullText.substring(startPos, endPos); return { content: transformedContent, hasChanges: true }; } // Return the original content when no changes are made return { content: scriptContent, hasChanges: false }; } catch (error) { // If parsing fails, fall back to the original content console.warn("Failed to parse inline script content:", error); return { content: undefined, hasChanges: false }; } } export async function transformJsxScriptTagsCode(code, manifest = {}) { // 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); let hasModifications = false; let needsRequestInfoImport = false; // Check for existing imports up front let hasRequestInfoImport = false; let sdkWorkerImportDecl; // Scan for imports only once sourceFile.getImportDeclarations().forEach((importDecl) => { const moduleSpecifier = importDecl.getModuleSpecifierValue(); if (moduleSpecifier === "rwsdk/worker") { sdkWorkerImportDecl = importDecl; // Check if requestInfo is already imported if (importDecl .getNamedImports() .some((namedImport) => namedImport.getName() === "requestInfo")) { hasRequestInfoImport = true; } } }); // Look for jsx function calls (jsx, jsxs, jsxDEV) sourceFile .getDescendantsOfKind(SyntaxKind.CallExpression) .forEach((callExpr) => { const expression = callExpr.getExpression(); const expressionText = expression.getText(); // Only process jsx/jsxs/jsxDEV calls if (expressionText !== "jsx" && expressionText !== "jsxs" && expressionText !== "jsxDEV") { return; } // Get arguments of the jsx call const args = callExpr.getArguments(); if (args.length < 2) return; // First argument should be the element type const elementType = args[0]; if (!Node.isStringLiteral(elementType)) return; const tagName = elementType.getLiteralValue(); // Process script and link tags if (tagName === "script" || tagName === "link") { // Second argument should be the props object const propsArg = args[1]; // Handle object literals with properties if (Node.isObjectLiteralExpression(propsArg)) { const properties = propsArg.getProperties(); // Variables to track script attributes let hasDangerouslySetInnerHTML = false; let hasNonce = false; let hasStringLiteralChildren = false; let hasSrc = false; // Variables to track link attributes let isPreload = false; let hrefValue = null; for (const prop of properties) { if (Node.isPropertyAssignment(prop)) { const propName = prop.getName(); const initializer = prop.getInitializer(); // Check for existing nonce if (propName === "nonce") { hasNonce = true; } // Check for dangerouslySetInnerHTML if (propName === "dangerouslySetInnerHTML") { hasDangerouslySetInnerHTML = true; } // Check for src attribute if (tagName === "script" && propName === "src") { hasSrc = true; // Also process src for manifest transformation if needed if (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) { const srcValue = initializer.getLiteralValue(); if (srcValue.startsWith("/") && manifest[srcValue.slice(1)]) { const path = srcValue.slice(1); // Remove leading slash const transformedSrc = manifest[path].file; const originalText = initializer.getText(); const isTemplateLiteral = Node.isNoSubstitutionTemplateLiteral(initializer); const quote = isTemplateLiteral ? "`" : originalText.charAt(0); // Preserve the original quote style if (isTemplateLiteral) { initializer.replaceWithText(`\`/${transformedSrc}\``); } else if (quote === '"') { initializer.replaceWithText(`"/${transformedSrc}"`); } else { initializer.replaceWithText(`'/${transformedSrc}'`); } hasModifications = true; } } } // Check for string literal children if (tagName === "script" && propName === "children" && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer))) { hasStringLiteralChildren = true; const scriptContent = initializer.getLiteralValue(); // Transform import statements in script content using ts-morph const { content: transformedContent, hasChanges } = transformScriptImports(scriptContent, manifest); if (hasChanges && transformedContent) { // Get the raw text with quotes to determine the exact format const isTemplateLiteral = Node.isNoSubstitutionTemplateLiteral(initializer); if (isTemplateLiteral) { // Simply wrap the transformed content in backticks initializer.replaceWithText("`" + transformedContent + "`"); } else { initializer.replaceWithText(JSON.stringify(transformedContent)); } hasModifications = true; } } // For link tags, first check if it's a preload/modulepreload if (tagName === "link") { if (propName === "rel" && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer))) { const relValue = initializer.getLiteralValue(); if (relValue === "preload" || relValue === "modulepreload") { isPreload = true; } } if (propName === "href" && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer))) { hrefValue = initializer.getLiteralValue(); } } } } // Add nonce to script tags if needed if (tagName === "script" && !hasNonce && !hasDangerouslySetInnerHTML && (hasStringLiteralChildren || hasSrc)) { // Add nonce property to the props object propsArg.addPropertyAssignment({ name: "nonce", initializer: "requestInfo.rw.nonce", }); if (!hasRequestInfoImport) { needsRequestInfoImport = true; } hasModifications = true; } // Transform href if this is a preload link if (tagName === "link" && isPreload && hrefValue && hrefValue.startsWith("/") && manifest[hrefValue.slice(1)]) { const path = hrefValue.slice(1); // Remove leading slash for (const prop of properties) { if (Node.isPropertyAssignment(prop) && prop.getName() === "href") { const initializer = prop.getInitializer(); if (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) { const transformedHref = manifest[path].file; const originalText = initializer.getText(); const isTemplateLiteral = Node.isNoSubstitutionTemplateLiteral(initializer); const quote = isTemplateLiteral ? "`" : originalText.charAt(0); // Preserve the original quote style if (isTemplateLiteral) { initializer.replaceWithText(`\`/${transformedHref}\``); } else if (quote === '"') { initializer.replaceWithText(`"/${transformedHref}"`); } else { initializer.replaceWithText(`'/${transformedHref}'`); } hasModifications = true; } } } } } } }); // Add requestInfo import if needed and not already imported if (needsRequestInfoImport && hasModifications) { if (sdkWorkerImportDecl) { // Module is imported but need to add requestInfo if (!hasRequestInfoImport) { sdkWorkerImportDecl.addNamedImport("requestInfo"); } } else { // Add new import declaration sourceFile.addImportDeclaration({ moduleSpecifier: "rwsdk/worker", namedImports: ["requestInfo"], }); } } // Return the transformed code only if modifications were made if (hasModifications) { return { code: sourceFile.getFullText(), map: null, }; } return; } export const transformJsxScriptTagsPlugin = ({ manifestPath, }) => { let isBuild = false; return { name: "rwsdk:transform-jsx-script-tags", configResolved(config) { isBuild = config.command === "build"; }, async transform(code) { if (this.environment.name !== "worker") { return; } const manifest = isBuild ? await readManifest(manifestPath) : {}; return transformJsxScriptTagsCode(code, manifest); }, }; };