esm.sh icon indicating copy to clipboard operation
esm.sh copied to clipboard

esm.sh/tsx - support transform of relative imports (e.g. `import App from './app.tsx'`)

Open crutch12 opened this issue 1 month ago • 2 comments

With current esm.sh/tsx implementation you are not able to import relative .tsx files and preprocess them with esm.sh/tsx

It reduces ways of esm.sh/tsx usage.

Example

index.html

<!DOCTYPE html>
<html>
<head>
  <script type="importmap">
    {
      "imports": {
        "react": "https://esm.sh/[email protected]",
        "react-dom/client": "https://esm.sh/[email protected]/client"
      }
    }
  </script>
  <script type="module" src="https://esm.sh/tsx"></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    import { createRoot } from "react-dom/client"
    import { App } from "./app.tsx" // generates import { App } from "script-0.tsx/app.tsx"
    createRoot(root).render(<App />)
  </script>
</body>
</html>

app.tsx

export function App() {
  return <div>App</div>
}

Result

Expected (desired) result: esm.sh/tsx transformed ./app.tsx into valid js content and this code should work fine

Actual result: you get an error

tsx:6 Uncaught TypeError: Failed to resolve module specifier "script-0.tsx/app.tsx". Relative references must start with either "/", "./", or "../".

The same problem with dynamic imports (e.g. const { App } = await import('./app.tsx'))

Solutions

1. Automatically replace all relative imports with await __dynamicImport(...)

async function __dynamicImport(path, options) {
  const code = await fetch(path).then(r => r.text())
  const hash = getHash(code)

  if (localStorage.getItem(hash)) { // should also check for result hash
    // local usage, just import() data uri and execute it "inline"
    return import(`data:text/javascript,${encodeURIComponent(localStorage.getItem(hash))}`, options)
    // or use Blob instead of data uri
  }

  if (location.hostname == 'localhost') {
    const output = await tsx.build(code) // esm.sh tsx build method
    localStorage.setItem(hash, output)
    // local usage, just import() data uri and execute it "inline"
    return import(`data:text/javascript,${encodeURIComponent(output)}`, options)
    // or use Blob instead of data uri
  }
  else {
    // check if code was uploaded beforehand
    const builtCodeUrl = await fetch(esmRelativeUrl(hash, path)).catch(() => null)

    // build result is uploaded to build file, we can import it instead of original file
    // e.g. esm.sh/tsx/f4829acaeea20828e385cdb4e3845b64dc6d9def/app.tsx
    if (result) return import(builtCodeUrl, options) // import uploaded code by url

    // no build result, we should build a new one
    const output = await tsx.build(code)
    // upload result to server
    const uploadedUrl = await fetch(esmRelativeUrl(hash, path), { method: 'POST', body: output  })
    return import(uploadedUrl, options) // import uploaded code by url
  }
}

function esmRelativeUrl(hash, path) {
  return new URL(hash + path, import.meta.url) // esm.sh/tsx/f4829acaeea20828e385cdb4e3845b64dc6d9def/app.tsx
}

function getHash(text) {
  return '...' // generate hash
}
// import { App } from "./app.tsx"
const { App } = await __dynamicImport('./app.tsx')
// now it works!

Pros

  • it works (mostly)

Cons

  • requires top level await support (or we can transpile it like vite-plugin-top-level-await)
    • most of the browsers (including Safari) support this feature
  • cyclic imports will stuck (maybe it's possible to fix it? I'm not sure)
  • we should handle every relative imports (even .js files), because those .js files may import .tsx files
  • we should also replace all import.meta.url/import.meta.resolve usages for data uri executions

2. Service Workers

If we register a Service Worker that will handle every import ... from '___.tsx' request, we may leave result built code as is, since service worker will resolve every import and transform result into valid js code.

Example

// service-worker.js

self.addEventListener("fetch", fetchHandler)

function fetchHandler(event) {
  if (event.request.destination !== 'script') return;
  if (!event.request.url.startsWith(self.location.origin)) return;

  const match = event.request.url.match(/\.(jsx|ts|tsx|css|vue)/)
  if (match) {
    event.respondWith((async () => {
      const text = fetch(event.request).then(target => target.text())

      const hash = getHash(text)
      
      const output = await tsx.build(code) // or fetch uploaded to server // or save to Caches API

      const networkResponse = new Response(output, {
        headers: { 'Content-Type': 'application/javascript' }
      })

      return networkResponse 

     })())
  }
}
import { App } from "./app.tsx" // processed and transformed by Service Worker
// now it works!

I've tried to implement it here: https://github.com/crutch12/esbuild-standalone

And it works great.

Pros

  • automatically handles .tsx imports, no __dynamicImport usage
  • no cyclic imports problems
  • handle only .tsx/.ts/.jsx files

Cons

  • requires register Service Worker (it's a compelling wall in most cases)

crutch12 avatar Nov 23 '25 11:11 crutch12

@ije hi 👋

Wdyt?

If you like any of these solutions, I would like to provide PR for this feature

crutch12 avatar Nov 23 '25 12:11 crutch12

You can use https://esm.sh/x instead:

- <script type="module" src="./app.tsx"></script>
+ <meta name="version" content="1"> // cache control tag
+ <script src="https://esm.sh/x" href="./app.tsx"></script>

This is a wip feature not list on the docs, that allows you to import TS(X)/Vue/Svelte in browsers without build step

ije avatar Nov 23 '25 14:11 ije