WEBJS_PUBLIC_* env vars accessible as process.env.* in the browser#14
Merged
Conversation
…browser Adds an SSR-injected inline script that defines window.process.env with all server-side env vars whose name starts with WEBJS_PUBLIC_, plus NODE_ENV based on dev/prod mode. Counterpart of Next.js's NEXT_PUBLIC_ convention, without a build step. Two consequences: * App code can write process.env.WEBJS_PUBLIC_API_URL in components and the value is available at runtime in the browser. No props threading required for things like Stripe publishable keys, analytics IDs, Sentry DSN. * Vendor bundles that probe process.env.NODE_ENV (lit, react, etc.) no longer throw ReferenceError in the browser. Fixes a latent bug. Security: only WEBJS_PUBLIC_* prefixed vars cross the wire. Other server env vars stay on the server. Values are JSON-encoded and '</' sequences are escaped so a value containing '</script>' cannot terminate the inline script tag. The shim emits before importMapTag() so it runs before any vendor bundle or user module executes. CSP nonces, when present on the request, propagate to the shim script tag.
Two test files. Unit (test/public-env-shim.test.js): * Only WEBJS_PUBLIC_ prefixed vars cross to output * NODE_ENV reflects dev/prod * Undefined values skipped * Non-string values coerced * '</' escape protects against script tag injection * CSP nonce propagation * Output JS is valid (executed in a function and inspected) Integration (in test/ssr.test.js): * Full ssrPage render with WEBJS_PUBLIC_API_URL set in process.env * Confirms shim appears in the HTML head * Confirms unprefixed secrets do not leak into the SSR output Restores process.env around the integration test so it doesn't pollute other tests.
Replaces the bare 'never reference process.env in components' warning with a three-section structure: * Server-only env vars: the default, no prefix, never reach the browser * Public env vars: WEBJS_PUBLIC_* are exposed to the browser via window.process.env, with example component code * NODE_ENV defined automatically based on dev/prod, so vendor bundles that probe it just work States the fail-closed naming convention (typos read as undefined, secrets cannot accidentally leak) and frames the pattern as the no-build equivalent of Next.js's NEXT_PUBLIC_.
Three markdown surfaces now reference the new public env var convention introduced in c65cccc: * Root AGENTS.md: new top-level section explaining the server-only-by-default rule, the WEBJS_PUBLIC_* prefix that exposes vars to the browser, the auto-defined NODE_ENV side benefit, and a pointer to publicEnvShim() in ssr.js. * Templates AGENTS.md: short section between Imports and Component pattern, framed as 'server vs browser' so AI agents working in scaffolded apps see the rule at the right place in the file. * Templates CONVENTIONS.md: extends the Sensible defaults env var table with a WEBJS_PUBLIC_* row and a fail-closed note immediately below.
The shim is a small but important piece of the no-build pipeline: it gives end users zero-config browser env vars without breaking the source-equals-runtime invariant. Adds a short section between 'Why auto-bundle vendor deps' and 'Granular cache invalidation', framed as 'how do we get the Next.js NEXT_PUBLIC_ DX without a build step.' Full user-facing docs continue to live on the Configuration page; this page just owns the mechanism story for the no-build narrative.
Flags process.env.X reads in component files where X is not
WEBJS_PUBLIC_* and not NODE_ENV. The SSR shim only exposes
those two categories to the browser; any other read either
leaks a secret into the SSR'd HTML or reads as undefined
after hydration.
Catches the static-dot-access case (the 95% pattern). Dynamic
bracket access and destructuring are not detected by this
regex; the planned runtime AsyncLocalStorage proxy (future
branch) will catch those at SSR time.
Skipped for files with the .server.{js,ts} suffix or 'use
server' directive, which are server-only and may read any
env var freely.
Six cases: * flags process.env.SECRET in a component (positive) * allows process.env.WEBJS_PUBLIC_* (negative) * allows process.env.NODE_ENV (negative) * skips .server.ts files even when under components/ (negative) * dedupes: each unique env var name flagged once per file * does not fire on non-component files (page.ts)
… page Explains the SSR-time leak risk that the runtime shim does not catch (a component's render() runs on the server and can read any env var, then interpolate it into the SSR'd HTML). Cites the new lint rule as the write-time defense.
Mirrors what scaffolded webjs apps get, adapted for the framework repo's own test runner. Two gates: 1. Block direct commits to main/master (use a feature branch) 2. Run npm test, refuse the commit if it fails Adds a 'prepare' script to root package.json that sets core.hooksPath to .hooks so the hook activates on npm install for any contributor who clones the repo. To bypass in emergencies: git commit --no-verify. Driven by an audit gap: the scaffold templates have shipped a pre-commit since PR #13 merged, but the framework repo itself had no equivalent, so my own commits were not gated by tests.
This was referenced May 19, 2026
vivek7405
added a commit
that referenced
this pull request
May 21, 2026
Third slice. Wires the slot runtime into the WebComponent lifecycle and
refines render-client.js's slot bind so fallback content is captured
once at compile time and cloned freshly per instance.
component.js:
- Light-DOM activation now runs in two phases:
Phase 1 (before _performRender): if not hydrating, call
captureAuthoredChildren(host) to move authored children into the
slot state's assignment table. The renderer's replaceChildren()
call would otherwise destroy them on first render.
Phase 2 (after _performRender, slots are now live DOM): if the
host was hydrating, call adoptSSRAssignments(host) to record
SSR-placed children so the first projection pass is a no-op.
Then call attachSlotObservers(host) so future authored-child
mutations and slot-name changes drive incremental projection.
- New __isHydrating() helper inspects this.firstChild for the
framework's <!--webjs-hydrate--> marker and records the result
on __hydratedAtActivate so phase two can branch correctly.
- disconnectedCallback now calls detachSlotObservers(host) for
light-DOM hosts. The per-host slot state (assignment table,
pending fragments, last snapshots) is preserved across disconnect,
so a re-attached element picks up where it left off.
render-client.js:
- discoverSlots() now MOVES the slot's authored children into a
fallbackTemplate DocumentFragment stored on the SLOT
PartDescriptor. The cached templateEl's slot becomes empty, so
every clone starts empty too. This eliminates the cloning-the-
fallback-out-of-the-slot dance the previous bind step did.
- bindPart for slot now clones the descriptor's fallbackTemplate
into a per-instance holding fragment and stamps it on the slot
via SLOT_FALLBACK_FRAG for slot.js to swap in. The slot itself
is left untouched at bind time, which makes hydration trivially
correct (SSR-projected children stay in place; the slot-part
just sets up its fallback supply for later transitions).
- PartDescriptor typedef gains an optional fallbackTemplate field.
Verified across 115 existing core unit tests (component, render-client,
render-server, directives, registry, css, html, context, task,
suspense, repeat, testing). All pass.
What remains: render-server.js injectDSD upgrade for SSR slot
substitution (Task #13), then the 62-case test suite (Task #14),
then docs (Task #15).
vivek7405
added a commit
that referenced
this pull request
May 21, 2026
Fourth slice. Upgrades injectDSD to project authored children into
<slot> positions during server-side rendering for light-DOM
WebComponents, with full parity to the client-side projection rules
and the shadow-DOM <slot> spec.
When a light-DOM component's render() output contains <slot> tags,
injectDSD now:
1. Finds the source HTML's matching closing tag for the custom
element by walking forward with depth tracking for nested
same-tag elements.
2. Extracts the authored inner HTML between the element's opening
and closing tags.
3. Partitions the authored HTML by each top-level child's slot=""
attribute. Text nodes, comment nodes, and elements without
slot="" all route to the default-slot bucket.
4. Walks the rendered template's <slot> tags in document order and
substitutes each with a framework-marked
<slot data-webjs-light data-projection="actual"|"fallback">
element carrying either the projected children or the slot's
authored fallback content. Multiple slots with the same name
follow the first-wins rule per spec.
5. Emits one edit spanning the entire opening-to-closing range of
the source element. Inner custom elements among authored
children are processed via the recursive injectDSD call on the
substituted output, not by the outer loop (a new sort + overlap
filter drops the duplicate inner edits that were enumerated
against the original html before substitution).
When a component's render() output has NO <slot> tags, the old SSR
shape is preserved unchanged: edit at the opening tag only, leave
authored children adjacent to the rendered template, closing tag
untouched. This keeps existing components that never used slots
behaving exactly as before.
Shadow DOM components are completely unaffected. Their native <slot>
elements live inside the DSD <template shadowrootmode="open"> block
and the browser handles projection from the host's light-DOM children
into the shadow tree natively. No framework substitution there.
New helpers in render-server.js:
isVoidElement(tag) tag is a void element (br, img...).
findClosingTagInString(html, ...) depth-tracked matching close tag.
extractSlotAttr(attrsRaw) pulls slot="..." value or null.
partitionAuthoredBySlot(html) groups authored inner HTML by slot.
appendStringToMap(map, k, v) concatenating map insert helper.
substituteSlotsInRender(...) walks <slot> tags, emits framework
marker variants with projection or
fallback content, first-wins per
name across the document.
End-to-end smoke (server-only):
<my-card>
<h2 slot="header">Title</h2>
<p>Body</p>
<span slot="footer">Foot</span>
</my-card>
renders to
<my-card><!--webjs-hydrate-->
<div class="card">
<header><slot data-webjs-light data-projection="actual" name="header">
<h2 slot="header">Title</h2>
</slot></header>
<main><slot data-webjs-light data-projection="actual">
<p>Body</p>
</slot></main>
<footer><slot data-webjs-light data-projection="actual" name="footer">
<span slot="footer">Foot</span>
</slot></footer>
</div>
</my-card>
Fallback content surfaces correctly when no children match; first-wins
holds across duplicate same-named slots; shadow DOM passthrough is
unchanged. 127 existing unit tests across component, render-client,
render-server, directives, registry, css, html, context, task,
suspense, repeat, testing, blog-smoke, json-negotiation, and
light-dom-ssr all pass.
What remains: the 62-case test suite (Task #14) and docs +
convention rule (Task #15).
vivek7405
added a commit
that referenced
this pull request
May 21, 2026
WEBJS_PUBLIC_* env vars accessible as process.env.* in the browser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-user webjs apps can now read
process.env.WEBJS_PUBLIC_*directly in browser-bound code, the no-build equivalent of Next.js'sNEXT_PUBLIC_convention.How it works
SSR injects an inline
<script>in the HTML head before the importmap and any module code:After that,
process.env.WEBJS_PUBLIC_API_URLis a real property read on a real object in the browser. No transform step. No build.What's exposed and what isn't
WEBJS_PUBLIC_.DATABASE_URL,AUTH_SECRET, OAuth secrets, third-party API keys never reach the browser, even if a component naively writesprocess.env.DATABASE_URL(reads asundefined).NODE_ENV: always defined,'development'inwebjs dev,'production'inwebjs start. Side benefit: fixes vendor bundles that probeprocess.env.NODE_ENV(lit, react, etc.) and previously threw ReferenceError in the browser.Safety
undefined.</escaped to<\/so a malicious env value containing</script>cannot terminate the inline script tag.Commits
Three:
feat(server): the shim in ssr.js, with apublicEnvShim()helper exported for teststest: 10 unit tests intest/public-env-shim.test.jsplus 1 integration test intest/ssr.test.jsdocs(site): replaces the old "never reference process.env in components" warning with a real explanation on the Configuration pageTest plan
npm testpasses, 907 of 907 (11 new shim tests)