This commit is contained in:
Seth Trowbridge 2025-02-27 11:37:15 -05:00
commit e160d41d58
14 changed files with 406 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
deno.lock

48
README.md Normal file
View File

@ -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 <hosted-location>/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.

9
deno.json Normal file
View File

@ -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"
}
}

80
introspect.ts Normal file
View File

@ -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};
}

11
jsapi.test.tsx Normal file
View File

@ -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);
})

67
mod.ts Normal file
View File

@ -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<typeof Mapper.importmapPlugin>[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<string, string>}). 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<ESBuild.BuildResult<ESBuild.BuildOptions>>
{
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 };

152
serve.tsx Normal file
View File

@ -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<void>
{
// 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<string, string> = 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<string|void>=>
{
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

2
test/app-dynamic.tsx Normal file
View File

@ -0,0 +1,2 @@
console.log("hey included")
export default "async include";

10
test/app.tsx Normal file
View File

@ -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(<h1>sup!</h1>, document.querySelector("#app")||document.body);

2
test/deep/deep.tsx Normal file
View File

@ -0,0 +1,2 @@
import "../app.tsx";
console.log("we deep!");

10
test/deno.json Normal file
View File

@ -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"
}
}

9
test/index.html Normal file
View File

@ -0,0 +1,9 @@
<html>
<head>
</head>
<body>
<div id="app"></div>
</body>
<script src="/app.tsx" type="module"></script>
</html>

2
test/leaf.ts Normal file
View File

@ -0,0 +1,2 @@
const message:string = "leaf default export!"
export default ()=>console.log(message);

3
test/other.tsx Normal file
View File

@ -0,0 +1,3 @@
import React from "react";
console.log(React.createElement("div", {}, "hey"));
export default "TEST STRING";