Skip to content

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

@crutch12

Description

@crutch12

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions