- JavaScript 74.2%
- Go 24.7%
- HTML 1.1%
| docs | ||
| public | ||
| v1 | ||
| go.mod | ||
| LICENSE | ||
| light | ||
| light.go | ||
| main.go | ||
| README.md | ||
| SKILL.md | ||
light
light is a client-side component framework in a single file, with no build step and no dependencies. The server delivers components — ES modules bundling markup, style, and behavior — and light mounts them in the page as isolated shadow-DOM islands. Components communicate through named event buses, and a bus can be connected to the backend over a websocket or a polled endpoint, so in-app and client/server messaging share the same API.
The backend can be written in any language: it serves the component modules and, for live features, speaks a small JSON protocol described below. This repository includes a reference backend in Go.
go run . # demo backend — open http://localhost:4173 in two windows
Documentation
- docs/reference.md — complete client API: every element, field, method, option, and console message
- docs/protocol.md — the wire contract and how to write a backend in any language (+ the Go hub's API)
- docs/patterns.md — the idioms: families and libraries, events up/calls down, sticky state, theming
- SKILL.md — a loadable skill that teaches LLM agents the contract and idioms up front, so they build with light correctly on the first try
The README below is the narrative tour; the docs are the exhaustive version.
Pinned builds live in v1/ — serve the folder (the demo backend
exposes it at /v1/) and import one file:
<script type="module">
import { light } from '/v1/light.min.js'; // 7.2 KB, ~3.4 KB gzipped
</script>
The build is capped at 8 KB minified, so the framework plus a page fit inside one initial TCP congestion window (~14 KB).
Why
light is built around two goals.
1. Components you can understand in one read. A component is a single file holding its markup, styles, and behavior, and its isolation is enforced by the platform, not by convention: styles cannot leak in or out, and DOM queries cannot reach outside the component. Understanding or modifying a component never requires reading a global stylesheet, a theme layer, or the rest of the page — everything true about the component is in its file. There are no specificity conflicts to untangle, no dozens of overlapping CSS declarations to trace. This is as deliberate for LLMs as for people: an agent can safely edit a component after reading only that component, with a guarantee — rather than a hope — that nothing else is affected.
2. One way to communicate. Sibling widgets, parent and child, and the
backend all talk through the same primitive: emit and on over named
buses. Effects propagate through subscriptions instead of hand-written
wiring, and how a bus is routed — scoped to a subtree, page-global, or
connected to the server — is declared outside the components that use it.
Client and server state live in the same layer: sticky bus events carry the
current value to anything that mounts, whenever it mounts.
A consequence of the first goal, unplanned but welcome: sharing a component is sharing a file. Since a component has no imports, no build step, and no external stylesheet, a component library is just a folder of files — copy one, or reference it by URL, and it works. There is no dependency tree to manage. One file: all the HTML, CSS, and JS in one place.
Hello, component
A component is an ES module living at a URL:
// served at /components/hello
import { html } from '/v1/light.min.js';
export default {
css: `p { color: hotpink; }`,
render: props => html`<p>hello, ${props.name}!</p>`,
mount(root, props, ctx) {
// the DOM from render() exists now — attach listeners, subscribe to buses
},
};
The html tag escapes every interpolation, so props and data are inert
text by default. Nested html results and arrays of them compose without
double-escaping; raw() embeds a string as markup when you really mean it.
Plain strings are also accepted from render — escaping is then on you.
Use it anywhere — in a page, or in another component's render() — as a
tag. Attributes become props:
<x-light src="/components/hello" name="world"></x-light>
When the tag enters the DOM, light fetches the module, attaches a shadow
root, applies the CSS, injects the rendered markup, and calls mount.
Anatomy of a component
Every field is optional:
| field | what it is |
|---|---|
css |
styles for this component, parsed once and shared by all instances |
render(props) |
pure function: props in, HTML string out |
mount(root, props, ctx) |
behavior hook, called once when the DOM is live; may return @event handlers |
on |
static @event handlers (see Declarative events) |
update(props, ctx) |
called when attributes change after mount; default is a morphed re-render |
provides: ['name'] |
declares scoped buses for this component's subtree |
mount receives:
root— the component's shadow root. Both styles and DOM are isolated at this boundary:root.querySelectoronly sees this component's own markup, outside styles don't leak in, and the component's styles don't leak out. Class names and ids only need to be unique within the component. (Isolation is structural, not a security boundary: a component module is plain JavaScript running with normal page privileges — it is your server's code, exactly as trusted as any script you serve.)props— the host tag's attributes, as a plain object of strings. Props are live: changing an attribute after mount re-renders the component (or calls itsupdatehook), so a parent pushes new configuration down by writing attributes on the child's tag. State that changes without a parent can live on a bus (sticky) and triggerctx.rerender()from inside.srcitself is the component's identity and changing it after mount has no effect (light warns in dev builds); remove the element and create a new one to swap components.ctx— framework services:{ buses, provide, signal, host, rerender }.
Cleanup is automatic. When the element is removed from the DOM, ctx.signal
(an AbortSignal) fires: all bus subscriptions made through ctx.buses are
released. Use the same signal for anything else you start:
mount(root, props, { signal }) {
const id = setInterval(tick, 2000);
signal.addEventListener('abort', () => clearInterval(id));
}
Moving a component in the DOM (reordering a list, drag & drop) is transparent: the element reconnects within the same task, nothing is aborted, and its DOM, listeners, and subscriptions survive intact. Removing an element and reinserting it later is a fresh mount from scratch — no state handover, same rule as a re-rendered subtree.
While a module loads, the element shows a throbber in its shadow root —
loading="off" disables it, loading="some text" shows that text
instead. A failed load renders an error box with the failing src.
Re-rendering
render is not one-shot. ctx.rerender() runs it again and morphs
the result into the live DOM — matching nodes are kept and synced in
place, so focus, selection, and input contents survive, and unchanged
child components are not remounted:
mount(root, props, ctx) {
let count = 0;
root.querySelector('button').addEventListener('click', () => {
count++;
ctx.rerender(); // patches the DOM, keeps the <input> focused
}, { signal: ctx.signal });
}
Changing a host attribute after mount re-renders too — a parent re-render
that writes new attributes onto a child's tag propagates by itself.
Components that prefer manual DOM updates define update(props, ctx)
instead, and attribute changes call it rather than re-rendering.
Declarative events
Render output can bind events with @event attributes; the handler is
looked up in mount's return value merged over the component's on
field:
export default {
render: p => html`<button @click="save">save</button>`,
mount(root, props, ctx) {
return {
save(e, el) { ctx.buses.api.post('items', collect(root)); },
};
},
};
Handlers receive (event, el), ride on the component's abort signal (no
cleanup), and are re-wired after every morph without duplicating. Manual
addEventListener(..., { signal }) in mount remains available for
anything else.
Composition
A parent renders children as tags. Children need no imports or registration — the custom-element lifecycle loads them, in parallel, and repeated component types share one module fetch:
render: () => `
<h1>crew quarters</h1>
<x-light src="/components/chat" name="Port Pod"></x-light>
<x-light src="/components/chat" name="Starboard Pod"></x-light>
`,
Mounting at runtime is plain DOM:
const el = document.createElement('x-light');
el.setAttribute('src', '/components/monitor');
someContainer.append(el);
One file, many components
A module can export more than one component. The default export answers
the URL itself; named exports are addressed with a URL fragment:
// /components/chat
export default {
render: () => `
<x-light src="#log"></x-light>
<x-light src="#input"></x-light>
`,
};
export const log = { css: `...`, render: () => `...`, mount() {} };
export const input = { css: `...`, render: () => `...`, mount() {} };
src |
resolves to |
|---|---|
/components/chat |
the module's default export |
/components/chat#log |
the module's log export |
#log |
the log export of the enclosing component's module |
The fragment never reaches the network: however many components a module exports, it is fetched once, and every reference on the page shares that one fetch. Children declared with fragments mount with no additional requests — a composite widget arrives whole.
The bare #name form keeps a file self-contained: it never mentions its
own URL, so it can be renamed, copied, or served from a different path
without editing its contents.
Two shapes earn a shared file. A family: a component and the private parts only it uses. And a library: a file of self-contained primitives referenced from everywhere —
<x-light src="/lib#prim-panel" heading="SETTINGS" closable style="--accent: #7fdb8f">
<x-light src="/lib#form-input" placeholder="search…"></x-light>
</x-light>
Library primitives stay context-free: they signal up with bubbling DOM
events (press, field:submit, panel:close), take configuration as
props, and expose their colors as CSS custom properties so consumers theme
them from the tag. However many components use the library, it costs one
fetch.
A grab-bag of unrelated components is neither shape — it trades away the one-file-one-read property that makes components easy to work on.
Parent and child: events up, calls down
A child announces things by dispatching a DOM event that bubbles out through its shadow boundary:
// child
root.host.dispatchEvent(new CustomEvent('panel:close', { bubbles: true, composed: true }));
// parent — event retargeting makes e.target the child's <x-light> element
root.addEventListener('panel:close', e => e.target.remove());
Parents act on children by selecting the child element and calling it directly.
Buses
For components that don't share a parent/child relationship,
ctx.buses.<name> is a named channel, created on first use:
buses.cart.emit('item-added', { sku: 'x-99' });
buses.cart.on('item-added', e => badge.textContent = e.detail.sku);
Subscriptions are bound to the component's lifetime and need no manual cleanup.
An emit can be sticky: the last payload is stored and replayed immediately to any later subscriber. Use this for anything that represents current state rather than a one-off occurrence:
buses.session.emit('login', user, { sticky: true });
// a component mounted later still learns who is logged in, immediately
Sticky means the same thing everywhere. On a page-local bus the page remembers; on a server-connected bus the flag crosses the wire and the server remembers, so a client that connects later gets caught up too. One option, one mental model — a client-side todo list and a multi-window chat use the exact same call.
Scoped buses
By default, bus names are global. If a component uses a bus internally and
may appear on the page more than once, scope the bus to its subtree with
provides:
export default {
provides: ['thread'], // this instance's subtree gets its own 'thread' bus
render: p => `
<x-light src="#log"></x-light>
<x-light src="#input"></x-light>
`,
};
Bus names resolve upward through the tree: scoped → global →
server-connected. Consumers always write buses.thread and get the
nearest bus with that name, so the same component works unchanged in any
context:
<x-light src="#log"></x-light> <!-- parent's scoped bus -->
<x-light src="/components/chat#log" bus="comms"></x-light> <!-- a global, server-connected bus -->
Server buses
To connect a bus to the backend, declare it once in the page:
<script type="module">
import { light } from '/v1/light.min.js';
light.serverBus('station', { ws: '/light/ws' }); // live push
light.serverBus('comms', { ws: '/light/ws' }); // same socket, multiplexed
light.serverBus('news', { poll: '/api/news', every: 10_000 }); // polled endpoint
light.serverBus('api', { rest: '/api' }); // request/response verbs
</script>
Components subscribe to server buses exactly like any other bus:
mount(root, props, { buses }) {
buses.station.on('telemetry', e => updateGauges(e.detail));
}
Transport semantics:
- WebSocket buses are server-authoritative. A client emit is sent to the server and fires locally only when the server broadcasts it back, so every client observes the same event stream in the same order.
- Poll buses fetch the endpoint on the given interval and emit whatever it returns. Client emits are POSTed to the endpoint and also fire locally right away.
- All websocket buses share one connection per URL. The connection
reconnects with exponential backoff; connection state is published as a
sticky
server:statusevent onbuses.system.
A server bus is a page bus whose subscribers span clients: the same emit
/on calls, the same sticky semantics, the same cleanup — the only
difference is where the bus is declared, and that happens in the app shell,
not in components.
The framework also emits component:mounted and component:unmounted on
buses.system for every component on the page.
Requests: HTTP verbs on a bus
Events carry state; requests carry commands. A bus connected with
{ rest: url } exposes the verbs — the message path is appended to the
bus root, so it's plain REST any backend router handles natively:
const { target } = await buses.api.get('temp'); // GET /api/temp
await buses.api.put('temp', { target: 22 }); // PUT /api/temp
await buses.api.post('orders', { items }); // POST /api/orders
await buses.api.delete('orders/42'); // DELETE /api/orders/42
Replies are parsed JSON (or null for 204); non-2xx rejects. get sends
its detail as query parameters. The intended split: commands request,
state broadcasts — a mutation goes up as a verb, and the resulting state
change comes back to every client through a push or poll bus, not through
the reply.
Backend integration
The full client/server contract is one JSON shape:
websocket text frame, both directions:
{"bus": "comms", "type": "post", "detail": {...}, "sticky": true?}
poll bus:
GET → {"type": "...", "detail": {...}} (or an array of them)
POST ← {"type": "...", "detail": {...}} (client emits)
Server semantics mirror a page-local bus, stretched across clients:
- A client frame with no registered handler is relayed to every client as-is — sender included, so all clients share one event order. A chat or presence bus needs zero server code.
- Frames marked
stickyare remembered per(bus, type)and replayed to each client that connects, so new clients receive current state immediately — just like late subscribers on a page. - Register a handler for a
(bus, type)to intercept instead: validate, persist, rate-limit. The handler then owns whether and what to relay.
Go (this repository)
light.go implements the protocol in pure stdlib, including the websocket
framing, in about 300 lines. Server-side usage mirrors the client API:
h := newHub()
http.Handle("/light/ws", h)
// push to all clients; late joiners receive the latest value on connect
station := h.bus("station")
station.emitSticky("telemetry", state)
// optional: intercept client emits — validate, persist, then relay (or not)
comms := h.bus("comms")
comms.on("post", func(detail json.RawMessage) {
if acceptable(detail) {
comms.emit("post", detail)
}
})
The demo's STATION COMMS bus registers no handler at all — the hub's relay
does everything. Before exposing a hub beyond localhost, add an Origin
check and per-bus authorization to ServeHTTP; as written, every connected
client receives every frame.
Content Security Policy
Components are real ES modules — no eval, no inline script — so light runs under a strict policy. The demo backend sends one:
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:
style-src 'unsafe-inline' is only needed for inline style="--accent:…"
theming on tags; keep bus wiring in a file (app.js), not an inline script.
Any other language
A poll bus works with any endpoint that returns the expected shape:
# python/flask
@app.get("/api/news")
def news():
return {"type": "notice", "detail": {"text": latest_headline()}}
light.serverBus('news', { poll: '/api/news', every: 10_000 });
The demo — Lighthouse Station
| try this | what it shows |
|---|---|
| watch TELEMETRY | server-pushed state: the component holds a subscription, not a fetch loop |
| adjust CLIMATE | rest bus verbs: PUT /api/temp, then the cabin drifts toward the target via the push bus |
| open two windows, use STATION COMMS | client → server → all clients with zero per-bus server code (hub relay); new windows see the last post via sticky replay; the Send button throbs until the server round-trips your post |
| type in one chat pod | a scoped thread bus — the other pod (and the server) never sees the message; the pod itself mirrors a copy to SYSTEM LOG |
| click + monitor | runtime mounting + sticky replay: the new chip shows data immediately |
| click ✕ on a pod | an event bubbles up, the parent removes the child, the unmount cascade appears in SYSTEM LOG |
| restart the Go server | SYSTEM LOG shows the disconnect, backoff reconnect, and state resync |
| ANNOUNCEMENT bar | a poll bus: a plain JSON endpoint re-fetched every 10 seconds |
Current limitations
- Load waterfall: each nesting level costs a round trip, since a parent
must load before its children are discovered. Servers can mitigate with
<link rel="modulepreload">hints for known subtrees. - No state handover: removing and reinserting a component remounts it from scratch. (Moving one — disconnect and reconnect within the same task — is transparent and preserves everything.)
- Buses are cooperative, not access-controlled — scoping prevents accidental collisions, not deliberate interference from other page code.
- No auth or rooms on server buses yet: every connected client receives every frame. Per-connection subscriptions belong in the server hub when needed.