Skip to main content

Browser entries

A module can ship code that runs in a page: a renderer, a component, a client for the API its controllers serve. exports.browser: is where it says so. An entry is built by the kernel — from source in a checkout, at publish for a release — and a controller asks for it by name and serves what it gets.

A browser entry is never imported by a kernel. It is data a controller hands to a browser, which is why it has its own block and its own layer rather than riding in exports.code:.

Declaring an entry​

kind: Telo.Library
metadata:
name: Badges
version: 1.0.0
exports:
browser:
- specifier: "@acme/badges"
path: ./browser/badges.js
source: ./browser/src/badges.tsx
abi: ui-1
external: [react, react/jsx-runtime]
exports: [StatusPill]
Field
specifierrequiredThe name the entry is imported by — a key of the page's import map — and the name a resource refers to it by. One entry per specifier.
pathrequiredWhere the built file goes, relative to the module root. .js or .mjs.
sourcerequired in a checkoutThe file path is built from.
abioptional<family>-<version>: the contract between the entry and the page that loads it. A host compares it with its own and decides what a mismatch means.
externaloptionalBare specifiers the host supplies through its import map. They stay imports in the output; everything else the entry imports is bundled into it.
exportsoptionalThe export names a resource may name. The build fails if the entry lacks one.

There is no format — a browser entry is always an ES module — and no os, arch or libc: it is one file for every platform. Writing any of them, a malformed entry, a specifier declared twice, or a path or source that is absolute or points above the module root is BROWSER_ENTRY_INVALID — and the entry does not exist for anything that names its specifier.

exports: is a Telo.Library block, so a browser entry belongs to a library. An application that needs one puts it in a library it imports.

How entries are built​

The kernel builds an entry as an ES module for a browser: ES2022, the automatic JSX runtime, the source export condition (so a workspace package is bundled from its sources), and process.env.NODE_ENV set to "production". A stylesheet the sources import is emitted as a file beside the entry.

Entries that declare the same external list are built together, with code splitting, so what they share ends up in one chunk both import:

exports:
browser:
- { specifier: "@acme/badges", path: ./browser/badges.js, source: ./src/badges.tsx, external: [react] }
- { specifier: "@acme/charts", path: ./browser/charts.js, source: ./src/charts.tsx, external: [react] }

That is how two entries of one module come to share a dependency. Listing the dependency as external in one entry and bundling it in the other does not work for a CommonJS package, which is wrapped at build time and cannot be reached through an import a browser resolves. Entries with different external lists are separate builds and share nothing.

In a source checkout an entry is built the first time something asks for it, into <cache-root>/browser-src/, and rebuilt when any file the build read changes. A build that fails is ERR_BROWSER_BUILD_FAILED, and there is no prebuilt file to fall back to.

Reaching an entry from a controller​

const entry = await ctx.resolveControllerBrowserEntry("@acme/renderer");
MethodResolves against
ctx.resolveControllerBrowserEntry(specifier)the module declaring the kind — the code the controller's own module ships
ctx.resolveBrowserEntry(specifier)the module that declared the resource — the code its author named

Both return the same shape:

Field
filefile:// URI of the built ES module
siblingsfile:// URIs of everything else it loads: shared chunks, and its stylesheet
digesta content digest over all of those files — it changes when any byte a page would load does
abi, external, exportsas the entry declares them

The siblings keep their place relative to the entry, which imports its chunks by relative path — serve them the way they sit on disk. digest is what a content-addressed URL or an ETag is made from. A consumer that serves an entry's files reads them when it resolves them, and serves those bytes: in a checkout the build cache may delete a superseded build, so a path kept for a later read can be gone.

Both methods are served by the Node kernel only.

What an entry loads is recorded by its build in a file beside it, <path>.siblings.json:

{ "files": [ { "path": "browser/chunks/shared-4F2A.js" } ] }

Each path is relative to the module root. The kernel serves only what that list names, and only when every path is a regular file inside the directory the entry was built or extracted into: a list that is missing, is not this shape, or names a file outside that directory is ERR_BROWSER_ENTRY_UNAVAILABLE. The name is reserved — telo publish and telo package refuse a module that declares its own file at <path>.siblings.json.

Naming an export from a resource​

A kind lets its resources name an export of a browser entry with x-telo-browser-export:

kind: Telo.Definition
metadata:
name: Component
schema:
type: object
properties:
entry: { type: string }
export:
type: string
x-telo-browser-export: { entry: /entry }

entry is a JSON Pointer, relative to the object holding the annotated field, to the sibling string that names the browser entry's specifier. A resource then reads:

kind: Ui.Component
metadata:
name: statusPill
entry: "@acme/badges"
export: StatusPill

The specifier must be one the module declaring the resource lists under exports.browser, and the export one that entry lists under exports:

at telo checkat creation
the module declares no such entryBROWSER_ENTRY_UNKNOWNERR_BROWSER_ENTRY_UNKNOWN
the entry declares no such exportBROWSER_EXPORT_UNKNOWNERR_BROWSER_EXPORT_UNKNOWN

An entry another module ships under the same name does not count. A resource names code its own module ships, which is also what ctx.resolveBrowserEntry resolves.

In a published module​

telo publish and telo release build every entry with the same builder and put the results in a browser layer — one per distinct abi — holding each entry, its chunks and stylesheet, and the <path>.siblings.json listing them. Nothing needs a files: entry. A runtime fetches a browser layer only when one of its entries is resolved; telo install and telo package always include it, since it is the same on every platform. The Rust kernel skips browser layers.