energo
v1.0.0-beta.11
Published
Reference-grade Solid + Vite framework for Bun: file-based App Router, document-owned root layouts, render-to-string-only SSR, route-level data and assets, middleware/API routes, SSG.
Maintainers
Readme
energo
A fast, minimal Solid + Vite framework built for Bun.
File-based routing, server-side rendering, route-level data, API routes, middleware, static-site generation — one small, strictly typed package with zero runtime dependencies.
Why Energo
- Bun-native speed. The server runs directly on
Bun.serve— no Node compatibility layers, no adapters, no hidden overhead on the hot path. - Zero runtime dependencies. The published package depends on nothing at runtime. What you install is what runs.
- Routing compiled ahead of time. Route plans are generated at build time — no filesystem scanning and no route resolution cost per request.
- Predictable SSR. Synchronous
renderToStringonly: simple mental model, no streaming edge cases, HTML goes out fast. - Strict by default. Fail-closed validation everywhere — config, routing, cookies, redirects and static export reject invalid input with clear, prefixed errors instead of guessing.
- A deliberately small API. Every export lives on an explicit subpath.
No root import, no barrel files, no
index.tsmaze — tree-shakeable ESM all the way.
| Layer | Technology |
| -------- | -------------------------- |
| Runtime | Bun ≥ 1.3.13 |
| UI | Solid (solid-js) |
| Bundler | Vite + vite-plugin-solid |
| Language | TypeScript (strict) |
Quick start
Install the framework and its peers:
bun add -d energo solid-js vite vite-plugin-solidenergo does not depend on typescript, at any version. Add it for your own
type checking if you want it.
Register the plugin in vite.config.ts:
import { defineConfig } from 'vite'
import { energo } from 'energo/vite'
export default defineConfig({
plugins: [energo()],
})Create a root layout and your first page. The root layout owns the whole
document — it renders <html>, <head> and <body> itself. The entire
document hydrates as one tree, so signals, event handlers and <Link>
navigation work anywhere — including a header rendered by the root layout:
// src/app/routes/layout.tsx
import type { JSX } from 'solid-js'
export default function RootLayout(props: { children: JSX.Element }): JSX.Element {
return (
<html lang="en">
<head />
<body>{props.children}</body>
</html>
)
}Document assembly validates that root-layout output is one unambiguous HTML
document before framework head, payload and entry markup is inserted. Its
private scanner is tokenizer-aware but deliberately accepts a constrained
document grammar rather than claiming to implement the browser's complete HTML
tree builder. Root-layout elements must be properly nested and explicitly
closed, and direct <head> content is limited to metadata elements that remain
in the HTML in head insertion mode. Only <head> and <body> may be direct
<html> element children. The scanner also models states that can
hide apparent structure, including escaped/double-escaped scripts, comments,
foreign content and integration points. A root layout must not use
<plaintext>, must not hide document structural tags inside <template>, and
must have the same document structure whether <noscript> is parsed with
scripting enabled or disabled; ambiguous output fails closed with an
[energo] render error. This proves the direct browser ownership of
framework-inserted head and body anchors; application markup inside those
anchors must still be conforming HTML.
// src/app/routes/page.tsx
import { Title } from 'energo/head'
export default function HomePage() {
return (
<main>
<Title>Hello Energo</Title>
<h1>Hello Energo</h1>
</main>
)
}Run it:
energo dev # development server with HMR
energo build # production build + SSG
energo start # serve the production buildRouting
Routes live under src/app/routes/. Directories define URL segments, files
define what a segment renders or handles:
| File | Role |
| --------------- | ------------------------------------------------------------------ |
| layout.tsx | Shared UI for a branch; receives props.children |
| page.tsx | The routable UI for a segment |
| error.tsx | Error boundary view; receives props.error ({ message, name? }) |
| not-found.tsx | 404 view for the branch |
| route.ts | API endpoint (exports HTTP method handlers) |
| middleware.ts | Branch-scoped middleware |
Solid represents JSX-valued component props as lazy accessors. Render an
optional slot directly when it is read once. If a slot is read conditionally or
more than once, resolve it through Solid's children()
helper first:
import { Show, children } from 'solid-js'
function Shell(props: { header?: JSX.Element; children: JSX.Element }) {
const header = children(() => props.header)
return (
<section>
<Show when={header()}>{header()}</Show>
{props.children}
</section>
)
}Do not use an unresolved JSX subtree as both the condition and content, such as
<Show when={props.header}>{props.header}</Show>. That asks Solid control flow
to materialize component output as condition data and can break SSR hydration.
Segment naming:
| Directory | Matches | params |
| ------------- | ----------------------------- | ----------------------------- |
| blog | /blog | — |
| [slug] | /post-1 | slug: string |
| [...rest] | /a/b/c (one or more) | rest: string[] |
| [[...rest]] | / and /a/b/c (zero+) | rest: string[] \| undefined |
| (marketing) | nothing — grouping only | — |
| _internal | nothing — private, not routed | — |
Only the route files and segment forms listed above are conventions.
loading.tsx, server-companion or alternate-extension route files,
metadata/RSC route suffixes, parallel @slot segments, intercepting segments
and optional single parameters fail the build instead of being silently
ignored or treated as static routes. The /_energo namespace and leading Bun
route markers are reserved. Catch-all segments must be terminal, optional
catch-alls own their base path, and parameter names or transparent groups do
not distinguish otherwise identical URL shapes.
Parameter names are Energo identifiers, not Bun route tokens. They may contain
Unicode, emoji, combining characters, hyphens, or begin with a digit (for
example [naïve], [💥], or [9id]); names must be non-empty, unambiguous,
and unique within one pattern. Energo preserves these exact keys in params
and uses private positional aliases only at the Bun transport boundary.
At the matching boundary, route params are own properties of a null-prototype
record, so names such as __proto__, constructor, and toString remain
exact data keys in server contexts and client route state. Optional catch-all
base routes expose their exact key with the value undefined.
Static route directory names use one canonical URL spelling. Literal route
text is ASCII RFC 3986 pchar data; non-ASCII text must be UTF-8
percent-encoded with uppercase hex (caf%C3%A9, not café or
caf%c3%a9). Unreserved characters must stay literal (A, not %41), and a
literal static segment cannot begin with : or *.
Params are URL-decoded once with tolerant Web semantics; malformed percent-encoding stays a literal value instead of breaking the request. A URL can be owned by either a page or an API route, never both — collisions fail the build.
When several traversable branches expose a not-found.tsx, Energo selects one
deterministically: greater structural depth wins, then the complete segment
vector is compared left-to-right with static segments above dynamic segments
and dynamic segments above catch-alls. Route groups do not affect this rank.
The same selection is used for hard loads, both route-data endpoint forms and
managed browser navigation; an equal-rank ambiguity fails closed.
During development, adding, removing or renaming a documented route convention publishes one atomic route revision across page, API, middleware and both route-data transports. A rejected revision leaves the previous accepted routes available, and fixing the source resumes regeneration without restarting the Vite server. Required and optional catch-all ownership changes follow the same rule.
Data loading
Colocate a loader with a page or layout. It runs on the server for the initial HTML and for client-side navigation data requests:
// src/app/routes/blog/[slug]/page.tsx
import { useServerData } from 'energo/router/hooks'
import { notFound } from 'energo/router/not-found'
import { redirect } from 'energo/router/redirect'
import type { GetServerData } from 'energo/router/types'
interface PostData {
title: string
}
export const getServerData: GetServerData<PostData> = async (ctx) => {
if (ctx.params['slug'] === 'legacy') return redirect('/blog', 308)
const post = await loadPost(String(ctx.params['slug']), ctx.signal)
if (post === undefined) return notFound()
ctx.response.headers.set('Cache-Control', 'public, max-age=60')
return { title: post.title }
}
export default function PostPage() {
const data = useServerData<PostData>()
return <h1>{data()?.title}</h1>
}The loader context carries params, url, request, headers, cookies
(read-only), locals, response (mutable status + headers) and
signal. If the client disconnects while a loader or API handler is still
running, that request-owned signal aborts; cancellation is control flow and is
not reported through server.onError. A loader returns finite plain JSON data, bounded sparse arrays,
redirect(url, status?), notFound() or undefined. Sparse arrays preserve
their length, present indexes and holes across initial HTML, client route-data
and static output; they are not passed through native JSON array semantics,
which would collapse holes to null. Cycles, non-finite numbers, unsupported
runtime objects and malformed or unbounded sparse encodings are rejected
fail-closed at the owning boundary with a path-attributed error. An own
enumerable __proto__ member is also rejected at encode and decode boundaries
instead of being handed to userland as a prototype-sensitive record key.
Ordinary keys such as constructor and toString remain user data.
Loaders across the layout/page branch run in parallel (one Promise.all
per request), not top-down. Do not rely on a parent layout loader having
finished before a child loader starts. When a parent resolves to a redirect
or notFound(), child loaders have already executed — their side effects are
not rolled back, but their results and response headers are discarded from
the response.
Across the branch, a redirect() takes precedence over a notFound(), which
takes precedence over a thrown error — regardless of layer depth. A child
page's redirect() therefore wins even if a parent layout loader threw,
because redirect and notFound are deliberate control-flow outcomes while an
error is an incidental failure.
For browser builds Energo removes the server loader export and declarations
reachable only from it, but it preserves JavaScript module semantics. Ordinary
dependencies still evaluate, observable top-level initializers and failures
remain, runtime enum initializers execute, and using / await using resources
are disposed. Put code that must never enter the client graph behind an explicit
.server module boundary; an unmarked dependency is treated as an ordinary ESM
module, not silently converted into server-only code.
Dynamic imports in a projected route module must use a string-literal module specifier. Energo rebases and validates that exact client edge; a computed specifier is rejected because its complete target graph cannot be proven free of server-only modules. A literal dynamic import remains lazy and follows the normal Vite client-build semantics.
Static data (SSG)
getServerStaticData runs at build time instead of per request, and
getServerStaticPaths enumerates the concrete paths to prerender for dynamic
segments:
import type { GetServerStaticData, GetServerStaticPaths } from 'energo/router/types'
export const getServerStaticPaths: GetServerStaticPaths = () => ({
// Concrete pathnames or params entries — both forms are accepted.
paths: ['/blog/hello', { params: { slug: 'world' } }],
})
export const getServerStaticData: GetServerStaticData<{ title: string }> = (ctx) => ({
title: `Post: ${String(ctx.params['slug'])}`,
})Static loaders see only params, url and signal — there is no request,
no cookies and no locals at build time. Enumerated paths are
collision-checked at build.
Navigation
Use <Link> for managed client-side navigation with prefetch:
import { Link } from 'energo/link'
;<Link href="/blog" prefetch="visible" activeClass="active">
Blog
</Link>prefetch accepts 'hover' (default), 'visible', true (eager) or false.
The registration reacts to prop changes: leaving visible mode retires its
observer before the next mode takes effect. replace, scroll, activeClass
/ inactiveClass and end control history, scroll restoration and active-state
matching.
Managed push/replace navigation scrolls to the target fragment when one is
present and otherwise scrolls to the top; scroll={false} preserves the
current position. Back/forward traversal restores the position owned by that
history entry. Energo only restores history entries that it created: traversing
to userland pushState data falls back to a native load rather than treating
foreign state as a framework snapshot. Navigation across a locale or root
document boundary likewise performs a native load and never replaces the
living root layout in place.
Managed links follow HTML token semantics: an omitted/empty target and every
ASCII-case variant of _self stay in the current document, while other
browsing-context targets remain native. rel=external opts out only as an exact
ASCII-whitespace-separated, ASCII-case-insensitive token; substrings such as
no-external do not disable client navigation or prefetch.
Route-data prefetch reuse honors response Cache-Control, Age and Date
using the RFC current-age calculation, including request transit time. Invalid,
overflowing or stale freshness metadata and no-cache / no-store are never
reused for a later click. A response without explicit freshness or a reuse
prohibition can use Energo's five-second same-navigation intent window; this
bounded prefetch policy is not an HTTP cache and visible-only warmup is not
retained after it settles.
The registry holds at most 32 prefetch entries per live document. Adding a
newer entry evicts the oldest one; pending network work owned only by an
evicted entry is aborted instead of continuing in the background. A newer
navigation likewise cancels an older navigation that is waiting on an
in-flight prefetch, and the older result can never commit late.
Route data and route-owned assets must become ready before history and UI are committed. A failure before commit falls back to a native load without exposing stale route UI. A failure after commit does not roll back the already committed URL/document state and is reported through the public router error event.
Hooks from energo/router/hooks (all reactive):
| Hook | Returns |
| -------------------- | ----------------------------------------------------------------- |
| useServerData<T>() | Accessor for the nearest loader's data |
| useParams() | Route params record |
| useLocation() | { pathname, search, hash, href, searchParams, query, queryAll } |
| useSearchParams() | [view, setSearchParams] tuple |
| useNavigate() | (href, options?) => Promise<NavigationResult> |
| useLocale() | Active locale string |
| useRouter() | Full router API |
useParams() and the record view returned by useSearchParams() are stable,
readonly, null-prototype live views. Indexed reads, Object.keys,
Object.hasOwn, in, property descriptors, spread, and JSON all observe the
same current own keys; __proto__, constructor, and toString are ordinary
data names. useLocation() applies the same own-property semantics to its
fixed field set. For static output, Energo first hydrates against the
query-free prerendered snapshot and then reconciles these live views to the
actual browser query and fragment.
The setter returned by useSearchParams() patches the current query rather
than replacing it wholesale. Supplied scalar or array values replace that key,
null and undefined remove that key, and omitted keys remain unchanged.
Repeated values retain their supplied order; the current pathname and fragment
are preserved.
For browser-only UI (widgets touching window, third-party embeds), wrap it
in <ClientOnly> from energo/client-only — the server renders the optional
fallback, the client swaps in the real content after hydration.
Head management
<Title>, <Meta> and <LinkTag> from energo/head register head tags
from anywhere in the tree. Deeper tags override shallower ones with the same
identity (one <title>, <meta name>/property/http-equiv/charset
deduplication, component-wise <link> identity):
import { LinkTag, Meta, Title } from 'energo/head'
<Title>Dashboard</Title>
<Meta name="description" content="Team dashboard" />
<LinkTag rel="canonical" href="https://example.com/dashboard" />Attribute names are allow-listed and values are validated and escaped
fail-closed before any HTML is emitted; href accepts relative or absolute
http(s) URLs only.
API routes
route.ts exports one handler per HTTP method and always returns a Web
Response:
// src/app/routes/api/users/[id]/route.ts
import type { ApiHandlerContext } from 'energo/router/types'
export function GET(ctx: ApiHandlerContext) {
return Response.json({ id: ctx.params['id'], role: ctx.locals['role'] })
}
export async function POST(ctx: ApiHandlerContext) {
const body = await ctx.request.json()
return Response.json({ created: body }, { status: 201 })
}Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD,
OPTIONS. Unsupported methods get 405 Method Not Allowed with a populated
Allow header, HEAD falls back to GET unless defined explicitly, and
OPTIONS is synthesized when absent. Request bodies are raw Web Request
bodies with Fetch single-consumption semantics; server.maxRequestBodyBytes
enforces deterministic 400/413 before your code runs.
Middleware
middleware.ts applies to its branch and everything below. It must return a
Response — either its own or the one from next():
// src/app/routes/middleware.ts
import type { Middleware } from 'energo/router/types'
const middleware: Middleware = async (ctx, next) => {
const session = ctx.cookies.get('session')
if (session === undefined && ctx.url.pathname.startsWith('/app')) {
return new Response(null, { status: 302, headers: { Location: '/login' } })
}
ctx.locals['userId'] = session
return await next()
}
export default middlewarectx.locals is a request-scoped, typed bag shared across middleware, loaders
and API handlers. ctx.response.headers merges into the final response.
Returning undefined or swallowing next()'s response is a framework error
— middleware cannot silently drop a response.
Cookies
energo/http/cookies is a single, strict RFC 6265 cookie API used both by
the framework (ctx.cookies) and directly on Web primitives:
import { deleteCookie, getCookie, setCookie } from 'energo/http/cookies'
const theme = getCookie(request, 'theme')
setCookie(headers, 'session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600,
prefix: 'host', // emits __Host-session with enforced invariants
})
deleteCookie(headers, 'session', { prefix: 'host', secure: true })Invalid names and values are rejected or ignored fail-closed; __Secure- /
__Host- prefix invariants, SameSite=None; Secure pairing and the 400-day
expiry cap are enforced at the API boundary.
Request parsing uses the strict RFC 6265 cookie-octet grammar. Optional
SP/HTAB around pairs and = is treated as bad whitespace, but raw whitespace,
quotes, commas, semicolons, backslashes, controls and non-ASCII value bytes are
not accepted as cookie data. One complete surrounding quote pair is removed;
its contents still follow cookie-octet. Percent escapes are validated as wire
data before decoding, so values emitted by serializeCookie() round-trip while
malformed percent escapes remain literal. On duplicate names the first valid
pair wins; an invalid pair cannot reserve a name.
Configuration
All configuration is optional. Create energo.config.ts next to
vite.config.ts when you need it:
import { defineConfig } from 'energo/config'
export default defineConfig({
server: {
port: 3000,
maxRequestBodyBytes: 1024 * 1024,
},
i18n: { locales: ['en', 'fr'], defaultLocale: 'en' },
security: {
csp: {
policy: "default-src 'self'; script-src 'self' 'nonce-{{nonce}}'",
nonce: true,
},
},
build: { output: 'server' },
})| Section | What it controls |
| ---------- | -------------------------------------------------------------------------------------------------------------------------- |
| server | port, host, idleTimeout, reusePort, unix, tls (file paths only), maxRequestBodyBytes, onError hook |
| i18n | locales + defaultLocale; every locale is served under /<locale>/…, the default locale also owns the unprefixed paths |
| security | Opt-in CSP policy with per-request {{nonce}} substitution and reportOnly mode |
| build | output ('server' | 'static'), distDir, staticDir, sourcemap, minify, cssMinify, target |
| css | CSS Modules class-name strategy |
| vite | Guarded, scope-aware escape hatch for shared/dev and client/SSR Vite config |
For energo start and serveBuiltApp(), relative server.unix and
server.tls file paths are resolved against the built application root, not
the caller's ambient working directory.
Unknown keys and invalid values fail the build with exact,
[energo/config]-prefixed messages. Config can also be a function of the
build phase (defineConfig((phase, ctx) => ({ … }))).
Each command lifecycle evaluates that function once. In particular,
energo start uses the same normalized result for CLI environment/default
precedence, built-artifact validation and server startup, so side effects and
nondeterministic values are never repeated or allowed to diverge.
CLI build-output overrides are applied only after the raw config shape has
passed validation, so an invalid build value cannot be hidden by --output.
The guarded Vite hook validates its permitted alias, define, CSS, PostCSS and
dev-proxy value forms before merging them into Vite configuration.
Production Vite config has three explicit hook targets. Put root resolve.alias,
CSS and common define values in shared; put target-specific define values
in client or ssr. Development uses the single dev target, which also owns
server.proxy. Vite aliases and CSS are build-shared by upstream design, so
returning either field from client or ssr is a configuration error instead
of silently leaking the client value into SSR:
export default defineConfig({
vite(config, context) {
if (context.target === 'shared') {
config.resolve = { alias: { '@app': '/absolute/path/to/src' } }
return
}
if (context.target === 'client' || context.target === 'ssr') {
config.define = { __BUILD_TARGET__: JSON.stringify(context.target) }
}
},
})Build invocation order is deterministic: shared, client, then ssr.
Alias object and array forms use the same replacement semantics, and an exact
matcher has one effective rule.
The final build table lists every materialized dynamic static path, including
required and optional catch-all expansions and their locale-prefixed output
paths; slow expansions keep their individual timing diagnostics.
Static export
energo build --output static # or build.output: 'static' in configProduces a self-contained out/** tree — HTML, route-data JSON and client
assets only — ready for any dumb static host (CDN, nginx, GitHub Pages). The
export is validated fail-closed: server-only code, sourcemaps, build
internals and framework metadata are proven absent from the final artifact,
path collisions fail the build, and a root 404.html is always emitted.
Generated route-data files additionally pass the exact canonical framework
JSON schema and planned-route identity check after being written to staging;
unknown or duplicate framework fields fail the build. Ordinary public/client
JSON assets and user strings such as node:fs remain data, not code signals.
Publication uses an owned, journaled directory transaction: concurrent export
writers conflict explicitly, an interrupted swap is recovered on the next
run, and unrelated sibling directories are never removed by name pattern.
Symbolic links inside the client/public source trees are rejected instead of
being followed into the deploy artifact.
The complete application build has a separate success-commit owner: client,
SSR, prerender and optional static output are written under one UUID staging
session. Vite resolves one shared build config and plugin pipeline for that
entire app lifecycle, so config functions execute once and every environment
is checked against the owner's exact staging root before writes begin. Exact
file inventories are verified, and dist plus out become
visible only after every Vite buildApp hook has succeeded. A failed or killed
build preserves the previously published trees. Programmatic builds must call
buildEnergoApp({ plugins: [energo()] }); raw Vite build()/builder.buildApp()
are rejected because they cannot own this publication boundary.
Client assets are provenance-checked across the Vite main graph, production
workers, CSS/import-meta URL transforms and publicDir: only bundle-declared
or validated public files with unchanged byte identities may reach
server/static output. The build serializes that exact versioned path/source/
SHA-256 inventory as dist/.energo-client-artifacts.json; energo start
revalidates the complete client tree once at startup and builds its static
route map from the verified inventory, including copied publicDir files.
Static export consumes the same verified dist/client snapshot and never
re-reads live publicDir. Public filesystem names are encoded per URL segment,
so spaces, Unicode, percent signs, route-syntax characters and .well-known/**
remain addressable without becoming Bun route patterns. _energo/**, public
sourcemaps, unknown files and byte drift fail closed. No inventory scan or hash
work occurs on the request hot path. The .server marker applies to every asset
extension and Vite query channel, not only TypeScript/JavaScript modules.
Runtime-only features (CSP nonces, middleware, API routes) reject static
output at build time instead of silently degrading.
Framework-owned build validation failures carry a stable owner prefix so a
caller can distinguish the rejected contract boundary from a fixture syntax,
dependency or toolchain failure. Public owner categories are
[energo/codegen], [energo/vite], [energo/static-export],
[energo/config], [energo/build], [energo/build-output] and
[energo/client-artifacts]; explanatory prose after the prefix is diagnostic,
not a string-matching API. Configuration owns invalid build-path values;
[energo/build] owns the outer multi-target publication transaction.
CLI
energo dev Start the Vite development server (HMR + SSR middleware).
energo build Build client + SSR + SSG in a single pass.
energo start Run the production server (reads dist/, loads energo.config.ts).
--host [host] Bind host for dev/start (no value = 0.0.0.0).
--port <port> Bind port for dev/start.
--output <mode> Build output mode: server | static.There is intentionally no energo preview: vite preview would serve only
static client files and silently bypass SSR, middleware and API routes — use
energo build && energo start instead.
Module map
Every public export lives on an explicit subpath — there is no root import:
| Subpath | Exports |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| energo/vite | energo() Vite plugin, buildEnergoApp() canonical programmatic build |
| energo/config | defineConfig, config types |
| energo/head | Title, Meta, LinkTag |
| energo/link | Link |
| energo/client-only | ClientOnly |
| energo/router/hooks | useServerData, useParams, useLocation, useSearchParams, useNavigate, useLocale, useRouter |
| energo/router/redirect | redirect() |
| energo/router/not-found | notFound() |
| energo/router/types | GetServerData, Middleware, ApiHandlerContext, … (types only) |
| energo/http/cookies | getCookie, getCookies, parseCookie, serializeCookie, setCookie, deleteCookie |
| energo/client | Client hydration entry (used by generated code) |
| energo/server/serve | createServer, createRequestHandler, serveBuiltApp — programmatic server embedding |
| energo/server/config, energo/server/manifest, energo/server/types, energo/render/* | Advanced server/render integration surfaces |
Deeper design and security documentation (docs/architecture.md,
docs/security.md) lives in the repository.
License
MIT
