@crossdelta/platform-sdk 0.22.0 → 0.22.2
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/CHANGELOG.md +30 -0
- package/bin/cli.mjs +110 -109
- package/bin/templates/workspace/.claude/rules/cloudevents.md +85 -0
- package/bin/templates/workspace/.github/README.md +67 -0
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/action.yml +27 -0
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/index.js +179 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/action.yml +21 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/index.js +370 -0
- package/bin/templates/workspace/.github/actions/prepare-build-context/action.yml +167 -0
- package/bin/templates/workspace/.github/actions/setup-bun-install/action.yml.hbs +57 -0
- package/bin/templates/workspace/.github/dependabot.yml +18 -0
- package/bin/templates/workspace/.github/workflows/build-and-deploy.yml.hbs +409 -0
- package/bin/templates/workspace/.github/workflows/lint-and-tests.yml.hbs +83 -0
- package/bin/templates/workspace/.github/workflows/publish-packages.yml +228 -0
- package/bin/templates/workspace/.vscode/extensions.json +8 -0
- package/bin/templates/workspace/.vscode/settings.json +20 -0
- package/bin/templates/workspace/apps/.gitkeep +0 -0
- package/bin/templates/workspace/docs/.gitkeep +0 -0
- package/bin/templates/workspace/infra/services/.gitkeep +0 -0
- package/bin/templates/workspace/packages/.gitkeep +0 -0
- package/bin/templates/workspace/services/.gitkeep +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "services/*/src/events/**"
|
|
4
|
+
- "packages/contracts/**"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# @crossdelta/cloudevents Conventions
|
|
8
|
+
|
|
9
|
+
Rules for working with the CloudEvents package and event handlers.
|
|
10
|
+
|
|
11
|
+
## Handler Pattern
|
|
12
|
+
|
|
13
|
+
- Handlers are files matching `*.handler.ts`, auto-discovered via glob pattern.
|
|
14
|
+
- Every handler exports a **default** from `handleEvent()` — this returns a **class** (not an instance). The framework instantiates it with `new`.
|
|
15
|
+
- Handlers are thin wrappers: parse → delegate to use-case. No business logic in handlers.
|
|
16
|
+
- Handler file naming: `<event-type>.handler.ts` (e.g. `call-received.handler.ts`).
|
|
17
|
+
|
|
18
|
+
## Wildcard Handler (`type: '*'`)
|
|
19
|
+
|
|
20
|
+
- Use `type: '*'` for catch-all handlers (audit logging, metrics).
|
|
21
|
+
- Wildcard is a **fallback** — specific handlers (`type: 'order.created'`) always win.
|
|
22
|
+
- Schema should be `z.record(z.string(), z.unknown())` since any payload shape is valid.
|
|
23
|
+
- Only one wildcard handler per consumer is recommended.
|
|
24
|
+
|
|
25
|
+
## JetStream vs. NATS Core
|
|
26
|
+
|
|
27
|
+
| Pattern | Transport | Function | When to use |
|
|
28
|
+
| ------------- | --------- | ----------------------------------- | -------------------------------------- |
|
|
29
|
+
| Async events | JetStream | `consumeJetStreams()` / `publish()` | Persistence needed, multiple consumers |
|
|
30
|
+
| Sync commands | NATS Core | `consumeNatsEvents()` / `request()` | Caller needs immediate result |
|
|
31
|
+
|
|
32
|
+
**Rule of thumb:** If the caller can retry (e.g. PBX webhook) → Request-Reply. If the message must survive crashes → JetStream.
|
|
33
|
+
|
|
34
|
+
**JetStream ↔ Core conflict:** A JetStream Stream whose subjects overlap with a NATS Core Request-Reply subject will silently intercept messages. If migrating from JetStream to Core, remove the stream first.
|
|
35
|
+
|
|
36
|
+
## `consumeJetStreams` Options
|
|
37
|
+
|
|
38
|
+
- `startFrom: 'all'` — replay full history (use for audit, analytics)
|
|
39
|
+
- `startFrom: 'new'` — only new messages (default, use for domain services)
|
|
40
|
+
- `consumer` — durable consumer name, must be unique per service
|
|
41
|
+
- `discover` — glob pattern for handler auto-discovery
|
|
42
|
+
|
|
43
|
+
## Contracts
|
|
44
|
+
|
|
45
|
+
- Live in `packages/contracts/src/events/<domain>/<event>.ts`
|
|
46
|
+
- Use `createContract({ type, channel, schema })` — schema is Zod v4
|
|
47
|
+
- `channel.stream` defines the JetStream stream routing
|
|
48
|
+
- **Subject = event `type`** (singular): a contract's NATS subject defaults to its `type` (e.g. `order.completed`) — **not** pluralized. Override explicitly with `channel.subject`. Keep domain events singular (`order.completed`, not `orders.completed`); the stream **name** is the plural collection (`ORDERS`).
|
|
49
|
+
- **String helpers pluralize**: `publish('order.x')` / `request('order.x')` map the domain token to plural (`order.x` → `orders.x`), so Core request subjects like `orders.intake` live in the plural namespace. JetStream streams consuming contract events **must bind `order.*` (singular), never `orders.*`** — otherwise they miss contract events and intercept the plural Core request subjects (a real bug we hit: `order.completed` published, stream bound to `orders.*`, event lost).
|
|
50
|
+
- Contracts are for **inter-service communication** only — not for HTTP ingress schemas
|
|
51
|
+
- **Domain constants belong in the shared types package**, not in contracts. Contracts import them for their Zod enums. Never re-define a status or direction constant inside a contract file — two definitions drift, and the one in the contract wins silently at the wire.
|
|
52
|
+
- **Contracts may import the types package, never the reverse.** Contracts pull in `@crossdelta/cloudevents`, which is Node.js-only; a types package that imports contracts stops being usable in a browser build.
|
|
53
|
+
|
|
54
|
+
## NATS Migrations
|
|
55
|
+
|
|
56
|
+
See [Infrastructure](infrastructure.md) for migration conventions (`infra/migrations/`).
|
|
57
|
+
|
|
58
|
+
## Publishing Checklist
|
|
59
|
+
|
|
60
|
+
1. Bump version in `package.json`
|
|
61
|
+
2. Update `CHANGELOG.md`
|
|
62
|
+
3. **Push to remote before publishing** — npm publish reads from the repo
|
|
63
|
+
4. Trigger workflow: `gh workflow run publish-packages.yml -f package=cloudevents`
|
|
64
|
+
5. After publishing: the `sync-templates` pre-commit hook auto-updates downstream `package.json` references
|
|
65
|
+
|
|
66
|
+
## Distributed Tracing
|
|
67
|
+
|
|
68
|
+
When `@crossdelta/telemetry` is installed (optional peer dep), trace context propagation is automatic — no service-level configuration needed:
|
|
69
|
+
|
|
70
|
+
- **Publish**: `traceparent` is injected as a CloudEvent extension field.
|
|
71
|
+
- **Consume**: `traceparent` is extracted and restored in ALS before handler execution.
|
|
72
|
+
|
|
73
|
+
This gives end-to-end distributed traces across NATS in Grafana.
|
|
74
|
+
|
|
75
|
+
## Logging
|
|
76
|
+
|
|
77
|
+
- `createLogger(enabled)` gates `info`/`warn` behind `enabled` flag.
|
|
78
|
+
- `debug` requires **both** `enabled` AND `LOG_LEVEL=debug` env var — otherwise suppressed.
|
|
79
|
+
- `error` always logs regardless of `enabled`.
|
|
80
|
+
- Set `LOG_LEVEL=debug` only in local dev or when explicitly troubleshooting — it is noisy in production.
|
|
81
|
+
|
|
82
|
+
## Handler Testing
|
|
83
|
+
|
|
84
|
+
- `handleEvent()` returns a class → use `new HandlerClass()` in tests, not `HandlerClass()`.
|
|
85
|
+
- Access handler metadata via `HandlerClass.__eventarcMetadata`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# CI/CD Workflows
|
|
2
|
+
|
|
3
|
+
This project includes GitHub Actions workflows for continuous integration and deployment.
|
|
4
|
+
|
|
5
|
+
## Workflows
|
|
6
|
+
|
|
7
|
+
### Pull Request Checks (`lint-and-tests.yml`)
|
|
8
|
+
|
|
9
|
+
Runs on every pull request to `main`:
|
|
10
|
+
- Lints the codebase (`bun lint`)
|
|
11
|
+
- Runs tests (`bun test`)
|
|
12
|
+
- Uses dependency caching for faster builds
|
|
13
|
+
|
|
14
|
+
### Build and Deploy (`build-and-deploy.yml`)
|
|
15
|
+
|
|
16
|
+
Runs on pushes to `main` and after package publishing:
|
|
17
|
+
- Builds Docker images for changed scopes (apps/services)
|
|
18
|
+
- Pushes images to GitHub Container Registry (GHCR)
|
|
19
|
+
- Deploys infrastructure using Pulumi
|
|
20
|
+
|
|
21
|
+
## Required Secrets
|
|
22
|
+
|
|
23
|
+
Configure these secrets in your GitHub repository settings:
|
|
24
|
+
|
|
25
|
+
| Secret | Description |
|
|
26
|
+
|--------|-------------|
|
|
27
|
+
| `PULUMI_ACCESS_TOKEN` | Pulumi Cloud access token for infrastructure deployment |
|
|
28
|
+
| `DIGITALOCEAN_TOKEN` | DigitalOcean API token for DOKS/spaces access |
|
|
29
|
+
|
|
30
|
+
## Required Variables
|
|
31
|
+
|
|
32
|
+
Configure these variables in your GitHub repository settings:
|
|
33
|
+
|
|
34
|
+
| Variable | Description | Example |
|
|
35
|
+
|----------|-------------|---------|
|
|
36
|
+
| `PULUMI_STACK_BASE` | Base name for Pulumi stacks | `myorg/myproject` |
|
|
37
|
+
|
|
38
|
+
## Automatic Permissions
|
|
39
|
+
|
|
40
|
+
These are handled automatically via `permissions` in workflows:
|
|
41
|
+
- `GITHUB_TOKEN` - GitHub-provided token for GHCR push and API access
|
|
42
|
+
|
|
43
|
+
## Custom Actions
|
|
44
|
+
|
|
45
|
+
The workflows use these local actions:
|
|
46
|
+
|
|
47
|
+
| Action | Purpose |
|
|
48
|
+
|--------|---------|
|
|
49
|
+
| `setup-bun-install` | Setup Bun runtime with caching |
|
|
50
|
+
| `generate-scope-matrix` | Discover Docker-enabled scopes for matrix builds |
|
|
51
|
+
| `check-image-tag-exists` | Skip builds if image tag already exists in GHCR |
|
|
52
|
+
| `prepare-build-context` | Flatten turbo prune output for Docker builds |
|
|
53
|
+
|
|
54
|
+
## Infrastructure Configuration
|
|
55
|
+
|
|
56
|
+
Each service in `infra/services/*.ts` can be configured with:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
const config: K8sServiceConfig = {
|
|
60
|
+
name: 'my-service',
|
|
61
|
+
containerPort: 4001,
|
|
62
|
+
skip: false, // Set to true to skip deployment
|
|
63
|
+
// ... other config
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Services with `skip: true` will be excluded from deployment.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Check image tag exists
|
|
2
|
+
inputs:
|
|
3
|
+
scope-short-name:
|
|
4
|
+
description: Short directory name of the scope (e.g., storefront)
|
|
5
|
+
required: true
|
|
6
|
+
image-tag:
|
|
7
|
+
description: Checksum-based image tag to look for
|
|
8
|
+
required: true
|
|
9
|
+
github-token:
|
|
10
|
+
description: GitHub token with read:packages to query GHCR
|
|
11
|
+
required: true
|
|
12
|
+
repository-owner:
|
|
13
|
+
description: GitHub organization or user that owns the container package
|
|
14
|
+
required: false
|
|
15
|
+
repository-prefix:
|
|
16
|
+
description: Prefix used for GHCR packages (defaults to $GHCR_REPOSITORY_PREFIX or "platform")
|
|
17
|
+
required: false
|
|
18
|
+
max-pages:
|
|
19
|
+
description: Maximum number of pagination pages to inspect (100 tags each)
|
|
20
|
+
required: false
|
|
21
|
+
default: '5'
|
|
22
|
+
outputs:
|
|
23
|
+
exists:
|
|
24
|
+
description: 'true if the tag already exists in GHCR'
|
|
25
|
+
runs:
|
|
26
|
+
using: node24
|
|
27
|
+
main: index.js
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check Image Tag Exists Action
|
|
3
|
+
*
|
|
4
|
+
* Checks if a specific Docker image tag already exists in GitHub Container Registry (GHCR).
|
|
5
|
+
* Used to skip redundant builds when the image checksum hasn't changed.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* - uses: ./.github/actions/check-image-tag-exists
|
|
9
|
+
* with:
|
|
10
|
+
* scope-short-name: storefront
|
|
11
|
+
* image-tag: abc123def456
|
|
12
|
+
* github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
13
|
+
*
|
|
14
|
+
* @outputs exists - 'true' if the tag exists, 'false' otherwise
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { appendFileSync } = require('node:fs')
|
|
18
|
+
const { exit } = require('node:process')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Builds environment variable keys for GitHub Actions inputs.
|
|
22
|
+
* @param {string} name - Input name (e.g., 'scope-short-name')
|
|
23
|
+
* @returns {string[]} Possible environment variable keys
|
|
24
|
+
*/
|
|
25
|
+
const buildInputKeys = (name) => {
|
|
26
|
+
const trimmed = name.trim()
|
|
27
|
+
const upper = trimmed.toUpperCase()
|
|
28
|
+
const normalized = upper.replace(/[^A-Z0-9]+/g, '_')
|
|
29
|
+
return Array.from(new Set([`INPUT_${upper}`, `INPUT_${normalized}`]))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Retrieves a GitHub Actions input value from environment variables.
|
|
34
|
+
* @param {string} name - Input name as defined in action.yml
|
|
35
|
+
* @param {Object} options - Options
|
|
36
|
+
* @param {boolean} [options.required=false] - Whether the input is required
|
|
37
|
+
* @param {string} [options.defaultValue=''] - Default value if input is not set
|
|
38
|
+
* @returns {string} The input value
|
|
39
|
+
*/
|
|
40
|
+
const getInput = (name, { required = false, defaultValue = '' } = {}) => {
|
|
41
|
+
const keys = buildInputKeys(name)
|
|
42
|
+
const raw = keys.map((key) => process.env[key]).find((value) => typeof value === 'string')
|
|
43
|
+
const value = (typeof raw === 'string' ? raw : defaultValue).trim()
|
|
44
|
+
|
|
45
|
+
if (required && !value) {
|
|
46
|
+
console.error(`Input "${name}" is required`)
|
|
47
|
+
exit(1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sets a GitHub Actions output value.
|
|
55
|
+
* @param {string} name - Output name
|
|
56
|
+
* @param {string} value - Output value
|
|
57
|
+
*/
|
|
58
|
+
const setOutput = (name, value) => {
|
|
59
|
+
const outputFile = process.env.GITHUB_OUTPUT
|
|
60
|
+
if (outputFile) {
|
|
61
|
+
appendFileSync(outputFile, `${name}=${value}\n`)
|
|
62
|
+
} else {
|
|
63
|
+
console.log(`${name}=${value}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Required inputs
|
|
68
|
+
const scopeShortName = getInput('scope-short-name', { required: true })
|
|
69
|
+
const imageTag = getInput('image-tag', { required: true })
|
|
70
|
+
const githubToken = getInput('github-token', { required: true })
|
|
71
|
+
|
|
72
|
+
// Optional inputs - defaults from environment for portability
|
|
73
|
+
const repositoryOwner = getInput('repository-owner', { defaultValue: process.env.GITHUB_REPOSITORY_OWNER })
|
|
74
|
+
const repositoryPrefixInput = getInput('repository-prefix')
|
|
75
|
+
const repositoryPrefix =
|
|
76
|
+
repositoryPrefixInput || process.env.GHCR_REPOSITORY_PREFIX || process.env.GITHUB_REPOSITORY?.split('/')[1] || 'platform'
|
|
77
|
+
const maxPages = Number(getInput('max-pages', { defaultValue: '5' }))
|
|
78
|
+
|
|
79
|
+
// GitHub API request headers
|
|
80
|
+
const headers = {
|
|
81
|
+
Authorization: `Bearer ${githubToken}`,
|
|
82
|
+
Accept: 'application/vnd.github+json',
|
|
83
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Construct the full package name (e.g., 'platform/storefront')
|
|
87
|
+
const packageName = `${repositoryPrefix}/${scopeShortName}`
|
|
88
|
+
const encodedPackage = encodeURIComponent(packageName)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetches a page of container versions from the GitHub Packages API.
|
|
92
|
+
* @param {number} page - Page number (1-indexed)
|
|
93
|
+
* @returns {Promise<Object>} Result with versions array, hasMore flag, and notFound flag
|
|
94
|
+
*/
|
|
95
|
+
const fetchVersions = async (page) => {
|
|
96
|
+
const url = `https://api.github.com/orgs/${repositoryOwner}/packages/container/${encodedPackage}/versions?per_page=100&page=${page}`
|
|
97
|
+
const response = await fetch(url, { headers })
|
|
98
|
+
|
|
99
|
+
// 404 means the package doesn't exist yet (first build)
|
|
100
|
+
if (response.status === 404) {
|
|
101
|
+
return { notFound: true, versions: [], hasMore: false }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const body = await response.text()
|
|
106
|
+
throw new Error(`GitHub API error (${response.status}): ${body}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = await response.json()
|
|
110
|
+
const versions = Array.isArray(data) ? data : []
|
|
111
|
+
return {
|
|
112
|
+
versions,
|
|
113
|
+
hasMore: versions.length === 100, // Full page means there might be more
|
|
114
|
+
notFound: false,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Checks if the target tag exists in a list of container versions.
|
|
120
|
+
* @param {Object[]} versions - Array of version objects from GitHub API
|
|
121
|
+
* @param {string} targetTag - The tag to search for
|
|
122
|
+
* @returns {boolean} True if tag is found
|
|
123
|
+
*/
|
|
124
|
+
const tagExistsInVersions = (versions, targetTag) => {
|
|
125
|
+
for (const version of versions) {
|
|
126
|
+
const tags = version?.metadata?.container?.tags
|
|
127
|
+
if (!Array.isArray(tags)) continue
|
|
128
|
+
if (tags.includes(targetTag)) {
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Checks if the image tag exists in GHCR by paginating through all versions.
|
|
137
|
+
* @returns {Promise<boolean>} True if tag exists
|
|
138
|
+
*/
|
|
139
|
+
const check = async () => {
|
|
140
|
+
const targetTag = imageTag.trim()
|
|
141
|
+
|
|
142
|
+
// Paginate through versions until we find the tag or run out of pages
|
|
143
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
144
|
+
const { versions, hasMore, notFound } = await fetchVersions(page)
|
|
145
|
+
|
|
146
|
+
// Package doesn't exist yet - tag definitely doesn't exist
|
|
147
|
+
if (notFound) {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check if target tag is in this page
|
|
152
|
+
if (tagExistsInVersions(versions, targetTag)) {
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// No more pages to check
|
|
157
|
+
if (!hasMore) {
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Main entry point - runs the check and outputs the result.
|
|
167
|
+
*/
|
|
168
|
+
const run = async () => {
|
|
169
|
+
try {
|
|
170
|
+
const exists = await check()
|
|
171
|
+
setOutput('exists', String(exists))
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Failed to check image tag existence:', error)
|
|
174
|
+
setOutput('exists', 'false')
|
|
175
|
+
exit(1)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
run()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Generate scope matrix
|
|
2
|
+
description: Discover Docker scopes and output JSON matrix entries for changed scopes
|
|
3
|
+
inputs:
|
|
4
|
+
scope-roots:
|
|
5
|
+
description: Comma/space separated list of directories containing scope folders
|
|
6
|
+
required: false
|
|
7
|
+
force-scope-short-names:
|
|
8
|
+
description: Optional short-name list to force into the matrix even if unchanged
|
|
9
|
+
required: false
|
|
10
|
+
force-all:
|
|
11
|
+
description: When true, include ALL discovered scopes regardless of changes
|
|
12
|
+
required: false
|
|
13
|
+
default: 'false'
|
|
14
|
+
outputs:
|
|
15
|
+
scopes:
|
|
16
|
+
description: JSON array describing scopes for the matrix strategy
|
|
17
|
+
scopes_count:
|
|
18
|
+
description: Number of scopes detected in the matrix
|
|
19
|
+
runs:
|
|
20
|
+
using: node24
|
|
21
|
+
main: index.js
|