Skip to content

Repository files navigation

@itsy/config

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.

install

pnpm add @itsy/config

use

On 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 onto

or

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 things

the init entry

import '@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')

shared config (JSON block)

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.

several configs on one page

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).

typing your config (TypeScript)

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 AppConfig
import { createConfig } from '@itsy/config/server'

createConfig({ isDev: true, token: 'abc' }) // checked against AppConfig

Skip 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 config into another variable. import { config } is a live binding — it updates itself when initConfig/setConfig runs. export const cfg = config snapshots the empty starting object. Re-export the binding instead: export { config }.
  • getConfig/getConfigFromString aren't covered by Register. They read whatever element or string you hand them, so pass the shape per call: getConfig<AppConfig>(el). Defaults to unknown.
  • window[id] is untyped. initConfig also parks a frozen copy there, and id is dynamic, so reading it needs a cast: (window as unknown as Record<string, unknown>)[id].

server api

createConfig

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.

client api

All exports below come from @itsy/config (also available as @itsy/config/client).

config

config: Readonly<ResolvedConfig>

Effectively the object you provided to createConfig, with any functions stripped. Frozen, and a live binding — see gotchas.

initConfig

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 when element has no id; a frozen copy of the config is then placed on window[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

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

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

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

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.

About

a utility for passing environment/config info from server to client

Resources

Contributing

Stars

Watchers

Forks

Releases

Contributors

Languages