@ossy/platform 1.22.3 → 1.22.5

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.22.3",
3
+ "version": "1.22.5",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,8 +14,7 @@
14
14
  "./proxy-internal": "./src/proxy-internal.js",
15
15
  "./runtime": "./src/runtime.js",
16
16
  "./site-loader": "./src/site-loader.js",
17
- "./worker": "./src/worker-entry.js",
18
- "./worker-runtime": "./src/worker-runtime.js"
17
+ "./tasks": "./src/index.js"
19
18
  },
20
19
  "scripts": {
21
20
  "start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\""
@@ -24,8 +23,8 @@
24
23
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
25
24
  "license": "MIT",
26
25
  "dependencies": {
27
- "@ossy/router": "^1.23.3",
28
- "@ossy/sdk": "^1.23.3",
26
+ "@ossy/router": "^1.23.5",
27
+ "@ossy/sdk": "^1.23.5",
29
28
  "cookie-parser": "^1.4.7",
30
29
  "dotenv": ">=16.0.0 <18.0.0",
31
30
  "express": ">=5.0.0 <6.0.0",
@@ -35,5 +34,5 @@
35
34
  "src",
36
35
  "Dockerfile"
37
36
  ],
38
- "gitHead": "dfb68d196df3c7c291ea321901c4a1017fb0c8c1"
37
+ "gitHead": "d8bcbdb5771d6dbbdcce2df410a4e22b424c408a"
39
38
  }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { TaskService } from './tasks/task-service.js'
2
+ export { loadAndRegisterTasks } from './tasks/task-registry.js'
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Minimal cron expression matcher for the 5-field standard format:
3
+ * minute hour day-of-month month day-of-week
4
+ *
5
+ * Each field supports:
6
+ * * — any value
7
+ * n — exact value
8
+ * a-b — inclusive range
9
+ * a,b,c — comma-separated list (each item may itself be a range or step)
10
+ * *\/n — step from 0 (e.g. *\/5 on minutes = 0,5,10,15,20,25,30,35,40,45,50,55)
11
+ * a-b\/n — step within range
12
+ */
13
+
14
+ /**
15
+ * Parse a single cron field against a numeric value.
16
+ * @param {string} field - One cron field token.
17
+ * @param {number} value - Current time component to test.
18
+ * @param {number} min - Minimum valid value for this field.
19
+ * @param {number} max - Maximum valid value for this field.
20
+ * @returns {boolean}
21
+ */
22
+ function matchField(field, value, min, max) {
23
+ if (field === '*') return true
24
+
25
+ for (const part of field.split(',')) {
26
+ if (matchPart(part.trim(), value, min, max)) return true
27
+ }
28
+ return false
29
+ }
30
+
31
+ function matchPart(part, value, min, max) {
32
+ // Step: */n or a-b/n
33
+ if (part.includes('/')) {
34
+ const [rangePart, stepStr] = part.split('/')
35
+ const step = parseInt(stepStr, 10)
36
+ if (isNaN(step) || step <= 0) return false
37
+
38
+ const [rangeMin, rangeMax] = rangePart === '*'
39
+ ? [min, max]
40
+ : rangePart.includes('-')
41
+ ? rangePart.split('-').map(Number)
42
+ : [parseInt(rangePart, 10), max]
43
+
44
+ if (value < rangeMin || value > rangeMax) return false
45
+ return (value - rangeMin) % step === 0
46
+ }
47
+
48
+ // Range: a-b
49
+ if (part.includes('-')) {
50
+ const [lo, hi] = part.split('-').map(Number)
51
+ return value >= lo && value <= hi
52
+ }
53
+
54
+ // Exact
55
+ return parseInt(part, 10) === value
56
+ }
57
+
58
+ /**
59
+ * Returns true when the given cron expression matches the provided Date.
60
+ *
61
+ * @param {string} expression - 5-field cron string (e.g. "* /5 * * * *" without the space).
62
+ * @param {Date} date - Date to test against (defaults to now).
63
+ * @returns {boolean}
64
+ */
65
+ export function matchesCron(expression, date = new Date()) {
66
+ if (!expression || typeof expression !== 'string') return false
67
+
68
+ const fields = expression.trim().split(/\s+/)
69
+ if (fields.length !== 5) return false
70
+
71
+ const [minuteF, hourF, domF, monthF, dowF] = fields
72
+
73
+ const minute = date.getMinutes() // 0–59
74
+ const hour = date.getHours() // 0–23
75
+ const dom = date.getDate() // 1–31
76
+ const month = date.getMonth() + 1 // 1–12
77
+ const dow = date.getDay() // 0–6 (0=Sunday)
78
+
79
+ return (
80
+ matchField(minuteF, minute, 0, 59) &&
81
+ matchField(hourF, hour, 0, 23) &&
82
+ matchField(domF, dom, 1, 31) &&
83
+ matchField(monthF, month, 1, 12) &&
84
+ matchField(dowF, dow, 0, 6)
85
+ )
86
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Glob matcher: ** matches any chars including /, * matches any chars except /.
3
+ */
4
+ export function matchesGlob(pattern, value) {
5
+ if (!pattern || value == null) return false
6
+ const escaped = pattern
7
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
8
+ .replace(/\*\*/g, '§§') // protect ** before replacing *
9
+ .replace(/\*/g, '[^/]*')
10
+ .replace(/§§/g, '.*')
11
+ return new RegExp(`^${escaped}$`).test(value)
12
+ }
@@ -0,0 +1,75 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
5
+
6
+ /**
7
+ * Discover all *.task.js files from:
8
+ * 1. The cwd's own src/ directory (recursive)
9
+ * 2. Any node_modules package that has "ossy": { "src": ... } in package.json
10
+ *
11
+ * Returns an array of file:// URLs suitable for dynamic import().
12
+ */
13
+ export async function discoverTaskFiles({ cwd }) {
14
+ const files = []
15
+
16
+ // 1. Walk local src/
17
+ await walkDir(join(cwd, 'src'), files)
18
+
19
+ // 2. Walk node_modules packages with ossy.src
20
+ const nodeModulesDir = join(cwd, 'node_modules')
21
+ try {
22
+ const entries = await readdir(nodeModulesDir, { withFileTypes: true })
23
+ for (const entry of entries) {
24
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
25
+
26
+ // Handle scoped packages (@ossy/...)
27
+ if (entry.name.startsWith('@')) {
28
+ const scopedEntries = await readdir(join(nodeModulesDir, entry.name), { withFileTypes: true })
29
+ for (const scoped of scopedEntries) {
30
+ const pkgPath = join(nodeModulesDir, entry.name, scoped.name)
31
+ await checkPackageForTasks(pkgPath, files)
32
+ }
33
+ continue
34
+ }
35
+
36
+ const pkgPath = join(nodeModulesDir, entry.name)
37
+ await checkPackageForTasks(pkgPath, files)
38
+ }
39
+ } catch {
40
+ // node_modules doesn't exist yet
41
+ }
42
+
43
+ return files
44
+ }
45
+
46
+ async function checkPackageForTasks(pkgPath, files) {
47
+ try {
48
+ const pkgJson = JSON.parse(await readFile(join(pkgPath, 'package.json'), 'utf8'))
49
+ const ossySrc = pkgJson?.ossy?.src
50
+ if (!ossySrc) return
51
+ await walkDir(join(pkgPath, ossySrc), files)
52
+ } catch {
53
+ // no package.json or not readable
54
+ }
55
+ }
56
+
57
+ async function walkDir(dir, files) {
58
+ try {
59
+ const entries = await readdir(dir, { withFileTypes: true })
60
+ for (const entry of entries) {
61
+ const full = join(dir, entry.name)
62
+ if (entry.isDirectory()) {
63
+ await walkDir(full, files)
64
+ } else if (TASK_FILE_PATTERN.test(entry.name)) {
65
+ files.push(pathToFileUrl(full))
66
+ }
67
+ }
68
+ } catch {
69
+ // dir doesn't exist
70
+ }
71
+ }
72
+
73
+ function pathToFileUrl(filePath) {
74
+ return new URL('file://' + resolve(filePath)).href
75
+ }
@@ -0,0 +1,17 @@
1
+ import { discoverTaskFiles } from './task-loader.js'
2
+ import { TaskService } from './task-service.js'
3
+
4
+ export async function loadAndRegisterTasks({ cwd }) {
5
+ const taskFiles = await discoverTaskFiles({ cwd })
6
+
7
+ for (const fileUrl of taskFiles) {
8
+ try {
9
+ const taskModule = await import(fileUrl)
10
+ if (taskModule.metadata) {
11
+ TaskService.registerTask(taskModule)
12
+ }
13
+ } catch (err) {
14
+ console.error(`[TaskService] Failed to load task ${fileUrl}:`, err.message)
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,124 @@
1
+ import { matchesGlob } from './glob.js'
2
+ import { matchesCron } from './cron.js'
3
+
4
+ const SCHEDULER_INTERVAL_MS = 60_000
5
+
6
+ export class TaskService {
7
+
8
+ static _stopped = false
9
+
10
+ /** @type {Array<{ metadata: object, handler: function }>} */
11
+ static _tasks = []
12
+
13
+ /** @type {ReturnType<typeof setInterval> | null} */
14
+ static _schedulerInterval = null
15
+
16
+ /**
17
+ * Register a task module to be dispatched on matching events
18
+ * and/or on a cron schedule.
19
+ * @param {{ metadata: { id: string, triggers?: Array, schedule?: string }, default: function }} taskModule
20
+ */
21
+ static registerTask(taskModule) {
22
+ const handler = taskModule.default
23
+ const metadata = taskModule.metadata
24
+
25
+ if (typeof handler !== 'function') {
26
+ console.error(`[TaskService] Task "${metadata?.id}" has no default export function — skipping`)
27
+ return
28
+ }
29
+ if (!metadata?.id) {
30
+ console.error('[TaskService] Task has no metadata.id — skipping')
31
+ return
32
+ }
33
+
34
+ TaskService._tasks.push({ metadata, handler })
35
+ console.log(
36
+ `[TaskService] Registered task "${metadata.id}" with ${metadata.triggers?.length ?? 0} trigger(s)` +
37
+ (metadata.schedule ? ` and schedule "${metadata.schedule}"` : ''),
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Dispatches a single event (fullDocument from the changestream) to all
43
+ * registered tasks whose triggers match.
44
+ * @param {object} event - The eventstore fullDocument.
45
+ */
46
+ static dispatch(event) {
47
+ if (!event) return
48
+
49
+ for (const { metadata, handler } of TaskService._tasks) {
50
+ const triggers = metadata.triggers ?? []
51
+ const matched = triggers.some(trigger => TaskService._matchesTrigger(trigger, event))
52
+
53
+ if (!matched) continue
54
+
55
+ console.log(`[TaskService] Dispatching task "${metadata.id}"`)
56
+
57
+ Promise.resolve()
58
+ .then(() => handler({ event, sdk: null }))
59
+ .catch(error =>
60
+ console.error(`[TaskService] Task "${metadata.id}" failed`, error),
61
+ )
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Starts a 60-second interval that fires each scheduled task whose cron
67
+ * expression matches the current minute. Calls the handler directly
68
+ * (no synthetic MongoDB events).
69
+ */
70
+ static startScheduler() {
71
+ const scheduledTasks = TaskService._tasks.filter(t => !!t.metadata.schedule)
72
+ if (scheduledTasks.length === 0) return
73
+
74
+ console.log(`[TaskService] Scheduler started for ${scheduledTasks.length} task(s)`)
75
+
76
+ TaskService._stopped = false
77
+ TaskService._schedulerInterval = setInterval(() => {
78
+ if (TaskService._stopped) return
79
+
80
+ const now = new Date()
81
+
82
+ for (const { metadata, handler } of scheduledTasks) {
83
+ if (!matchesCron(metadata.schedule, now)) continue
84
+
85
+ console.log(`[TaskService] Schedule fired for task "${metadata.id}"`)
86
+
87
+ Promise.resolve()
88
+ .then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: null }))
89
+ .catch(error =>
90
+ console.error(`[TaskService] Scheduled task "${metadata.id}" failed`, error),
91
+ )
92
+ }
93
+ }, SCHEDULER_INTERVAL_MS)
94
+ }
95
+
96
+ /**
97
+ * Clears the scheduler interval.
98
+ */
99
+ static stop() {
100
+ TaskService._stopped = true
101
+ if (TaskService._schedulerInterval !== null) {
102
+ clearInterval(TaskService._schedulerInterval)
103
+ TaskService._schedulerInterval = null
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Returns true when a trigger descriptor matches the given eventstore document.
109
+ *
110
+ * Matching rules:
111
+ * trigger.aggregateType — exact match against event.aggregateType
112
+ * trigger.event — exact match against event.type
113
+ * trigger.resource.type — glob match (supports *) against event.payload.type
114
+ * trigger.location.startsWith — prefix match against event.payload.location
115
+ */
116
+ static _matchesTrigger(trigger, event) {
117
+ if (trigger.aggregateType && trigger.aggregateType !== event.aggregateType) return false
118
+ if (trigger.event && trigger.event !== event.type) return false
119
+ if (trigger.resource?.type && !matchesGlob(trigger.resource.type, event.payload?.type)) return false
120
+ if (trigger.location?.startsWith && !event.payload?.location?.startsWith?.(trigger.location.startsWith)) return false
121
+ return true
122
+ }
123
+
124
+ }
@@ -1,17 +0,0 @@
1
- import 'dotenv/config'
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
- import { runWorkerScheduler } from './worker-runtime.js'
6
-
7
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
- const buildDir = process.env.OSSY_BUILD_DIR
9
- ? path.resolve(process.env.OSSY_BUILD_DIR)
10
- : path.resolve(__dirname, '..', '..', '..', 'build')
11
- const manifestPath = path.join(buildDir, 'manifest.json')
12
-
13
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
14
- const entries = Array.isArray(manifest.entries) ? manifest.entries : []
15
- const tasks = entries.filter((e) => e.type === 'task')
16
-
17
- runWorkerScheduler(tasks, buildDir)
@@ -1,127 +0,0 @@
1
- import path from 'node:path'
2
- import { pathToFileURL } from 'node:url'
3
- import { SDK } from '@ossy/sdk'
4
-
5
- function resolveTaskEntryUrl (entryUrl, buildDir) {
6
- if (!entryUrl) throw new Error('[@ossy/platform][worker] Task entry URL missing')
7
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(entryUrl)) return entryUrl
8
- const trimmed = entryUrl.startsWith('/') ? entryUrl.slice(1) : entryUrl
9
- return pathToFileURL(path.join(buildDir, 'public', trimmed)).href
10
- }
11
-
12
- /**
13
- * @param {Array<{ id: string, entry: string }>} tasks
14
- * @param {string} buildDir - absolute path to the build directory
15
- */
16
- export function runWorkerScheduler (tasks, buildDir) {
17
- const sdk = SDK.of({
18
- workspaceId: process.env.OSSY_WORKSPACE_ID,
19
- apiUrl: process.env.OSSY_API_URL,
20
- authorization: process.env.OSSY_API_TOKEN,
21
- })
22
-
23
- const jobsClient = /** @type {{ getUnprocessed: () => Promise<unknown[]> }} */ (sdk.jobs)
24
- let status = 'running'
25
-
26
- console.log('Starting scheduler')
27
- main()
28
-
29
- setInterval(() => {
30
- if (status === 'running') return
31
- status = 'running'
32
- try {
33
- main()
34
- } catch (error) {
35
- console.log('Error running main')
36
- console.error(error)
37
- status = 'idle'
38
- }
39
- }, 3000)
40
-
41
- function main () {
42
- console.log('Looking for jobs')
43
- jobsClient
44
- .getUnprocessed()
45
- .then(async (jobs) => {
46
- if (!jobs || !jobs.length) {
47
- console.log('No jobs found, going idle')
48
- status = 'idle'
49
- return
50
- }
51
-
52
- const jobsGroupedByResourceId = groupJobsByResourceId(jobs)
53
- console.log(`Found ${jobs.length} jobs between ${jobsGroupedByResourceId.length} resources`)
54
-
55
- const processedGroups = jobsGroupedByResourceId.map(([resourceId, groupJobs]) => {
56
- console.log(`Processing group for resourceId ${resourceId}`)
57
- return processJobsSequentially(groupJobs)
58
- .then(() => console.log(`Completed group for resourceId ${resourceId}`))
59
- .catch((err) => {
60
- console.log(`Failed to process group for resourceId ${resourceId}`)
61
- console.error(err)
62
- })
63
- })
64
-
65
- try {
66
- await Promise.allSettled(processedGroups)
67
- console.log('Finished processing of groups...')
68
- console.log('Going idle')
69
- status = 'idle'
70
- console.log('----------------------------------')
71
- console.groupEnd()
72
- } catch (error) {
73
- console.log('Error processing groups')
74
- console.error(error)
75
- status = 'idle'
76
- }
77
- })
78
- .catch((error) => {
79
- console.log('Error getting jobs')
80
- console.error(error)
81
- status = 'idle'
82
- })
83
- }
84
-
85
- function groupJobsByResourceId (jobList) {
86
- return Object.entries(
87
- jobList.reduce((acc, job) => {
88
- const content = /** @type {{ resourceId?: string }} */ (job.content || {})
89
- const rid = content.resourceId
90
- return {
91
- ...acc,
92
- [rid]: [...(acc[rid] || []), job],
93
- }
94
- }, /** @type {Record<string, unknown[]>} */ ({}))
95
- )
96
- }
97
-
98
- async function processJobsSequentially (jobList) {
99
- console.log(`Processing ${jobList.length} jobs`)
100
- for (const job of jobList) {
101
- console.log(`Processing job ${job.id}`)
102
- const task = tasks.find((t) => t.id === job.type)
103
- if (!task) {
104
- console.log('No handler found for job', job.id)
105
- continue
106
- }
107
-
108
- console.log(`Handler found for ${task.id}`)
109
- try {
110
- const mod = await import(resolveTaskEntryUrl(task.entry, buildDir))
111
- const jobSdk = SDK.of({
112
- workspaceId: job.belongsTo,
113
- authorization: process.env.OSSY_API_TOKEN,
114
- })
115
- const runner = typeof mod.run === 'function' ? mod.run : mod.default
116
- if (typeof runner !== 'function') {
117
- console.log(`Task ${task.id} has no callable handler`)
118
- continue
119
- }
120
- await runner({ sdk: jobSdk, job }).catch(() => {})
121
- } catch (error) {
122
- console.error(error)
123
- console.log('Failed to processing job')
124
- }
125
- }
126
- }
127
- }