@luwiostack/bootstrap
v0.1.0
Published
Bootstrap (startup) config for apps — fetch the one config document before mount, with ETag caching + stale-while-revalidate, then build your app from it. Runtime/reactive state belongs in your data layer (e.g. TanStack Query), not here. React-free core +
Readme
@luwiostack/bootstrap
Fetch your app's startup config — the one document a backend owns that decides how the app boots
(which locales to mount, feature flags, a default) — once, before mount, with ETag caching,
validation, and stale-while-revalidate, then build your app from it. A React-free loader
(@luwiostack/bootstrap) plus React bindings — a provider and hooks (@luwiostack/bootstrap/react).
It knows nothing about locales or the rest of Luwio, so it works in any app.
Bootstrap config, not runtime state. This is for the single document you load once at startup. Data that changes during a session and the UI should react to — settings, live flags, per-language content, anything refetched — belongs in your data layer (e.g.
@luwiostack/http+ TanStack Query), not here.
Many apps can't render until they know something the backend decides at runtime — which locales to
mount, feature flags, a theme, an API base. @luwiostack/bootstrap packages the fetch → cache →
revalidate loop for that one document so you don't hand-roll it: load it before mount, build the app
(and router) from it, then read it anywhere in React.
npm i @luwiostack/bootstrapThe bootstrap
import { createBootstrap, httpSource } from '@luwiostack/bootstrap'
import { sessionStore } from '@luwiostack/storage'
export const bootstrap = createBootstrap<AppConfig>({
fetch: httpSource('/api/config'), // conditional GET (sends If-None-Match, reads ETag)
storage: sessionStore('my-app:bootstrap'), // optional — survives a refresh, like the browser's HTTP cache
})
const config = await bootstrap.load() // in your entry, before mountingcreateBootstrap returns { load, get, revalidate, watch }:
load()— fetch (or revalidate against the cache) and return the config; marks its version as applied. A browser refresh re-runs it; the conditional request keeps it cheap (304) when nothing changed. Concurrentload()calls share a single in-flight request, so it's safe to call from several places at once (or under React StrictMode).get()— the last loaded value, read synchronously. Throws before the firstload()— in practice you alwaysawait load()before mount, so it's ready by the time React reads it.revalidate()— re-check without touching the running app; resolves{ changed, version }.watch(onChange, { intervalMs? })— stale-while-revalidate: re-checks on focus / visibility / interval and firesonChangeonce when the published version diverges. Returns an unsubscribe. A failed background re-check (a network blip, or a newly-published config thatvalidaterejects) is swallowed — you keep the config you have — so passdebug: trueto log those outcomes (and every fetch) under[bootstrap].
storage is optional. It only remembers the last-seen version across reloads, so load() can
send If-None-Match on the next boot and take a 304. If your endpoint already ships
Cache-Control + ETag, the browser's own HTTP cache does that revalidation for you — so you
can drop storage entirely and httpSource still gets a cheap 304 when nothing changed. Omit it to
always go to the network (well, to the browser cache); add it when you want the last config kept in
sessionStorage/localStorage independently of HTTP caching. Within a single page session,
revalidate()/watch() work either way.
map shapes the raw JSON into what your app consumes (only the raw body is cached):
createBootstrap<AppConfigJson, AppConfig>({ fetch, storage, map: (json) => hydrate(json) })validate runs on a freshly fetched body before it's cached or mapped. Throw to reject a
bad config — the error propagates out of load() (to your entry's catch, or an error boundary), so
you surface it on screen. A rejected config never poisons the cache.
createBootstrap({ fetch, storage, validate: (c) => ConfigSchema.parse(c) }) // zod / valibot / …combineBootstrap merges several endpoints into one bootstrap (fetched in parallel, keyed by
name, change detected across all). Bind it to a const so map's parameter type infers:
const fetch = combineBootstrap({ theme: httpSource('/api/theme'), features: httpSource('/api/features') })
export const bootstrap = createBootstrap({ fetch, map: ({ theme, features }) => ({ ...theme, ...features }) })httpSource(url, { init? }) passes any RequestInit (headers, mode, credentials, signal)
straight through and adds If-None-Match itself; it falls back to hashing the body if the server
sends no ETag. A non-2xx response, or a body that isn't JSON (a proxy error page, an auth
redirect), throws a clear error out of load() rather than a raw parse failure. Storage (from
@luwiostack/storage): sessionStore (default), localStore, memoryStore.
Not just HTTP. fetch is any (version) => Promise<FetchResult>, so config can come from
anywhere. For a bundled/imported value use fileSource — the non-network counterpart, versioned
by a hash of the data (so a rebuilt file counts as a change):
import { createBootstrap, fileSource } from '@luwiostack/bootstrap'
import config from './config.json'
createBootstrap({ fetch: fileSource(config) })
// code-split instead: fileSource(() => import('./config.json').then((m) => m.default))Or write your own fetch: return { status: 'fresh', version, data } for a body, or
{ status: 'unchanged', version } to reuse the stored copy (from an env var, localStorage, a
<script> — anything with a version you control).
React — @luwiostack/bootstrap/react
The bootstrap loads outside React, before mount — await bootstrap.load() in your entry, build the
router from it, then mount under <BootstrapProvider>. Because it resolved before mount, reading it in
a component is a plain synchronous call: no Suspense, no loading or error branch.
import { createRoot } from 'react-dom/client'
import { BootstrapProvider } from '@luwiostack/bootstrap/react'
import { RouterProvider } from '@luwiostack/router'
import { bootstrap } from './bootstrap'
const root = createRoot(document.getElementById('root')!)
root.render(<Splash />)
bootstrap
.load()
.then((config) =>
root.render(
<BootstrapProvider bootstrap={bootstrap}>
<RouterProvider router={createAppRouter(config)} />
</BootstrapProvider>,
),
)
.catch((err) => root.render(<BootError error={err} />)) // a rejected load (incl. validate) lands hereuseBootstrap — read the config
useBootstrap<T>() returns the loaded config anywhere below the provider. Wrap it once next to where
you create the bootstrap for a typed, app-specific hook.
import { useBootstrap } from '@luwiostack/bootstrap/react'
export const useAppConfig = () => useBootstrap<AppConfig>()
function ThemeToggle() {
const { theme } = useAppConfig()
return <button>Theme: {theme}</button>
}useBootstrapUpdate — the reload nudge
An open tab won't notice a publish on its own. useBootstrapUpdate() reads the bootstrap from the
provider, runs its watch(), and flips available when the backend publishes a newer config. Config
is only re-applied on reload — so nudge, don't swap it under the user.
const { available, reload } = useBootstrapUpdate()
if (available) return <button onClick={reload}>New configuration available — Reload</button>Bootstrap vs runtime state
@luwiostack/bootstrap is for the one language-independent document you load once at startup
(which locales exist, the default, feature flags, a theme). Anything that changes during a session
— the classic case is the per-language content once a language is active — is ongoing state, and
belongs in your data layer: @luwiostack/http + TanStack Query, keyed so it refetches on
change.
import { createRequest, type Endpoint } from '@luwiostack/http'
import { useHttpClient } from '@luwiostack/http/react'
import { useQuery } from '@tanstack/react-query'
const getLanguageConfig: Endpoint<[lang: string, signal?: AbortSignal], LanguageConfig> = (
client, lang, signal,
) => createRequest(client, { path: `/config/${lang}`, signal })
function LanguagePanel({ lang }: { lang: string }) {
const client = useHttpClient()
const { data } = useQuery({
queryKey: ['language-config', lang],
queryFn: ({ signal }) => getLanguageConfig(client, lang, signal),
})
return <Panel config={data} />
}Rule of thumb: if it's needed to build the app (router, providers) and doesn't change without a reload, it's bootstrap. If the UI should re-render when it changes, it's runtime state — reach for TanStack Query, not this package.
