@aurostack/stacks 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/cli/src/generate.mjs +13 -1
- package/cli/stack.mjs +1 -1
- package/package.json +1 -1
- package/templates/nest-api/files/.bin/secrets.ts +15 -6
- package/templates/nest-api/files/.env.example +29 -2
- package/templates/nest-api/files/.env.test.example +1 -1
- package/templates/nest-api/files/_gitignore +1 -0
- package/templates/nest-api/files/compose.yml +16 -0
- package/templates/nest-api/files/package.json +18 -3
- package/templates/nest-api/files/src/app.module.ts +9 -1
- package/templates/nest-api/files/src/app.setup.ts +8 -7
- package/templates/nest-api/files/src/common/__tests__/temporal.service.spec.ts +66 -0
- package/templates/nest-api/files/src/common/common.module.ts +4 -3
- package/templates/nest-api/files/src/common/constants/pino.ts +14 -0
- package/templates/nest-api/files/src/common/controllers/index.ts +0 -1
- package/templates/nest-api/files/src/common/modules/index.ts +0 -1
- package/templates/nest-api/files/src/common/modules/logger.module.ts +5 -8
- package/templates/nest-api/files/src/common/services/config.service.ts +43 -5
- package/templates/nest-api/files/src/common/services/index.ts +1 -0
- package/templates/nest-api/files/src/common/services/temporal.service.ts +66 -0
- package/templates/nest-api/files/src/instrumentation.ts +117 -0
- package/templates/nest-api/files/src/main.ts +3 -1
- package/templates/nest-api/files/src/notifications/services/notifications.service.ts +4 -2
- package/templates/nest-api/files/src/telemetry/__tests__/config.spec.ts +67 -0
- package/templates/nest-api/files/src/telemetry/config.ts +62 -0
- package/templates/nest-api/template.json +54 -14
- package/templates/node-worker/files/.bin/secrets.ts +15 -6
- package/templates/node-worker/files/.env.example +27 -0
- package/templates/node-worker/files/Dockerfile +25 -2
- package/templates/node-worker/files/Dockerfile.dev +25 -2
- package/templates/node-worker/files/_gitignore +1 -0
- package/templates/node-worker/files/ecosystem.config.cjs +2 -0
- package/templates/node-worker/files/package.json +24 -4
- package/templates/node-worker/files/src/config.ts +23 -0
- package/templates/node-worker/files/src/index.ts +40 -4
- package/templates/node-worker/files/src/instrumentation.ts +110 -0
- package/templates/node-worker/files/src/logger.ts +14 -1
- package/templates/node-worker/files/src/telemetry/config.ts +61 -0
- package/templates/node-worker/files/src/temporal/activities/index.ts +14 -0
- package/templates/node-worker/files/src/temporal/worker.ts +84 -0
- package/templates/node-worker/files/src/temporal/workflows/index.ts +23 -0
- package/templates/node-worker/files/src/utils/worker.ts +10 -2
- package/templates/node-worker/template.json +43 -1
- package/templates/py-worker/files/.env.example +28 -0
- package/templates/py-worker/files/config.py +32 -1
- package/templates/py-worker/files/requirements.txt +16 -0
- package/templates/py-worker/files/temporal/__init__.py +0 -0
- package/templates/py-worker/files/temporal/activities.py +13 -0
- package/templates/py-worker/files/temporal/workflows.py +38 -0
- package/templates/py-worker/files/utils/db.py +20 -27
- package/templates/py-worker/files/utils/logger.py +9 -2
- package/templates/py-worker/files/utils/telemetry.py +154 -0
- package/templates/py-worker/files/utils/temporal.py +55 -0
- package/templates/py-worker/files/utils/worker.py +14 -3
- package/templates/py-worker/files/worker.py +16 -0
- package/templates/py-worker/template.json +36 -0
- package/templates/react-app/derive.sh +2 -1
- package/templates/react-app/files/.bin/secrets.js +8 -2
- package/templates/react-app/files/.env.example +11 -0
- package/templates/react-app/files/.forgejo/workflows/build-and-push.yml +3 -0
- package/templates/react-app/files/Dockerfile +10 -0
- package/templates/react-app/files/_gitignore +1 -0
- package/templates/react-app/files/build.sh +3 -1
- package/templates/react-app/files/package.json +2 -0
- package/templates/react-app/files/src/lib/env.ts +10 -0
- package/templates/react-app/files/src/main.tsx +4 -0
- package/templates/react-app/files/src/shared/auth/provider.tsx +2 -0
- package/templates/react-app/files/src/shared/layouts/error-boundary.tsx +2 -0
- package/templates/react-app/files/src/shared/telemetry/index.ts +80 -0
- package/templates/react-app/template.json +16 -2
- package/templates/react-monorepo/files/.forgejo/workflows/build-and-push.yml +15 -11
- package/templates/react-monorepo/files/Dockerfile +21 -7
- package/templates/react-monorepo/files/_gitignore +1 -0
- package/templates/react-monorepo/files/apps/admin/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/admin/.env.example +11 -0
- package/templates/react-monorepo/files/apps/admin/package.json +1 -0
- package/templates/react-monorepo/files/apps/admin/src/app/router.tsx +11 -7
- package/templates/react-monorepo/files/apps/admin/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/auth/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/auth/.env.example +11 -0
- package/templates/react-monorepo/files/apps/auth/package.json +1 -0
- package/templates/react-monorepo/files/apps/auth/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/auth/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/client/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/client/.env.example +11 -0
- package/templates/react-monorepo/files/apps/client/package.json +1 -0
- package/templates/react-monorepo/files/apps/client/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/client/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/client/src/vite-env.d.ts +1 -1
- package/templates/react-monorepo/files/apps/landing/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/landing/.env.example +11 -0
- package/templates/react-monorepo/files/apps/landing/package.json +1 -0
- package/templates/react-monorepo/files/apps/landing/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/landing/src/main.tsx +4 -0
- package/templates/react-monorepo/files/build.sh +3 -1
- package/templates/react-monorepo/files/packages/auth/package.json +1 -0
- package/templates/react-monorepo/files/packages/auth/src/provider.tsx +2 -0
- package/templates/react-monorepo/files/packages/layouts/package.json +1 -0
- package/templates/react-monorepo/files/packages/layouts/src/error-boundary.tsx +2 -0
- package/templates/react-monorepo/files/packages/telemetry/eslint.config.mjs +3 -0
- package/templates/react-monorepo/files/packages/telemetry/package.json +20 -0
- package/templates/react-monorepo/files/packages/telemetry/src/index.ts +80 -0
- package/templates/react-monorepo/files/packages/telemetry/tsconfig.json +4 -0
- package/templates/react-monorepo/files/packages/types/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/turbo.json +1 -1
- package/templates/react-monorepo/template.json +87 -9
- package/templates/nest-api/files/src/common/controllers/metrics.controller.ts +0 -21
- package/templates/nest-api/files/src/common/interceptors/index.ts +0 -1
- package/templates/nest-api/files/src/common/interceptors/metrics.interceptor.ts +0 -37
- package/templates/nest-api/files/src/common/modules/metrics.module.ts +0 -28
package/README.md
CHANGED
|
@@ -115,6 +115,65 @@ The shared code is literally the same files: `templates/react-app/derive.sh`
|
|
|
115
115
|
re-flattens `packages/*` into `src/shared/*` and rewrites the import specifiers,
|
|
116
116
|
so a fix in one lands in the other. See that template's README.
|
|
117
117
|
|
|
118
|
+
### Observability
|
|
119
|
+
|
|
120
|
+
Every template ships with OpenTelemetry (backend) or OpenObserve RUM (browser)
|
|
121
|
+
wired in and switched on by default (`--without observability` for `nest-api`,
|
|
122
|
+
`--without telemetry` for the rest). Nothing is sent until a project is
|
|
123
|
+
connected; unset, the SDKs are never even loaded.
|
|
124
|
+
|
|
125
|
+
| Template | Sends |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `nest-api` | Traces (HTTP, GraphQL, Prisma, pg, ioredis, BullMQ jobs), logs with trace ids, runtime/HTTP/queue metrics |
|
|
128
|
+
| `node-worker` | Job traces, Prisma/Redis spans, logs, metrics |
|
|
129
|
+
| `py-worker` | One trace per job, Redis spans, logs, metrics |
|
|
130
|
+
| `react-app` / `react-monorepo` | Page views, errors (including the error boundary's), slow resources, user actions, the signed-in user id, console errors as logs. API calls carry `traceparent`, so a click links to the backend trace it caused |
|
|
131
|
+
|
|
132
|
+
To connect a project to its OpenObserve organization:
|
|
133
|
+
|
|
134
|
+
1. Create an organization for the project in OpenObserve.
|
|
135
|
+
2. **Backend:** from IAM → Ingestion Tokens, copy the org's ingestion token
|
|
136
|
+
(`o2oi_…`) and set `OPENOBSERVE_ORG` and `OPENOBSERVE_TOKEN`
|
|
137
|
+
(`OPENOBSERVE_URL` defaults to `https://o2.aurostack.co`). Each service
|
|
138
|
+
writes to its own stream, named after `OTEL_SERVICE_NAME`.
|
|
139
|
+
3. **Frontend:** from Ingestion → RUM, copy the RUM token and set
|
|
140
|
+
`VITE_OPENOBSERVE_ORG` and `VITE_OPENOBSERVE_CLIENT_TOKEN`. It is a
|
|
141
|
+
write-only token built to ship in a bundle. Never put the backend's
|
|
142
|
+
ingestion token there.
|
|
143
|
+
4. Add each app origin to the instance's `ZO_CORS_ALLOWED_ORIGINS`, or the
|
|
144
|
+
browser's RUM posts are blocked.
|
|
145
|
+
|
|
146
|
+
Any other OTLP backend works too: set `OTEL_EXPORTER_OTLP_ENDPOINT` (and
|
|
147
|
+
`_HEADERS`) instead of the `OPENOBSERVE_*` variables. `OTEL_SDK_DISABLED=true`
|
|
148
|
+
turns telemetry off. Session replay is off by default
|
|
149
|
+
(`sessionReplaySampleRate` in `initTelemetry`). The first request or two of a
|
|
150
|
+
page load go out before the RUM SDK has started, so they carry no trace header.
|
|
151
|
+
Source-map upload for readable browser stack traces needs OpenObserve
|
|
152
|
+
Enterprise.
|
|
153
|
+
|
|
154
|
+
### Background work: BullMQ or Temporal
|
|
155
|
+
|
|
156
|
+
BullMQ is built in. Temporal is opt-in (`--with temporal`) for the backend
|
|
157
|
+
templates, alongside the queues rather than instead of them:
|
|
158
|
+
|
|
159
|
+
- **BullMQ** for fire-and-forget jobs: send an email, resize an image, a
|
|
160
|
+
cron-style sweep.
|
|
161
|
+
- **Temporal** for work that spans many steps, waits (minutes to months, or
|
|
162
|
+
for a signal), or must resume exactly where it stopped after a crash or
|
|
163
|
+
deploy: onboarding sequences, payments and refunds, multi-service sagas.
|
|
164
|
+
|
|
165
|
+
| Template | `--with temporal` adds |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `nest-api` | `TemporalService` (a lazily-connected client for starting and querying workflows) and Temporal's dev server in `compose.yml` (UI on `:8233`) |
|
|
168
|
+
| `node-worker` | A Temporal worker next to the BullMQ consumers, with an example workflow and activity in `src/temporal`. Its image switches to Debian: Temporal's native core doesn't run on Alpine |
|
|
169
|
+
| `py-worker` | The same in Python (`temporal/`), with a span per workflow and activity when telemetry is on |
|
|
170
|
+
|
|
171
|
+
Every template reads the same settings: `TEMPORAL_ADDRESS`,
|
|
172
|
+
`TEMPORAL_NAMESPACE`, `TEMPORAL_TASK_QUEUE`, and for production mTLS the
|
|
173
|
+
`TEMPORAL_TLS_CA`, `TEMPORAL_TLS_CERT` and `TEMPORAL_TLS_KEY` PEMs. Locally
|
|
174
|
+
they default to the dev server with no TLS. The API starts workflows on the
|
|
175
|
+
task queue a worker polls: `main` for node-worker, `python` for py-worker.
|
|
176
|
+
|
|
118
177
|
## How it works
|
|
119
178
|
|
|
120
179
|
Templates are **subtractive**. `templates/<name>/files` is a complete, runnable
|
package/cli/src/generate.mjs
CHANGED
|
@@ -197,7 +197,19 @@ export function generate(manifest, target, options) {
|
|
|
197
197
|
|
|
198
198
|
const prune = prunes.get(destRel);
|
|
199
199
|
if (prune && path.basename(destRel) === 'package.json') {
|
|
200
|
-
|
|
200
|
+
// Keys name packages as the template spells them (`@acme/telemetry`),
|
|
201
|
+
// but the content has already been through the rename map.
|
|
202
|
+
const renamed = (keys) =>
|
|
203
|
+
new Set(
|
|
204
|
+
[...keys].map((key) =>
|
|
205
|
+
applyReplacements(key, replacements, enabled, evalExpr)
|
|
206
|
+
)
|
|
207
|
+
);
|
|
208
|
+
const result = prunePackageJson(content, {
|
|
209
|
+
dependencies: renamed(prune.dependencies),
|
|
210
|
+
devDependencies: renamed(prune.devDependencies),
|
|
211
|
+
scripts: prune.scripts
|
|
212
|
+
});
|
|
201
213
|
content = result.raw;
|
|
202
214
|
report.pruned += result.removed;
|
|
203
215
|
}
|
package/cli/stack.mjs
CHANGED
|
@@ -94,7 +94,7 @@ ${c.bold('extract options')}
|
|
|
94
94
|
|
|
95
95
|
${c.bold('Examples')}
|
|
96
96
|
stack new nest-api ~/Projects/acme/api --with realtime,media
|
|
97
|
-
stack new react-monorepo ~/Projects/acme/web --
|
|
97
|
+
stack new react-monorepo ~/Projects/acme/web --with app-landing,app-admin
|
|
98
98
|
stack extract nest-api --source ~/Projects/acme/web/backend
|
|
99
99
|
`;
|
|
100
100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aurostack/stacks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Scaffold new projects from house templates: a NestJS API, React app or Turborepo monorepo, and Node or Python workers. Subtractive templates plus a zero-dependency generator.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Aurostack (https://github.com/aurostack-org)",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import shell from 'shelljs';
|
|
2
3
|
import yargs from 'yargs';
|
|
3
4
|
import { hideBin } from 'yargs/helpers';
|
|
@@ -52,9 +53,7 @@ const getCmd = (env: string) => {
|
|
|
52
53
|
if (!PROJECT_ID) {
|
|
53
54
|
throw new Error('INFISICAL_PROJECT_ID is not set — nothing to fetch from.');
|
|
54
55
|
}
|
|
55
|
-
return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env}
|
|
56
|
-
env
|
|
57
|
-
)}`;
|
|
56
|
+
return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env}`;
|
|
58
57
|
};
|
|
59
58
|
|
|
60
59
|
const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
|
|
@@ -62,7 +61,17 @@ const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
|
|
|
62
61
|
(async () => {
|
|
63
62
|
const env = await getArg();
|
|
64
63
|
const command = getCmd(env);
|
|
64
|
+
const file = getEnvFile(env);
|
|
65
65
|
log(env);
|
|
66
|
-
shell
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// Export first and write only on success: a shell `>` redirect would empty
|
|
67
|
+
// the existing file before infisical had the chance to fail.
|
|
68
|
+
const result = shell.exec(command, { silent: true });
|
|
69
|
+
if (result.code !== 0) {
|
|
70
|
+
throw new Error(result.stderr.trim() || `infisical exited with ${result.code}`);
|
|
71
|
+
}
|
|
72
|
+
fs.writeFileSync(file, result.stdout);
|
|
73
|
+
console.log(`Wrote ${file}`);
|
|
74
|
+
})().catch((err: unknown) => {
|
|
75
|
+
console.error(err instanceof Error ? err.message : err);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
|
@@ -31,6 +31,17 @@ OAUTH_GOOGLE_CLIENT_SECRET=''
|
|
|
31
31
|
LOG_LEVEL='debug'
|
|
32
32
|
|
|
33
33
|
# @feature:start observability
|
|
34
|
+
# OpenTelemetry → OpenObserve: traces, logs and metrics. Leave empty and
|
|
35
|
+
# telemetry stays off (local development, tests). Per project:
|
|
36
|
+
# OPENOBSERVE_ORG the project's organization identifier
|
|
37
|
+
# OPENOBSERVE_TOKEN that org's ingestion token (IAM → Ingestion Tokens, o2oi_…)
|
|
38
|
+
# OPENOBSERVE_STREAM defaults to the service name (OTEL_SERVICE_NAME).
|
|
39
|
+
# Any other OTLP backend: set OTEL_EXPORTER_OTLP_ENDPOINT/HEADERS instead.
|
|
40
|
+
OPENOBSERVE_URL='https://o2.aurostack.co'
|
|
41
|
+
OPENOBSERVE_ORG=''
|
|
42
|
+
OPENOBSERVE_TOKEN=''
|
|
43
|
+
OTEL_SERVICE_NAME='acme-api'
|
|
44
|
+
|
|
34
45
|
# Limits past which /health reports down. Size them to the container's real
|
|
35
46
|
# limits: an orchestrator restarts instances that fail this check.
|
|
36
47
|
HEALTH_HEAP_MAX_MB='300'
|
|
@@ -40,8 +51,8 @@ HEALTH_DISK_THRESHOLD='0.8'
|
|
|
40
51
|
HEALTH_DISK_PATH='/'
|
|
41
52
|
# @feature:end
|
|
42
53
|
|
|
43
|
-
# @feature:start openapi, queue
|
|
44
|
-
# Guards /docs, /openapi-json
|
|
54
|
+
# @feature:start openapi, queue
|
|
55
|
+
# Guards /docs, /openapi-json and /dashboard.
|
|
45
56
|
BASIC_AUTH_USER='admin'
|
|
46
57
|
BASIC_AUTH_PASS='Secret@1'
|
|
47
58
|
# @feature:end
|
|
@@ -72,6 +83,22 @@ S3_ACCESS_KEY_ID=''
|
|
|
72
83
|
S3_SECRET_ACCESS_KEY=''
|
|
73
84
|
# @feature:end
|
|
74
85
|
|
|
86
|
+
# @feature:start temporal
|
|
87
|
+
# Temporal: durable workflows. Locally, the dev server in compose.yml (no TLS;
|
|
88
|
+
# UI on http://localhost:8233). In production, the server address, the
|
|
89
|
+
# project's namespace and its mTLS client certificate (plus the CA that signed
|
|
90
|
+
# the server's), as PEM. Escaped \n are accepted, so each fits on one line.
|
|
91
|
+
TEMPORAL_ADDRESS='localhost:7233'
|
|
92
|
+
TEMPORAL_NAMESPACE='default'
|
|
93
|
+
# Where workflows started here are queued. The worker that runs them must poll
|
|
94
|
+
# the same queue (its TEMPORAL_TASK_QUEUE); a mismatch is silent, workflows
|
|
95
|
+
# just sit waiting.
|
|
96
|
+
TEMPORAL_TASK_QUEUE='main'
|
|
97
|
+
TEMPORAL_TLS_CA=''
|
|
98
|
+
TEMPORAL_TLS_CERT=''
|
|
99
|
+
TEMPORAL_TLS_KEY=''
|
|
100
|
+
# @feature:end
|
|
101
|
+
|
|
75
102
|
# @feature:start rate-limit
|
|
76
103
|
RATE_LIMIT_ENABLED='true'
|
|
77
104
|
# Throttler TTLs are in milliseconds.
|
|
@@ -34,6 +34,22 @@ services:
|
|
|
34
34
|
ports:
|
|
35
35
|
- 6379:6379
|
|
36
36
|
# @feature:end
|
|
37
|
+
# @feature:start temporal
|
|
38
|
+
# Temporal's development server (the CLI's `server start-dev`): gRPC on 7233,
|
|
39
|
+
# Web UI on http://localhost:8233, state kept in the temporal volume.
|
|
40
|
+
temporal:
|
|
41
|
+
container_name: acme-temporal
|
|
42
|
+
image: temporalio/temporal:1.9.1
|
|
43
|
+
restart: always
|
|
44
|
+
command: server start-dev --ip 0.0.0.0 --db-filename /home/temporal/temporal.db
|
|
45
|
+
ports:
|
|
46
|
+
- 7233:7233
|
|
47
|
+
- 8233:8233
|
|
48
|
+
volumes:
|
|
49
|
+
# The image runs as `temporal`; mounting its home keeps the volume writable.
|
|
50
|
+
- temporal:/home/temporal
|
|
51
|
+
# @feature:end
|
|
37
52
|
|
|
38
53
|
volumes:
|
|
39
54
|
database:
|
|
55
|
+
temporal: # @feature temporal
|
|
@@ -71,29 +71,44 @@
|
|
|
71
71
|
"@nestjs/terminus": "^12.1.0",
|
|
72
72
|
"@nestjs/throttler": "^6.7.1",
|
|
73
73
|
"@nestjs/websockets": "^12.1.0",
|
|
74
|
+
"@opentelemetry/api": "^1.9.1",
|
|
75
|
+
"@opentelemetry/auto-instrumentations-node": "^0.80.0",
|
|
76
|
+
"@opentelemetry/core": "^2.11.0",
|
|
77
|
+
"@opentelemetry/exporter-logs-otlp-proto": "^0.222.0",
|
|
78
|
+
"@opentelemetry/exporter-metrics-otlp-proto": "^0.222.0",
|
|
79
|
+
"@opentelemetry/exporter-trace-otlp-proto": "^0.222.0",
|
|
80
|
+
"@opentelemetry/instrumentation": "^0.222.0",
|
|
81
|
+
"@opentelemetry/instrumentation-express": "^0.70.0",
|
|
82
|
+
"@opentelemetry/resources": "^2.11.0",
|
|
83
|
+
"@opentelemetry/sdk-logs": "^0.222.0",
|
|
84
|
+
"@opentelemetry/sdk-metrics": "^2.11.0",
|
|
85
|
+
"@opentelemetry/sdk-node": "^0.222.0",
|
|
86
|
+
"@opentelemetry/semantic-conventions": "^1.43.0",
|
|
74
87
|
"@paralleldrive/cuid2": "^3.3.0",
|
|
75
88
|
"@prisma/adapter-pg": "^7.4.2",
|
|
76
89
|
"@prisma/client": "^7.4.2",
|
|
90
|
+
"@prisma/instrumentation": "^7.10.0",
|
|
77
91
|
"@react-email/components": "^0.0.7",
|
|
78
92
|
"@react-email/render": "^0.0.7",
|
|
79
93
|
"@scalar/nestjs-api-reference": "^1.2.22",
|
|
80
94
|
"@socket.io/redis-adapter": "^8",
|
|
95
|
+
"@temporalio/client": "^1.24.0",
|
|
81
96
|
"@thallesp/nestjs-better-auth": "^2.8.0",
|
|
82
|
-
"@willsoto/nestjs-prometheus": "^6.1.1",
|
|
83
97
|
"axios": "^1.4.0",
|
|
84
98
|
"better-auth": "^1.7.6",
|
|
85
|
-
"bullmq": "^
|
|
99
|
+
"bullmq": "^6.3.9",
|
|
100
|
+
"bullmq-otel": "^2.0.1",
|
|
86
101
|
"express": "^5.2.1",
|
|
87
102
|
"express-basic-auth": "^1.2.1",
|
|
88
103
|
"graphql": "^16.12.0",
|
|
89
104
|
"helmet": "^7.0.0",
|
|
105
|
+
"import-in-the-middle": "^3.5.1",
|
|
90
106
|
"ioredis": "^5.4.1",
|
|
91
107
|
"moment": "^2.29.4",
|
|
92
108
|
"nestjs-pino": "^5.2.0",
|
|
93
109
|
"nodemailer": "^8.0.11",
|
|
94
110
|
"pino": "^10.3.1",
|
|
95
111
|
"pino-http": "^11.0.0",
|
|
96
|
-
"prom-client": "^15.1.3",
|
|
97
112
|
"randomstring": "^1.3.0",
|
|
98
113
|
"react": "^18.2.0",
|
|
99
114
|
"react-email": "^1.9.4",
|
|
@@ -4,6 +4,7 @@ import { HttpModule } from '@nestjs/axios'; // @feature http-client
|
|
|
4
4
|
import { ScheduleModule } from '@nestjs/schedule'; // @feature scheduler
|
|
5
5
|
import { MailerModule } from '@nestjs-modules/mailer'; // @feature mail
|
|
6
6
|
import { BullModule } from '@nestjs/bullmq'; // @feature queue
|
|
7
|
+
import { BullMQOtel } from 'bullmq-otel'; // @feature queue
|
|
7
8
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; // @feature rate-limit
|
|
8
9
|
import { AuthModule, AuthGuard } from '@thallesp/nestjs-better-auth';
|
|
9
10
|
import { betterAuth } from 'better-auth';
|
|
@@ -204,7 +205,14 @@ import { OriginBuilder } from 'common/misc';
|
|
|
204
205
|
},
|
|
205
206
|
defaultJobOptions: {
|
|
206
207
|
removeOnComplete: true
|
|
207
|
-
}
|
|
208
|
+
},
|
|
209
|
+
// Job spans for producers and workers; a no-op unless the
|
|
210
|
+
// OpenTelemetry SDK is running (see src/instrumentation.ts).
|
|
211
|
+
telemetry: new BullMQOtel({
|
|
212
|
+
tracerName: 'acme-api',
|
|
213
|
+
meterName: 'acme-api',
|
|
214
|
+
enableMetrics: true
|
|
215
|
+
})
|
|
208
216
|
})
|
|
209
217
|
}),
|
|
210
218
|
BullBoardModule.forRoot({
|
|
@@ -10,7 +10,7 @@ import { SwaggerModule } from '@nestjs/swagger'; // @feature openapi
|
|
|
10
10
|
import { HttpAdapterHost, Reflector } from '@nestjs/core';
|
|
11
11
|
import helmet from 'helmet';
|
|
12
12
|
import express from 'express';
|
|
13
|
-
import expressBasicAuth from 'express-basic-auth'; // @feature openapi, queue
|
|
13
|
+
import expressBasicAuth from 'express-basic-auth'; // @feature openapi, queue
|
|
14
14
|
import { apiReference } from '@scalar/nestjs-api-reference'; // @feature openapi
|
|
15
15
|
import { AuthService } from '@thallesp/nestjs-better-auth'; // @feature openapi
|
|
16
16
|
import { Logger as PinoLogger } from 'nestjs-pino';
|
|
@@ -117,12 +117,13 @@ export const enableHelmet = (app: INestApplication) => {
|
|
|
117
117
|
app.use(helmet());
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
-
// @feature:start openapi, queue
|
|
120
|
+
// @feature:start openapi, queue
|
|
121
121
|
/**
|
|
122
122
|
* Lock the operator-facing surfaces behind basic auth. Every path added here is
|
|
123
|
-
* something that leaks internals if left open — the API reference
|
|
124
|
-
*
|
|
125
|
-
* the same commit that
|
|
123
|
+
* something that leaks internals if left open — the API reference (Scalar at
|
|
124
|
+
* /docs, Swagger UI at /openapi, the raw document at /openapi-json) and the
|
|
125
|
+
* queue dashboard — so a new one belongs in this list on the same commit that
|
|
126
|
+
* mounts it.
|
|
126
127
|
*/
|
|
127
128
|
export const enableBasicAuth = (app: INestApplication) => {
|
|
128
129
|
const config = app.get(CustomConfigService);
|
|
@@ -131,9 +132,9 @@ export const enableBasicAuth = (app: INestApplication) => {
|
|
|
131
132
|
app.use(
|
|
132
133
|
[
|
|
133
134
|
'/docs', // @feature openapi
|
|
135
|
+
'/openapi', // @feature openapi
|
|
134
136
|
'/openapi-json', // @feature openapi
|
|
135
|
-
'/dashboard'
|
|
136
|
-
'/metrics' // @feature observability
|
|
137
|
+
'/dashboard' // @feature queue
|
|
137
138
|
],
|
|
138
139
|
expressBasicAuth({
|
|
139
140
|
users: { [user]: password },
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { CustomConfigService, TemporalService, temporalTls } from 'common/services';
|
|
3
|
+
|
|
4
|
+
const tls = { ca: '', cert: '', key: '' };
|
|
5
|
+
|
|
6
|
+
describe('TemporalService', () => {
|
|
7
|
+
let service: TemporalService;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
11
|
+
providers: [
|
|
12
|
+
TemporalService,
|
|
13
|
+
{
|
|
14
|
+
provide: CustomConfigService,
|
|
15
|
+
useValue: {
|
|
16
|
+
temporal: {
|
|
17
|
+
address: 'localhost:7233',
|
|
18
|
+
namespace: 'acme',
|
|
19
|
+
taskQueue: 'main',
|
|
20
|
+
tls
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}).compile();
|
|
26
|
+
|
|
27
|
+
service = module.get(TemporalService);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('builds a client for the configured namespace without connecting', () => {
|
|
31
|
+
expect(service.client.options.namespace).toBe('acme');
|
|
32
|
+
expect(service.taskQueue).toBe('main');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('closes cleanly even if it never connected', async () => {
|
|
36
|
+
await expect(service.onApplicationShutdown()).resolves.toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('temporalTls', () => {
|
|
41
|
+
const cert = '-----BEGIN CERTIFICATE-----\\nMIIB\\n-----END CERTIFICATE-----';
|
|
42
|
+
const key = '-----BEGIN PRIVATE KEY-----\\nMIGH\\n-----END PRIVATE KEY-----';
|
|
43
|
+
|
|
44
|
+
it('is plaintext when nothing is set', () => {
|
|
45
|
+
expect(temporalTls(tls)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('builds a client certificate pair, unescaping one-line PEM', () => {
|
|
49
|
+
const result = temporalTls({ ca: cert, cert, key });
|
|
50
|
+
expect(result?.clientCertPair?.crt.toString()).toBe(
|
|
51
|
+
'-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
|
|
52
|
+
);
|
|
53
|
+
expect(result?.clientCertPair?.key.toString()).toContain('\nMIGH\n');
|
|
54
|
+
expect(result?.serverRootCACertificate?.toString()).toContain('\nMIIB\n');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('omits the CA when only the client pair is set (publicly trusted server)', () => {
|
|
58
|
+
const result = temporalTls({ ca: '', cert, key });
|
|
59
|
+
expect(result?.serverRootCACertificate).toBeUndefined();
|
|
60
|
+
expect(result?.clientCertPair).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects a certificate without its key', () => {
|
|
64
|
+
expect(() => temporalTls({ ...tls, cert })).toThrow(/set together/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -2,7 +2,6 @@ import { Global, Module } from '@nestjs/common';
|
|
|
2
2
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
3
3
|
import { TerminusModule } from '@nestjs/terminus'; // @feature observability
|
|
4
4
|
import { LoggerModule } from './modules';
|
|
5
|
-
import { MetricsModule } from './modules'; // @feature observability
|
|
6
5
|
// The queue module itself is generic; here it is only needed to register the
|
|
7
6
|
// `mail` queue, so it rides on the mail feature.
|
|
8
7
|
import { QueueModule } from './modules'; // @feature mail
|
|
@@ -16,6 +15,7 @@ import { CacheService } from './services'; // @feature cache
|
|
|
16
15
|
import { MailService } from './services'; // @feature mail
|
|
17
16
|
import { RedisThrottlerStorage } from './services'; // @feature rate-limit
|
|
18
17
|
import { FeatureFlagService } from './services'; // @feature feature-flags
|
|
18
|
+
import { TemporalService } from './services'; // @feature temporal
|
|
19
19
|
import { HealthController } from './controllers'; // @feature observability
|
|
20
20
|
import { MailProcessor } from './processors'; // @feature mail
|
|
21
21
|
import { FeatureFlagGuard } from './guards'; // @feature feature-flags
|
|
@@ -37,7 +37,6 @@ import Config from './services/config.service';
|
|
|
37
37
|
LoggerModule,
|
|
38
38
|
// @feature:start observability
|
|
39
39
|
TerminusModule,
|
|
40
|
-
MetricsModule,
|
|
41
40
|
// @feature:end
|
|
42
41
|
QueueModule.register('mail') // @feature mail
|
|
43
42
|
],
|
|
@@ -56,6 +55,7 @@ import Config from './services/config.service';
|
|
|
56
55
|
RedisThrottlerStorage, // @feature rate-limit
|
|
57
56
|
FeatureFlagService, // @feature feature-flags
|
|
58
57
|
FeatureFlagGuard, // @feature feature-flags
|
|
58
|
+
TemporalService, // @feature temporal
|
|
59
59
|
PrismaHealthIndicator // @feature observability
|
|
60
60
|
],
|
|
61
61
|
exports: [
|
|
@@ -66,7 +66,8 @@ import Config from './services/config.service';
|
|
|
66
66
|
CacheService, // @feature cache
|
|
67
67
|
MailService, // @feature mail
|
|
68
68
|
RedisThrottlerStorage, // @feature rate-limit
|
|
69
|
-
FeatureFlagService // @feature feature-flags
|
|
69
|
+
FeatureFlagService, // @feature feature-flags
|
|
70
|
+
TemporalService // @feature temporal
|
|
70
71
|
]
|
|
71
72
|
})
|
|
72
73
|
export class CommonModule {}
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pino-pretty is a dev dependency, so the production image doesn't have it.
|
|
3
|
+
* Pretty-print only where it's installed; anywhere else (an image run with an
|
|
4
|
+
* APP_ENV other than production, say) log JSON to stdout instead of crashing.
|
|
5
|
+
*/
|
|
6
|
+
export const PRETTY_AVAILABLE = (() => {
|
|
7
|
+
try {
|
|
8
|
+
require.resolve('pino-pretty');
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
})();
|
|
14
|
+
|
|
1
15
|
export const PINO_CONSOLE_TARGET = {
|
|
2
16
|
target: 'pino-pretty',
|
|
3
17
|
options: {
|
|
@@ -7,7 +7,8 @@ import { LoggerService } from '../services';
|
|
|
7
7
|
import {
|
|
8
8
|
PINO_CONSOLE_TARGET,
|
|
9
9
|
PINO_FILE_TARGET,
|
|
10
|
-
PINO_CONSOLE_TARGET_PROD
|
|
10
|
+
PINO_CONSOLE_TARGET_PROD,
|
|
11
|
+
PRETTY_AVAILABLE
|
|
11
12
|
} from '../constants';
|
|
12
13
|
|
|
13
14
|
@Global()
|
|
@@ -19,14 +20,11 @@ import {
|
|
|
19
20
|
config: CustomConfigService,
|
|
20
21
|
gen: GeneratorService
|
|
21
22
|
) => ({
|
|
22
|
-
exclude: [
|
|
23
|
-
{ method: RequestMethod.ALL, path: 'health' },
|
|
24
|
-
{ method: RequestMethod.ALL, path: 'metrics' }
|
|
25
|
-
],
|
|
23
|
+
exclude: [{ method: RequestMethod.ALL, path: 'health' }],
|
|
26
24
|
pinoHttp: {
|
|
27
25
|
level: config.logger.level,
|
|
28
26
|
transport:
|
|
29
|
-
config.app.env === 'production'
|
|
27
|
+
config.app.env === 'production' || !PRETTY_AVAILABLE
|
|
30
28
|
? { targets: [PINO_CONSOLE_TARGET_PROD, PINO_FILE_TARGET] }
|
|
31
29
|
: { targets: [PINO_CONSOLE_TARGET, PINO_FILE_TARGET] },
|
|
32
30
|
autoLogging: {
|
|
@@ -41,8 +39,7 @@ import {
|
|
|
41
39
|
'/dashboard',
|
|
42
40
|
'/openapi',
|
|
43
41
|
'/openapi-json',
|
|
44
|
-
'/docs'
|
|
45
|
-
'/metrics'
|
|
42
|
+
'/docs'
|
|
46
43
|
]; // Bull Board + API docs, all mounted outside Nest
|
|
47
44
|
return muted.some((p) => path === p || path.startsWith(p + '/'));
|
|
48
45
|
}
|
|
@@ -54,7 +54,7 @@ namespace Config {
|
|
|
54
54
|
}
|
|
55
55
|
// @feature:end
|
|
56
56
|
|
|
57
|
-
// @feature:start openapi, queue
|
|
57
|
+
// @feature:start openapi, queue
|
|
58
58
|
export interface BasicAuth {
|
|
59
59
|
user: string;
|
|
60
60
|
password: string;
|
|
@@ -114,6 +114,17 @@ namespace Config {
|
|
|
114
114
|
}
|
|
115
115
|
// @feature:end
|
|
116
116
|
|
|
117
|
+
// @feature:start temporal
|
|
118
|
+
export interface Temporal {
|
|
119
|
+
address: string;
|
|
120
|
+
namespace: string;
|
|
121
|
+
/** Where workflows started from here are queued (the worker polls it). */
|
|
122
|
+
taskQueue: string;
|
|
123
|
+
/** PEM; all empty means plaintext (the local dev server). */
|
|
124
|
+
tls: { ca: string; cert: string; key: string };
|
|
125
|
+
}
|
|
126
|
+
// @feature:end
|
|
127
|
+
|
|
117
128
|
export interface OAuthGoogle {
|
|
118
129
|
clientId: string;
|
|
119
130
|
clientSecret: string;
|
|
@@ -129,13 +140,14 @@ namespace Config {
|
|
|
129
140
|
health: Health; // @feature observability
|
|
130
141
|
database: Database;
|
|
131
142
|
redis: Redis; // @feature cache
|
|
132
|
-
basicAuth: BasicAuth; // @feature openapi, queue
|
|
143
|
+
basicAuth: BasicAuth; // @feature openapi, queue
|
|
133
144
|
betterAuth: BetterAuth;
|
|
134
145
|
growthbook: Growthbook; // @feature feature-flags
|
|
135
146
|
superuser: Superuser;
|
|
136
147
|
smtp: SMTP; // @feature mail
|
|
137
148
|
s3: S3; // @feature media
|
|
138
149
|
rateLimit: RateLimit; // @feature rate-limit
|
|
150
|
+
temporal: Temporal; // @feature temporal
|
|
139
151
|
oauth: OAuth;
|
|
140
152
|
}
|
|
141
153
|
|
|
@@ -173,7 +185,7 @@ namespace Config {
|
|
|
173
185
|
HEALTH_DISK_THRESHOLD: num(z.number().min(0).max(1)).default(0.8),
|
|
174
186
|
HEALTH_DISK_PATH: str().default('/'),
|
|
175
187
|
// @feature:end
|
|
176
|
-
// @feature:start openapi, queue
|
|
188
|
+
// @feature:start openapi, queue
|
|
177
189
|
BASIC_AUTH_USER: str(),
|
|
178
190
|
BASIC_AUTH_PASS: str(),
|
|
179
191
|
// @feature:end
|
|
@@ -207,6 +219,14 @@ namespace Config {
|
|
|
207
219
|
RATE_LIMIT_AUTH_MAX: num().default(100),
|
|
208
220
|
RATE_LIMIT_IP_HEADERS: str().default('x-forwarded-for'),
|
|
209
221
|
// @feature:end
|
|
222
|
+
// @feature:start temporal
|
|
223
|
+
TEMPORAL_ADDRESS: str().default('localhost:7233'),
|
|
224
|
+
TEMPORAL_NAMESPACE: str().default('default'),
|
|
225
|
+
TEMPORAL_TASK_QUEUE: str().default('main'),
|
|
226
|
+
TEMPORAL_TLS_CA: z.string().default(''),
|
|
227
|
+
TEMPORAL_TLS_CERT: z.string().default(''),
|
|
228
|
+
TEMPORAL_TLS_KEY: z.string().default(''),
|
|
229
|
+
// @feature:end
|
|
210
230
|
// @feature:start feature-flags
|
|
211
231
|
GROWTHBOOK_API_HOST: str(),
|
|
212
232
|
GROWTHBOOK_CLIENT_KEY: str()
|
|
@@ -249,7 +269,7 @@ namespace Config {
|
|
|
249
269
|
password: env.REDIS_PASSWORD || ''
|
|
250
270
|
},
|
|
251
271
|
// @feature:end
|
|
252
|
-
// @feature:start openapi, queue
|
|
272
|
+
// @feature:start openapi, queue
|
|
253
273
|
basicAuth: {
|
|
254
274
|
user: env.BASIC_AUTH_USER || 'user',
|
|
255
275
|
password: env.BASIC_AUTH_PASS || 'password'
|
|
@@ -305,6 +325,18 @@ namespace Config {
|
|
|
305
325
|
.filter(Boolean)
|
|
306
326
|
},
|
|
307
327
|
// @feature:end
|
|
328
|
+
// @feature:start temporal
|
|
329
|
+
temporal: {
|
|
330
|
+
address: env.TEMPORAL_ADDRESS || 'localhost:7233',
|
|
331
|
+
namespace: env.TEMPORAL_NAMESPACE || 'default',
|
|
332
|
+
taskQueue: env.TEMPORAL_TASK_QUEUE || 'main',
|
|
333
|
+
tls: {
|
|
334
|
+
ca: env.TEMPORAL_TLS_CA || '',
|
|
335
|
+
cert: env.TEMPORAL_TLS_CERT || '',
|
|
336
|
+
key: env.TEMPORAL_TLS_KEY || ''
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
// @feature:end
|
|
308
340
|
oauth: {
|
|
309
341
|
google: {
|
|
310
342
|
clientId: env.OAUTH_GOOGLE_CLIENT_ID || '',
|
|
@@ -342,7 +374,7 @@ export class CustomConfigService {
|
|
|
342
374
|
}
|
|
343
375
|
// @feature:end
|
|
344
376
|
|
|
345
|
-
// @feature:start openapi, queue
|
|
377
|
+
// @feature:start openapi, queue
|
|
346
378
|
get basicAuth() {
|
|
347
379
|
return this.config.get('basicAuth', { infer: true });
|
|
348
380
|
}
|
|
@@ -380,6 +412,12 @@ export class CustomConfigService {
|
|
|
380
412
|
}
|
|
381
413
|
// @feature:end
|
|
382
414
|
|
|
415
|
+
// @feature:start temporal
|
|
416
|
+
get temporal() {
|
|
417
|
+
return this.config.get('temporal', { infer: true });
|
|
418
|
+
}
|
|
419
|
+
// @feature:end
|
|
420
|
+
|
|
383
421
|
get oauth() {
|
|
384
422
|
return this.config.get('oauth', { infer: true });
|
|
385
423
|
}
|
|
@@ -8,3 +8,4 @@ export * from './mail.service'; // @feature mail
|
|
|
8
8
|
export * from './throttler-storage.service'; // @feature rate-limit
|
|
9
9
|
export * from './auth-rate-limit.storage'; // @feature rate-limit
|
|
10
10
|
export * from './feature-flag.service'; // @feature feature-flags
|
|
11
|
+
export * from './temporal.service'; // @feature temporal
|