From e160d41d583335b6bd80ca7740394830f09aa78d Mon Sep 17 00:00:00 2001 From: Seth Trowbridge Date: Thu, 27 Feb 2025 11:37:15 -0500 Subject: [PATCH] init --- .gitignore | 1 + README.md | 48 ++++++++++++++ deno.json | 9 +++ introspect.ts | 80 +++++++++++++++++++++++ jsapi.test.tsx | 11 ++++ mod.ts | 67 +++++++++++++++++++ serve.tsx | 152 +++++++++++++++++++++++++++++++++++++++++++ test/app-dynamic.tsx | 2 + test/app.tsx | 10 +++ test/deep/deep.tsx | 2 + test/deno.json | 10 +++ test/index.html | 9 +++ test/leaf.ts | 2 + test/other.tsx | 3 + 14 files changed, 406 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deno.json create mode 100644 introspect.ts create mode 100644 jsapi.test.tsx create mode 100644 mod.ts create mode 100644 serve.tsx create mode 100644 test/app-dynamic.tsx create mode 100644 test/app.tsx create mode 100644 test/deep/deep.tsx create mode 100644 test/deno.json create mode 100644 test/index.html create mode 100644 test/leaf.ts create mode 100644 test/other.tsx 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