Importing modules and WebAssembly

A worker can use libraries from the web: write a static import with a URL, and RTCode.io fetches and bundles the module for you. WebAssembly works too, although RTEdge.net runs it differently from the playground. This article covers how imports are bundled in the playground and again at deploy, what is frozen when, and which WebAssembly patterns work in both places.

Note: Imports and WebAssembly work in the playground and, in edge workers, on RTEdge.net. The two bundle differently, so test the pattern that you deploy.

Importing modules

Import from a URL

Use static, top-level import declarations with full URLs, preferably protocol-relative. Default, named, namespace and side-effect imports are supported.

js
import { marked } from '//cdn.jsdelivr.net/npm/marked@latest/+esm'
  • Bare specifiers do not resolve. 'lodash' has no /, so it fails; 'lodash/fp' is read as a URL relative to the importing code, which is almost never what you want. Use full URLs everywhere in your own code.
  • Dynamic import() does not work in playground workers, because they run in a service worker, where the HTML specification disallows it. Workers cannot load code at runtime either, so avoid it in edge workers.
  • Top-level await and import.meta are errors in playground workers.
  • Specifiers that start with cloudflare: pass through to the Workers runtime. They work on RTEdge.net only: the playground cannot run them.

Example: render Markdown

The worker imports marked and answers with HTML. The iframe asks for :, which the worker sees as /.

html
<iframe src=:></iframe>
<script type=worker edge>
  import { marked } from '//cdn.jsdelivr.net/npm/marked@latest/+esm'
  export default {
    fetch: () => new Response(marked.parse('# Hello\n\nRendered by **marked**.'), { headers: { 'content-type': 'text/html;charset=utf-8' } })
  }
</script>

Example: Markdown from a worker asset

Here the Markdown lives in the page as a worker:fs asset that the worker reads from env.FS. With an empty worker:fs value the asset takes the o:= name, .md. The reload attribute makes every edit a full output load, so the asset stays fresh while you type.

html
<iframe src=:></iframe>
<script o:=.md worker:fs type=text/markdown reload>
# Hello from the worker
</script>
<script type=worker edge>
  import { marked } from '//cdn.jsdelivr.net/npm/marked@latest/+esm'
  export default {
    fetch: (request, { FS }) => new Response(marked.parse(FS['.md'].body), { headers: { 'content-type': 'text/html;charset=utf-8' } })
  }
</script>

How the code is bundled

In the playground, the editor bundles the imports in the browser, with esbuild compiled to WebAssembly, on every full output load. Modules are fetched by the editor page, so the hosts that serve them must allow cross-origin requests (CORS). A toast shows the progress of each module and has a Cancel button. The bundle is cached in memory per set of imports: editing your worker code reuses the cached bundle, and changing, adding or reordering imports triggers a new build. Moving versions such as @latest resolve only when the cache misses.

The bundled code lands in the same function body as your code. It runs in sloppy mode and runs again for each request, and import.meta.url becomes the URL of the service worker. A library that locates a .wasm file or other files relative to import.meta.url will therefore look on out.rtcode.io.

At deploy, static imports and re-exports whose specifier starts with https://, http:// or // are resolved and become part of the deployed worker. Relative imports inside a fetched module resolve against that module's URL. From the playground, a remote build service bundles the worker at every deploy, without the cache of your browser. The deployed worker never loads modules at runtime. Because the two bundlers differ, check the deployed worker after the first deploy.

Imports are frozen

On RTEdge.net, a URL such as …@latest/+esm resolves once per deploy. To pick up upstream changes, deploy again.

In the playground, run Clear: Worker Import Cache from the command palette (F1), then press F5 to rebuild against the current upstream versions. The command does not reload the output by itself, and reloading the editor empties the cache too. The command also has the shortcut Ctrl+Shift+C / ⌘+Shift+C, which browsers may reserve for their own use; the palette always works.

Limits and failures

  • A playground build stops with Load exceeds size budget when the loaded modules exceed 20 × 1024² characters of text, or 10 MiB gzipped in total. The import segment itself is checked the same way.
  • If bundling fails, because of a network error, an unresolvable import, the budget or a cancel, the error is logged as Error occurred while processing:. Worker requests then fail until a later full load builds successfully: fix the cause and press F5.
  • While the worker contains import or export and does not parse, the playground cannot update the output. It resumes once the worker parses again.
  • On RTEdge.net, Cloudflare validates the module at upload. An unresolvable import fails the upload, and the playground shows each Cloudflare error as a toast for its route.

WebAssembly

Cloudflare Workers do not allow WebAssembly.compile, compileStreaming, instantiate with a byte buffer or instantiateStreaming while a request runs. RTEdge.net works around this at deploy: it finds WebAssembly in your code, uploads pre-compiled modules and rewrites the calls. It can only find certain source shapes, which the sections below list. The examples use //rc.efn.kr/add.wasm, a small public module that exports add.

In the playground

Every standard API works:

  • WebAssembly.instantiateStreaming(fetch(url)) needs the type application/wasm and CORS headers on the file. It resolves to { module, instance }, and the exports are on .instance.exports.
  • WebAssembly.instantiate(bytes, imports) resolves to { module, instance } as well.
  • WebAssembly.compile(bytes) followed by new WebAssembly.Instance(module, imports) gives the exports on .exports directly.
  • ES modules with inlined WebAssembly, such as xxhash-wasm, work through static imports.

Keep initialization promises in module scope, or create them lazily inside the handler, so the cached handler reuses them. Do not call fetch() at the top level of an export default worker: there the name refers to your own handler.

On RTEdge.net

The deploy compiler recognizes these shapes. The results look as they do in the playground: byte and stream instantiation give objects with .instance.exports, and new WebAssembly.Instance(module, imports) exposes .exports.

Source shapeAt the edge
new Uint8Array([…]), new globalThis.Uint8Array([…]) or Uint8Array.from([…]) with integer literals from 0 to 255, or .buffer of one of themExtracted when the bytes begin with the \0asm magic and there are at least 8 of them. They can be anywhere in the module, including inside bundled libraries.
A string literal that holds the raw \0asm… bytes, or a base64 or base64url string literal of at least 12 charactersExtracted. Bytes in a template literal (backticks) are not.
WebAssembly.instantiate(…) or WebAssembly.compile(…), written literally, when at least one embedded module was foundThe call is routed through a shim. Bytes that match an extracted module resolve to the pre-compiled module: compile to a WebAssembly.Module, instantiate to { module, instance }. Passing a Module works, and bytes that do not match fail.
x = await WebAssembly.instantiateStreaming(f('<URL>'), imports), as a declaration or an assignment, ??= included, where f is any identifier called with exactly one string literalThe .wasm file is fetched at deploy, so the URL must be absolute or protocol-relative. The expression becomes { module, get instance() }, and the instance is created on first access with your import object.

These are not extracted and fail at the edge:

  • instantiateStreaming without await, as in p ??= WebAssembly.instantiateStreaming(fetch(url));
  • computed URLs, and compileStreaming;
  • new WebAssembly.Module(bytes), which is expected to fail like the other runtime compiles;
  • bytes that your code builds at runtime;
  • calls through an alias, such as const W = WebAssembly; W.instantiate(…), or through globalThis.WebAssembly.

At the top level of an edge worker, building WebAssembly instances from extracted modules is fine. A fetch() or a timer there throws during startup and fails the upload, so start streaming work inside the handler and cache the result.

A pattern that works in both

The first module comes from literal bytes, and the second is streamed with an awaited and assigned instantiateStreaming inside the handler. Open :1+2+3 for the sum, 6.

html
<iframe src=:1+2+3></iframe>
<script type=worker edge>
  const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])
  const empty = WebAssembly.instantiate(bytes, {})
  let adder
  export default {
    fetch: async request => {
      adder ??= await WebAssembly.instantiateStreaming(fetch('//rc.efn.kr/add.wasm'))
      const { add } = adder.instance.exports
      const sum = new URL(request.url).pathname.slice(1).split('+').reduce((s, x) => add(s, +x || 0), 0)
      return Response.json({ sum, exports: Object.keys((await empty).instance.exports).length })
    }
  }
</script>

You should see {"sum":6,"exports":0}. After you deploy, https://name.rt.ht/:1+2+3 returns the same JSON.

Availability

FeaturePlaygroundRTEdge.netNotes
Static URL importsYesYesBundled in the browser in the playground, by a build service at every deploy.
cloudflare: importsNoYes
Dynamic import()NoNo
Any WebAssembly APIYesPartialThe edge supports the extracted shapes only.

See also