Compare commits

..

4 Commits

Author SHA1 Message Date
3aa0b3a7ff cleanup 2024-07-05 08:54:39 -04:00
2f21977fca misc cleanup 2024-07-03 15:09:21 -04:00
b5aece91e1 lots of changes 2024-06-14 17:07:50 -04:00
85631f2173 prep work started 2024-06-12 17:05:41 -04:00
9 changed files with 203 additions and 131 deletions

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.

View File

@ -2,19 +2,8 @@
"name": "@ttf/wasm-bundle", "name": "@ttf/wasm-bundle",
"version": "0.1.0", "version": "0.1.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"imports": {
"react": "https://esm.sh/preact@10.22.0/compat",
"react/": "https://esm.sh/preact@10.22.0/compat/",
"other": "./other.tsx",
"entry": "./app.tsx",
"config": "./deno.json"
},
"tasks": { "tasks": {
"go": "deno run -A bundler-inc.tsx", "serve": "deno run -A --no-lock serve.tsx --path=./test --html=index.html",
"serve": "deno run -A serve.tsx" "jsapi": "deno run -A --no-lock jsapi.tsx"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
} }
} }

View File

@ -1,18 +0,0 @@
{
"version": "3",
"remote": {
"https://deno.land/x/esbuild@v0.19.2/wasm.js": "5ffeb3d973e57351eb4d2d03ffafc8ce5672e946d0f0a786c4aed2ca29cec004",
"https://deno.land/x/jsonct@v0.1.0/mod.ts": "dba7e7f3529be6369f5c718e3a18b69f15ffa176006d2a7565073ce6c5bd9f3f",
"https://deno.land/x/jsonct@v0.1.0/src/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462",
"https://deno.land/x/jsonct@v0.1.0/src/parse.ts": "a3a016822446b0584b40bae9098df480db5590a9915c9e3c623ba2801cf1b8df",
"https://esm.sh/esbuild-plugin-importmaps@1.0.0": "ab9e79660ff4d57d2ed7ef5e8516fbe0e79305267f22e1e7270d0a17ae0c2029",
"https://esm.sh/preact@10.22.0/compat/jsx-runtime": "a2f6ddc2ce374813df1c13826a9ad010e90b5a70a989f1069a367ef60dd52eb0",
"https://esm.sh/stable/preact@10.22.0/denonext/compat.js": "7c0b206984707cfef58efae492ea8d5212b8f620dd8c83294a5c832fb422c766",
"https://esm.sh/stable/preact@10.22.0/denonext/compat/jsx-runtime.js": "fecfa3df69d580507801575175087de9a2a9fc23bb4900004a1f4cbd5b362634",
"https://esm.sh/stable/preact@10.22.0/denonext/hooks.js": "09230113132c216bbc3847aaad11289771e088be1b0eb9e49cbc724faaeac205",
"https://esm.sh/stable/preact@10.22.0/denonext/jsx-runtime.js": "de60943799b1cbe6066c4f83f4ca71ef37011d7f5be7bef58ed980e8ff3f996a",
"https://esm.sh/stable/preact@10.22.0/denonext/preact.mjs": "20c9563e051dd66e053d3afb450f61b48f2fa0d0ce4f69f8f0a2f23c1ef090da",
"https://esm.sh/v135/@jspm/import-map@1.0.8/denonext/import-map.mjs": "fc291e729df6bef849df47df8893b64749785ca65fd5fe1d0e7969db5d3b63ea",
"https://esm.sh/v135/esbuild-plugin-importmaps@1.0.0/denonext/esbuild-plugin-importmaps.mjs": "08b603d074dd2861345f7d224c255c46d7f7213a283026552c492f465fe595ce"
}
}

View File

@ -2,16 +2,21 @@ import { parse as JSONC } from "https://deno.land/x/jsonct@v0.1.0/mod.ts";
type JsonLike = { [key: string]: string | string[] | JsonLike; }; type JsonLike = { [key: string]: string | string[] | JsonLike; };
/** A `file://` url version of Deno.cwd() */ /** A `file://` url version of Deno.cwd() (contains trailing slash) */
export const Root = new URL(`file://${Deno.cwd().replaceAll("\\", "/")}`).toString() + "/"; export const Root:string = new URL(`file://${Deno.cwd().replaceAll("\\", "/")}`).toString() + "/";
export default async function HuntConfig() export default async function HuntConfig(directory=Root):Promise<{
imports: JsonLike;
compilerOptions: JsonLike;
}>
{ {
let path:string, json:JsonLike|undefined; let path:string, json:JsonLike|undefined;
console.log("searchig in directory", directory)
const loadJSON =async(inPath:string)=>{ const loadJSON =async(inPath:string)=>{
try{ try{
const resp = await fetch(Root + inPath); const path = new URL(inPath, directory);
if(inPath.endsWith(".jsonc")) console.log("looking at", path.href);
const resp = await fetch(path);
if(inPath.endsWith("./.jsonc"))
{ {
const text = await resp.text(); const text = await resp.text();
json = JSONC(text) as JsonLike; json = JSONC(text) as JsonLike;
@ -30,20 +35,20 @@ export default async function HuntConfig()
try// look for `deno.json` try// look for `deno.json`
{ {
json = await loadJSON("deno.json"); json = await loadJSON("./deno.json");
} }
catch(_e) catch(_e)
{ {
try // look for `deno.jsonc` try // look for `deno.jsonc`
{ {
json = await loadJSON("deno.jsonc"); json = await loadJSON("./deno.jsonc");
} }
catch(_e) catch(_e)
{ {
try // look in the vscode plugin settings try // look in the vscode plugin settings
{ {
json = await loadJSON(".vscode/settings.json") json = await loadJSON("./.vscode/settings.json")
path = json ? json["deno.config"] as string : ""; path = json ? json["deno.config"] as string : "";
json = undefined; json = undefined;
if(path) if(path)

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

26
mod.ts
View File

@ -1,14 +1,14 @@
import * as ESBuild from "https://deno.land/x/esbuild@v0.19.2/wasm.js"; 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 * as Mapper from "https://esm.sh/esbuild-plugin-importmaps@1.0.0"; // https://github.com/andstellar/esbuild-plugin-importmaps
import Introspect, {Root} from "./introspect.ts"; import Introspect from "./introspect.ts";
const prefix = "/_dot_importer_/"; const prefix = "/_dot_importer_/";
const resolvePlugin:ESBuild.Plugin = { const resolvePlugin =(fullPathDir:string):ESBuild.Plugin=>({
name: "resolve-plugin", name: "resolve-plugin",
setup(build) { setup(build) {
build.onResolve({ filter: /^(\.\/|\.\.\/).*/ }, (args)=> build.onResolve({ filter: /^(\.\/|\.\.\/).*/ }, (args)=>
{ {
let resolveRoot = args.importer||Root; let resolveRoot = args.importer||fullPathDir;
if(resolveRoot.startsWith(prefix)) if(resolveRoot.startsWith(prefix))
{ {
resolveRoot = resolveRoot.substring(prefix.length); resolveRoot = resolveRoot.substring(prefix.length);
@ -21,23 +21,31 @@ const resolvePlugin:ESBuild.Plugin = {
}); });
build.onLoad({ filter: /.*/, namespace:"http" }, async(args)=> { build.onLoad({ filter: /.*/, namespace:"http" }, async(args)=> {
const fetchPath = args.path.substring(prefix.length); const fetchPath = args.path.substring(prefix.length);
console.log("fetch path", fetchPath);
const result = await fetch(fetchPath); const result = await fetch(fetchPath);
const contents = await result.text(); const contents = await result.text();
return { contents, loader: `tsx` }; return { contents, loader: `tsx` };
}); });
}, },
}; });
await ESBuild.initialize({ worker: false }); await ESBuild.initialize({ worker: false });
export type ImportMap = Parameters<typeof Mapper.importmapPlugin>[0]; export type ImportMap = Parameters<typeof Mapper.importmapPlugin>[0];
export type BuildOptions = ESBuild.BuildOptions; export type BuildOptions = ESBuild.BuildOptions;
export default async function(buildOptions={} as BuildOptions, importMap:ImportMap|false = false):Promise<ESBuild.BuildResult<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 === false) if(!importMap)
{ {
importMap = await Introspect() as ImportMap; importMap = await Introspect(directory) as ImportMap
} }
console.log("using import map", importMap);
const configuration:ESBuild.BuildOptions = { const configuration:ESBuild.BuildOptions = {
entryPoints: ["entry"], entryPoints: ["entry"],
bundle: true, bundle: true,
@ -47,7 +55,7 @@ export default async function(buildOptions={} as BuildOptions, importMap:ImportM
jsxImportSource: "react", jsxImportSource: "react",
...buildOptions, ...buildOptions,
plugins: [ plugins: [
resolvePlugin, resolvePlugin(directory),
Mapper.importmapPlugin(importMap) as ESBuild.Plugin, Mapper.importmapPlugin(importMap) as ESBuild.Plugin,
...buildOptions.plugins||[] ...buildOptions.plugins||[]
] ]

177
serve.tsx
View File

@ -1,19 +1,39 @@
import bundler from "./mod.ts";
import mime from "https://esm.sh/mime@4.0.3"; import mime from "https://esm.sh/mime@4.0.3";
import { parseArgs } from "jsr:@std/cli/parse-args"; 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) if(Deno.mainModule == import.meta.url)
{ {
serve(parseArgs(Deno.args) as ServeArgs); serve(parseArgs(Deno.args) as ServeArgs);
} }
type ServeArgs = {path?:string, html?:string, port?:string|number}; export type ServeArgs = {path?:string, html?:string, port?:string|number};
function serve(settings:ServeArgs):void 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 basePath = "";
const SetDirectory =(inPath:string)=>{ let DenoConfig:ImportMap
const SetDirectory =async(inPath:string)=>{
if(inPath.startsWith("http://")||inPath.startsWith("https://")||inPath.startsWith("file://")) if(inPath.startsWith("http://")||inPath.startsWith("https://")||inPath.startsWith("file://"))
{ {
basePath = inPath; basePath = inPath;
@ -27,106 +47,103 @@ function serve(settings:ServeArgs):void
basePath = basePath + "/"; basePath = basePath + "/";
} }
console.log("Base Path:", basePath); console.log("Base Path:", basePath);
DenoConfig = await Introspect(basePath) as ImportMap;
console.log("found config", DenoConfig);
} }
await SetDirectory(settings.path||"./");
SetDirectory(settings.path||"./"); // bundler
const transpiled:Map<string, string> = new Map(); const transpiled:Map<string, string> = new Map();
const ServeJSCode =(inText:string)=> new Response(inText, {headers:{"content-type":"application/javascript"}}); const defaultConfig:ESBuild.BuildOptions = { entryPoints:[] as string[], outdir:"/", entryNames: `[dir][name]`, splitting: true };
const ServeStatic =async(inPath:string)=> 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 try
{ {
const handle = await fetch(basePath+inPath.substring(1)); const results = await bundler(basePath, buildConfig, DenoConfig);
return new Response(handle.body, {headers:{"content-type": mime.getType(inPath)??""}}) if(results.outputFiles)
} {
catch(_e) results.outputFiles.forEach(output=>{transpiled.set(output.path, output.text); console.log("building", output.path);})
{ return results.outputFiles[0].text;
return resp404; }
} }
catch(_e){return;}
}; };
// index html serving
const resp404 = new Response("404", {status:404}); const resp404 = new Response("404", {status:404});
let respIndex:false|Response|Promise<Response> = false; const resp304 = new Response(null, {status:304});
let respIndexBody:false|string = false;
if(settings.html) if(settings.html)
{ {
if(settings.html.indexOf("<") != -1) if(settings.html.indexOf("<") != -1) // if html is actual markup instead of a url
{ {
respIndex= new Response(settings.html, {headers:{"content-type":"text-html"}}); respIndexBody = settings.html;
} }
else else
{ {
respIndex = ServeStatic(settings.html); const load = await fetch(basePath+settings.html);
respIndexBody = await load.text();
} }
} }
///// go
Deno.serve({port:parseInt(settings.port as string)||8000}, async(req)=>{ Deno.serve({port:parseInt(settings.port as string)||8000}, async(req)=>
const url = new URL(req.url); {
const index = url.pathname.lastIndexOf("."); const checkHash = req.headers.get("if-none-match")
if(index > -1) if(checkHash === ETag){
{ return resp304;
const ext = url.pathname.substring(index+1);
if(ext === "ts" || ext == "tsx" || ext == "js" || ext == "jsx")
{
if(ext == "js")
{
const lookup = transpiled.get(url.pathname);
if(lookup)
{
return ServeJSCode(lookup);
}
}
else
{
const lookup = transpiled.get(url.pathname.substring(0, index)+".js");
if(lookup)
{
return ServeJSCode(lookup);
}
}
try
{
const results = await bundler({
entryPoints:[basePath+url.pathname],
outdir:"/",
entryNames: `[dir][name]`,
splitting: true
});
if(results.outputFiles)
{
results.outputFiles.forEach(output=>transpiled.set(output.path, output.text))
return ServeJSCode(results.outputFiles[0].text);
}
else
{
throw(new Error("no output"));
}
}
catch(_e)
{
return resp404;
}
}
else
{
return ServeStatic(url.pathname);
}
} }
if(respIndex) 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")
{ {
return respIndex; 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 else
{ {
let indexLookup = url.pathname; try{
if(!indexLookup.endsWith("/")) const handle = await fetch(basePath+path.substring(1));
{ headers["content-type"] = "text/html"
indexLookup = indexLookup+"/"; return new Response(handle.body, {headers});
}
catch(_e){
return resp404;
} }
return ServeStatic(indexLookup+"index.html");
} }
}); });

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

2
test/leaf.ts Normal file
View File

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