lautjs
v0.6.7
Published
An islands meta-framework for Bun 1.4. Preact + signals, kinu UI, no build step.
Maintainers
Readme
Laut
An islands meta-framework for Bun 1.4, with no build step at all. Preact + signals, kinu UI built in, JSX everywhere, native ES modules and an import map in the browser.
Laut is take a rest after work or it can be sea — the thing that surrounds the islands, and the part of your page that never ships a byte of JavaScript.
Building an app with Laut? → GUIDE.md
What Laut gives an app
| | |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| Runtime | Bun 1.4 — one process, Bun.serve, Bun.Transpiler |
| UI | Preact 10 + @preact/signals |
| Components | kinu — wired up out of the box |
| Rendering Type | Server-rendered HTML, islands hydrated on the client — on load, on idle, on visible, or on a media query |
| Routing | An explicit route table + a client router that swaps <main> |
| Styling | Plain CSS, co-located with components, linked automatically |
| i18n | Optional, built in — JSON tables, one signal, works in sea and islands |
| Build | None. No bundler, no dist/, no manifest, no codegen |
The whole idea in one line: the server writes the page as HTML — in sea —and the interactive part in islands. The full explanation is in the guide.
Repo layout
laut/
├── src/ the framework — this is what ships, as `lautjs`
├── template/ the starter app `bun run scaffold` copies
├── create/ `create-lautjs` — what `bun create lautjs my-app` runs
├── scripts/ scaffold / sync-create / link / unlink — dev tooling, not published
├── GUIDE.md how to build an app with Laut
└── README.md you are hereTwo packages, one name
laut was already taken on npm, so the framework publishes as lautjs and the scaffolder as create-lautjs. There is no alias: an app depends on "lautjs": "^0.4.0", it lands at node_modules/lautjs, and every import … from "lautjs" resolves to it directly.
The name is load-bearing on both sides of the wire. lautjs/client and the lautjs/ prefix are baked into the import map the browser is served, so the bare specifier has to resolve in the browser exactly as it does on the server — an app that renames it renders and then fails to hydrate.
src/ — the framework
| File | What it does |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
| index.ts | The server-side public surface. Everything an app imports from lautjs. |
| config.ts | The Config / AppProps contract, and the one module-level slot holding it. |
| server.ts | createServer() — the Bun routes: app source, /lautjs/*, /vendor/*, /public/*, /__dev. |
| render.tsx | page() / renderPage() — full document vs. <main> fragment vs. shell swap. |
| Island.tsx | createIsland(registry) — stamps the data-island wrapper carrying its own module URL and its when strategy. |
| Head.tsx | <Head> — is the <head>: SEO and social tags, stylesheets, import map, preloads, boot script, dev socket. |
| DevReload.tsx | The dev script tag. Renders null in production. |
| preload.ts | What bunfig.toml preloads: installs the .css loader. |
| css.d.ts | Ambient *.css module, so import "./Foo.css" typechecks. |
| runtime/transpile.ts | Per-request .tsx → ESM, cached by mtime. Rewrites CSS imports. |
| runtime/vendor.ts | Bare specifier → node_modules file, and the import map. |
| runtime/css.ts | The Bun plugin that records which stylesheets the graph imported. |
| runtime/respond.ts | Compression, etags, 304s, file serving. |
| runtime/dev.ts | The file watcher and the /__dev SSE stream. |
| runtime/api.ts | route(), HttpError — the JSON endpoint plumbing. |
| client/* | The browser half: boot, hydrate (island scheduling), nav (router), css. Served at /lautjs/*. |
| i18n/index.ts | The isomorphic translation engine. Holds no tables of its own. |
The one rule
src/ never imports app code. Everything it needs arrives through createServer(config). That is what lets the framework live in node_modules and still know nothing about you — and it is the rule to check any change against.
Two consequences worth remembering:
- The island registry, the layout map and the locale tables all live in the app. The framework only ever sees a string it passes back.
Document.tsxis the app's, not Laut's -- but the<head>inside it is not app business, so<Head>is it: the element and the SEO and social tags, the stylesheet links, the inlined sheets, the import map, the module preloads, the boot script and the dev socket, in the order they have to be in. What the app still supplies is what is genuinely its own: any extra tags as children, and the shape of the<body>. kinu's sheet and/public/app.cssare linked by default -- every app links them -- withstylesfor more andnoDefaultCSSto drop both.stylesheets(),inlineStyles(),getImportMap(),clientPreloads()and<DevReload/>stay exported for a hand-rolled head.Head.tsxis the one component insrc/that renders app-shaped markup, and it still holds no app knowledge: everything it prints arrives asHeadData, whichrenderPageresolves from the page'stitle/metaexports andcreateServer({ site }). Merge, absolute URLs and title template happen there, so an app never writes anog:tag.- Stylesheets under
cssInlineStylesheet(4 KB) are pasted into the document in production rather than linked, so request count stops scaling with island count.<Head>renders both halves; a hand-rolled head that linksstylesheets()withoutinlineStyles()loses those sheets in production -- dev links everything, so dev will not show it, and Laut warns instead. ZERO_JSis the same shape of thing aslayout: a page exports it, the framework passes it through tohead.ZERO_JSwithout acting on it, and<Head>is what actually leaves the import map, the preloads and the boot script out. Laut's only opinion is a dev-mode warning when such a page renders an island, since that island can never hydrate.
How the browser gets framework code
The app's boot.ts does import { start } from "lautjs/client". That is a bare specifier, so the import map has to resolve it:
"lautjs/client": "/lautjs/client/index.ts"
"lautjs/": "/lautjs/"/lautjs/* is served by createServer out of import.meta.dir — wherever Laut is installed — transpiled on demand exactly like app source. Same URL every time, so the browser keeps one instance.
lautjs (bare) is deliberately not in the import map: src/index.ts is
server-only, and an island that imports it should fail loudly rather than half-work.
Developing
bun install
bun run check # tsc --noEmit over src/
bun run formatTrying it in a real app
# 1. create an app anywhere (or use one you already have)
bun run scaffold ../my-app
# 2. point it at this working tree, in place of the published package
bun run link ../my-app
# 3. run it
cd ../my-app && bun install && bun devSkip step 2 to run against npm — a scaffolded app already depends on the published Laut.
Undoing that
bun run unlink ../my-app # restore the app's own copies
bun run unlink ../my-app --global # …and unregister lautjs from Bun's registry
bun run unlink --global # just unregister; leave apps aloneEditing Laut while an app runs
bun --hot does not watch inside node_modules, so restart the app's server after changing Laut's server code. Browser-side modules (src/client/*) are re-fetched on reload and need nothing.
Publishing
Two tarballs. The framework, from the repo root:
bun run check
bun pm pack # inspect the tarball before you trust it
npm publish # or: bun publishThe scaffolder, from create/ — its prepack re-vendors template/ into the tarball, so publish it after any template change or bun create keeps handing out the old starter:
cd create
npm publish # runs `bun ../scripts/sync-create.ts` firstBump create-lautjs whenever template/ changes. The "lautjs": "^X.Y.Z" range in template/package.json is only a fallback: the scaffolder asks npm for lautjs@latest and rewrites the range as it writes the new app's package.json, so an app is born on the newest framework even when bunx hands out a months-old create-lautjs. It keeps the vendored range if the registry is unreachable, and warns when the scaffolder itself is behind — that one only bunx create-lautjs@<version> can fix, since bunx caches create-lautjs@latest under its own lockfile in $TMPDIR.
Known gaps
Whether Laut fits before finding out the hard way:
- No file-based routing.
routes.tsis a hand-written object. - No nested layouts. A page picks one layout, flat.
- No streaming SSR / Suspense.
load()is awaited, then the page renders. - No CSS modules, no scoping compiler. Convention only —
[data-island="Name"] .thing. - No islands inside islands as a first-class concept — a nested interactive component is just a child of its island and hydrates with it.
- No prefetch on hover. Navigation starts on click.
- The import map is the dependency system. A package with deep internal
imports or conditional exports may need several
vendorentries, or may not work in the browser at all without a bundler. - Symlinks under
public/are followed./public/*serves whatever path it resolves to; nothing re-checks that the real path is still inside the project. Placing a symlink there means already having write access to the repo -- which is exactly where a dependency's postinstall script stands. Covered as known behaviour by LAUT-SRV-11.
Testing, and the standard it borrows from, are in TESTING.md.
License
MIT.
