@aurostack/stacks 0.1.1 → 0.2.0
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/package.json +1 -1
- package/templates/nest-api/files/.env.example +29 -2
- package/templates/nest-api/files/.env.test.example +1 -1
- 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 +5 -6
- 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/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 +2 -6
- 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/telemetry/__tests__/config.spec.ts +67 -0
- package/templates/nest-api/files/src/telemetry/config.ts +62 -0
- package/templates/nest-api/template.json +36 -10
- 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/ecosystem.config.cjs +2 -0
- package/templates/node-worker/files/package.json +23 -4
- package/templates/node-worker/files/src/config.ts +23 -0
- package/templates/node-worker/files/src/index.ts +13 -0
- package/templates/node-worker/files/src/instrumentation.ts +110 -0
- 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 +9 -1
- 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/telemetry.py +154 -0
- package/templates/py-worker/files/utils/temporal.py +55 -0
- package/templates/py-worker/files/utils/worker.py +12 -1
- 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/.env.example +11 -0
- 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 +14 -0
- 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/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
- 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/.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/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/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/template.json +46 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aurostack/stacks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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)",
|
|
@@ -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,11 +117,11 @@ 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
|
-
* dashboard
|
|
123
|
+
* something that leaks internals if left open — the API reference and the queue
|
|
124
|
+
* dashboard — so a new one belongs in this list on
|
|
125
125
|
* the same commit that mounts it.
|
|
126
126
|
*/
|
|
127
127
|
export const enableBasicAuth = (app: INestApplication) => {
|
|
@@ -132,8 +132,7 @@ export const enableBasicAuth = (app: INestApplication) => {
|
|
|
132
132
|
[
|
|
133
133
|
'/docs', // @feature openapi
|
|
134
134
|
'/openapi-json', // @feature openapi
|
|
135
|
-
'/dashboard'
|
|
136
|
-
'/metrics' // @feature observability
|
|
135
|
+
'/dashboard' // @feature queue
|
|
137
136
|
],
|
|
138
137
|
expressBasicAuth({
|
|
139
138
|
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 {}
|
|
@@ -19,10 +19,7 @@ import {
|
|
|
19
19
|
config: CustomConfigService,
|
|
20
20
|
gen: GeneratorService
|
|
21
21
|
) => ({
|
|
22
|
-
exclude: [
|
|
23
|
-
{ method: RequestMethod.ALL, path: 'health' },
|
|
24
|
-
{ method: RequestMethod.ALL, path: 'metrics' }
|
|
25
|
-
],
|
|
22
|
+
exclude: [{ method: RequestMethod.ALL, path: 'health' }],
|
|
26
23
|
pinoHttp: {
|
|
27
24
|
level: config.logger.level,
|
|
28
25
|
transport:
|
|
@@ -41,8 +38,7 @@ import {
|
|
|
41
38
|
'/dashboard',
|
|
42
39
|
'/openapi',
|
|
43
40
|
'/openapi-json',
|
|
44
|
-
'/docs'
|
|
45
|
-
'/metrics'
|
|
41
|
+
'/docs'
|
|
46
42
|
]; // Bull Board + API docs, all mounted outside Nest
|
|
47
43
|
return muted.some((p) => path === p || path.startsWith(p + '/'));
|
|
48
44
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
|
2
|
+
import { Client, Connection, type TLSConfig } from '@temporalio/client';
|
|
3
|
+
import { CustomConfigService } from './config.service';
|
|
4
|
+
import type Config from './config.service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Temporal client for starting and querying workflows; a worker (node-worker
|
|
8
|
+
* or py-worker) polling the same namespace and task queue runs them.
|
|
9
|
+
*
|
|
10
|
+
* The connection is lazy: nothing is dialled until the first call, so the API
|
|
11
|
+
* boots, and serves everything else, while Temporal is unreachable.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const handle = await this.temporal.client.workflow.start('example', {
|
|
15
|
+
* taskQueue: this.temporal.taskQueue,
|
|
16
|
+
* workflowId: `example-${user.id}`, // one run per id: a natural dedupe key
|
|
17
|
+
* args: [{ name: user.name }]
|
|
18
|
+
* });
|
|
19
|
+
* const result = await handle.result(); // or return handle.workflowId
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Reach for Temporal over the BullMQ queue when the work spans many steps,
|
|
23
|
+
* waits (for minutes to months, or on a signal), or must resume exactly where
|
|
24
|
+
* it stopped after a crash. A fire-and-forget job is still a queue job.
|
|
25
|
+
*/
|
|
26
|
+
@Injectable()
|
|
27
|
+
export class TemporalService implements OnApplicationShutdown {
|
|
28
|
+
readonly client: Client;
|
|
29
|
+
/** The default task queue, from TEMPORAL_TASK_QUEUE. */
|
|
30
|
+
readonly taskQueue: string;
|
|
31
|
+
private readonly connection: Connection;
|
|
32
|
+
|
|
33
|
+
constructor(config: CustomConfigService) {
|
|
34
|
+
const { address, namespace, taskQueue, tls } = config.temporal;
|
|
35
|
+
this.connection = Connection.lazy({ address, tls: temporalTls(tls) });
|
|
36
|
+
this.client = new Client({ connection: this.connection, namespace });
|
|
37
|
+
this.taskQueue = taskQueue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async onApplicationShutdown() {
|
|
41
|
+
await this.connection.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* mTLS settings from PEM strings; none at all means plaintext (`null`).
|
|
47
|
+
* Escaped `\n` are unescaped, so a certificate fits on one line of an env
|
|
48
|
+
* file.
|
|
49
|
+
*/
|
|
50
|
+
export function temporalTls({
|
|
51
|
+
ca,
|
|
52
|
+
cert,
|
|
53
|
+
key
|
|
54
|
+
}: Config.Temporal['tls']): TLSConfig | null {
|
|
55
|
+
if (!ca && !cert && !key) return null;
|
|
56
|
+
if (!cert || !key) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must be set together'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const pem = (value: string) => Buffer.from(value.replace(/\\n/g, '\n'));
|
|
62
|
+
return {
|
|
63
|
+
...(ca && { serverRootCACertificate: pem(ca) }),
|
|
64
|
+
clientCertPair: { crt: pem(cert), key: pem(key) }
|
|
65
|
+
};
|
|
66
|
+
}
|