@ossy/platform 1.31.2 → 1.32.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 +95 -0
- package/package.json +9 -5
- package/src/email.integration.js +26 -0
- package/src/index.js +1 -0
- package/src/integration.service.js +63 -0
- package/src/proxy-internal.js +8 -4
- package/src/resources/resource-template.registry.js +5 -1
- package/src/runtime.js +9 -6
- package/src/server.js +45 -18
- package/src/site-loader.js +6 -3
- package/src/tasks/change-stream.js +13 -10
- package/src/tasks/task-registry.js +4 -1
- package/src/tasks/task-service.js +26 -11
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @ossy/platform
|
|
2
|
+
|
|
3
|
+
Ossy application server runtime — Express-based server that loads the build manifest produced by `@ossy/app` and serves pages, APIs, tasks, and integrations.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## `*.integration.js` — third-party integration primitive
|
|
8
|
+
|
|
9
|
+
An integration file declares a named third-party client (e.g. a database, email provider, or payment SDK) that the platform connects to once at startup and makes available to every task.
|
|
10
|
+
|
|
11
|
+
### File naming
|
|
12
|
+
|
|
13
|
+
Name the file `<slug>.integration.js` (or `.mjs` / `.cjs`) and place it anywhere inside `src/`. The build pipeline discovers it automatically using the `*.integration.js` pattern, just like `*.task.js` and `*.page.jsx`.
|
|
14
|
+
|
|
15
|
+
### Required exports
|
|
16
|
+
|
|
17
|
+
| Export | Type | Description |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `id` | `string` | Unique slug used to retrieve the client — e.g. `'email'`, `'stripe'`. |
|
|
20
|
+
| `credentials` | `string[]` | Names of environment variables that must be present. If any are missing the integration is skipped at startup with a warning. |
|
|
21
|
+
| `connect` | `async ({ env }) => client` | Called once at startup with the full `process.env`. Returns the client object stored by `id`. If it throws, the integration is skipped (non-fatal). |
|
|
22
|
+
|
|
23
|
+
### Minimal example
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
// src/stripe.integration.js
|
|
27
|
+
import Stripe from 'stripe'
|
|
28
|
+
|
|
29
|
+
export const id = 'stripe'
|
|
30
|
+
|
|
31
|
+
export const credentials = ['STRIPE_SECRET_KEY']
|
|
32
|
+
|
|
33
|
+
export async function connect ({ env }) {
|
|
34
|
+
return new Stripe(env.STRIPE_SECRET_KEY)
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Consuming integrations inside a task
|
|
39
|
+
|
|
40
|
+
The platform injects `integrations` into every task's `run()` call alongside `sdk`:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
// src/send-invoice.task.js
|
|
44
|
+
export const metadata = {
|
|
45
|
+
id: 'send-invoice',
|
|
46
|
+
triggers: [{ aggregateType: 'Invoice', event: 'InvoiceCreated' }],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function run ({ event, sdk, integrations }) {
|
|
50
|
+
const stripe = integrations.get('stripe') // null if credentials are missing
|
|
51
|
+
if (!stripe) return
|
|
52
|
+
|
|
53
|
+
await stripe.invoices.send(event.payload.stripeInvoiceId)
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`integrations.get(id)` returns `null` when the integration was not loaded (missing credentials, failed `connect()`, or not declared anywhere).
|
|
58
|
+
|
|
59
|
+
### Manifest output
|
|
60
|
+
|
|
61
|
+
After `app build` the manifest contains an `integrations` array:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"integrations": [
|
|
66
|
+
{
|
|
67
|
+
"id": "stripe",
|
|
68
|
+
"entry": "/static/stripe.integration-7f2a.js",
|
|
69
|
+
"credentials": ["STRIPE_SECRET_KEY"]
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Built-in integrations
|
|
76
|
+
|
|
77
|
+
`@ossy/platform` ships with the following integration that is auto-discovered for every app using the package:
|
|
78
|
+
|
|
79
|
+
| id | Credentials | Client |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `email` | `SES_REGION`, `SES_ACCESS_KEY_ID`, `SES_SECRET_ACCESS_KEY` | `SESClient` from `@aws-sdk/client-ses` |
|
|
82
|
+
|
|
83
|
+
If the SES env vars are not set the `email` integration is silently skipped and `integrations.get('email')` returns `null`.
|
|
84
|
+
|
|
85
|
+
### `IntegrationService` API
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
import { IntegrationService } from '@ossy/platform/integrations'
|
|
89
|
+
|
|
90
|
+
// Load a list of already-imported integration modules (done automatically by the server).
|
|
91
|
+
await IntegrationService.load(modules, process.env)
|
|
92
|
+
|
|
93
|
+
// Retrieve a connected client by id.
|
|
94
|
+
const client = IntegrationService.get('email') // SESClient | null
|
|
95
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"./site-loader": "./src/site-loader.js",
|
|
20
20
|
"./tasks": "./src/index.js",
|
|
21
21
|
"./resources": "./src/resources/index.js",
|
|
22
|
-
"./definition": "./src/Definition.js"
|
|
22
|
+
"./definition": "./src/Definition.js",
|
|
23
|
+
"./integrations": "./src/integration.service.js"
|
|
23
24
|
},
|
|
24
25
|
"scripts": {
|
|
25
26
|
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\""
|
|
@@ -28,8 +29,11 @@
|
|
|
28
29
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
29
30
|
"license": "MIT",
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@ossy/
|
|
32
|
+
"@aws-sdk/client-ses": "^3.0.0",
|
|
33
|
+
"@ossy/event-store": "^1.1.0",
|
|
34
|
+
"@ossy/observability": "^1.1.0",
|
|
35
|
+
"@ossy/router": "^1.33.0",
|
|
36
|
+
"@ossy/sdk": "^1.33.0",
|
|
33
37
|
"cookie-parser": "^1.4.7",
|
|
34
38
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
35
39
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -40,5 +44,5 @@
|
|
|
40
44
|
"src",
|
|
41
45
|
"Dockerfile"
|
|
42
46
|
],
|
|
43
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "9a8a1bb0466d35001425d241d77c2c9ab31e11d3"
|
|
44
48
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SESClient } from '@aws-sdk/client-ses'
|
|
2
|
+
|
|
3
|
+
export const id = 'email'
|
|
4
|
+
|
|
5
|
+
export const credentials = [
|
|
6
|
+
'SES_REGION',
|
|
7
|
+
'SES_ACCESS_KEY_ID',
|
|
8
|
+
'SES_SECRET_ACCESS_KEY',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns an AWS SES client configured from env vars.
|
|
13
|
+
* Tasks receive this via `integrations.get('email')`.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ env: NodeJS.ProcessEnv }} opts
|
|
16
|
+
* @returns {SESClient}
|
|
17
|
+
*/
|
|
18
|
+
export async function connect ({ env }) {
|
|
19
|
+
return new SESClient({
|
|
20
|
+
region: env.SES_REGION,
|
|
21
|
+
credentials: {
|
|
22
|
+
accessKeyId: env.SES_ACCESS_KEY_ID,
|
|
23
|
+
secretAccessKey: env.SES_SECRET_ACCESS_KEY,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
}
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { ChangeStream } from './tasks/change-stream.js'
|
|
|
4
4
|
export { registerResourceTemplate, getSystemResourceTemplates } from './resources/resource-template.registry.js'
|
|
5
5
|
export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './resources/resource-template.validation.js'
|
|
6
6
|
export { Definition } from './Definition.js'
|
|
7
|
+
export { IntegrationService } from './integration.service.js'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
|
|
3
|
+
const log = createLogger('platform')
|
|
4
|
+
|
|
5
|
+
/** @type {Record<string, unknown>} */
|
|
6
|
+
const _clients = {}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages third-party integration clients declared via `*.integration.js` files.
|
|
10
|
+
*
|
|
11
|
+
* Integrations are loaded once at server startup. If a required env var is
|
|
12
|
+
* missing, or if `connect()` throws, the integration is skipped with a warning
|
|
13
|
+
* so the server can still boot without every optional credential being present.
|
|
14
|
+
*/
|
|
15
|
+
export const IntegrationService = {
|
|
16
|
+
/**
|
|
17
|
+
* Connect all integration modules, storing each resulting client by id.
|
|
18
|
+
* Pass the array of already-imported integration modules (each exporting
|
|
19
|
+
* `id`, `credentials`, and `connect`).
|
|
20
|
+
*
|
|
21
|
+
* @param {Array<{ id: string, credentials: string[], connect: (opts: { env: NodeJS.ProcessEnv }) => Promise<unknown> | unknown }>} modules
|
|
22
|
+
* @param {NodeJS.ProcessEnv} env Usually `process.env`.
|
|
23
|
+
*/
|
|
24
|
+
async load (modules, env) {
|
|
25
|
+
for (const mod of modules) {
|
|
26
|
+
const { id, credentials = [], connect } = mod
|
|
27
|
+
|
|
28
|
+
if (typeof id !== 'string' || id.trim() === '') {
|
|
29
|
+
log.warn('[IntegrationService] Skipping integration with missing or invalid "id"')
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const missing = credentials.filter((key) => !env[key])
|
|
34
|
+
if (missing.length > 0) {
|
|
35
|
+
log.warn(
|
|
36
|
+
`[IntegrationService] Skipping integration "${id}" — missing env var(s): ${missing.join(', ')}`,
|
|
37
|
+
)
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
_clients[id] = await connect({ env })
|
|
43
|
+
log.info(`[IntegrationService] Connected integration "${id}"`)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log.warn(
|
|
46
|
+
`[IntegrationService] Integration "${id}" connect() failed — skipping: ${err && err.message ? err.message : err}`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Retrieve a connected client by integration id.
|
|
54
|
+
* Returns `null` when the integration was not loaded (missing credentials,
|
|
55
|
+
* connect failure, or not declared in the manifest).
|
|
56
|
+
*
|
|
57
|
+
* @param {string} id
|
|
58
|
+
* @returns {unknown | null}
|
|
59
|
+
*/
|
|
60
|
+
get (id) {
|
|
61
|
+
return _clients[id] ?? null
|
|
62
|
+
},
|
|
63
|
+
}
|
package/src/proxy-internal.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
|
|
3
|
+
const log = createLogger('platform')
|
|
4
|
+
|
|
1
5
|
/** Node/Express can join duplicate workspace headers as `id, id` — API expects a single id. */
|
|
2
6
|
function normalizeWorkspaceIdHeader (value) {
|
|
3
7
|
if (!value || value === 'undefined') return undefined
|
|
@@ -39,14 +43,14 @@ export function ProxyInternal () {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
if (req.originalUrl.startsWith('/@ossy/users/me/app-settings') && req.method === 'GET') {
|
|
42
|
-
|
|
46
|
+
log.info('[@ossy/platform][proxy] GET /@ossy/users/me/app-settings')
|
|
43
47
|
const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
|
|
44
48
|
res.status(200)
|
|
45
49
|
res.json(userSettings)
|
|
46
50
|
return
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
log.info(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl}`)
|
|
50
54
|
|
|
51
55
|
const domain = process.env.OSSY_API_URL || 'https://api.ossy.se'
|
|
52
56
|
const url = `${domain}${req.originalUrl?.replace('/@ossy', '/api/v0')}`
|
|
@@ -79,7 +83,7 @@ export function ProxyInternal () {
|
|
|
79
83
|
try {
|
|
80
84
|
data = trimmed === '' ? null : JSON.parse(trimmed)
|
|
81
85
|
} catch (error) {
|
|
82
|
-
|
|
86
|
+
log.error('[@ossy/platform][proxy][error]', undefined, error)
|
|
83
87
|
res.removeHeader('content-length')
|
|
84
88
|
const st = response.status
|
|
85
89
|
if (st === 401 || st === 403 || st === 404) {
|
|
@@ -103,7 +107,7 @@ export function ProxyInternal () {
|
|
|
103
107
|
})
|
|
104
108
|
})
|
|
105
109
|
.catch((error) => {
|
|
106
|
-
|
|
110
|
+
log.error('[@ossy/platform][proxy][error]', undefined, error)
|
|
107
111
|
const status = error.status
|
|
108
112
|
res.status(status || 500)
|
|
109
113
|
res.json({ message: error.message || 'Internal Server Error' })
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
|
|
3
|
+
const log = createLogger('platform')
|
|
4
|
+
|
|
1
5
|
/** @type {object[]} */
|
|
2
6
|
const systemTemplates = []
|
|
3
7
|
|
|
@@ -12,7 +16,7 @@ export function registerResourceTemplate(template) {
|
|
|
12
16
|
if (typeof template.id !== 'string' || template.id.trim() === '') return
|
|
13
17
|
if (systemTemplates.find(t => t.id === template.id)) return
|
|
14
18
|
systemTemplates.push(template)
|
|
15
|
-
|
|
19
|
+
log.info(`[ResourceTemplateRegistry] Registered system template: ${template.id}`)
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
package/src/runtime.js
CHANGED
|
@@ -7,6 +7,9 @@ import { Router as OssyRouter } from '@ossy/router'
|
|
|
7
7
|
import { loadManifest, resolveEntryUrl } from './server.js'
|
|
8
8
|
import { ProxyInternal } from './proxy-internal.js'
|
|
9
9
|
import { loadSite, invalidateSite } from './site-loader.js'
|
|
10
|
+
import { createLogger } from '@ossy/observability'
|
|
11
|
+
|
|
12
|
+
const log = createLogger('platform')
|
|
10
13
|
|
|
11
14
|
const DEFAULT_PORT = 3000
|
|
12
15
|
|
|
@@ -119,7 +122,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
119
122
|
try {
|
|
120
123
|
context = await getSiteContext(domain)
|
|
121
124
|
} catch (err) {
|
|
122
|
-
|
|
125
|
+
log.error(`[@ossy/platform] Failed to load site for ${domain}`, undefined, err)
|
|
123
126
|
res.status(503).type('text').send(`Site not available: ${domain}`)
|
|
124
127
|
return
|
|
125
128
|
}
|
|
@@ -177,7 +180,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
177
180
|
const html = await mod.render(props)
|
|
178
181
|
res.status(200).type('html').send(html)
|
|
179
182
|
} catch (err) {
|
|
180
|
-
|
|
183
|
+
log.error(`[@ossy/platform] Request error for ${domain}${requestUrl}`, undefined, err)
|
|
181
184
|
if (!res.headersSent) res.status(500).type('text').send('Internal Server Error')
|
|
182
185
|
}
|
|
183
186
|
})
|
|
@@ -188,8 +191,8 @@ export async function startRuntime ({ port } = {}) {
|
|
|
188
191
|
httpServer.once('error', reject)
|
|
189
192
|
})
|
|
190
193
|
|
|
191
|
-
|
|
192
|
-
|
|
194
|
+
log.info(`[@ossy/platform] Runtime running on http://localhost:${resolvedPort}`)
|
|
195
|
+
log.info('[@ossy/platform] Press Ctrl+C to stop.')
|
|
193
196
|
|
|
194
197
|
const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
|
|
195
198
|
|
|
@@ -197,7 +200,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
197
200
|
const handleShutdown = async (signal) => {
|
|
198
201
|
if (shuttingDown) return
|
|
199
202
|
shuttingDown = true
|
|
200
|
-
|
|
203
|
+
log.info(`[@ossy/platform] Received ${signal}, shutting down…`)
|
|
201
204
|
try { await closeServer() } finally { process.exit(0) }
|
|
202
205
|
}
|
|
203
206
|
process.on('SIGINT', () => handleShutdown('SIGINT'))
|
|
@@ -208,6 +211,6 @@ export async function startRuntime ({ port } = {}) {
|
|
|
208
211
|
|
|
209
212
|
// Run directly: node src/runtime.js
|
|
210
213
|
startRuntime().catch((err) => {
|
|
211
|
-
|
|
214
|
+
log.error('[@ossy/platform] Runtime failed to start', undefined, err)
|
|
212
215
|
process.exit(1)
|
|
213
216
|
})
|
package/src/server.js
CHANGED
|
@@ -7,9 +7,14 @@ import { Router as OssyRouter } from '@ossy/router'
|
|
|
7
7
|
import cookieParser from 'cookie-parser'
|
|
8
8
|
import { ProxyInternal } from './proxy-internal.js'
|
|
9
9
|
import { SDK } from '@ossy/sdk'
|
|
10
|
+
import { AggregateRebuild } from '@ossy/event-store'
|
|
10
11
|
import { TaskService } from './tasks/task-service.js'
|
|
11
12
|
import { ChangeStream } from './tasks/change-stream.js'
|
|
12
13
|
import { registerResourceTemplate } from './resources/resource-template.registry.js'
|
|
14
|
+
import { IntegrationService } from './integration.service.js'
|
|
15
|
+
import { createLogger } from '@ossy/observability'
|
|
16
|
+
|
|
17
|
+
const log = createLogger('@ossy/platform')
|
|
13
18
|
|
|
14
19
|
const DEFAULT_PORT = 3000
|
|
15
20
|
const MANIFEST_FILE = 'manifest.json'
|
|
@@ -49,9 +54,11 @@ export function loadManifest (buildDir) {
|
|
|
49
54
|
const components = Array.isArray(manifest.components) ? manifest.components : []
|
|
50
55
|
const resourceTemplates = Array.isArray(manifest.resourceTemplates) ? manifest.resourceTemplates : []
|
|
51
56
|
const aggregates = Array.isArray(manifest.aggregates) ? manifest.aggregates : []
|
|
57
|
+
const integrations = Array.isArray(manifest.integrations) ? manifest.integrations : []
|
|
58
|
+
const startups = Array.isArray(manifest.startups) ? manifest.startups : []
|
|
52
59
|
for (const e of entries) {
|
|
53
60
|
if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
|
|
54
|
-
|
|
61
|
+
log.warn(`Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
return {
|
|
@@ -62,6 +69,8 @@ export function loadManifest (buildDir) {
|
|
|
62
69
|
components,
|
|
63
70
|
resourceTemplates,
|
|
64
71
|
aggregates,
|
|
72
|
+
integrations,
|
|
73
|
+
startups,
|
|
65
74
|
config: manifest.config || {},
|
|
66
75
|
}
|
|
67
76
|
}
|
|
@@ -100,7 +109,7 @@ export async function startServer (options = {}) {
|
|
|
100
109
|
TaskService.registerTask(mod)
|
|
101
110
|
}
|
|
102
111
|
} catch (err) {
|
|
103
|
-
|
|
112
|
+
log.error(`Failed to load task "${task.id}"`, undefined, err)
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
|
|
@@ -108,28 +117,46 @@ export async function startServer (options = {}) {
|
|
|
108
117
|
registerResourceTemplate(template)
|
|
109
118
|
}
|
|
110
119
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// Integration loading — import each bundled integration module, check its
|
|
121
|
+
// declared credentials, and call connect({ env }) to obtain a client.
|
|
122
|
+
// Missing credentials or a failing connect() are non-fatal: the integration
|
|
123
|
+
// is skipped with a warning so the server still starts.
|
|
124
|
+
const integrationModules = []
|
|
125
|
+
for (const intEntry of manifest.integrations ?? []) {
|
|
126
|
+
try {
|
|
127
|
+
integrationModules.push(await import(resolveEntryUrl(intEntry.entry, buildDir)))
|
|
128
|
+
} catch (err) {
|
|
129
|
+
log.warn(`Failed to import integration "${intEntry.id}"`, undefined, err)
|
|
130
|
+
}
|
|
122
131
|
}
|
|
132
|
+
await IntegrationService.load(integrationModules, process.env)
|
|
123
133
|
|
|
134
|
+
// Aggregate registration — mirrors task and resource-template registration above.
|
|
135
|
+
// AggregateRebuild is provided by @ossy/event-store.
|
|
124
136
|
if (AggregateRebuild) {
|
|
125
137
|
for (const agg of manifest.aggregates ?? []) {
|
|
126
138
|
try {
|
|
127
139
|
const mod = await import(resolveEntryUrl(agg.entry, buildDir))
|
|
128
140
|
AggregateRebuild.registerAggregate(mod)
|
|
129
141
|
} catch (err) {
|
|
130
|
-
|
|
142
|
+
log.error(`Failed to load aggregate "${agg.id}"`, undefined, err)
|
|
131
143
|
}
|
|
132
144
|
}
|
|
145
|
+
|
|
146
|
+
AggregateRebuild.BuildAndSaveAll().catch((error) => {
|
|
147
|
+
log.error('BuildAndSaveAll failed — is MongoDB reachable?', undefined, error)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const startup of manifest.startups ?? []) {
|
|
152
|
+
try {
|
|
153
|
+
const mod = await import(resolveEntryUrl(startup.entry, buildDir))
|
|
154
|
+
if (typeof mod.run === 'function') {
|
|
155
|
+
await mod.run({ env: process.env })
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
log.error(`Failed to run startup "${startup.id}"`, undefined, err)
|
|
159
|
+
}
|
|
133
160
|
}
|
|
134
161
|
|
|
135
162
|
// Register the SDK so all tasks receive it as `sdk`.
|
|
@@ -234,7 +261,7 @@ export async function startServer (options = {}) {
|
|
|
234
261
|
|
|
235
262
|
res.status(404).send('Not found')
|
|
236
263
|
} catch (err) {
|
|
237
|
-
|
|
264
|
+
log.error('Request handling failed', undefined, err)
|
|
238
265
|
if (!res.headersSent) {
|
|
239
266
|
res.status(500).type('text').send('Internal Server Error')
|
|
240
267
|
}
|
|
@@ -247,8 +274,8 @@ export async function startServer (options = {}) {
|
|
|
247
274
|
httpServer.once('error', reject)
|
|
248
275
|
})
|
|
249
276
|
|
|
250
|
-
|
|
251
|
-
|
|
277
|
+
log.info(`Running on http://localhost:${port}`)
|
|
278
|
+
log.info('Press Ctrl+C to stop.')
|
|
252
279
|
|
|
253
280
|
const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
|
|
254
281
|
|
|
@@ -256,7 +283,7 @@ export async function startServer (options = {}) {
|
|
|
256
283
|
const handleShutdown = async (signal) => {
|
|
257
284
|
if (shuttingDown) return
|
|
258
285
|
shuttingDown = true
|
|
259
|
-
|
|
286
|
+
log.info(`Received ${signal}, shutting down...`)
|
|
260
287
|
try {
|
|
261
288
|
await closeServer()
|
|
262
289
|
} finally {
|
package/src/site-loader.js
CHANGED
|
@@ -2,6 +2,9 @@ import path from 'path'
|
|
|
2
2
|
import os from 'os'
|
|
3
3
|
import { mkdir, writeFile } from 'fs/promises'
|
|
4
4
|
import { SDK, ResourcesList } from '@ossy/sdk'
|
|
5
|
+
import { createLogger } from '@ossy/observability'
|
|
6
|
+
|
|
7
|
+
const log = createLogger('platform')
|
|
5
8
|
|
|
6
9
|
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
7
10
|
const DOWNLOAD_CONCURRENCY = 10
|
|
@@ -125,7 +128,7 @@ export async function loadSite (domain) {
|
|
|
125
128
|
const buildDir = path.join(BASE_DIR, domain)
|
|
126
129
|
const cmsLocation = `/@ossy/apps/${domain}`
|
|
127
130
|
|
|
128
|
-
|
|
131
|
+
log.info(`[@ossy/platform] Loading site for ${domain} (workspace ${workspaceId}) from CMS ${cmsLocation}…`)
|
|
129
132
|
|
|
130
133
|
const sdk = SDK.of({
|
|
131
134
|
apiUrl: API_URL,
|
|
@@ -146,7 +149,7 @@ export async function loadSite (domain) {
|
|
|
146
149
|
|
|
147
150
|
await runWithConcurrency(tasks, DOWNLOAD_CONCURRENCY)
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
log.info(`[@ossy/platform] Site ${domain} ready — ${files.length} file(s) in ${buildDir}`)
|
|
150
153
|
|
|
151
154
|
cache.set(domain, { buildDir, workspaceId, loadedAt: Date.now() })
|
|
152
155
|
return buildDir
|
|
@@ -158,5 +161,5 @@ export async function loadSite (domain) {
|
|
|
158
161
|
*/
|
|
159
162
|
export function invalidateSite (domain) {
|
|
160
163
|
cache.delete(domain)
|
|
161
|
-
|
|
164
|
+
log.info(`[@ossy/platform] Cache invalidated for ${domain}`)
|
|
162
165
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { TaskService } from './task-service.js'
|
|
2
|
+
import { createLogger } from '@ossy/observability'
|
|
3
|
+
|
|
4
|
+
const log = createLogger('platform')
|
|
2
5
|
|
|
3
6
|
function isMongoTopologyClosedError(error) {
|
|
4
7
|
if (!error || typeof error !== 'object') return false
|
|
@@ -24,7 +27,7 @@ export class ChangeStream {
|
|
|
24
27
|
ChangeStream._reconnectAttempts = 0
|
|
25
28
|
ChangeStream._dbUrl = dbUrl
|
|
26
29
|
ChangeStream._open().catch((error) => {
|
|
27
|
-
|
|
30
|
+
log.error('[ChangeStream] Change stream could not be opened', undefined, error)
|
|
28
31
|
ChangeStream._scheduleReconnect()
|
|
29
32
|
})
|
|
30
33
|
}
|
|
@@ -61,7 +64,7 @@ export class ChangeStream {
|
|
|
61
64
|
if (prev) {
|
|
62
65
|
prev.close().catch(() => {})
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
log.info('[ChangeStream] New MongoClient instance created')
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
static _scheduleReconnect() {
|
|
@@ -70,19 +73,19 @@ export class ChangeStream {
|
|
|
70
73
|
const delaySecs = Math.min(Math.pow(2, ChangeStream._reconnectAttempts), 30)
|
|
71
74
|
ChangeStream._reconnectAttempts++
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
log.info(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
|
|
74
77
|
|
|
75
78
|
setTimeout(() => {
|
|
76
79
|
if (ChangeStream._stopped) return
|
|
77
80
|
ChangeStream._open().catch((error) => {
|
|
78
|
-
|
|
81
|
+
log.error('[ChangeStream] Change stream could not be opened', undefined, error)
|
|
79
82
|
ChangeStream._scheduleReconnect()
|
|
80
83
|
})
|
|
81
84
|
}, delaySecs * 1000)
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
static async _open() {
|
|
85
|
-
|
|
88
|
+
log.info('[ChangeStream] Watching for changes')
|
|
86
89
|
|
|
87
90
|
const dbName = process.env.DB_NAME || 'test'
|
|
88
91
|
const client = await ChangeStream._getClient()
|
|
@@ -95,7 +98,7 @@ export class ChangeStream {
|
|
|
95
98
|
const wasReconnecting = ChangeStream._reconnectAttempts > 0
|
|
96
99
|
ChangeStream._reconnectAttempts = 0
|
|
97
100
|
if (wasReconnecting) {
|
|
98
|
-
|
|
101
|
+
log.info('[ChangeStream] Reconnected.')
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
// Ensures only one reconnect is scheduled if both 'error' and 'close' fire for the same failure.
|
|
@@ -108,7 +111,7 @@ export class ChangeStream {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
changeStream.on('error', (error) => {
|
|
111
|
-
|
|
114
|
+
log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
|
|
112
115
|
if (isMongoTopologyClosedError(error)) {
|
|
113
116
|
ChangeStream._resetClient().catch(() => {})
|
|
114
117
|
}
|
|
@@ -116,17 +119,17 @@ export class ChangeStream {
|
|
|
116
119
|
})
|
|
117
120
|
|
|
118
121
|
changeStream.on('change', (change) => {
|
|
119
|
-
|
|
122
|
+
log.info('[ChangeStream] Change detected')
|
|
120
123
|
TaskService.dispatch(change.fullDocument)
|
|
121
124
|
})
|
|
122
125
|
|
|
123
126
|
changeStream.on('close', () => {
|
|
124
|
-
|
|
127
|
+
log.info('[ChangeStream] close detected')
|
|
125
128
|
scheduleOnce()
|
|
126
129
|
})
|
|
127
130
|
|
|
128
131
|
changeStream.on('end', () => {
|
|
129
|
-
|
|
132
|
+
log.info('[ChangeStream] end detected')
|
|
130
133
|
scheduleOnce()
|
|
131
134
|
})
|
|
132
135
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { discoverTaskFiles } from './task-loader.js'
|
|
2
2
|
import { TaskService } from './task-service.js'
|
|
3
|
+
import { createLogger } from '@ossy/observability'
|
|
4
|
+
|
|
5
|
+
const log = createLogger('platform')
|
|
3
6
|
|
|
4
7
|
export async function loadAndRegisterTasks({ cwd }) {
|
|
5
8
|
const taskFiles = await discoverTaskFiles({ cwd })
|
|
@@ -11,7 +14,7 @@ export async function loadAndRegisterTasks({ cwd }) {
|
|
|
11
14
|
TaskService.registerTask(taskModule)
|
|
12
15
|
}
|
|
13
16
|
} catch (err) {
|
|
14
|
-
|
|
17
|
+
log.error(`[TaskService] Failed to load task ${fileUrl}`, { message: err.message }, err)
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { matchesGlob } from './glob.js'
|
|
2
2
|
import { matchesCron } from './cron.js'
|
|
3
|
+
import { IntegrationService } from '../integration.service.js'
|
|
4
|
+
import { createLogger, metrics } from '@ossy/observability'
|
|
3
5
|
|
|
4
6
|
const SCHEDULER_INTERVAL_MS = 60_000
|
|
7
|
+
const _serviceLog = createLogger('TaskService')
|
|
5
8
|
|
|
6
9
|
export class TaskService {
|
|
7
10
|
|
|
@@ -40,17 +43,17 @@ export class TaskService {
|
|
|
40
43
|
const metadata = taskModule.metadata
|
|
41
44
|
|
|
42
45
|
if (typeof handler !== 'function') {
|
|
43
|
-
|
|
46
|
+
_serviceLog.error(`Task "${metadata?.id}" has no exported "run" function — skipping`)
|
|
44
47
|
return
|
|
45
48
|
}
|
|
46
49
|
if (!metadata?.id) {
|
|
47
|
-
|
|
50
|
+
_serviceLog.error('Task has no metadata.id — skipping')
|
|
48
51
|
return
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
TaskService._tasks.push({ metadata, handler })
|
|
52
|
-
|
|
53
|
-
`
|
|
55
|
+
_serviceLog.info(
|
|
56
|
+
`Registered task "${metadata.id}" with ${metadata.triggers?.length ?? 0} trigger(s)` +
|
|
54
57
|
(metadata.schedule ? ` and schedule "${metadata.schedule}"` : ''),
|
|
55
58
|
)
|
|
56
59
|
}
|
|
@@ -71,13 +74,19 @@ export class TaskService {
|
|
|
71
74
|
|
|
72
75
|
if (!matched) continue
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
_serviceLog.info(`Dispatching task "${metadata.id}"`)
|
|
75
78
|
|
|
79
|
+
const _taskId = metadata.id
|
|
80
|
+
const _start = Date.now()
|
|
76
81
|
Promise.resolve()
|
|
77
|
-
.then(() => handler({ event, sdk: effectiveSdk }))
|
|
82
|
+
.then(() => handler({ event, sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) }))
|
|
78
83
|
.catch(error =>
|
|
79
|
-
|
|
84
|
+
_serviceLog.error(`Task "${metadata.id}" failed`, undefined, error),
|
|
80
85
|
)
|
|
86
|
+
.finally(() => {
|
|
87
|
+
try { metrics.timing('task.duration', Date.now() - _start, { task: _taskId }) } catch {}
|
|
88
|
+
try { metrics.increment('task.run', { task: _taskId }) } catch {}
|
|
89
|
+
})
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
|
|
@@ -90,7 +99,7 @@ export class TaskService {
|
|
|
90
99
|
const scheduledTasks = TaskService._tasks.filter(t => !!t.metadata.schedule)
|
|
91
100
|
if (scheduledTasks.length === 0) return
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
_serviceLog.info(`Scheduler started for ${scheduledTasks.length} task(s)`)
|
|
94
103
|
|
|
95
104
|
TaskService._stopped = false
|
|
96
105
|
TaskService._schedulerInterval = setInterval(() => {
|
|
@@ -101,13 +110,19 @@ export class TaskService {
|
|
|
101
110
|
for (const { metadata, handler } of scheduledTasks) {
|
|
102
111
|
if (!matchesCron(metadata.schedule, now)) continue
|
|
103
112
|
|
|
104
|
-
|
|
113
|
+
_serviceLog.info(`Schedule fired for task "${metadata.id}"`)
|
|
105
114
|
|
|
115
|
+
const _taskId = metadata.id
|
|
116
|
+
const _start = Date.now()
|
|
106
117
|
Promise.resolve()
|
|
107
|
-
.then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: TaskService._sdk }))
|
|
118
|
+
.then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: TaskService._sdk, integrations: IntegrationService, log: createLogger(metadata.id) }))
|
|
108
119
|
.catch(error =>
|
|
109
|
-
|
|
120
|
+
_serviceLog.error(`Scheduled task "${metadata.id}" failed`, undefined, error),
|
|
110
121
|
)
|
|
122
|
+
.finally(() => {
|
|
123
|
+
try { metrics.timing('task.duration', Date.now() - _start, { task: _taskId }) } catch {}
|
|
124
|
+
try { metrics.increment('task.run', { task: _taskId }) } catch {}
|
|
125
|
+
})
|
|
111
126
|
}
|
|
112
127
|
}, SCHEDULER_INTERVAL_MS)
|
|
113
128
|
}
|