evanw / esbuild

An extremely fast bundler for the web
https://esbuild.github.io/
MIT License
37.96k stars 1.13k forks source link

idea: bundle embed or static file inside a single file. #3612

Open wenerme opened 8 months ago

wenerme commented 8 months ago

I want do where to ask about this feature, but I do think this is possible for esbuild.

I want to deliver a single file which can run as a server, that serve the static or dynamic ssr content. I already use esbuild to bundle everything into a single cli.mjs, but esbuild left other content behind. I know I can do this by file loader like import content from './index.html', but I think maybe

import efs from './dist' with {type:'dir'}

If this can return a streamich/memfs, maybe a hono server can accept a fs object to use the embeded files, do we can deliver a single file that can provide a functional web app.

Just like //go:embed for golang or include_dir for Rust.

evanw commented 8 months ago

You are welcome to write a plugin to do that: https://esbuild.github.io/plugins/

wenerme commented 8 months ago

Ok, let me try, this works, but I don't know how to filter by with type:embed, workaround by force a prefix embed:

import fs from 'node:fs/promises';
import path from 'node:path';
import * as esbuild from 'esbuild';
import type { Plugin } from 'esbuild';

let embedPlugin: Plugin = {
  name: 'embed',
  setup(build) {
    build.onResolve({ filter: /^embed:.*/, namespace: 'file' }, (args) => {
      switch (args.kind) {
        case 'import-statement':
        case 'dynamic-import':
          break;
        default:
          return null;
      }

      let p = args.path.slice('embed:'.length);
      if (p.startsWith('.')) {
        p = path.resolve(args.resolveDir, p);
      }
      return {
        path: p,
        namespace: 'embed',
        pluginData: {
          resolveDir: args.resolveDir,
        },
      };
    });

    build.onLoad({ filter: /.*/, namespace: 'embed' }, async (args) => {
      const resolveDir = args.pluginData.resolveDir;
      if (args.with?.type !== 'embed') {
        return null;
      }
      const cwd = args.with.cwd || '/app';
      console.log(`embed ${path.relative(resolveDir, args.path)} to ${cwd}`);
      const stat = await fs.stat(args.path);
      if (!stat.isDirectory()) {
        throw new Error(`Embed need a directory: ${args.path}`);
      }

      async function readDirectory(dir: string, basePath = dir, result: Record<string, any> = {}) {
        const files = await fs.readdir(dir, { withFileTypes: true });

        for (const file of files) {
          const filePath = path.join(dir, file.name);
          const relativePath = path.relative(basePath, filePath);

          if (file.isDirectory()) {
            readDirectory(filePath, basePath, result);
          } else {
            result[path.resolve(cwd, relativePath)] = await fs.readFile(filePath, 'utf8');
          }
        }

        return result;
      }

      const o = await readDirectory(args.path);

      return {
        contents: `
import { memfs } from 'memfs';

const {fs:lfs,vol} = memfs(${JSON.stringify(o)}, '/app/');
const fs = lfs.promises
export {
  fs,vol,lfs
}
        `,
        loader: 'js',
        resolveDir: resolveDir,
      };
    });
  },
};

await esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  outfile: 'out.mjs',
  plugins: [embedPlugin],
  format: 'esm',
  platform: 'node',
  target: 'node20',
  banner: {
    js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);var __filename;var __dirname;{const {fileURLToPath} = await import('url');const {dirname} = await import('path');var __filename = fileURLToPath(import.meta.url); __dirname = dirname(__filename)};`,
  },
});

types.d.ts

declare module 'embed:*' {
  const fs: typeof import('node:fs/promises');
  export { fs };
}

app.ts

import { fs } from './src/utils' with { type: 'embed', cwd: '/app/' };

console.log(`FS`, await fs.readdir('/app'));
console.log(`Content`, await fs.readFile('/app/Closer.ts', 'utf-8'));
evanw commented 7 months ago

It's not just you. There isn't a way to declaratively filter by type: 'embed' right now. I still need to add an additional API for that. Right now you have to write code that checks the with map provided to the plugin. It should be the same thing, but just less efficient than it could be if there was a declarative API (since it would avoid some unnecessary IPC traffic).