Skip to content

WEBJS_PUBLIC_* env vars accessible as process.env.* in the browser#14

Merged
vivek7405 merged 9 commits into
mainfrom
feat/public-env-shim
May 19, 2026
Merged

WEBJS_PUBLIC_* env vars accessible as process.env.* in the browser#14
vivek7405 merged 9 commits into
mainfrom
feat/public-env-shim

Conversation

@vivek7405
Copy link
Copy Markdown
Collaborator

Summary

End-user webjs apps can now read process.env.WEBJS_PUBLIC_* directly in browser-bound code, the no-build equivalent of Next.js's NEXT_PUBLIC_ convention.

How it works

SSR injects an inline <script> in the HTML head before the importmap and any module code:

<script>
  window.process = window.process || {};
  window.process.env = Object.assign(window.process.env || {}, {
    "WEBJS_PUBLIC_API_URL": "https://api.example.com",
    "WEBJS_PUBLIC_STRIPE_KEY": "pk_live_abc",
    "NODE_ENV": "production"
  });
</script>

After that, process.env.WEBJS_PUBLIC_API_URL is a real property read on a real object in the browser. No transform step. No build.

What's exposed and what isn't

  • Exposed: every env var whose name starts with WEBJS_PUBLIC_.
  • Server-only: everything else. DATABASE_URL, AUTH_SECRET, OAuth secrets, third-party API keys never reach the browser, even if a component naively writes process.env.DATABASE_URL (reads as undefined).
  • NODE_ENV: always defined, 'development' in webjs dev, 'production' in webjs start. Side benefit: fixes vendor bundles that probe process.env.NODE_ENV (lit, react, etc.) and previously threw ReferenceError in the browser.

Safety

  • Prefix is fail-closed. Typos read as undefined.
  • JSON-encoded values with </ escaped to <\/ so a malicious env value containing </script> cannot terminate the inline script tag.
  • CSP nonce, when present on the request, propagates to the shim's script tag (matches the existing inline scripts).

Commits

Three:

  • feat(server): the shim in ssr.js, with a publicEnvShim() helper exported for tests
  • test: 10 unit tests in test/public-env-shim.test.js plus 1 integration test in test/ssr.test.js
  • docs(site): replaces the old "never reference process.env in components" warning with a real explanation on the Configuration page

Test plan

  • npm test passes, 907 of 907 (11 new shim tests)
  • Unit tests cover filter, NODE_ENV, undefined handling, value coercion, script-tag-injection escaping, nonce propagation, and that the emitted script body parses and executes correctly
  • Integration test confirms a real SSR render injects the shim with the right values and that unprefixed secrets do not leak

vivek7405 added 9 commits May 19, 2026 14:57
…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.
@vivek7405 vivek7405 merged commit 7d54420 into main May 19, 2026
@vivek7405 vivek7405 deleted the feat/public-env-shim branch May 19, 2026 10:13
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant