commit e160d41d583335b6bd80ca7740394830f09aa78d Author: Seth Trowbridge Date: Thu Feb 27 11:37:15 2025 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..941fcf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +deno.lock \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3dabe8d --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# ESBuild Typescript Bunder + +## Supports import maps and runs on Deno Deploy +Uses the WASM version of ESBuild for use on Deno Deploy and adds some plugins to allow for import maps. ***Unfortunately, `npm:*` and `jsr:*` specifiers are not supported currently***. + +### Manual Bundle API +```typescript +import Bundle from "./mod.ts"; +Bundle( + // directory to work from + "./some_folder", + + // ESBuild configuration (entry points are relative to "directory"): + {entry:["./app.tsx"]}, + + // import map (if omitted, will scan for a deno configuration within "directory" and use that) + {"imports": { + "react": "https://esm.sh/preact@10.22.0/compat", + "react/": "https://esm.sh/preact@10.22.0/compat/" + }} +); +if(outputFiles){ + for(const item of outputFiles){ + // do something with the output... + console.log(item.text); + } +} +``` + +### Static bundle server + +#### API +```typescript +import Serve from "./serve.tsx"; +Serve({path:"./some_folder", html:"index.html", port:8080}); +``` + +#### CLI +``` +deno run -A /serve.tsx --path=./some_folder --html=index.html --port=8080 +``` + +Accepts three optional settings: +- `path` A directory to serve content from. Defaults to the current working directory. +- `html` A path to an html file or actual html string to *always use* when the server would serve up a non-file route. If `html` is a path, it is assumed to be relative to `path`. If not specified, routes that don't match files will 404. +- `port` Port number (default is 8000). + +When requests to typescript files are made to the server, it will treat that file as an entrypoint and return the corresponding plain javascript bundle. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..820ca77 --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "name": "@ttf/wasm-bundle", + "version": "0.1.0", + "exports": "./mod.ts", + "tasks": { + "serve": "deno run -A --no-lock serve.tsx --path=./test --html=index.html", + "jsapi": "deno run -A --no-lock jsapi.tsx" + } +} \ No newline at end of file diff --git a/introspect.ts b/introspect.ts new file mode 100644 index 0000000..b6c9033 --- /dev/null +++ b/introspect.ts @@ -0,0 +1,80 @@ +import { parse as JSONC } from "https://deno.land/x/jsonct@v0.1.0/mod.ts"; + +type JsonLike = { [key: string]: string | string[] | JsonLike; }; + +/** A `file://` url version of Deno.cwd() (contains trailing slash) */ +export const Root:string = new URL(`file://${Deno.cwd().replaceAll("\\", "/")}`).toString() + "/"; +export default async function HuntConfig(directory=Root):Promise<{ + imports: JsonLike; + compilerOptions: JsonLike; +}> +{ + let path:string, json:JsonLike|undefined; + console.log("searchig in directory", directory) + const loadJSON =async(inPath:string)=>{ + try{ + const path = new URL(inPath, directory); + console.log("looking at", path.href); + const resp = await fetch(path); + if(inPath.endsWith("./.jsonc")) + { + const text = await resp.text(); + json = JSONC(text) as JsonLike; + } + else + { + json = await resp.json(); + } + return json; + } + catch(_e) + { + return {}; + } + } + + try// look for `deno.json` + { + json = await loadJSON("./deno.json"); + } + catch(_e) + { + + try // look for `deno.jsonc` + { + json = await loadJSON("./deno.jsonc"); + } + catch(_e) + { + try // look in the vscode plugin settings + { + json = await loadJSON("./.vscode/settings.json") + path = json ? json["deno.config"] as string : ""; + json = undefined; + if(path) + { + json = await loadJSON(path) + } + } + catch(_e) + { + // cant find a config using the vscode plugin + } + } + } + + if(!json) + { + json = {}; + } + + + path = json.importMap as string; + if(!json.imports && path) + { + json.imports = await loadJSON(path) as JsonLike; + } + + return json as {imports:JsonLike, compilerOptions:JsonLike}; + +} \ No newline at end of file diff --git a/jsapi.test.tsx b/jsapi.test.tsx new file mode 100644 index 0000000..c3b0571 --- /dev/null +++ b/jsapi.test.tsx @@ -0,0 +1,11 @@ +import * as Test from "https://deno.land/std@0.224.0/assert/mod.ts"; +import Bundle, {ESBuild} from "./mod.ts"; + + +const path = await import.meta.resolve("./test/"); +const {outputFiles} = await Bundle(path, {entryPoints:["./app.tsx"]}); + +Deno.test("check", ()=>{ + Test.assert(outputFiles?.length == 1); + Test.assert(outputFiles[0].text.length > 1000); +}) diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..a957ea1 --- /dev/null +++ b/mod.ts @@ -0,0 +1,67 @@ +import * as ESBuild from "https://deno.land/x/esbuild@v0.19.2/wasm.js"; +import * as Mapper from "https://esm.sh/esbuild-plugin-importmaps@1.0.0"; // https://github.com/andstellar/esbuild-plugin-importmaps +import Introspect from "./introspect.ts"; + +const prefix = "/_dot_importer_/"; +const resolvePlugin =(fullPathDir:string):ESBuild.Plugin=>({ + name: "resolve-plugin", + setup(build) { + build.onResolve({ filter: /^(\.\/|\.\.\/).*/ }, (args)=> + { + let resolveRoot = args.importer||fullPathDir; + if(resolveRoot.startsWith(prefix)) + { + resolveRoot = resolveRoot.substring(prefix.length); + } + const output:ESBuild.OnResolveResult = { + path:prefix + new URL(args.path, resolveRoot).href, + namespace:"http", + } + return output; + }); + build.onLoad({ filter: /.*/, namespace:"http" }, async(args)=> { + const fetchPath = args.path.substring(prefix.length); + console.log("fetch path", fetchPath); + const result = await fetch(fetchPath); + const contents = await result.text(); + return { contents, loader: `tsx` }; + }); + }, +}); + +await ESBuild.initialize({ worker: false }); +export type ImportMap = Parameters[0]; +export type BuildOptions = ESBuild.BuildOptions; +/** + * + * @param directory Full file:// or http(s):// path to the directory containing assets you want to build (needed to resolve relative imports) + * @param buildOptions ESBuild "build" options (will be merged with "reasonable defaults") for docs: https://esbuild.github.io/api/#general-options + * @param importMap An object to act as the import map ({imports:Record}). If this is left blank, a configuration will be scanned for in the "directory" + * @returns build result + */ +export default async function(directory:string, buildOptions={} as BuildOptions, importMap?:ImportMap):Promise> +{ + if(!importMap) + { + importMap = await Introspect(directory) as ImportMap + } + console.log("using import map", importMap); + const configuration:ESBuild.BuildOptions = { + entryPoints: ["entry"], + bundle: true, + minify: true, + format: "esm", + jsx: "automatic", + jsxImportSource: "react", + ...buildOptions, + plugins: [ + resolvePlugin(directory), + Mapper.importmapPlugin(importMap) as ESBuild.Plugin, + ...buildOptions.plugins||[] + ] + }; + + const result = await ESBuild.build(configuration); + return result; +} +export { ESBuild }; \ No newline at end of file diff --git a/serve.tsx b/serve.tsx new file mode 100644 index 0000000..283ec83 --- /dev/null +++ b/serve.tsx @@ -0,0 +1,152 @@ +import mime from "https://esm.sh/mime@4.0.3"; +import { parseArgs } from "jsr:@std/cli/parse-args"; +import bundler, {type ESBuild, type ImportMap} from "./mod.ts"; +import Introspect from "./introspect.ts"; + +if(Deno.mainModule == import.meta.url) +{ + serve(parseArgs(Deno.args) as ServeArgs); +} + +export type ServeArgs = {path?:string, html?:string, port?:string|number}; +async function serve(settings:ServeArgs):Promise +{ + // etag hash + const ETag = new Date().getTime().toString(); + + // extra parsing + const ExtensionsJS = ["tsx", "ts", "jsx", "js", "jsx", "mjs"]; + const PathExtensionType=(inPath:string)=> + { + const path = inPath.endsWith("/") ? inPath.substring(0, inPath.length-1) : inPath; + const posDot = path.lastIndexOf("."); + const extension = (posDot > path.lastIndexOf("/")) ? path.substring(posDot+1) : null; + let type:string|null = null + if(extension) + { + type = (ExtensionsJS.includes(extension)) ? "application/javascript" : mime.getType(extension); + } + + return {path, extension, type}; + } + + // base path + let basePath = ""; + let DenoConfig:ImportMap + const SetDirectory =async(inPath:string)=>{ + if(inPath.startsWith("http://")||inPath.startsWith("https://")||inPath.startsWith("file://")) + { + basePath = inPath; + } + else + { + basePath = new URL(inPath, new URL(`file://${Deno.cwd().replaceAll("\\", "/")}/`).toString()).href; + } + if(!basePath.endsWith("/")) + { + basePath = basePath + "/"; + } + console.log("Base Path:", basePath); + + DenoConfig = await Introspect(basePath) as ImportMap; + console.log("found config", DenoConfig); + } + await SetDirectory(settings.path||"./"); + + // bundler + const transpiled:Map = new Map(); + const defaultConfig:ESBuild.BuildOptions = { entryPoints:[] as string[], outdir:"/", entryNames: `[dir][name]`, splitting: true }; + const bundle =async(pathName:string, pathBase:string, buildConfig:ESBuild.BuildOptions = {}):Promise=> + { + const lookupKey = pathName.substring(0, pathName.lastIndexOf("."))+".js"; + const lookup = transpiled.get(lookupKey); + if(lookup) + { + return lookup; + } + buildConfig = {...defaultConfig, ...buildConfig} + buildConfig.entryPoints = [pathBase+pathName]; + try + { + const results = await bundler(basePath, buildConfig, DenoConfig); + if(results.outputFiles) + { + results.outputFiles.forEach(output=>{transpiled.set(output.path, output.text); console.log("building", output.path);}) + return results.outputFiles[0].text; + } + } + catch(_e){return;} + }; + + // index html serving + const resp404 = new Response("404", {status:404}); + const resp304 = new Response(null, {status:304}); + let respIndexBody:false|string = false; + if(settings.html) + { + if(settings.html.indexOf("<") != -1) // if html is actual markup instead of a url + { + respIndexBody = settings.html; + } + else + { + const load = await fetch(basePath+settings.html); + respIndexBody = await load.text(); + } + } + + ///// go + Deno.serve({port:parseInt(settings.port as string)||8000}, async(req)=> + { + const checkHash = req.headers.get("if-none-match") + if(checkHash === ETag){ + return resp304; + } + + const url = new URL(req.url); + const {path, type} = PathExtensionType(url.pathname); + const headers = {"content-type":type||"", ETag, "Cache-Control":"max-age=3600"} + + // serve javascript + if(type === "application/javascript") + { + const checkBuild = await bundle(path, basePath); + if(checkBuild) + { + return new Response(checkBuild, {headers}); + } + } + + // serve static + if(type) + { + try{ + const handle = await fetch(basePath+path.substring(1)); + return new Response(handle.body, {headers}); + } + catch(_e){ + return resp404; + } + } + + // serve routes/implied index html files + if(respIndexBody) + { + return new Response(respIndexBody, {headers}); + } + else + { + try{ + const handle = await fetch(basePath+path.substring(1)); + headers["content-type"] = "text/html" + return new Response(handle.body, {headers}); + } + catch(_e){ + return resp404; + } + } + + }); +} + +export default serve \ No newline at end of file diff --git a/test/app-dynamic.tsx b/test/app-dynamic.tsx new file mode 100644 index 0000000..4de9ac5 --- /dev/null +++ b/test/app-dynamic.tsx @@ -0,0 +1,2 @@ +console.log("hey included") +export default "async include"; \ No newline at end of file diff --git a/test/app.tsx b/test/app.tsx new file mode 100644 index 0000000..204350f --- /dev/null +++ b/test/app.tsx @@ -0,0 +1,10 @@ +import * as React from "react" +const dyn = await import("./app-dynamic.tsx"); +console.log(dyn); + + +import Other from "./other.tsx"; + +console.log(Other); + +React.render(

sup!

, document.querySelector("#app")||document.body); \ No newline at end of file diff --git a/test/deep/deep.tsx b/test/deep/deep.tsx new file mode 100644 index 0000000..c12fca5 --- /dev/null +++ b/test/deep/deep.tsx @@ -0,0 +1,2 @@ +import "../app.tsx"; +console.log("we deep!"); \ No newline at end of file diff --git a/test/deno.json b/test/deno.json new file mode 100644 index 0000000..5368ab6 --- /dev/null +++ b/test/deno.json @@ -0,0 +1,10 @@ +{ + "imports": { + "react": "https://esm.sh/preact@10.22.0/compat", + "react/": "https://esm.sh/preact@10.22.0/compat/" + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} \ No newline at end of file diff --git a/test/index.html b/test/index.html new file mode 100644 index 0000000..acdad26 --- /dev/null +++ b/test/index.html @@ -0,0 +1,9 @@ + + + + + +
+ + + diff --git a/test/leaf.ts b/test/leaf.ts new file mode 100644 index 0000000..9ff7736 --- /dev/null +++ b/test/leaf.ts @@ -0,0 +1,2 @@ +const message:string = "leaf default export!" +export default ()=>console.log(message); diff --git a/test/other.tsx b/test/other.tsx new file mode 100644 index 0000000..0104b81 --- /dev/null +++ b/test/other.tsx @@ -0,0 +1,3 @@ +import React from "react"; +console.log(React.createElement("div", {}, "hey")); +export default "TEST STRING"; \ No newline at end of file