Skip to content

Commit c85b28d

Browse files
feat: build-time substitution for import.meta.env.STX_PUBLIC_* in client bundles
Mirrors the server-side `$env` template context onto the client. Any env var matching the configured prefix (default `STX_PUBLIC_`) is now inlined as `import.meta.env.<KEY>` at build time across every client-bundling path: - inline-assets.ts (Bun.build for <script src=...>) - client-script-bundler.ts (Bun.build for <script client> with imports) - utils.ts (Bun.Transpiler for inline <script client>) - store-loader.ts (Bun.Transpiler for resources/stores/*.ts) - variable-extractor.ts (Bun.Transpiler for <script> introspection) .env: STX_PUBLIC_API_URL=https://api.example.com store: const api = import.meta.env.STX_PUBLIC_API_URL built: const api = "https://api.example.com" Vite-compatible. Build-time replacement, zero runtime cost, dead-branch tree-shaking works (`if (import.meta.env.X) { ... }` prunes when X is ""). Stores and any other plain `.ts` files now get the same env story as templates without per-app `window.X` injection boilerplate. New module `packages/stx/src/public-env.ts` exports `getPublicEnvDefine()` and `getPublicEnv()` for any consumer that needs the same substitution map. Both are re-exported from the package barrel.
1 parent 0a09f70 commit c85b28d

7 files changed

Lines changed: 67 additions & 2 deletions

File tree

packages/stx/src/client-script-bundler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import path from 'node:path'
1313
import fs from 'node:fs' // kept for mkdir/rmSync (no Bun equivalent for dir ops)
1414
import type { BunPlugin } from 'bun'
15+
import { getPublicEnvDefine } from './public-env'
1516

1617
// Known imports that are NOT user imports — handled by other transforms
1718
const EXTERNAL_PATTERNS = [
@@ -194,6 +195,7 @@ export async function bundleClientScript(
194195
plugins: [createBundlePlugin(projectRoot)],
195196
define: {
196197
'process.env.NODE_ENV': minify ? '"production"' : '"development"',
198+
...getPublicEnvDefine(),
197199
},
198200
// Resolve relative imports from the template's directory
199201
root: templateDir,

packages/stx/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export * from './forms'
2727

2828
// Core modules
2929
export * from './env'
30+
export { getPublicEnv, getPublicEnvDefine } from './public-env'
3031
export * from './a11y'
3132
export * from './analytics'
3233
export * from './analyzer'

packages/stx/src/inline-assets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* replacing them with inline <script> and <style> blocks.
77
*/
88
import path from 'node:path'
9+
import { getPublicEnvDefine } from './public-env'
910

1011
/**
1112
* Check if a URL is external (http, https, or protocol-relative)
@@ -109,6 +110,7 @@ export async function processInlineAssets(
109110
entrypoints: [resolvedPath],
110111
target: 'browser',
111112
minify: false,
113+
define: getPublicEnvDefine(),
112114
})
113115
if (result.outputs.length > 0) {
114116
fileContent = await result.outputs[0].text()

packages/stx/src/public-env.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Public env exposure for client bundles
3+
*
4+
* Mirrors the server-side `$env` template context (process.ts:762) onto the
5+
* client side via build-time substitution. Any env var matching `envPrefix`
6+
* (default `STX_PUBLIC_`) gets inlined as `import.meta.env.STX_PUBLIC_*` in
7+
* scripts bundled for the browser.
8+
*
9+
* .env: STX_PUBLIC_API_URL=https://api.example.com
10+
* store: const api = import.meta.env.STX_PUBLIC_API_URL
11+
* built: const api = "https://api.example.com"
12+
*
13+
* Vite-style. Build-time, zero runtime cost.
14+
*/
15+
16+
import process from 'node:process'
17+
18+
/**
19+
* Build the `define` map every client bundler/transpiler should pass to
20+
* Bun.build / new Bun.Transpiler({ define }).
21+
*
22+
* Returns substitutions for both `import.meta.env.<KEY>` (preferred, Vite-
23+
* compatible) and a single `import.meta.env` literal that maps to the full
24+
* object so consumers can do `if (import.meta.env.X)` and similar idiomatic
25+
* patterns.
26+
*/
27+
export function getPublicEnvDefine(envPrefix: string = 'STX_PUBLIC_'): Record<string, string> {
28+
const define: Record<string, string> = {}
29+
const envObject: Record<string, string> = {}
30+
31+
for (const [key, value] of Object.entries(process.env)) {
32+
if (!key.startsWith(envPrefix) || value === undefined)
33+
continue
34+
define[`import.meta.env.${key}`] = JSON.stringify(value)
35+
envObject[key] = value
36+
}
37+
38+
// Whole-object substitution lets `Object.keys(import.meta.env)` and similar
39+
// dynamic patterns work, not just direct key access.
40+
define['import.meta.env'] = JSON.stringify(envObject)
41+
42+
return define
43+
}
44+
45+
/**
46+
* Get the public env values as a plain object. Useful for places that want
47+
* the raw map (e.g. tests, debugging) rather than the define-shaped strings.
48+
*/
49+
export function getPublicEnv(envPrefix: string = 'STX_PUBLIC_'): Record<string, string> {
50+
const out: Record<string, string> = {}
51+
for (const [key, value] of Object.entries(process.env)) {
52+
if (key.startsWith(envPrefix) && value !== undefined)
53+
out[key] = value
54+
}
55+
return out
56+
}

packages/stx/src/store-loader.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import path from 'node:path'
1313
import { loadStxConfig } from './config'
14+
import { getPublicEnvDefine } from './public-env'
1415

1516
const _cachedStoreScripts = new Map<string, string>()
1617

@@ -88,7 +89,7 @@ export async function getStoreScript(storesDir?: string): Promise<string | null>
8889
// but in the browser these are already globals — just strip the imports.
8990
const chunks: string[] = []
9091

91-
const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'browser' })
92+
const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'browser', define: getPublicEnvDefine() })
9293

9394
for (const file of sortedFiles) {
9495
try {

packages/stx/src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { unescapeHtml } from './expressions'
2121
import { transformStoreImports } from './store-imports'
2222
import { LRUCache } from './performance-utils'
2323
import { processDirectives } from './process'
24+
import { getPublicEnvDefine } from './public-env'
2425
import { processScopedStyles } from './style-scoping'
2526

2627
// Re-export from extracted modules for backward compatibility
@@ -109,6 +110,7 @@ export function transpileTypeScript(code: string): string {
109110
const transpiler = new Bun.Transpiler({
110111
loader: 'ts',
111112
target: 'browser',
113+
define: getPublicEnvDefine(),
112114
})
113115
let result = transpiler.transformSync(processedCode)
114116

packages/stx/src/variable-extractor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { findMatchingDelimiter } from './parser/tokenizer'
3636
// instance that document-shell.ts reads from. Using require() inside the
3737
// wrapped useHead would create a separate module instance with its own state.
3838
import { useHead as headUseHead, useSeoMeta as headUseSeoMeta, getHead as headGetHead } from './head'
39+
import { getPublicEnvDefine } from './public-env'
3940

4041
/**
4142
* Extract declared variable names from converted CommonJS script.
@@ -324,7 +325,7 @@ export async function extractVariables(
324325
// Strip TypeScript syntax using Bun.Transpiler for full TS support
325326
let jsContent: string
326327
try {
327-
const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'browser' })
328+
const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'browser', define: getPublicEnvDefine() })
328329
// Strip .stx component imports before transpiling
329330
let processedCode = scriptContent.replace(/^\s*import\s+\w+\s+from\s+['"][^'"]*\.stx['"]\s*;?\s*$/gm, '')
330331
processedCode = processedCode.replace(/^\s*import\s+['"][^'"]*\.stx['"]\s*;?\s*$/gm, '')

0 commit comments

Comments
 (0)