a utility for passing environment/config info from server to client
Serialize config on the server, drop it into your markup, read it on the client from one shared
config object. Zero dependencies, ESM-only, and runtime-neutral — Node, Deno, workers, edge.
pnpm add @itsy/configOn the server, create a config string:
import { createConfig } from '@itsy/config/server'
const configString = createConfig({ isDev: true, token: 'abc' })
// then add this string to your rendered markup
// e.g. - `<div id="app" ${configString}></div>`Then on the client, read the config string:
import '@itsy/config/init' // finds the element the config was rendered ontoor
import { initConfig } from '@itsy/config'
initConfig('my-dom-element')the config export is then available for use anywhere in your app:
import { config } from '@itsy/config'
if (config.isDev) // do development-specific thingsimport '@itsy/config/init' is the zero-argument convenience form. It does not assume #app —
the payload is its own marker, so the element's id is irrelevant:
| looks for | comes from |
|---|---|
[data-config] |
createConfig(cfg) — the default attribute form |
[data-config-json] |
a marker you add to your JSON <script> block |
#app |
fallback, when neither of the above matches |
All of this markup is found by a bare import '@itsy/config/init':
<div id="app" data-config="eyJ..."></div>
<main id="whatever-you-like" data-config="eyJ..."></main>
<section data-config="eyJ..."></section>
<script type="application/json" data-config-json>
{ "isDev": true }
</script>Like initConfig, it never throws — multiple config elements, a missing element, a malformed payload, or importing it
where there is no document all console.warn and leave config untouched.
Tip
Want a missing config to be fatal? Check isConfigured in your entry module — /init has
already run by then, since static imports are hoisted.
import '@itsy/config/init'
import { isConfigured } from '@itsy/config'
if (!isConfigured()) throw new Error('app: config missing')For a single config shared by many components, embed it once as a JSON <script> block
instead of a per-element attribute. On the server, ask for JSON:
import { createConfig } from '@itsy/config/server'
const data = createConfig({ isDev: true, token: 'abc' }, { json: true })
// <script type="application/json" data-config-json>${data}</script>On the client, the block is found by the init entry:
import '@itsy/config/init'The data-config-json attribute is a selector hook and nothing more — add it and /init finds
the block, leave it off and point initConfig at the block yourself:
import { initConfig } from '@itsy/config'
initConfig('#app-config')getConfig/initConfig auto-detect the source: a data-config attribute is base64-decoded;
otherwise the element's JSON text content is parsed. Either way the result lands on the shared
config singleton, so one block serves every importer.
Unlike the attribute form — which is consumed on read — a JSON block stays in the DOM, so it can be read any number of times. That makes it the right shape when several independently bundled scripts on the page each need the same config.
config is one object per copy of the library. Two widgets that need different configs cannot
share it, so skip the shared export and read each element directly:
import { getConfig } from '@itsy/config'
const settings = getConfig(document.querySelector('#widget-a'))Or give each widget its own initConfig call, if each is bundled separately and therefore has
its own config:
import { initConfig } from '@itsy/config'
initConfig('#widget-a')import '@itsy/config/init' cannot serve this case — that is why it refuses to choose when it
sees more than one marked element. As a cross-bundle escape hatch, initConfig also parks a
frozen copy on window[id] (see gotchas).
Tell TypeScript your config shape once — then config and createConfig are typed everywhere,
with no generics at the call site and no change to how you import.
Put this wherever your config type already lives:
// src/app-config.ts
export interface AppConfig {
isDev: boolean
token: string
}
declare module '@itsy/config' {
interface Register {
config: AppConfig
}
}TypeScript picks this up automatically — you never import this file for it to take effect. It
just has to be part of your project (matched by include in tsconfig.json, which by default
covers every .ts/.d.ts file).
Now everything resolves from that one shape:
import { config } from '@itsy/config'
config.isDev // boolean — `config` is typed as AppConfigimport { createConfig } from '@itsy/config/server'
createConfig({ isDev: true, token: 'abc' }) // checked against AppConfigSkip all of this — or use plain JS — and config is Readonly<Record<string, unknown>>.
Everything still works, values just come back as unknown.
Prefer a separate .d.ts file?
That works too, with one rule: the file must have at least one top-level import or export.
Without one, TypeScript reads declare module '@itsy/config' as "here is a brand new package
named @itsy/config" and replaces the real types — you'll see
error TS2305: Module '"@itsy/config"' has no exported member 'config'.
// src/itsy-config.d.ts — the `import type` is what makes this a module
import type { AppConfig } from './app-config'
declare module '@itsy/config' {
interface Register {
config: AppConfig
}
}No import needed? Add export {} at the bottom instead.
Gotchas
- Don't copy
configinto another variable.import { config }is a live binding — it updates itself wheninitConfig/setConfigruns.export const cfg = configsnapshots the empty starting object. Re-export the binding instead:export { config }. getConfig/getConfigFromStringaren't covered byRegister. They read whatever element or string you hand them, so pass the shape per call:getConfig<AppConfig>(el). Defaults tounknown.window[id]is untyped.initConfigalso parks a frozen copy there, andidis dynamic, so reading it needs a cast:(window as unknown as Record<string, unknown>)[id].
createConfig(config: object, options?: { raw?: boolean, json?: boolean }): string
Base64-encodes a stringified copy of the object you pass in. Functions are dropped.
| options | returns |
|---|---|
| none | data-config='eyJ...' — an attribute string, drop it straight into a tag |
{ raw: true } |
eyJ... — the bare base64 string |
{ json: true } |
{"isDev":true} — for a <script type="application/json"> block |
Both raw and json default to false.
Why base64?
The data-config='...' value is embedded directly in HTML. JSON always contains double quotes,
and config values may carry unsanitized data (single quotes, <, >, </script>, line
separators). base64's alphabet (A–Z a–z 0–9 + / =) contains none of these, so the attribute
can never break out — no attribute-injection XSS. The { json: true } path skips base64 and
instead escapes < > / and line/paragraph separators so the payload is safe inside a <script>
block. Encoding/decoding is runtime-neutral (no Buffer), so createConfig runs on Node, Deno,
workers, and edge runtimes.
All exports below come from @itsy/config (also available as @itsy/config/client).
config: Readonly<ResolvedConfig>
Effectively the object you provided to createConfig, with any functions stripped. Frozen, and
a live binding — see gotchas.
initConfig(element?: Element | string, options?: { configId?: string })
Reads config off element and publishes it to the shared config export.
element— the DOM element you rendered the configString onto, either an Element or a querySelector string. Defaults to'#app'.configId— the config field used as the key whenelementhas noid; a frozen copy of the config is then placed onwindow[id]for reference. Defaults to'_id'.
initConfig never throws. A missing element or malformed payload logs a console.warn and
leaves config untouched.
The '#app' default applies to explicit calls only — import '@itsy/config/init' locates the
element instead, see the init entry.
isConfigured(): boolean
true once config holds a config — after a successful initConfig, or any setConfig
(including setConfig({})). false while config is still the empty starting object.
Use it to hard-fail on your own terms, since nothing here throws on a missing config — see
the init entry. A failed initConfig leaves both config and this flag
alone, so a successful init followed by a failed one stays true.
A function rather than an exported boolean on purpose: a let export would carry the same
copy-snapshot trap as config, where reading it too early pins the value.
setConfig(config: object): void
Replaces config with a frozen copy — handy in tests, or when config arrives from somewhere
other than the DOM. Marks it configured, so isConfigured() is true afterwards.
getConfig<T = unknown>(element: Element): T
Returns the config found on the element, without touching the shared config. Reads a base64
data-config attribute (removed after read), or the element's JSON text content when that
attribute is absent. Throws if neither is present.
getConfigFromString<T = unknown>(str: string): T
Parses a base64 config string — what createConfig(cfg, { raw: true }) produces — into a frozen
object. Throws on empty input.