├── .github └── workflows │ └── update.yml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── features ├── devserver.ts ├── filesystem.ts ├── httpImports.ts └── templates.ts ├── mod.ts ├── schema.json ├── tests ├── index.ts ├── jsr.test.ts ├── jsr.ts └── smoke.test.ts └── types.ts /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: Update Deno Dependencies 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" # Human: 12am every day 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | update: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4.2.2 17 | - uses: denoland/setup-deno@v2 18 | with: 19 | deno-version: v2.x 20 | - name: Update dependencies 21 | run: deno run -A --import-map=https://deno.land/x/update/deno.json https://deno.land/x/update/mod.ts -b 22 | - name: Test 23 | run: deno test -A 24 | - uses: stefanzweifel/git-auto-commit-action@v5 25 | with: 26 | commit_message: Update dependencies 27 | branch: main 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esbuild_serve 2 | 3 | Live Reload. Persistent HTTP Import Resolution. Templates. Dev Server. Polyfills. A Deno Web bundler. 4 | 5 | ## Get denoing 🦕🔨 6 | 7 | ### Pages 8 | 9 | Pages are a simple way to get going fast. 10 | 11 | ```ts 12 | import { serve } from "https://deno.land/x/esbuild_serve/mod.ts"; 13 | 14 | serve({ 15 | port: 8100, 16 | pages: { 17 | "index": "demo/index.ts" 18 | } 19 | }); 20 | ``` 21 | This will automatically create a HTML Template for you. If you want to have a custom one just place it in `templates/demo/index.html`. 22 | 23 | ### Custom Assets 24 | 25 | Adding plain assets to your build folder goes like this 26 | 27 | ```ts 28 | import { serve } from "https://deno.land/x/esbuild_serve/mod.ts"; 29 | 30 | serve({ 31 | pages: { "index": "index.ts" }, 32 | assets: { 33 | "favicon.ico": "./static/favicon.ico" 34 | } 35 | }) 36 | ``` 37 | 38 | ### Custom Templates 39 | 40 | If you have have a setup like this: `serve({ pages: { "/document/page/index": "index.ts" } })` 41 | 42 | ``` 43 | Resoultion will be like this: 44 | /templates/document/page/index.html 45 | 46 | If this fails, then: 47 | /templates/index.html 48 | 49 | Fallback: 50 | Autogenerated via filename 51 | ``` 52 | 53 | You can place an html file at these locations. 54 | 55 | ## Releasing your bundle 56 | 57 | As simple as starting deno with the args `deno run -A serve.ts build` 58 | 59 | ## Spicing your Builds 60 | 61 | 1. Define globalThis variables 62 | 63 | ```ts 64 | serve({ 65 | globals: { 66 | "BUID_TIMESTAMP": new Date().getTime(), 67 | "GIT_BRANCH": getGitBranch(), 68 | "COPYRIGHT_YEAR": new Date().getFullYear() 69 | } 70 | }) 71 | ``` 72 | 73 | 2. Adding Polyfills 74 | 75 | ```ts 76 | serve({ 77 | polyfills: [ 78 | "https://unpkg.com/construct-style-sheets-polyfill", 79 | "https://unpkg.com/compression-streams-polyfill" 80 | ] 81 | }) 82 | ``` 83 | 84 | ## Note 85 | 86 | - Since v1.2.0 live reload is fully done by esbuild (since 0.17) and the dev server is based opon esbuild (with custom routing added on top) 87 | 88 | ## Internal Plugins 89 | 90 | Only want templates or http imports? just import them! 91 | 92 | ```ts 93 | import { build } from "https://deno.land/x/esbuild/mod.js"; 94 | import { autoTemplates } from "https://deno.land/x/esbuild_serve/features/templates.ts"; 95 | import { httpImports } from "https://deno.land/x/esbuild_serve/features/httpImports.ts"; 96 | build({ 97 | entryPoints: { 98 | app: "app.ts" 99 | }, 100 | plugins: [ 101 | autoTemplates({ 102 | pages: { 103 | "app": "./app.ts" 104 | } 105 | }), 106 | httpImports({ sideEffects: false }), 107 | ] 108 | }) 109 | ``` 110 | -------------------------------------------------------------------------------- /features/devserver.ts: -------------------------------------------------------------------------------- 1 | import { green } from "jsr:@std/fmt@1.0.3/colors"; 2 | import { 3 | ServerSentEventStream, 4 | type ServerSentEventMessage, 5 | } from "jsr:@std/http@1.0.9"; 6 | import { context, type BuildOptions } from "https://deno.land/x/esbuild@v0.25.5/mod.js"; 7 | import { ServeConfig } from "../types.ts"; 8 | 9 | export async function startDevServer(commonConfig: BuildOptions, c: ServeConfig) { 10 | const startTime = performance.now(); 11 | console.log(`🚀 ${green("serve")} @ http://localhost:${c.port ?? 1337}`); 12 | const ctx = await context({ 13 | ...commonConfig, 14 | minify: false, 15 | banner: { 16 | ...commonConfig.banner ?? {}, 17 | js: `${commonConfig.banner?.js || ''};new EventSource("/esbuild").addEventListener('change', () => window?.location?.reload?.());` 18 | }, 19 | splitting: false, 20 | outdir: c.outDir ?? "dist", 21 | logLevel: "error", 22 | write: true, 23 | sourcemap: true 24 | }); 25 | 26 | // Enable watch mode 27 | await ctx.watch(); 28 | 29 | // Enable serve mode 30 | const { port } = await ctx.serve({ 31 | servedir: c.outDir ?? "dist", 32 | }); 33 | 34 | const triggers = <(() => void)[]>[]; 35 | 36 | const changes = new EventSource(`http://localhost:${port}/esbuild`); 37 | let hadChanges = false; 38 | 39 | changes.addEventListener('change', () => { 40 | if (!hadChanges) { 41 | 42 | hadChanges = true; 43 | return; 44 | } 45 | console.log(`📦 Rebuild finished!`); 46 | triggers.forEach(() => { 47 | triggers.pop()?.(); 48 | }); 49 | }); 50 | 51 | // We are creating a proxy so we can have custom routing. 52 | Deno.serve({ 53 | port: c.port ?? 1337, 54 | onListen: () => { 55 | console.log(`📦 Started in ${green(`${(performance.now() - startTime).toFixed(2)}ms`)}`); 56 | } 57 | }, async (e) => { 58 | // proxy everything to internal esbuild dev server; 59 | const url = new URL(e.url); 60 | url.port = port.toString(); 61 | 62 | if (url.pathname == "/esbuild") { 63 | const { readable, writable } = new TransformStream(); 64 | 65 | triggers.push(async () => { 66 | try { 67 | const writer = writable.getWriter(); 68 | await writer.write({ event: "change", data: "change" }); 69 | writer.releaseLock(); 70 | 71 | } catch { 72 | // 73 | } 74 | }); 75 | 76 | return new Response(readable.pipeThrough(new ServerSentEventStream()), { 77 | headers: { 78 | "content-type": "text/event-stream", 79 | "cache-control": "no-cache", 80 | }, 81 | }); 82 | } 83 | const rsp = await fetch(url); 84 | 85 | // We can't disable the file directory page 86 | const text = await rsp.clone().text(); 87 | const isFileDirectoryPage = text.includes(`Directory: ${url.pathname}/`) && text.includes("📁"); 88 | 89 | // esbuild doesn't automaticly append .html so we do it here 90 | if (!rsp.ok || isFileDirectoryPage) { 91 | url.pathname += ".html"; 92 | return await fetch(url); 93 | } 94 | 95 | return rsp; 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /features/filesystem.ts: -------------------------------------------------------------------------------- 1 | import { ensureDirSync } from "jsr:@std/fs@1.0.5"; 2 | 3 | export function ensureNestedFolderExists(path: string, root: string) { 4 | if (!path.includes("/")) return; 5 | const target = path.split("/").filter((_, i, l) => i != l.length - 1).join("/"); 6 | 7 | for (const folder of target 8 | .split('/') 9 | .map((entry, index, list) => (`/${list.filter((_, innerIndex) => innerIndex < index).join("/")}/${entry}`) 10 | .replace("//", "/") // first element would start with a double slash 11 | )) { 12 | ensureDirSync(`${root}${folder}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /features/httpImports.ts: -------------------------------------------------------------------------------- 1 | import { green } from "jsr:@std/fmt@1.0.3/colors"; 2 | import type { 3 | Loader, 4 | OnLoadArgs, 5 | OnLoadResult, 6 | OnResolveArgs, 7 | Plugin 8 | } from "https://deno.land/x/esbuild@v0.25.5/mod.d.ts"; 9 | 10 | const namespace = "esbuild_serve:http-import"; 11 | const possibleLoaders: Loader[] = [ 'js', 'jsx', 'ts', 'tsx', 'css', 'json', 'text', 'base64', 'file', 'dataurl', 'binary', 'default' ]; 12 | const binaryLoaders: Loader[] = [ 'binary', 'file', "dataurl" ]; 13 | import { fromFileUrl } from "jsr:@std/path@1.0.3"; 14 | 15 | let CACHE = await caches.open("esbuild_serve_0"); 16 | 17 | export async function reload() { 18 | await caches.delete("esbuild_serve_0"); 19 | CACHE = await caches.open("esbuild_serve_0"); 20 | } 21 | 22 | export type Options = { 23 | sideEffects?: boolean; 24 | allowPrivateModules?: boolean; 25 | disableCache?: boolean; 26 | reloadOnCachedError?: boolean; 27 | onCacheMiss?: (path: string) => void; 28 | onCacheHit?: (path: string) => void; 29 | preventRemapOfJSR?: boolean; 30 | }; 31 | 32 | export function remapPathBasedOnSettings(options: Options, path: string): string { 33 | 34 | if (!options.preventRemapOfJSR && path.startsWith("jsr:")) { 35 | return `https://esm.sh/jsr/${path.replace(/^jsr:\/?/, "")}`; 36 | } 37 | 38 | return path; 39 | }; 40 | 41 | export const httpImports = (options: Options = {}): Plugin => ({ 42 | name: namespace, 43 | setup(build) { 44 | build.onResolve({ filter: /^[^\.]+/ }, ({ path, importer, namespace: name }: OnResolveArgs) => { 45 | // fix for missing baseURL in import.meta.resolve 46 | if (name == namespace && path.startsWith("/")) 47 | return { path: new URL(path, importer).toString(), namespace }; 48 | 49 | if (import.meta.resolve(path).startsWith("file:")) 50 | return { path: fromFileUrl(import.meta.resolve(path)) }; 51 | // return { path: new URL(path, importer).toString(), namespace }; 52 | 53 | const resolve = remapPathBasedOnSettings(options, import.meta.resolve(remapPathBasedOnSettings(options, path))); 54 | return { path: resolve, namespace }; 55 | }); 56 | build.onResolve({ filter: /^https:\/\// }, ({ path }: OnResolveArgs) => ({ path, namespace })); 57 | build.onResolve({ filter: /.*/, namespace }, ({ path, importer }: OnResolveArgs) => ({ 58 | sideEffects: options.sideEffects ?? false, 59 | namespace, 60 | path: path.startsWith(".") 61 | ? new URL(path.replace(/\?.*/, ""), importer).toString() 62 | : import.meta.resolve(path), 63 | })); 64 | build.onLoad({ filter: /.*/, namespace }, async ({ path }: OnLoadArgs): Promise => { 65 | if (path.startsWith("data:")) return { contents: path, loader: "base64" }; 66 | const headers = new Headers(); 67 | if (options.allowPrivateModules) appendAuthHeaderFromPrivateModules(path, headers); 68 | const source = await useResponseCacheElseLoad(options, path, headers); 69 | if (!source.ok) throw new Error(`GET ${path} failed: status ${source.status}`); 70 | const contents = await source.clone().text(); 71 | // contents = await handeSourceMaps(contents, source, headers); 72 | const { pathname } = new URL(path); 73 | 74 | const loaderFromContentType = { 75 | "application/typescript": "ts", 76 | "application/javascript": "js" 77 | }[ source.headers.get("content-type")?.split(";").at(0) ?? "" ] ?? undefined; 78 | 79 | const predefinedLoader = build.initialOptions.loader?.[ `.${pathname.split(".").at(-1)}` ]; 80 | 81 | const guessLoader = (pathname.match(/[^.]+$/)?.[ 0 ]) as (Loader | undefined); 82 | 83 | // Choose Loader. 84 | const loader = predefinedLoader 85 | ?? loaderFromContentType 86 | ?? (possibleLoaders.includes(guessLoader!) ? guessLoader : undefined) 87 | ?? "file"; 88 | 89 | return { 90 | contents: binaryLoaders.includes(loader ?? "default") 91 | ? new Uint8Array(await source.clone().arrayBuffer()) 92 | : contents, 93 | loader 94 | }; 95 | }); 96 | } 97 | }); 98 | 99 | async function useResponseCacheElseLoad(options: Options, path: string, headers: Headers): Promise { 100 | const url = new URL(path); 101 | const res = await CACHE.match(url); 102 | if (res && !options.disableCache) { 103 | options.onCacheHit?.(path); 104 | return res; 105 | } 106 | console.log(`🔭 Caching ${green(path)}`); 107 | options.onCacheMiss?.(path); 108 | const newRes = await fetch(path, { headers }); 109 | if (newRes.ok) 110 | await CACHE.put(url, newRes.clone()); 111 | return newRes; 112 | } 113 | 114 | function appendAuthHeaderFromPrivateModules(path: string, headers: Headers) { 115 | const env = Deno.env.get("DENO_AUTH_TOKENS")?.trim(); 116 | if (!env) return; 117 | 118 | try { 119 | const denoAuthToken = env.split(";").find(x => new URL(`https://${x.split("@").at(-1)!}`).hostname == new URL(path).hostname); 120 | 121 | if (!denoAuthToken) return; 122 | 123 | if (denoAuthToken.includes(":")) 124 | headers.append("Authorization", `Basic ${btoa(denoAuthToken.split('@')[ 0 ])}`); 125 | else 126 | headers.append("Authorization", `Bearer ${denoAuthToken.split('@')[ 0 ]}`); 127 | 128 | } catch (error) { 129 | console.log(error, env); 130 | return; 131 | } 132 | } -------------------------------------------------------------------------------- /features/templates.ts: -------------------------------------------------------------------------------- 1 | import { copySync, emptyDirSync } from "jsr:@std/fs@1.0.5"; 2 | import { Plugin } from "https://deno.land/x/esbuild@v0.25.5/mod.js"; 3 | import { ensureNestedFolderExists } from "./filesystem.ts"; 4 | import { assert } from "jsr:@std/assert@1.0.6"; 5 | 6 | export function provideTemplate(id: string, outdir: string, template: string, c: TemplateConfig) { 7 | if (id.endsWith("/")) 8 | throw new Error(`${id} is not allowed to end with a slash`); 9 | ensureNestedFolderExists(id, outdir); 10 | try { 11 | copySync(`${template}/${id}.html`, `${outdir}/${id}.html`); 12 | } catch { 13 | try { 14 | fallbackTemplate(c, template, outdir, id); 15 | } catch (_) { 16 | autoGeneratedTemplate(c, outdir, id); 17 | } 18 | } 19 | } 20 | 21 | export function autoGeneratedTemplate(opts: TemplateConfig, outdir: string, id: string) { 22 | const fallbackName = id.split("/").at(-1); 23 | assert(fallbackName); 24 | Deno.writeTextFileSync(`${outdir}/${id}.html`, opts.defaultTemplate?.(fallbackName, id) ?? ``, { create: true }); 25 | } 26 | 27 | export function fallbackTemplate(c: TemplateConfig, template: string, outdir: string, id: string) { 28 | const fallbackName = id.split("/").at(-1); 29 | 30 | if (!c.preventTemplateRootFallback) 31 | copySync(`${template}/${fallbackName}.html`, `${outdir}/${id}.html`); 32 | else 33 | console.error(`🥲 Couldn't find template for ${id}`); 34 | } 35 | export type TemplateConfig = { 36 | templateRoot?: string; 37 | outDir?: string; 38 | assets?: Record; 39 | pages: Record; 40 | htmlEntries?: string[]; 41 | preventTemplateRootFallback?: boolean; 42 | defaultTemplate?: (name: string, path: string) => string; 43 | }; 44 | export const autoTemplates = (c: TemplateConfig): Plugin => ({ 45 | name: "templates", 46 | setup(build) { 47 | const template = c.templateRoot ?? "templates"; 48 | const outdir = c.outDir ?? "dist"; 49 | 50 | build.onStart(() => { 51 | emptyDirSync(outdir); 52 | if (c.assets) 53 | for (const [ publicPath, privatePath ] of Object.entries(c.assets)) { 54 | ensureNestedFolderExists(publicPath, outdir); 55 | copySync(privatePath, `${outdir}/${publicPath}`); 56 | } 57 | for (const id of [ ...Object.keys(c.pages), ...c.htmlEntries ?? [] ]) { 58 | provideTemplate(id, outdir, template, c); 59 | } 60 | }); 61 | } 62 | }); -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { build, BuildOptions } from "https://deno.land/x/esbuild@v0.25.5/mod.js"; 2 | import { ServeConfig } from "./types.ts"; 3 | import { autoTemplates } from "./features/templates.ts"; 4 | import { httpImports, reload } from "./features/httpImports.ts"; 5 | import { startDevServer } from "./features/devserver.ts"; 6 | 7 | export async function serve(c: ServeConfig) { 8 | const outdir = c.outDir ?? "dist"; 9 | 10 | const [ first, second ] = Deno.args; 11 | 12 | const noExit = Deno.env.has("NO_EXIT"); 13 | const shouldReload = Deno.env.has("RELOAD") || second === "--reload" || first === "--reload"; 14 | const production = Deno.env.has("BUILD") || first === "build"; 15 | 16 | const config: BuildOptions = { 17 | metafile: true, 18 | external: [ 19 | "*.external.css", 20 | ...c.external ?? [] 21 | ], 22 | loader: { 23 | ".woff": "file", 24 | ".woff2": "file", 25 | ".ttf": "file", 26 | ".html": "file", 27 | ".svg": "file", 28 | ".png": "file", 29 | ".webp": "file", 30 | ".xml": "file", 31 | ".txt": "file", 32 | ...c.extraLoaders 33 | }, 34 | tsconfigRaw: { 35 | compilerOptions: { 36 | experimentalDecorators: true 37 | } 38 | }, 39 | plugins: [ 40 | autoTemplates(c), 41 | httpImports({ sideEffects: c.sideEffects }), 42 | ...c.plugins ?? [] 43 | ], 44 | inject: [ ...c.poylfills ?? [], ...c.shims ?? [] ], 45 | bundle: true, 46 | define: c.globals, 47 | entryPoints: { 48 | ...c.pages, 49 | ...c.noHtmlEntries 50 | }, 51 | outdir: `${outdir}/`, 52 | minify: true, 53 | splitting: Deno.env.has("CHUNKS") && production, 54 | format: "esm", 55 | logLevel: "info", 56 | chunkNames: "chunks/[name]-[hash]" 57 | }; 58 | 59 | if (shouldReload) 60 | await reload(); 61 | 62 | if (production) { 63 | const state = await build(config); 64 | if (!noExit) 65 | Deno.exit(state.errors.length > 0 ? 1 : 0); 66 | } else { 67 | await startDevServer(config, c); 68 | } 69 | } -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "type": "object", 4 | "properties": { 5 | "$schema": { 6 | "type": "string" 7 | }, 8 | "allowBreaking": { 9 | "description": "Allow breaking updates (major releases).", 10 | "default": false, 11 | "type": "boolean" 12 | }, 13 | "allowUnstable": { 14 | "description": "Allow unstable updates (prereleases).", 15 | "default": false, 16 | "type": "boolean" 17 | }, 18 | "readOnly": { 19 | "description": "Perform a dry run.", 20 | "default": false, 21 | "type": "boolean" 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | import { ulid } from "https://deno.land/std@0.224.0/ulid/mod.ts"; 2 | ulid(); 3 | console.log('Hello World!'); -------------------------------------------------------------------------------- /tests/jsr.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "https://deno.land/std@0.224.0/assert/assert.ts"; 2 | import { serve } from "../mod.ts"; 3 | import { assertEquals } from "https://deno.land/std@0.224.0/assert/assert_equals.ts"; 4 | 5 | 6 | Deno.test("jsr test", { 7 | sanitizeResources: false, 8 | sanitizeOps: false, 9 | }, async () => { 10 | Deno.env.set("NO_EXIT", "true"); 11 | Deno.env.set("BUILD", "true"); 12 | await serve({ 13 | pages: { 14 | "jsr": "tests/jsr.ts", 15 | } 16 | }); 17 | 18 | const cmd = await new Deno.Command("deno", { 19 | args: [ 20 | "run", 21 | "dist/jsr.js" 22 | ] 23 | }).output(); 24 | 25 | 26 | assert(cmd.stderr.length === 0, "stderr is not empty"); 27 | 28 | assertEquals((await new Response(cmd.stdout).text()).trim(), "1000"); 29 | }); -------------------------------------------------------------------------------- /tests/jsr.ts: -------------------------------------------------------------------------------- 1 | import { SECOND } from "jsr:@std/datetime"; 2 | 3 | console.log(SECOND); -------------------------------------------------------------------------------- /tests/smoke.test.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "../mod.ts"; 2 | 3 | 4 | Deno.test("smoke test", { 5 | sanitizeResources: false, 6 | sanitizeOps: false, 7 | }, async () => { 8 | Deno.env.set("NO_EXIT", "true"); 9 | Deno.env.set("BUILD", "true"); 10 | await serve({ 11 | pages: { 12 | "index.html": "tests/index.ts", 13 | } 14 | }); 15 | }); -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { Loader, Plugin } from "https://deno.land/x/esbuild@v0.25.5/mod.js"; 2 | 3 | export type ServeConfig = { 4 | /** default 1337 */ 5 | port?: number; 6 | /** automatically provide html templates */ 7 | pages: Record; 8 | /** default is `templates` */ 9 | templateRoot?: string; 10 | /** if a nested page wasn't found nested try use a key-equal one in the root folder */ 11 | preventTemplateRootFallback?: boolean; 12 | outDir?: string; 13 | assets?: Record, 14 | noHtmlEntries?: Record; 15 | htmlEntries?: string[]; 16 | extraLoaders?: Record, 17 | external?: string[], 18 | 19 | /** 20 | * Define Global KeyValues. 21 | * 22 | * `globals: { "ENV": "development"}` 23 | * 24 | * turn into `globalThis.ENV == development` 25 | */ 26 | globals?: Record; 27 | 28 | sideEffects?: boolean; 29 | 30 | /** 31 | * Append Plugins to the default plugins 32 | */ 33 | plugins?: Plugin[]; 34 | 35 | /** 36 | * Add polyfills to your entrypoints 37 | * 38 | * Should be URLs 39 | */ 40 | poylfills?: string[]; 41 | shims?: string[]; 42 | /** 43 | * Defaults to a simple css & js loader 44 | */ 45 | defaultTemplate?: (name: string, path: string) => string; 46 | }; 47 | --------------------------------------------------------------------------------