@ossy/platform 1.31.2 → 1.31.3

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 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.31.2",
3
+ "version": "1.31.3",
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,10 @@
28
29
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
29
30
  "license": "MIT",
30
31
  "dependencies": {
31
- "@ossy/router": "^1.32.2",
32
- "@ossy/sdk": "^1.32.2",
32
+ "@aws-sdk/client-ses": "^3.0.0",
33
+ "@ossy/event-store": "^1.0.1",
34
+ "@ossy/router": "^1.32.3",
35
+ "@ossy/sdk": "^1.32.3",
33
36
  "cookie-parser": "^1.4.7",
34
37
  "dotenv": ">=16.0.0 <18.0.0",
35
38
  "express": ">=5.0.0 <6.0.0",
@@ -40,5 +43,5 @@
40
43
  "src",
41
44
  "Dockerfile"
42
45
  ],
43
- "gitHead": "5c17e379298f7027f1dbdcf064a4aff69d1497d5"
46
+ "gitHead": "b8cbdc545a659e96af8948aa08a89316a2c2d193"
44
47
  }
@@ -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,60 @@
1
+ /** @type {Record<string, unknown>} */
2
+ const _clients = {}
3
+
4
+ /**
5
+ * Manages third-party integration clients declared via `*.integration.js` files.
6
+ *
7
+ * Integrations are loaded once at server startup. If a required env var is
8
+ * missing, or if `connect()` throws, the integration is skipped with a warning
9
+ * so the server can still boot without every optional credential being present.
10
+ */
11
+ export const IntegrationService = {
12
+ /**
13
+ * Connect all integration modules, storing each resulting client by id.
14
+ * Pass the array of already-imported integration modules (each exporting
15
+ * `id`, `credentials`, and `connect`).
16
+ *
17
+ * @param {Array<{ id: string, credentials: string[], connect: (opts: { env: NodeJS.ProcessEnv }) => Promise<unknown> | unknown }>} modules
18
+ * @param {NodeJS.ProcessEnv} env Usually `process.env`.
19
+ */
20
+ async load (modules, env) {
21
+ for (const mod of modules) {
22
+ const { id, credentials = [], connect } = mod
23
+
24
+ if (typeof id !== 'string' || id.trim() === '') {
25
+ console.warn('[IntegrationService] Skipping integration with missing or invalid "id"')
26
+ continue
27
+ }
28
+
29
+ const missing = credentials.filter((key) => !env[key])
30
+ if (missing.length > 0) {
31
+ console.warn(
32
+ `[IntegrationService] Skipping integration "${id}" — missing env var(s): ${missing.join(', ')}`,
33
+ )
34
+ continue
35
+ }
36
+
37
+ try {
38
+ _clients[id] = await connect({ env })
39
+ console.log(`[IntegrationService] Connected integration "${id}"`)
40
+ } catch (err) {
41
+ console.warn(
42
+ `[IntegrationService] Integration "${id}" connect() failed — skipping:`,
43
+ err && err.message ? err.message : err,
44
+ )
45
+ }
46
+ }
47
+ },
48
+
49
+ /**
50
+ * Retrieve a connected client by integration id.
51
+ * Returns `null` when the integration was not loaded (missing credentials,
52
+ * connect failure, or not declared in the manifest).
53
+ *
54
+ * @param {string} id
55
+ * @returns {unknown | null}
56
+ */
57
+ get (id) {
58
+ return _clients[id] ?? null
59
+ },
60
+ }
package/src/server.js CHANGED
@@ -7,9 +7,11 @@ 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'
13
15
 
14
16
  const DEFAULT_PORT = 3000
15
17
  const MANIFEST_FILE = 'manifest.json'
@@ -49,6 +51,8 @@ export function loadManifest (buildDir) {
49
51
  const components = Array.isArray(manifest.components) ? manifest.components : []
50
52
  const resourceTemplates = Array.isArray(manifest.resourceTemplates) ? manifest.resourceTemplates : []
51
53
  const aggregates = Array.isArray(manifest.aggregates) ? manifest.aggregates : []
54
+ const integrations = Array.isArray(manifest.integrations) ? manifest.integrations : []
55
+ const startups = Array.isArray(manifest.startups) ? manifest.startups : []
52
56
  for (const e of entries) {
53
57
  if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
54
58
  console.warn(`[@ossy/platform][server] Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
@@ -62,6 +66,8 @@ export function loadManifest (buildDir) {
62
66
  components,
63
67
  resourceTemplates,
64
68
  aggregates,
69
+ integrations,
70
+ startups,
65
71
  config: manifest.config || {},
66
72
  }
67
73
  }
@@ -108,19 +114,22 @@ export async function startServer (options = {}) {
108
114
  registerResourceTemplate(template)
109
115
  }
110
116
 
111
- // Aggregate registrationmirrors task and resource-template registration above.
112
- // AggregateRebuild lives in the co-located api package. The relative path resolves
113
- // correctly both in the monorepo layout (ossy/packages/platform/src/ api/) and
114
- // when the published package is installed inside api/node_modules/@ossy/platform/src/.
115
- // A try/catch ensures the server starts cleanly even when the api source is absent.
116
- let AggregateRebuild = null
117
- try {
118
- const rebuildMod = await import(new URL('../../../../api/src/aggregate/aggregate.rebuild.js', import.meta.url).href)
119
- AggregateRebuild = rebuildMod.AggregateRebuild
120
- } catch {
121
- // AggregateRebuild not reachable from this installation — aggregate registration skipped.
117
+ // Integration loadingimport each bundled integration module, check its
118
+ // declared credentials, and call connect({ env }) to obtain a client.
119
+ // Missing credentials or a failing connect() are non-fatal: the integration
120
+ // is skipped with a warning so the server still starts.
121
+ const integrationModules = []
122
+ for (const intEntry of manifest.integrations ?? []) {
123
+ try {
124
+ integrationModules.push(await import(resolveEntryUrl(intEntry.entry, buildDir)))
125
+ } catch (err) {
126
+ console.warn(`[@ossy/platform][server] Failed to import integration "${intEntry.id}":`, err.message)
127
+ }
122
128
  }
129
+ await IntegrationService.load(integrationModules, process.env)
123
130
 
131
+ // Aggregate registration — mirrors task and resource-template registration above.
132
+ // AggregateRebuild is provided by @ossy/event-store.
124
133
  if (AggregateRebuild) {
125
134
  for (const agg of manifest.aggregates ?? []) {
126
135
  try {
@@ -130,6 +139,21 @@ export async function startServer (options = {}) {
130
139
  console.error(`[@ossy/platform][server] Failed to load aggregate "${agg.id}":`, err.message)
131
140
  }
132
141
  }
142
+
143
+ AggregateRebuild.BuildAndSaveAll().catch((error) => {
144
+ console.error('[@ossy/platform][server] BuildAndSaveAll failed — is MongoDB reachable?', error)
145
+ })
146
+ }
147
+
148
+ for (const startup of manifest.startups ?? []) {
149
+ try {
150
+ const mod = await import(resolveEntryUrl(startup.entry, buildDir))
151
+ if (typeof mod.run === 'function') {
152
+ await mod.run({ env: process.env })
153
+ }
154
+ } catch (err) {
155
+ console.error(`[@ossy/platform][server] Failed to run startup "${startup.id}":`, err.message)
156
+ }
133
157
  }
134
158
 
135
159
  // Register the SDK so all tasks receive it as `sdk`.
@@ -1,5 +1,6 @@
1
1
  import { matchesGlob } from './glob.js'
2
2
  import { matchesCron } from './cron.js'
3
+ import { IntegrationService } from '../integration.service.js'
3
4
 
4
5
  const SCHEDULER_INTERVAL_MS = 60_000
5
6
 
@@ -74,7 +75,7 @@ export class TaskService {
74
75
  console.log(`[TaskService] Dispatching task "${metadata.id}"`)
75
76
 
76
77
  Promise.resolve()
77
- .then(() => handler({ event, sdk: effectiveSdk }))
78
+ .then(() => handler({ event, sdk: effectiveSdk, integrations: IntegrationService }))
78
79
  .catch(error =>
79
80
  console.error(`[TaskService] Task "${metadata.id}" failed`, error),
80
81
  )
@@ -104,7 +105,7 @@ export class TaskService {
104
105
  console.log(`[TaskService] Schedule fired for task "${metadata.id}"`)
105
106
 
106
107
  Promise.resolve()
107
- .then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: TaskService._sdk }))
108
+ .then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: TaskService._sdk, integrations: IntegrationService }))
108
109
  .catch(error =>
109
110
  console.error(`[TaskService] Scheduled task "${metadata.id}" failed`, error),
110
111
  )