Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules
.env
.env.*
!env/.env.shared.example
env/.env.shared
dist
coverage
*.log
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,20 @@ npm run dev:worker
npm run dev:stack
```

The API and worker each load dotenv from their own workspace directory. Put the API values in `apps/tracking-api/.env`, and put the worker values in `apps/router-worker/.env` if you are not exporting them in your shell.
The API and worker each load dotenv from their own workspace directory. This repo now supports a shared source file and generated per-workspace env files:

```bash
cp env/.env.shared.example env/.env.shared
npm run env:check
npm run env:sync
```

`npm run env:sync` generates:

- `apps/tracking-api/.env`
- `apps/router-worker/.env`
- `deploy/app/.env`
- `deploy/infra/.env`

Minimum local queue settings:

Expand Down
16 changes: 13 additions & 3 deletions docs/07-ops/runbooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,19 @@ Use this when you want to run the queue path locally end to end.

### Environment layout

- `apps/tracking-api/.env` is loaded when you run the API workspace script
- `apps/router-worker/.env` is loaded when you run the worker workspace script
- the two apps do not read a shared repo-root `.env` file by default, so copy the values into each workspace that needs them
- Shared source of truth: `env/.env.shared` (copy from `env/.env.shared.example`)
- Generate all runtime/deploy env files with:

```bash
npm run env:check
npm run env:sync
```

- Generated files:
- `apps/tracking-api/.env`
- `apps/router-worker/.env`
- `deploy/app/.env`
- `deploy/infra/.env`

Recommended minimum values:

Expand Down
43 changes: 43 additions & 0 deletions env/.env.shared.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Shared source of truth for env values.
# Copy to env/.env.shared, fill required values, then run: npm run env:sync

# --- API local runtime ---
PORT=3000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/tracking_base
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/tracking_base
NODE_ENV=development
METRICS_ENABLED=true
TRACKING_API_AUTH_MODE=off
TRACKING_API_SECRET=
TRACKING_SIGNATURE_SKEW_SECONDS=300
TRACKING_RATE_LIMIT_ENABLED=false
TRACKING_RATE_LIMIT_WINDOW_MS=60000
TRACKING_RATE_LIMIT_MAX_REQUESTS=120
TRACKING_CORS_ALLOW_ORIGINS=http://localhost:5173,https://prankbook.com,https://staging.prankbook.com/

# --- Queue runtime (shared by API + worker) ---
REDIS_URL=redis://localhost:6379/0
REDIS_HOST=
REDIS_PORT=6379
REDIS_DB=0
REDIS_USERNAME=
REDIS_PASSWORD=
ROUTER_QUEUE_NAME=router-deliveries
ROUTER_WORKER_NAME=router-delivery-worker
ROUTER_WORKER_CONCURRENCY=5

# --- Worker destination adapters (optional) ---
META_CONVERSIONS_ENDPOINT_URL=
META_PIXEL_ID=
META_ACCESS_TOKEN=
META_TEST_EVENT_CODE=
GOOGLE_CONVERSIONS_ENDPOINT_URL=
GOOGLE_CONVERSIONS_API_KEY=
TIKTOK_EVENTS_ENDPOINT_URL=
TIKTOK_ACCESS_TOKEN=

# --- Deploy app stack ---
TRACKING_API_IMAGE=ghcr.io/congthiendev/tracking_base_system/tracking-api:latest
ROUTER_WORKER_IMAGE=ghcr.io/congthiendev/tracking_base_system/router-worker:latest
TRACKING_API_PORT=3000
ADMIN_API_TOKEN=
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"preview:console": "npm run -w @tracking-base/tracking-console preview",
"test": "npm run -w @tracking-base/tracking-api test",
"test:shared-contracts": "npm run -w @tracking-base/shared-contracts test",
"env:sync": "node scripts/env-sync.mjs sync",
"env:check": "node scripts/env-sync.mjs check",
"migrate": "npm run -w @tracking-base/tracking-api migrate",
"validate:delete-by-user": "npm run -w @tracking-base/tracking-api validate:delete-by-user",
"parity:check": "npm run -w @tracking-base/tracking-api parity:check --",
Expand Down
230 changes: 230 additions & 0 deletions scripts/env-sync.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env node

import fs from 'node:fs';
import path from 'node:path';

const repoRoot = process.cwd();
const sharedEnvPath = path.join(repoRoot, 'env', '.env.shared');
const sharedEnvExamplePath = path.join(repoRoot, 'env', '.env.shared.example');

const OUTPUT_HEADER = '# Generated by scripts/env-sync.mjs from env/.env.shared\n';

const targets = [
{
name: 'tracking-api',
outputPath: path.join(repoRoot, 'apps', 'tracking-api', '.env'),
keys: [
'PORT',
'DATABASE_URL',
'TEST_DATABASE_URL',
'REDIS_URL',
'ROUTER_QUEUE_NAME',
'ROUTER_WORKER_NAME',
'ROUTER_WORKER_CONCURRENCY',
'REDIS_HOST',
'REDIS_PORT',
'REDIS_DB',
'REDIS_USERNAME',
'REDIS_PASSWORD',
'NODE_ENV',
'METRICS_ENABLED',
'TRACKING_API_AUTH_MODE',
'TRACKING_API_SECRET',
'TRACKING_SIGNATURE_SKEW_SECONDS',
'TRACKING_RATE_LIMIT_ENABLED',
'TRACKING_RATE_LIMIT_WINDOW_MS',
'TRACKING_RATE_LIMIT_MAX_REQUESTS',
'TRACKING_CORS_ALLOW_ORIGINS'
],
required: ['PORT', 'DATABASE_URL', 'TRACKING_API_AUTH_MODE']
},
{
name: 'router-worker',
outputPath: path.join(repoRoot, 'apps', 'router-worker', '.env'),
keys: [
'REDIS_URL',
'DATABASE_URL',
'ROUTER_QUEUE_NAME',
'ROUTER_WORKER_NAME',
'ROUTER_WORKER_CONCURRENCY',
'META_CONVERSIONS_ENDPOINT_URL',
'META_PIXEL_ID',
'META_ACCESS_TOKEN',
'META_TEST_EVENT_CODE',
'GOOGLE_CONVERSIONS_ENDPOINT_URL',
'GOOGLE_CONVERSIONS_API_KEY',
'TIKTOK_EVENTS_ENDPOINT_URL',
'TIKTOK_ACCESS_TOKEN'
],
required: ['REDIS_URL', 'ROUTER_QUEUE_NAME', 'ROUTER_WORKER_NAME', 'ROUTER_WORKER_CONCURRENCY']
},
{
name: 'deploy-app',
outputPath: path.join(repoRoot, 'deploy', 'app', '.env'),
keys: [
'TRACKING_API_IMAGE',
'ROUTER_WORKER_IMAGE',
'TRACKING_API_PORT',
'DATABASE_URL',
'ADMIN_API_TOKEN',
'REDIS_URL',
'ROUTER_QUEUE_NAME',
'ROUTER_WORKER_NAME',
'ROUTER_WORKER_CONCURRENCY',
'TRACKING_API_AUTH_MODE',
'TRACKING_API_SECRET',
'TRACKING_SIGNATURE_SKEW_SECONDS',
'TRACKING_RATE_LIMIT_ENABLED',
'TRACKING_RATE_LIMIT_WINDOW_MS',
'TRACKING_RATE_LIMIT_MAX_REQUESTS',
'TRACKING_CORS_ALLOW_ORIGINS',
'METRICS_ENABLED',
'META_CONVERSIONS_ENDPOINT_URL',
'META_PIXEL_ID',
'META_ACCESS_TOKEN',
'META_TEST_EVENT_CODE',
'GOOGLE_CONVERSIONS_ENDPOINT_URL',
'GOOGLE_CONVERSIONS_API_KEY',
'TIKTOK_EVENTS_ENDPOINT_URL',
'TIKTOK_ACCESS_TOKEN'
],
required: ['TRACKING_API_IMAGE', 'ROUTER_WORKER_IMAGE', 'DATABASE_URL', 'REDIS_URL']
},
{
name: 'deploy-infra',
outputPath: path.join(repoRoot, 'deploy', 'infra', '.env'),
keys: ['REDIS_PORT'],
required: ['REDIS_PORT']
}
];

function stripInlineComment(value) {
const index = value.search(/\s+#/);
if (index === -1) {
return value;
}
return value.slice(0, index).trimEnd();
}

function parseEnv(content) {
const values = new Map();
const lines = content.split(/\r?\n/);

for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}

const normalizedLine = line.startsWith('export ') ? line.slice(7).trimStart() : line;
const eqIndex = normalizedLine.indexOf('=');
if (eqIndex <= 0) {
continue;
}

const key = normalizedLine.slice(0, eqIndex).trim();
let value = normalizedLine.slice(eqIndex + 1);

if (!/^[_A-Za-z][_A-Za-z0-9]*$/.test(key)) {
continue;
}

const trimmedValue = value.trim();
if (
(trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
(trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))
) {
value = trimmedValue;
} else {
value = stripInlineComment(value).trim();
}

values.set(key, value);
}

return values;
}

function loadSharedValues() {
if (!fs.existsSync(sharedEnvPath)) {
const hint = fs.existsSync(sharedEnvExamplePath)
? 'Copy env/.env.shared.example to env/.env.shared and fill values first.'
: 'Create env/.env.shared first.';
throw new Error(`Missing ${sharedEnvPath}. ${hint}`);
}

const raw = fs.readFileSync(sharedEnvPath, 'utf8');
return parseEnv(raw);
}

function isMissingValue(value) {
return value === undefined || value === null || String(value).trim() === '';
}

function findMissingRequired(sharedValues) {
const errors = [];

for (const target of targets) {
const missing = target.required.filter((key) => isMissingValue(sharedValues.get(key)));
if (missing.length > 0) {
errors.push({ target: target.name, missing });
}
}

return errors;
}

function writeTargetEnvFiles(sharedValues) {
for (const target of targets) {
const bodyLines = target.keys.map((key) => `${key}=${sharedValues.get(key) ?? ''}`);
const output = `${OUTPUT_HEADER}${bodyLines.join('\n')}\n`;
fs.writeFileSync(target.outputPath, output, 'utf8');
console.log(`Updated ${path.relative(repoRoot, target.outputPath)}`);
}
}

function check(sharedValues) {
const errors = findMissingRequired(sharedValues);
if (errors.length === 0) {
console.log('All required env vars are present in env/.env.shared');
return;
}

console.error('Missing required env vars in env/.env.shared:');
for (const error of errors) {
console.error(`- ${error.target}: ${error.missing.join(', ')}`);
}
process.exitCode = 1;
}

function sync(sharedValues) {
const errors = findMissingRequired(sharedValues);
if (errors.length > 0) {
console.error('Cannot sync because required vars are missing in env/.env.shared:');
for (const error of errors) {
console.error(`- ${error.target}: ${error.missing.join(', ')}`);
}
process.exit(1);
}

writeTargetEnvFiles(sharedValues);
}

function main() {
const mode = process.argv[2] || 'sync';
if (!['sync', 'check'].includes(mode)) {
console.error('Usage: node scripts/env-sync.mjs [sync|check]');
process.exit(1);
}

const sharedValues = loadSharedValues();

if (mode === 'check') {
check(sharedValues);
return;
}

sync(sharedValues);
}

main();
Loading