Created
January 10, 2024 02:13
-
-
Save randallb/cf696de5867ecde39fec0d057364680c to your computer and use it in GitHub Desktop.
Deno esbuild plugin
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Loader, | |
PluginBuild, | |
} from "https://deno.land/x/[email protected]/mod.js"; | |
import { dirname, join } from "https://deno.land/[email protected]/path/mod.ts"; | |
import { createLogger } from "packages/logs/mod.ts"; | |
const log = createLogger("esbuildDenoPlugin", "debug"); | |
const logError = createLogger("esbuildDenoPlugin", "error"); | |
const LOCAL_NAMESPACE = "deno-local"; | |
const REMOTE_NAMESPACE = "deno-remote"; | |
const NPM_NAMESPACE = "npm"; | |
const NPM_RELATIVE_DEPENDENCY = "npm_relative_dependency"; | |
const NPM_SCOPED_NAMESPACE = "npm_scoped"; | |
const NPM_PACKAGE_JSON_NAMESPACE = "npm_package_json"; | |
const REMOTE_MODULE_REGEX = /^https?:\/\//; | |
const cacheLocations = await getCacheLocations(); | |
async function getCacheLocations() { | |
log("Fetching cache locations..."); | |
const p = Deno.run({ | |
cmd: [ | |
"deno", | |
"info", | |
], | |
stdout: "piped", | |
}); | |
const { code } = await p.status(); | |
if (code !== 0) { | |
throw new Error("deno info failed"); | |
} | |
const decoder = new TextDecoder("utf-8"); | |
const output = decoder.decode(await p.output()); | |
// deno-lint-ignore no-control-regex | |
const plainText = output.replace(/\x1b\[[0-9;]*m/g, ""); | |
const lines = plainText.split("\n"); | |
const denoDirLocation = lines.find((line) => | |
line.startsWith("DENO_DIR location: ") | |
)?.split( | |
"DENO_DIR location: ", | |
)[1]; | |
const remoteModulesCache = lines.find((line) => | |
line.startsWith("Remote modules cache: ") | |
)?.split( | |
"Remote modules cache: ", | |
)[1]; | |
const npmModulesCacheRoot = | |
lines.find((line) => line.startsWith("npm modules cache: "))?.split( | |
"npm modules cache: ", | |
)[1] ?? "./.deno/npm"; | |
const npmModulesCache = join(npmModulesCacheRoot, "registry.npmjs.org"); | |
log("Cache locations fetched successfully."); | |
return { | |
denoDirLocation, | |
remoteModulesCache, | |
npmModulesCache, | |
}; | |
} | |
async function checkIfNpmModule(pathWithNamespace: string) { | |
try { | |
const packageJsonString = await Deno.readTextFile( | |
join(Deno.env.get("BFF_ROOT") ?? Deno.cwd(), "package.json"), | |
); | |
const packageJson = JSON.parse(packageJsonString); | |
const dependencies = Object.keys(packageJson.dependencies ?? {}); | |
const path = pathWithNamespace.split("/")[0]; | |
const isPackageJson = dependencies.includes(path); | |
return isPackageJson; | |
} catch (error) { | |
if (error instanceof Deno.errors.NotFound) { | |
return false; | |
} | |
throw error; | |
} | |
} | |
export const esbuildDenoPlugin = { | |
name: "deno", | |
setup(build: PluginBuild) { | |
const { onResolve, onLoad } = build; | |
onResolve({ filter: REMOTE_MODULE_REGEX }, (args) => { | |
log(`Resolving remote module: ${args.path}`); | |
return { path: args.path, namespace: REMOTE_NAMESPACE }; | |
}); | |
onResolve({ filter: /.*/, namespace: REMOTE_NAMESPACE }, (args) => { | |
const path = new URL(args.path, args.importer).href; | |
if (path.startsWith("node:")) { | |
return { path, namespace: "empty" }; | |
} | |
log(`Resolving remote module: ${args.path}`); | |
const namespace = REMOTE_NAMESPACE; | |
return { path, namespace }; | |
}); | |
onResolve({ filter: /.*/, namespace: NPM_NAMESPACE }, async (args) => { | |
const [importerPackageName, ...rest] = args.importer.split("/"); | |
let path = args.path; | |
if (path.endsWith(".js")) { | |
path = path.replace(".js", ""); | |
} | |
const isRelativeNpmPath = path.startsWith(".") || path.startsWith("/"); | |
if (isRelativeNpmPath) { | |
const everythingExceptLast = rest.slice(0, -1); | |
path = everythingExceptLast.join("/") + "/" + path + ".js"; | |
log("resolving relative npm module:", path); | |
const mainEntrypointPath = await loadCachedNpmModulePath( | |
importerPackageName, | |
); | |
path = path = join(dirname(mainEntrypointPath), path); | |
return { path, namespace: NPM_RELATIVE_DEPENDENCY }; | |
} | |
log("resolving npm namespaced module:", path); | |
return { path, namespace: NPM_PACKAGE_JSON_NAMESPACE }; | |
}); | |
onResolve({ filter: /.*/, namespace: NPM_RELATIVE_DEPENDENCY }, (args) => { | |
const pathWithExtension = args.path.endsWith(".js") | |
? args.path | |
: args.path + ".js"; | |
if (args.path.startsWith(".")) { | |
log(`Resolving npm filepath dependency: ${args.path}`); | |
const path = join(dirname(args.importer), `${pathWithExtension}`); | |
return { path, namespace: NPM_RELATIVE_DEPENDENCY }; | |
} | |
// Check if the module is scoped | |
const isScoped = args.path.startsWith("@") && args.path.includes("/"); | |
log(`Resolving npm bare specifier dependency: ${args.path}`); | |
return { | |
path: args.path, | |
namespace: isScoped ? NPM_SCOPED_NAMESPACE : NPM_NAMESPACE, | |
}; | |
}); | |
onResolve({ filter: /.*/, namespace: NPM_SCOPED_NAMESPACE }, async ( | |
args, | |
) => { | |
const [namespace, packageName, ...rest] = args.importer.split("/"); | |
const packageIdentifier = `${namespace}/${packageName}`; | |
let paths = args.path; | |
if (paths.startsWith(".")) { | |
paths = paths.replace(".", ""); | |
} | |
const mainEntrypointPath = await loadCachedNpmModulePath( | |
packageIdentifier, | |
); | |
const restExceptLast = rest.slice(0, -1); | |
const path = join( | |
dirname(mainEntrypointPath), | |
restExceptLast.join("/"), | |
paths, | |
); | |
log(`Resolving npm scoped namespace dependency: ${args.path}`); | |
return { path, namespace: NPM_RELATIVE_DEPENDENCY }; | |
}); | |
onResolve({ filter: /.*/, namespace: NPM_PACKAGE_JSON_NAMESPACE }, async ( | |
args, | |
) => { | |
if (args.path.startsWith(".")) { | |
const mainEntrypointPath = await loadCachedNpmModulePath( | |
args.importer, | |
); | |
const path = join(dirname(mainEntrypointPath), args.path); | |
log(`Resolving npm package_json dependency: ${args.path}`); | |
return { path, namespace: NPM_RELATIVE_DEPENDENCY }; | |
} | |
log(`Resolving npm package.json: ${args.path}`); | |
return { path: args.path, namespace: NPM_PACKAGE_JSON_NAMESPACE }; | |
}); | |
onResolve({ filter: /.*/ }, async (args) => { | |
if (args.kind === "entry-point") { | |
return; | |
} | |
const isNpmModule = await checkIfNpmModule(args.path); | |
if (isNpmModule) { | |
log(`Resolving npm module: ${args.path}`); | |
if (args.path.includes("posthog-node")) { | |
return { path: args.path, namespace: "empty" }; | |
} | |
return { path: args.path, namespace: NPM_PACKAGE_JSON_NAMESPACE }; | |
} | |
if (args.path.startsWith("npm:") || isNpmModule) { | |
const path: string = args.path.split("npm:")[1] ?? args.path; | |
log(`Resolving npm prefixed module: ${args.path}`); | |
return { path, namespace: NPM_NAMESPACE }; | |
} | |
if (args.path.endsWith(".graphql")) { | |
const path = | |
new URL(import.meta.resolve(`packages/__generated__/${args.path}.ts`)) | |
.pathname; | |
return { | |
path, | |
namespace: LOCAL_NAMESPACE, | |
}; | |
} | |
const resolvedPath = import.meta.resolve(args.path); | |
if (resolvedPath.startsWith("file://")) { | |
const path = resolvedPath.replace("file://", ""); | |
log(`Resolving local module: ${args.path}`); | |
return { | |
path, | |
namespace: LOCAL_NAMESPACE, | |
}; | |
} | |
if (resolvedPath.startsWith("http")) { | |
const path = new URL(resolvedPath).href; | |
log(`Resolving remote module: ${args.path}`); | |
return { | |
path, | |
namespace: REMOTE_NAMESPACE, | |
}; | |
} | |
if (resolvedPath.startsWith("npm:")) { | |
const path: string = args.path.split("npm:")[1] ?? args.path; | |
return { path, namespace: NPM_NAMESPACE }; | |
} | |
}); | |
function getLoader(extension: string): Loader { | |
const validExtensions = [".js", ".jsx", ".ts", ".tsx"]; | |
if (validExtensions.includes(extension)) { | |
return extension as Loader; | |
} | |
return "ts"; | |
} | |
onLoad({ filter: /.*/, namespace: "empty" }, (args) => { | |
// Creating a Proxy to handle any named import dynamically. | |
// This will cater to both default and named exports. | |
const contents = ` | |
const handler = { | |
get: (target, prop) => undefined | |
}; | |
const moduleProxy = new Proxy({}, handler); | |
export default moduleProxy; | |
export const Buffer = undefined; | |
`; | |
const loader = "js"; // JavaScript loader for the content | |
log(`Handling 'empty' namespace for module: ${args.path}`); | |
return { contents, loader }; | |
}); | |
onLoad({ filter: /.*/, namespace: REMOTE_NAMESPACE }, async (args) => { | |
const cachePath = cacheLocations.remoteModulesCache + "/" + args.path; | |
let contents; | |
try { | |
await Deno.stat(cachePath); | |
contents = await Deno.readTextFile(cachePath); | |
} catch (error) { | |
if (error instanceof Deno.errors.NotFound) { | |
const source = await fetch(args.path); | |
if (!source.ok) { | |
throw new Error( | |
`Failed to fetch ${args.path}: ${source.status} ${source.statusText}`, | |
); | |
} | |
contents = await source.text(); | |
} else { | |
throw error; | |
} | |
} | |
const pattern = /\/\/# sourceMappingURL=(\S+)/; | |
const match = contents.match(pattern); | |
if (match) { | |
const sourceMapUrl = new URL(match[1], args.path); | |
const dataurl = await fetchSourceMap(sourceMapUrl); | |
const comment = `//# sourceMappingURL=${dataurl}`; | |
contents = contents.replace(pattern, comment); | |
} | |
const { pathname } = new URL(args.path); | |
const ext = pathname.match(/[^.]+$/); | |
const loader = getLoader(ext ? ext[0] : ""); | |
return { contents, loader }; | |
}); | |
onLoad({ filter: /.*/, namespace: LOCAL_NAMESPACE }, async (args) => { | |
const source = await Deno.readTextFile(args.path); | |
const graphqlTags = extractGraphqlTags(source); | |
let contents = source; | |
if (graphqlTags.length > 0) { | |
contents = await replaceTagsWithImports(source, graphqlTags); | |
} | |
const ext = args.path.match(/[^.]+$/); | |
const loader = (ext ? ext[0] : "ts") as Loader; | |
log(`Loading local module: ${args.path}`); | |
return { contents, loader }; | |
}); | |
onLoad({ filter: /.*/, namespace: NPM_NAMESPACE }, async (args) => { | |
// Get the npm identifier from the path | |
const npmIdentifier = args.path.split("/")[0]; | |
const pathParts = args.path.split("/").slice(1); | |
const mainEntrypointPath = await loadCachedNpmModulePath(npmIdentifier); | |
let requestedFile; | |
if (pathParts.length === 0) { | |
requestedFile = mainEntrypointPath; | |
} else { | |
const mainModuleDir = dirname(mainEntrypointPath); | |
const requestedFileWithoutExtension = join(mainModuleDir, ...pathParts); | |
requestedFile = `${requestedFileWithoutExtension}.js`; | |
} | |
const ext = requestedFile.split(".").pop(); | |
const loader = getLoader(ext ?? ""); | |
// Read the contents of the requested file | |
log("trying to load", requestedFile); | |
let contents; | |
try { | |
contents = await Deno.readTextFile(requestedFile); | |
} catch (e) { | |
logError(`Could not find module ${npmIdentifier} at ${requestedFile}`); | |
contents = ""; | |
} | |
log(`Loading npm module: ${args.path}`); | |
return { contents, loader }; | |
}); | |
onLoad( | |
{ filter: /.*/, namespace: NPM_RELATIVE_DEPENDENCY }, | |
async (args) => { | |
const ext = args.path.match(/[^.]+$/); | |
const loader = (ext ? ext[0] : "ts") as Loader; | |
log(`Loading npm relative dependency: ${args.path}`); | |
// Read the contents of the requested file | |
const contents = await Deno.readTextFile(args.path); | |
return { contents, loader }; | |
}, | |
); | |
onLoad({ filter: /.*/, namespace: NPM_SCOPED_NAMESPACE }, async (args) => { | |
log(`Loading npm scoped namespace: ${args.path}`); | |
// Get the scoped npm identifier from the path | |
const [scope, packageName] = args.path.split("/").slice(0, 2); | |
const scopedNpmIdentifier = `${scope}/${packageName}`; | |
const pathParts = args.path.split("/").slice(2); | |
const mainModuleDir = await loadCachedNpmModulePath( | |
scopedNpmIdentifier, | |
); | |
const requestedFileWithoutExtension = join( | |
dirname(mainModuleDir), | |
...pathParts, | |
); | |
const requestedFile = `${requestedFileWithoutExtension}.js`; | |
const ext = requestedFile.split(".").pop(); | |
const loader = getLoader(ext ?? ""); | |
// Read the contents of the requested file | |
const contents = await Deno.readTextFile(requestedFile); | |
return { contents, loader }; | |
}); | |
onLoad( | |
{ filter: /.*/, namespace: NPM_PACKAGE_JSON_NAMESPACE }, | |
async (args) => { | |
const [npmIdentifier, child] = args.path.split("/"); | |
const mainEntrypointPath = await loadCachedNpmModulePath(npmIdentifier); | |
let contents = await Deno.readTextFile(mainEntrypointPath); | |
if (child) { | |
const entrypointPath = join(dirname(mainEntrypointPath), child); | |
contents = await Deno.readTextFile(`${entrypointPath}.js`); | |
} | |
log(`Loading npm package.json: ${args.path}`); | |
return { contents, loader: "js" }; | |
}, | |
); | |
}, | |
}; | |
async function fetchSourceMap(url: URL) { | |
const map = await fetch(url); | |
const type = map.headers.get("content-type") ?? undefined; | |
const buffer = await map.arrayBuffer(); | |
const blob = new Blob([buffer], { type }); | |
const reader = new FileReader(); | |
return new Promise((cb) => { | |
reader.onload = (e) => cb(e.target?.result); | |
reader.readAsDataURL(blob); | |
}); | |
} | |
async function loadCachedNpmModulePath( | |
npmIdentifier: string, | |
retry = true, | |
): Promise<string> { | |
const cachePath = `${cacheLocations.npmModulesCache}/${npmIdentifier}`; | |
const registryPath = `${cachePath}/registry.json`; | |
let registryJsonString; | |
try { | |
registryJsonString = await Deno.readTextFile(registryPath); | |
} catch (error) { | |
if (error instanceof Deno.errors.NotFound) { | |
if (npmIdentifier.includes("/") && retry) { | |
const [packageName] = npmIdentifier.split("/"); | |
return loadCachedNpmModulePath(packageName, false); | |
} | |
if (npmIdentifier.startsWith("npm:") && retry) { | |
return loadCachedNpmModulePath(npmIdentifier.split(":")[1], false); | |
} | |
logError( | |
`Could not find module ${npmIdentifier} in cache at ${cachePath}`, | |
); | |
} | |
} | |
if (registryJsonString == null) { | |
return `infra/utils/empty.ts`; | |
} | |
const registryJson = JSON.parse(registryJsonString); | |
const currentVersion = registryJson["dist-tags"].latest; | |
const currentVersionPath = `${cachePath}/${currentVersion}`; | |
try { | |
const packageJsonString = await Deno.readTextFile( | |
`${currentVersionPath}/package.json`, | |
); | |
const packageJson = JSON.parse(packageJsonString); | |
const mainFile = packageJson.main; | |
const mainFilePath = `${currentVersionPath}/${mainFile}`; | |
return mainFilePath; | |
} catch (error) { | |
if (error instanceof Deno.errors.NotFound) { | |
logError( | |
`Could not find module ${npmIdentifier} in cache at ${currentVersionPath}`, | |
); | |
return `infra/utils/empty.ts`; | |
} | |
throw error; | |
} | |
} | |
const extractGraphqlTags = (contents: string) => { | |
const matches = Array.from(contents.matchAll(/graphql`([\s\S]+?)`/g)).map( | |
(match) => match[1].trim(), | |
); | |
return matches; | |
}; | |
const replaceTagsWithImports = async ( | |
contents: string, | |
matches: string[], | |
) => { | |
let updatedContents = contents; | |
const artifactsDirectory = "packages/__generated__"; | |
const replacements: Record<string, string> = {}; | |
for (const match of matches) { | |
const pattern = /^(?<operationType>\w+)\s+(?<operationName>\w+)/m; | |
const { _operationType, operationName } = match.match(pattern)?.groups ?? | |
{}; | |
const generatedFileName = `${operationName}.graphql.ts`; | |
const generatedFilePath = join(artifactsDirectory, generatedFileName); | |
try { | |
const filesystemPath = | |
new URL(import.meta.resolve(generatedFilePath)).pathname; | |
await Deno.stat(filesystemPath); | |
const replacement = | |
`(async () => { const importedModule = await import('${generatedFilePath}'); return importedModule.default; })()`; | |
replacements[match] = replacement; | |
} catch (_error) { | |
logError( | |
`Generated Relay file not found for query: ${generatedFilePath}, skipping replacement.`, | |
); | |
} | |
} | |
updatedContents = updatedContents.replace( | |
/graphql`([\s\S]+?)`/g, | |
(fullMatch, group) => { | |
const trimmedGroup = group.trim(); | |
return replacements[trimmedGroup] || fullMatch; | |
}, | |
); | |
return updatedContents; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment