Skip to content

Commit cb220c4

Browse files
authored
feat(gateway): add support for .env file parsing and integration with environment variable classification
* feat(gateway): add support for .env file parsing and integration with environment variable classification * feat(dev): enhance .env file handling and improve logging for development server * feat(docs): update README to reflect CLI hot reload capabilities and env file handling
1 parent 27f74b1 commit cb220c4

9 files changed

Lines changed: 377 additions & 92 deletions

File tree

cli/src/commands/dev.ts

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import chalk from 'chalk';
77
import * as fs from 'fs';
88
import * as path from 'path';
99
import { spawn } from 'child_process';
10-
import * as dotenv from 'dotenv';
1110

1211
/**
1312
* Get the default path to the bundled gateway binary.
@@ -24,20 +23,20 @@ function findGatewayBinary(specifiedPath?: string): string {
2423
if (specifiedPath) {
2524
return specifiedPath;
2625
}
27-
26+
2827
// Check bundled location first
2928
const bundledPath = getBundledGatewayPath();
3029
if (fs.existsSync(bundledPath)) {
3130
return bundledPath;
3231
}
33-
32+
3433
// Fall back to PATH
3534
return 'rep-gateway';
3635
}
3736

3837
export function createDevCommand(): Command {
3938
const cmd = new Command('dev');
40-
39+
4140
cmd
4241
.description('Run local development server with REP gateway')
4342
.option('-e, --env <path>', 'Path to .env file', '.env.local')
@@ -49,26 +48,23 @@ export function createDevCommand(): Command {
4948
.action(async (options) => {
5049
try {
5150
console.log(chalk.blue('Starting REP development server...\n'));
52-
53-
// Load environment variables from .env file
51+
5452
const envPath = options.env;
55-
if (fs.existsSync(envPath)) {
56-
console.log(chalk.gray(`Loading environment from: ${envPath}`));
57-
const result = dotenv.config({ path: envPath });
58-
if (result.error) {
59-
throw new Error(`Failed to load .env file: ${result.error.message}`);
60-
}
61-
console.log(chalk.gray(`Loaded ${Object.keys(result.parsed || {}).length} variable(s)\n`));
53+
const envFileExists = fs.existsSync(envPath);
54+
const absEnvPath = envFileExists ? path.resolve(envPath) : '';
55+
56+
if (envFileExists) {
57+
console.log(chalk.gray(`Using environment file: ${absEnvPath}`));
6258
} else {
63-
console.log(chalk.yellow(` Environment file not found: ${envPath}`));
59+
console.log(chalk.yellow(`Warning: Environment file not found: ${envPath}`));
6460
console.log(chalk.gray('Continuing with system environment variables\n'));
6561
}
66-
62+
6763
// Build gateway arguments
6864
const args: string[] = [];
69-
65+
7066
args.push('--port', options.port);
71-
67+
7268
if (options.proxy) {
7369
args.push('--mode', 'proxy');
7470
args.push('--upstream', options.proxy);
@@ -78,14 +74,26 @@ export function createDevCommand(): Command {
7874
} else {
7975
throw new Error('Either --proxy or --static must be specified');
8076
}
81-
77+
8278
if (options.hotReload) {
8379
args.push('--hot-reload');
8480
}
85-
81+
82+
// Pass the env file to the gateway so it reads it directly.
83+
// When hot reload is enabled, also configure file_watch mode
84+
// so the gateway re-reads the file on changes without a restart.
85+
if (absEnvPath) {
86+
args.push('--env-file', absEnvPath);
87+
88+
if (options.hotReload) {
89+
args.push('--hot-reload-mode', 'file_watch');
90+
args.push('--watch-path', absEnvPath);
91+
}
92+
}
93+
8694
// Find gateway binary
8795
const gatewayBin = findGatewayBinary(options.gatewayBin);
88-
96+
8997
// Log configuration
9098
console.log(chalk.blue('Configuration:'));
9199
console.log(chalk.gray(` Gateway binary: ${gatewayBin}`));
@@ -98,55 +106,58 @@ export function createDevCommand(): Command {
98106
console.log(chalk.gray(` Static dir: ${options.static}`));
99107
}
100108
console.log(chalk.gray(` Hot reload: ${options.hotReload ? 'enabled' : 'disabled'}`));
109+
if (options.hotReload && absEnvPath) {
110+
console.log(chalk.gray(` Watching: ${absEnvPath}`));
111+
}
101112
console.log('');
102-
113+
103114
// Spawn the gateway process
104115
console.log(chalk.blue(`Starting gateway: ${gatewayBin} ${args.join(' ')}\n`));
105-
116+
106117
const gateway = spawn(gatewayBin, args, {
107118
stdio: 'inherit',
108119
env: process.env,
109120
});
110-
121+
111122
// Handle gateway exit
112123
gateway.on('error', (err) => {
113-
console.error(chalk.red('\n✗ Failed to start gateway'));
124+
console.error(chalk.red('\nFailed to start gateway'));
114125
console.error(chalk.red(`Error: ${err.message}`));
115126
console.error(chalk.gray('\nTroubleshooting:'));
116127
console.error(chalk.gray(' - Ensure rep-gateway is installed and in PATH'));
117128
console.error(chalk.gray(' - Or specify the binary path with --gateway-bin'));
118129
console.error(chalk.gray(' - Build the gateway: cd gateway && make build'));
119130
process.exit(1);
120131
});
121-
132+
122133
gateway.on('exit', (code, signal) => {
123134
if (signal) {
124-
console.log(chalk.yellow(`\n⚠ Gateway terminated by signal: ${signal}`));
135+
console.log(chalk.yellow(`\nGateway terminated by signal: ${signal}`));
125136
} else if (code !== 0) {
126-
console.error(chalk.red(`\n✗ Gateway exited with code: ${code}`));
137+
console.error(chalk.red(`\nGateway exited with code: ${code}`));
127138
process.exit(code || 1);
128139
} else {
129140
console.log(chalk.gray('\nGateway stopped'));
130141
}
131142
process.exit(code || 0);
132143
});
133-
144+
134145
// Handle Ctrl+C
135146
process.on('SIGINT', () => {
136147
console.log(chalk.gray('\n\nShutting down...'));
137148
gateway.kill('SIGINT');
138149
});
139-
150+
140151
process.on('SIGTERM', () => {
141152
gateway.kill('SIGTERM');
142153
});
143-
154+
144155
} catch (err) {
145-
console.error(chalk.red('Dev server failed'));
156+
console.error(chalk.red('Dev server failed'));
146157
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
147158
process.exit(1);
148159
}
149160
});
150-
161+
151162
return cmd;
152163
}

examples/todo-react/README.md

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,10 @@ pnpm rep:dev
143143
pnpm rep:serve
144144
```
145145

146-
The CLI reads `.env.local`, strips comments, exports every `REP_*` var into
147-
the spawned gateway's environment, and passes the right `--mode`, `--upstream`,
148-
and `--static-dir` flags automatically.
146+
The CLI passes `--env-file .env.local` to the gateway so it reads the file
147+
directly. With `--hot-reload`, the CLI also sets `--hot-reload-mode file_watch`
148+
and `--watch-path` automatically — edit `.env.local` and the gateway picks up
149+
changes live via SSE, no restart needed.
149150

150151
---
151152

@@ -253,55 +254,58 @@ The gateway exposes a Server-Sent Events endpoint at `/rep/changes`. The SDK
253254
connects to it lazily when any `useRep()` hook mounts, and re-renders
254255
subscribed components when a change event arrives.
255256

256-
**Start the gateway with hot reload enabled:**
257+
#### Using the CLI (recommended)
258+
259+
The `rep:dev` and `rep:serve` scripts already pass `--hot-reload`, which
260+
automatically sets up file watching on `.env.local`:
257261

258262
```bash
259-
REP_PUBLIC_APP_TITLE="REP Todo" \
260-
REP_PUBLIC_ENV_NAME=development \
261-
REP_PUBLIC_API_URL=http://localhost:3001 \
262-
REP_PUBLIC_MAX_TODOS=5 \
263-
REP_SENSITIVE_ANALYTICS_KEY=ak_demo_abc123 \
264-
./gateway/bin/rep-gateway \
265-
--mode embedded \
266-
--static-dir examples/todo-react/dist \
267-
--hot-reload
263+
pnpm rep:serve
268264
```
269265

270-
Open `http://localhost:8080` and click **Show Config**. Observe the SSE
271-
connection in the Network tab (`/rep/changes`, type: `eventsource`).
266+
Open `http://localhost:8080`, then edit `.env.local` — for example, change
267+
`REP_PUBLIC_APP_TITLE=REP Todo` to `REP_PUBLIC_APP_TITLE=My Todos`. The
268+
gateway detects the file change, re-reads it, and pushes updates via SSE.
269+
The browser updates without a page reload.
272270

273-
**Trigger a reload — no rebuild, no page refresh:**
271+
#### Using the gateway directly
274272

275-
In a second terminal, send `SIGHUP` to the gateway process. It re-reads
276-
`os.Environ()`, rebuilds the payload, and broadcasts diffs over SSE:
273+
Use `--env-file` to point the gateway at a `.env` file, and `--hot-reload-mode
274+
file_watch` with `--watch-path` to watch it for changes:
277275

278276
```bash
279-
kill -HUP $(pgrep rep-gateway)
277+
./gateway/bin/rep-gateway \
278+
--mode embedded \
279+
--static-dir examples/todo-react/dist \
280+
--env-file .env.local \
281+
--hot-reload \
282+
--hot-reload-mode file_watch \
283+
--watch-path .env.local
280284
```
281285

282-
The connected browser tabs receive the event and `useRep()` hooks re-render
283-
with the updated values — **no page load**.
286+
Edit `.env.local` in your editor. The gateway detects the mtime change,
287+
re-reads the file, rebuilds the payload, and broadcasts diffs over SSE.
288+
Connected browser tabs re-render — **no page load, no restart**.
284289

285-
**Changing values between restarts (no rebuild):**
290+
#### Manual signal mode
286291

287-
To see different values, restart the gateway with new env vars — the built
288-
`dist/` is untouched:
292+
You can also trigger a reload manually with `SIGHUP` (useful for scripting):
289293

290294
```bash
291-
# Ctrl+C the running gateway, then:
292-
REP_PUBLIC_APP_TITLE="My Todos" \
293-
REP_PUBLIC_ENV_NAME=staging \
294-
REP_PUBLIC_MAX_TODOS=3 \
295+
# Start with signal mode (the default when --env-file is not watched)
296+
REP_PUBLIC_APP_TITLE="REP Todo" \
297+
REP_PUBLIC_ENV_NAME=development \
298+
REP_PUBLIC_API_URL=http://localhost:3001 \
299+
REP_PUBLIC_MAX_TODOS=5 \
295300
REP_SENSITIVE_ANALYTICS_KEY=ak_demo_abc123 \
296301
./gateway/bin/rep-gateway \
297302
--mode embedded \
298303
--static-dir examples/todo-react/dist \
299304
--hot-reload
300-
```
301305

302-
The title changes from `REP Todo` to `My Todos`, the badge turns amber
303-
(`staging`), and the add form locks after 3 todos — all from env vars, all
304-
without `npm run build`.
306+
# In another terminal:
307+
kill -HUP $(pgrep rep-gateway)
308+
```
305309

306310
> **Production hot reload:** In Kubernetes or Docker Swarm, env vars can be
307311
> rotated in-flight via ConfigMaps or secrets. The gateway detects the change
@@ -327,7 +331,7 @@ docker run --rm -p 8080:8080 \
327331
-e REP_PUBLIC_APP_TITLE="REP Todo" \
328332
-e REP_PUBLIC_ENV_NAME=development \
329333
-e REP_PUBLIC_API_URL=http://localhost:3001 \
330-
-e REP_PUBLIC_MAX_TODOS=5 \
334+
-e REP_PUBLIC_MAX_TODOS=55 \
331335
-e REP_SENSITIVE_ANALYTICS_KEY=ak_demo_abc123 \
332336
rep-todo
333337
```
@@ -400,8 +404,8 @@ variables differ between deployments — exactly like any other twelve-factor ap
400404
|---|---|---|
401405
| `pnpm rep:validate` | `rep validate --manifest .rep.yaml` | Validate manifest schema and constraints |
402406
| `pnpm rep:typegen` | `rep typegen --manifest .rep.yaml --output src/rep.d.ts` | Generate typed SDK overloads |
403-
| `pnpm rep:dev` | `rep dev --env .env.local --proxy http://localhost:5173` | Dev server (proxy mode) |
404-
| `pnpm rep:serve` | `rep dev --env .env.local --static ./dist --hot-reload` | Dev server (embedded, hot reload) |
407+
| `pnpm rep:dev` | `rep dev --env .env.local --proxy http://localhost:5173 --hot-reload` | Dev server (proxy mode, file watching) |
408+
| `pnpm rep:serve` | `rep dev --env .env.local --static ./dist --hot-reload` | Dev server (embedded, file watching) |
405409
| `pnpm rep:lint` | `rep lint --dir ./dist` | Scan build output for leaked secrets |
406410

407411
---

gateway/internal/config/classify.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,23 +87,45 @@ func (cv *ClassifiedVars) ServerMap() map[string]string {
8787
return m
8888
}
8989

90-
// ReadAndClassify reads all environment variables, filters for the REP_ prefix,
90+
// ReadAndClassify reads environment variables, filters for the REP_ prefix,
9191
// classifies them, strips prefixes, and validates uniqueness.
9292
//
93+
// When envFile is non-empty, the file is parsed first as a base layer.
94+
// Process environment variables (os.Environ) are then overlaid on top,
95+
// so real env vars always take precedence over the file.
96+
//
9397
// Per REP-RFC-0001 §3.2:
9498
// - Only REP_* prefixed variables are read.
9599
// - The classification prefix is stripped from the name.
96100
// - Names MUST be unique across all tiers after stripping.
97-
func ReadAndClassify() (*ClassifiedVars, error) {
98-
vars := &ClassifiedVars{}
99-
seen := make(map[string]string) // name → original key (for collision detection)
101+
func ReadAndClassify(envFile string) (*ClassifiedVars, error) {
102+
// Build a merged map: env file (base) + os.Environ() (override).
103+
merged := make(map[string]string)
104+
105+
if envFile != "" {
106+
fileVars, err := ParseEnvFile(envFile)
107+
if err != nil {
108+
return nil, fmt.Errorf("reading env file: %w", err)
109+
}
110+
for k, v := range fileVars {
111+
merged[k] = v
112+
}
113+
}
100114

115+
// Process environment overrides file values.
101116
for _, env := range os.Environ() {
102117
key, value, ok := strings.Cut(env, "=")
103118
if !ok {
104119
continue
105120
}
121+
merged[key] = value
122+
}
123+
124+
// Classify the merged set.
125+
vars := &ClassifiedVars{}
126+
seen := make(map[string]string) // name → original key (for collision detection)
106127

128+
for key, value := range merged {
107129
// Skip non-REP variables.
108130
if !strings.HasPrefix(key, "REP_") {
109131
continue
@@ -129,8 +151,6 @@ func ReadAndClassify() (*ClassifiedVars, error) {
129151
v.Name = strings.TrimPrefix(key, "REP_SERVER_")
130152
v.Tier = TierServer
131153
default:
132-
// REP_ prefixed but doesn't match a known tier — skip with a warning.
133-
// This covers things like REP_CUSTOM_FOO which don't fit the spec.
134154
continue
135155
}
136156

0 commit comments

Comments
 (0)