@ossy/platform 1.23.1 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.23.1",
3
+ "version": "1.24.1",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,16 +23,17 @@
23
23
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@ossy/router": "^1.24.1",
27
- "@ossy/sdk": "^1.24.1",
26
+ "@ossy/router": "^1.25.1",
27
+ "@ossy/sdk": "^1.25.1",
28
28
  "cookie-parser": "^1.4.7",
29
29
  "dotenv": ">=16.0.0 <18.0.0",
30
30
  "express": ">=5.0.0 <6.0.0",
31
+ "mongodb": "^7.2.0",
31
32
  "morgan": ">=1.10.1 <2.0.0"
32
33
  },
33
34
  "files": [
34
35
  "src",
35
36
  "Dockerfile"
36
37
  ],
37
- "gitHead": "c4dfd4a2cc4a6977ce00dc223c91dbaecc3348ba"
38
+ "gitHead": "c8a5e5af59c1975ea8e0c5a974202c6c146236c3"
38
39
  }
package/src/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { TaskService } from './tasks/task-service.js'
2
2
  export { loadAndRegisterTasks } from './tasks/task-registry.js'
3
+ export { ChangeStream } from './tasks/change-stream.js'
package/src/server.js CHANGED
@@ -6,6 +6,8 @@ import morgan from 'morgan'
6
6
  import { Router as OssyRouter } from '@ossy/router'
7
7
  import cookieParser from 'cookie-parser'
8
8
  import { ProxyInternal } from './proxy-internal.js'
9
+ import { TaskService } from './tasks/task-service.js'
10
+ import { ChangeStream } from './tasks/change-stream.js'
9
11
 
10
12
  const DEFAULT_PORT = 3000
11
13
  const MANIFEST_FILE = 'manifest.json'
@@ -42,6 +44,8 @@ export function loadManifest (buildDir) {
42
44
  const pages = entries.filter((e) => e.type === 'page' && validRoutable(e))
43
45
  const apis = entries.filter((e) => e.type === 'api' && validRoutable(e))
44
46
  const tasks = entries.filter((e) => e.type === 'task')
47
+ const components = Array.isArray(manifest.components) ? manifest.components : []
48
+ const resourceTemplates = Array.isArray(manifest.resourceTemplates) ? manifest.resourceTemplates : []
45
49
  for (const e of entries) {
46
50
  if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
47
51
  console.warn(`[@ossy/platform][server] Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
@@ -52,6 +56,8 @@ export function loadManifest (buildDir) {
52
56
  pages,
53
57
  apis,
54
58
  tasks,
59
+ components,
60
+ resourceTemplates,
55
61
  config: manifest.config || {},
56
62
  }
57
63
  }
@@ -83,6 +89,22 @@ export async function startServer (options = {}) {
83
89
  const manifest = loadManifest(buildDir)
84
90
  const config = manifest.config
85
91
 
92
+ for (const task of manifest.tasks ?? []) {
93
+ try {
94
+ const mod = await import(resolveEntryUrl(task.entry, buildDir))
95
+ if (mod.metadata) {
96
+ TaskService.registerTask(mod)
97
+ }
98
+ } catch (err) {
99
+ console.error(`[@ossy/platform][server] Failed to load task "${task.id}":`, err.message)
100
+ }
101
+ }
102
+ TaskService.startScheduler()
103
+
104
+ if (process.env.DB_URL) {
105
+ ChangeStream.start(process.env.DB_URL)
106
+ }
107
+
86
108
  const supportedLanguages = Array.isArray(config.supportedLanguages) ? config.supportedLanguages : []
87
109
  const defaultLanguage = config.defaultLanguage
88
110
 
@@ -158,11 +180,12 @@ export async function startServer (options = {}) {
158
180
  ...(req.userAppSettings || {}),
159
181
  theme: req.userAppSettings?.theme || cloneSerializable(config?.theme),
160
182
  themes: cloneSerializable(config?.themes),
161
- resourceTemplates: cloneSerializable(config?.resourceTemplates),
183
+ resourceTemplates: cloneSerializable(manifest.resourceTemplates),
162
184
  url: requestUrl,
163
185
  isAuthenticated: !!req.isAuthenticated,
164
186
  pages: manifest.pages.map((page) => ({ id: page.id, path: page.path })),
165
187
  pageId: pageRoute.id,
188
+ componentEntries: manifest.components,
166
189
  }
167
190
  const html = await mod.render(props)
168
191
  res.status(200).type('html').send(html)
@@ -0,0 +1,134 @@
1
+ import { TaskService } from './task-service.js'
2
+
3
+ function isMongoTopologyClosedError(error) {
4
+ if (!error || typeof error !== 'object') return false
5
+ if (error.name === 'MongoTopologyClosedError') return true
6
+ if (typeof error.message === 'string' && error.message.includes('Topology is closed')) return true
7
+ return false
8
+ }
9
+
10
+ export class ChangeStream {
11
+
12
+ static _stopped = false
13
+ static _reconnectAttempts = 0
14
+ static _dbUrl = null
15
+ static _client = null
16
+
17
+ /**
18
+ * Opens the MongoDB changestream and wires up reconnect logic.
19
+ * Logs errors; does not crash the process on Mongo outages.
20
+ * @param {string} dbUrl - MongoDB connection URL (process.env.DB_URL)
21
+ */
22
+ static start(dbUrl) {
23
+ ChangeStream._stopped = false
24
+ ChangeStream._reconnectAttempts = 0
25
+ ChangeStream._dbUrl = dbUrl
26
+ ChangeStream._open().catch((error) => {
27
+ console.error('[ChangeStream] Change stream could not be opened', error)
28
+ ChangeStream._scheduleReconnect()
29
+ })
30
+ }
31
+
32
+ static stop() {
33
+ ChangeStream._stopped = true
34
+ if (ChangeStream._client) {
35
+ ChangeStream._client.close().catch(() => {})
36
+ ChangeStream._client = null
37
+ }
38
+ }
39
+
40
+ static async _getMongoClient() {
41
+ const { MongoClient } = await import('mongodb')
42
+ return MongoClient
43
+ }
44
+
45
+ static async _getClient() {
46
+ if (!ChangeStream._client) {
47
+ const MongoClient = await ChangeStream._getMongoClient()
48
+ ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
49
+ serverSelectionTimeoutMS: 30_000,
50
+ })
51
+ }
52
+ return ChangeStream._client
53
+ }
54
+
55
+ static async _resetClient() {
56
+ const MongoClient = await ChangeStream._getMongoClient()
57
+ const prev = ChangeStream._client
58
+ ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
59
+ serverSelectionTimeoutMS: 30_000,
60
+ })
61
+ if (prev) {
62
+ prev.close().catch(() => {})
63
+ }
64
+ console.log('[ChangeStream] New MongoClient instance created')
65
+ }
66
+
67
+ static _scheduleReconnect() {
68
+ if (ChangeStream._stopped) return
69
+
70
+ const delaySecs = Math.min(Math.pow(2, ChangeStream._reconnectAttempts), 30)
71
+ ChangeStream._reconnectAttempts++
72
+
73
+ console.log(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
74
+
75
+ setTimeout(() => {
76
+ if (ChangeStream._stopped) return
77
+ ChangeStream._open().catch((error) => {
78
+ console.error('[ChangeStream] Change stream could not be opened', error)
79
+ ChangeStream._scheduleReconnect()
80
+ })
81
+ }, delaySecs * 1000)
82
+ }
83
+
84
+ static async _open() {
85
+ console.log('[ChangeStream] Watching for changes')
86
+
87
+ const dbName = process.env.DB_NAME || 'test'
88
+ const client = await ChangeStream._getClient()
89
+ const collection = client.db(dbName).collection('eventstore')
90
+
91
+ const changeStream = collection.watch([
92
+ { $match: { operationType: 'insert' } },
93
+ ])
94
+
95
+ const wasReconnecting = ChangeStream._reconnectAttempts > 0
96
+ ChangeStream._reconnectAttempts = 0
97
+ if (wasReconnecting) {
98
+ console.log('[ChangeStream] Reconnected.')
99
+ }
100
+
101
+ // Ensures only one reconnect is scheduled if both 'error' and 'close' fire for the same failure.
102
+ let reconnectScheduled = false
103
+ const scheduleOnce = () => {
104
+ if (!reconnectScheduled) {
105
+ reconnectScheduled = true
106
+ ChangeStream._scheduleReconnect()
107
+ }
108
+ }
109
+
110
+ changeStream.on('error', (error) => {
111
+ console.error('[ChangeStream] Change stream error (server keeps running)', error)
112
+ if (isMongoTopologyClosedError(error)) {
113
+ ChangeStream._resetClient().catch(() => {})
114
+ }
115
+ scheduleOnce()
116
+ })
117
+
118
+ changeStream.on('change', (change) => {
119
+ console.log('[ChangeStream] Change detected')
120
+ TaskService.dispatch(change.fullDocument)
121
+ })
122
+
123
+ changeStream.on('close', () => {
124
+ console.log('[ChangeStream] close detected')
125
+ scheduleOnce()
126
+ })
127
+
128
+ changeStream.on('end', () => {
129
+ console.log('[ChangeStream] end detected')
130
+ scheduleOnce()
131
+ })
132
+ }
133
+
134
+ }
@@ -19,11 +19,11 @@ export class TaskService {
19
19
  * @param {{ metadata: { id: string, triggers?: Array, schedule?: string }, default: function }} taskModule
20
20
  */
21
21
  static registerTask(taskModule) {
22
- const handler = taskModule.default
22
+ const handler = taskModule.run
23
23
  const metadata = taskModule.metadata
24
24
 
25
25
  if (typeof handler !== 'function') {
26
- console.error(`[TaskService] Task "${metadata?.id}" has no default export function — skipping`)
26
+ console.error(`[TaskService] Task "${metadata?.id}" has no exported "run" function — skipping`)
27
27
  return
28
28
  }
29
29
  if (!metadata?.id) {