@ossy/platform 1.14.1 → 1.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,22 +16,21 @@
16
16
  "./worker-runtime": "./src/worker-runtime.js"
17
17
  },
18
18
  "scripts": {
19
- "start": "PORT=3003 node ./src/server.js"
19
+ "start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\""
20
20
  },
21
21
  "keywords": [],
22
22
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "@ossy/router": "^1.15.1",
26
- "@ossy/sdk": "^1.15.1",
25
+ "@ossy/router": "^1.16.0",
26
+ "@ossy/sdk": "^1.16.0",
27
27
  "cookie-parser": "^1.4.7",
28
+ "dotenv": ">=16.0.0 <17.0.0",
28
29
  "express": ">=5.0.0 <6.0.0",
29
- "morgan": ">=1.10.1 <2.0.0",
30
- "react": ">=19.0.0 <20.0.0",
31
- "react-dom": ">=19.0.0 <20.0.0"
30
+ "morgan": ">=1.10.1 <2.0.0"
32
31
  },
33
32
  "files": [
34
33
  "src"
35
34
  ],
36
- "gitHead": "6476ba57e0bdf65f55dfd6671a59c7a7ad773327"
35
+ "gitHead": "db2ab43d6b3266c59eb53c8046811e0acb3f87f1"
37
36
  }
package/src/server.js CHANGED
@@ -1,59 +1,103 @@
1
1
  import fs from 'node:fs'
2
- import path from 'path'
3
- import { fileURLToPath, pathToFileURL } from 'node:url'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
4
  import express from 'express'
5
5
  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
9
 
10
- const currentDir = path.dirname(fileURLToPath(import.meta.url))
11
- const ossyDir = path.join(currentDir, '.ossy')
12
- const readJson = (name, fallback) => {
13
- try {
14
- return JSON.parse(fs.readFileSync(path.join(ossyDir, name), 'utf8'))
15
- } catch {
16
- return fallback
17
- }
10
+ const DEFAULT_PORT = 3000
11
+ const MANIFEST_FILE = 'manifest.json'
12
+
13
+ function parsePortFromArgv (argv) {
14
+ const flagIdx = argv.findIndex((a) => a === '--port' || a === '-p')
15
+ if (flagIdx !== -1 && argv[flagIdx + 1]) return argv[flagIdx + 1]
16
+ const eq = argv.find((a) => a.startsWith('--port='))
17
+ if (eq) return eq.split('=')[1]
18
+ return undefined
18
19
  }
19
20
 
20
- const loadDefaultFromRef = async (runtimeRefJson) => {
21
- const modulePath = readJson(runtimeRefJson, null)?.modulePath
22
- if (!modulePath || typeof modulePath !== 'string') return undefined
23
- try {
24
- const mod = await import(pathToFileURL(path.resolve(modulePath)).href)
25
- return mod?.default
26
- } catch {
27
- return undefined
21
+ function resolvePort (explicit) {
22
+ const raw = explicit ?? parsePortFromArgv(process.argv) ?? process.env.PORT
23
+ const parsed = Number.parseInt(String(raw ?? ''), 10)
24
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT
25
+ }
26
+
27
+ function loadManifest (buildDir) {
28
+ const manifestPath = path.join(buildDir, MANIFEST_FILE)
29
+ if (!fs.existsSync(manifestPath)) {
30
+ throw new Error(`[@ossy/platform][server] Build manifest not found: ${manifestPath}. Run \`app build\` first.`)
31
+ }
32
+ const raw = fs.readFileSync(manifestPath, 'utf8')
33
+ const manifest = JSON.parse(raw)
34
+ // The manifest is a flat `entries` array discriminated by `type`. Bucket
35
+ // them once at boot so the per-request hot path stays a simple lookup.
36
+ const entries = Array.isArray(manifest.entries) ? manifest.entries : []
37
+ const pages = entries.filter((e) => e.type === 'page')
38
+ const apis = entries.filter((e) => e.type === 'api')
39
+ const tasks = entries.filter((e) => e.type === 'task')
40
+ return {
41
+ entries,
42
+ pages,
43
+ apis,
44
+ tasks,
45
+ config: manifest.config || {},
28
46
  }
29
47
  }
30
48
 
31
- const apiRouteList = readJson('api.generated.json', [])
32
- const sitePageList = readJson('pages.generated.json', [])
33
- const buildTimeConfig = (await loadDefaultFromRef('server-config.runtime.json')) || {}
34
- const Middleware = (await loadDefaultFromRef('middleware.runtime.json')) || []
35
- let ssrRender = null
36
- try {
37
- ssrRender = (await import(pathToFileURL(path.resolve(currentDir, 'ssr', 'app.mjs')).href)).renderPage
38
- } catch {
39
- // SSR is optional at startup; page requests return 503 when unavailable.
49
+ // `entry` URLs in the manifest are public-relative (`/static/<file>`). Translate
50
+ // them to local file URLs for `import()` while passing absolute URLs (e.g. CDN
51
+ // hosted bundles) through unchanged.
52
+ export function resolveEntryUrl (entryUrl, buildDir) {
53
+ if (!entryUrl) throw new Error('[@ossy/platform][server] Entry URL missing in manifest entry')
54
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(entryUrl)) return entryUrl
55
+ const absolute = path.join(buildDir, 'public', entryUrl.startsWith('/') ? entryUrl.slice(1) : entryUrl)
56
+ return pathToFileURL(absolute).href
40
57
  }
41
58
 
42
- const app = express()
43
- const ROOT_PATH = path.resolve(currentDir, 'public')
44
- const explicitPortArgIdx = process.argv.findIndex((a) => a === '--port' || a === '-p')
45
- const explicitPortArg =
46
- explicitPortArgIdx !== -1
47
- ? process.argv[explicitPortArgIdx + 1]
48
- : process.argv.find((a) => a.startsWith('--port='))?.split('=')[1]
49
- const parsedPort = Number.parseInt(String(explicitPortArg ?? process.env.PORT ?? '3000'), 10)
50
- const port = Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : 3000
51
-
52
- const middleware = [
53
- morgan('tiny'),
54
- express.json({ strict: false }),
55
- cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'),
56
- (req, _res, next) => {
59
+ function cloneSerializable (value) {
60
+ return value == null ? value : JSON.parse(JSON.stringify(value))
61
+ }
62
+
63
+ export async function startServer (options = {}) {
64
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd()
65
+ const buildDir = path.resolve(cwd, options.buildDir || 'build')
66
+ const publicDir = path.resolve(buildDir, 'public')
67
+ const port = resolvePort(options.port)
68
+
69
+ if (!fs.existsSync(buildDir)) {
70
+ throw new Error(`[@ossy/platform][server] Build directory not found: ${buildDir}. Run \`app build\` first.`)
71
+ }
72
+
73
+ const manifest = loadManifest(buildDir)
74
+ const config = manifest.config
75
+
76
+ const supportedLanguages = Array.isArray(config.supportedLanguages) ? config.supportedLanguages : []
77
+ const defaultLanguage = config.defaultLanguage
78
+
79
+ const pageRouter = OssyRouter.of({
80
+ pages: manifest.pages,
81
+ supportedLanguages,
82
+ defaultLanguage,
83
+ })
84
+ const apiRouter = OssyRouter.of({ pages: manifest.apis })
85
+
86
+ const moduleCache = new Map()
87
+ async function loadEntry (entryUrl) {
88
+ if (moduleCache.has(entryUrl)) return moduleCache.get(entryUrl)
89
+ const promise = import(resolveEntryUrl(entryUrl, buildDir))
90
+ moduleCache.set(entryUrl, promise)
91
+ return promise
92
+ }
93
+
94
+ const userMiddleware = await loadMiddleware(buildDir)
95
+
96
+ const app = express()
97
+ app.use(morgan('tiny'))
98
+ app.use(express.json({ strict: false }))
99
+ app.use(cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'))
100
+ app.use((req, _res, next) => {
57
101
  const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
58
102
  req.userAppSettings = userSettings
59
103
  if (userSettings.workspaceId && !req.get('workspaceId')) {
@@ -62,82 +106,111 @@ const middleware = [
62
106
  const cookieHeader = req.headers.cookie
63
107
  req.isAuthenticated = cookieHeader ? cookieHeader.includes('auth=') : false
64
108
  next()
65
- },
66
- ...(Array.isArray(Middleware) ? Middleware : []),
67
- express.static(ROOT_PATH),
68
- ProxyInternal(),
69
- ]
70
-
71
- app.use(middleware)
72
-
73
- let supportedLanguages = buildTimeConfig?.supportedLanguages
74
- let defaultLanguage = buildTimeConfig?.defaultLanguage
75
- if ((!supportedLanguages || supportedLanguages.length <= 1) && sitePageList.length > 0) {
76
- const firstPage = sitePageList[0]
77
- if (firstPage && typeof firstPage.path === 'object' && firstPage.path != null) {
78
- supportedLanguages = Object.keys(firstPage.path)
79
- defaultLanguage = defaultLanguage || supportedLanguages[0]
80
- }
81
- }
109
+ })
110
+ for (const mw of userMiddleware) app.use(mw)
111
+ if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
112
+ app.use(ProxyInternal())
82
113
 
83
- const apiRouter = OssyRouter.of({ pages: apiRouteList })
84
- const pageRouter = OssyRouter.of({
85
- pages: sitePageList,
86
- supportedLanguages: Array.isArray(supportedLanguages) ? supportedLanguages : [],
87
- defaultLanguage,
88
- })
89
-
90
- app.all('*all', async (req, res) => {
91
- const requestUrl = req.originalUrl || '/'
92
- try {
93
- const apiRoute = apiRouter.getPageByUrl(requestUrl)
94
- if (apiRoute?.module) {
95
- const mod = await import(pathToFileURL(path.resolve(ossyDir, apiRoute.module)).href)
96
- await mod.default(req, res)
97
- return
98
- }
99
- if (req.method !== 'GET' && req.method !== 'HEAD') {
100
- res.status(404).send('Not found')
101
- return
102
- }
103
- const pageRoute = pageRouter.getPageByUrl(requestUrl)
104
- if (pageRoute) {
105
- if (typeof ssrRender !== 'function') {
106
- res.status(503).type('text').send('SSR runtime unavailable')
114
+ app.all('*all', async (req, res) => {
115
+ const requestUrl = req.originalUrl || '/'
116
+ try {
117
+ const apiRoute = apiRouter.getPageByUrl(requestUrl)
118
+ if (apiRoute) {
119
+ const apiEntry = manifest.apis.find((a) => a.id === apiRoute.id)
120
+ if (apiEntry) {
121
+ const mod = await loadEntry(apiEntry.entry)
122
+ if (typeof mod.handle === 'function') {
123
+ await mod.handle(req, res)
124
+ return
125
+ }
126
+ }
127
+ }
128
+
129
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
130
+ res.status(404).send('Not found')
131
+ return
132
+ }
133
+
134
+ const pageRoute = pageRouter.getPageByUrl(requestUrl)
135
+ if (pageRoute) {
136
+ const pageEntry = manifest.pages.find((p) => p.id === pageRoute.id)
137
+ if (!pageEntry) {
138
+ res.status(404).send('Not found')
139
+ return
140
+ }
141
+ const mod = await loadEntry(pageEntry.entry)
142
+ if (typeof mod.render !== 'function') {
143
+ res.status(503).type('text').send('SSR runtime unavailable')
144
+ return
145
+ }
146
+ const props = {
147
+ ...config,
148
+ theme: cloneSerializable(config?.theme),
149
+ themes: cloneSerializable(config?.themes),
150
+ resourceTemplates: cloneSerializable(config?.resourceTemplates),
151
+ url: requestUrl,
152
+ isAuthenticated: !!req.isAuthenticated,
153
+ pages: manifest.pages.map((page) => ({ id: page.id, path: page.path })),
154
+ pageId: pageRoute.id,
155
+ sidebarPrimaryCollapsed: false,
156
+ }
157
+ const html = await mod.render(props)
158
+ res.status(200).type('html').send(html)
107
159
  return
108
160
  }
109
- const appConfig = {
110
- ...buildTimeConfig,
111
- url: requestUrl,
112
- theme: buildTimeConfig?.theme || 'light',
113
- isAuthenticated: !!req.isAuthenticated,
114
- workspaceId: buildTimeConfig?.workspaceId,
115
- apiUrl: buildTimeConfig?.apiUrl,
116
- pages: sitePageList.map((page) => ({ id: page?.id, path: page?.path })),
117
- pageId: pageRoute.id,
118
- sidebarPrimaryCollapsed: false,
161
+
162
+ res.status(404).send('Not found')
163
+ } catch (err) {
164
+ console.error('[@ossy/platform][server] Request handling failed:', err)
165
+ if (!res.headersSent) {
166
+ res.status(500).type('text').send('Internal Server Error')
119
167
  }
120
- const html = await ssrRender(pageRoute.id, {
121
- ...appConfig,
122
- theme: appConfig?.theme == null ? appConfig?.theme : JSON.parse(JSON.stringify(appConfig.theme)),
123
- themes: appConfig?.themes == null ? appConfig?.themes : JSON.parse(JSON.stringify(appConfig.themes)),
124
- resourceTemplates:
125
- appConfig?.resourceTemplates == null
126
- ? appConfig?.resourceTemplates
127
- : JSON.parse(JSON.stringify(appConfig.resourceTemplates)),
128
- }, {})
129
- res.status(200).type('html').send(html)
130
- return
131
168
  }
132
- res.status(404).send('Not found')
133
- } catch (err) {
134
- console.error('[@ossy/platform][server] Request handling failed:', err)
135
- if (!res.headersSent) {
136
- res.status(500).type('text').send('Internal Server Error')
169
+ })
170
+
171
+ const server = await new Promise((resolve, reject) => {
172
+ const httpServer = app.listen(port)
173
+ httpServer.once('listening', () => resolve(httpServer))
174
+ httpServer.once('error', reject)
175
+ })
176
+
177
+ console.log(`[@ossy/platform][server] Running on http://localhost:${port}`)
178
+ console.log('[@ossy/platform][server] Press Ctrl+C to stop.')
179
+
180
+ const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
181
+
182
+ let shuttingDown = false
183
+ const handleShutdown = async (signal) => {
184
+ if (shuttingDown) return
185
+ shuttingDown = true
186
+ console.log(`\n[@ossy/platform][server] Received ${signal}, shutting down...`)
187
+ try {
188
+ await closeServer()
189
+ } finally {
190
+ process.exit(0)
137
191
  }
138
192
  }
139
- })
140
193
 
141
- app.listen(port, () => {
142
- console.log(`[@ossy/platform][server] Running on http://localhost:${port}`)
143
- })
194
+ process.on('SIGINT', () => handleShutdown('SIGINT'))
195
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'))
196
+
197
+ const lifetime = new Promise(() => {})
198
+ return { app, server, port, close: closeServer, lifetime }
199
+ }
200
+
201
+ async function loadMiddleware (buildDir) {
202
+ const candidates = [
203
+ path.resolve(buildDir, 'public', 'static', 'middleware.js'),
204
+ path.resolve(buildDir, 'middleware.js'),
205
+ ]
206
+ for (const candidate of candidates) {
207
+ if (!fs.existsSync(candidate)) continue
208
+ const mod = await import(pathToFileURL(candidate).href)
209
+ const value = mod.default
210
+ if (Array.isArray(value)) return value
211
+ if (typeof value === 'function') return [value]
212
+ }
213
+ return []
214
+ }
215
+
216
+ export default startServer
@@ -4,7 +4,14 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { runWorkerScheduler } from './worker-runtime.js'
6
6
 
7
- const __ossyDir = path.dirname(fileURLToPath(import.meta.url)) + '/.ossy'
8
- const tasks = JSON.parse(fs.readFileSync(path.join(__ossyDir, 'tasks.generated.json'), 'utf8')) ?? []
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')
9
12
 
10
- runWorkerScheduler(tasks, __ossyDir)
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)
@@ -2,11 +2,18 @@ import path from 'node:path'
2
2
  import { pathToFileURL } from 'node:url'
3
3
  import { SDK } from '@ossy/sdk'
4
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
+
5
12
  /**
6
- * @param {Array<{ type: string, module: string }>} tasks
7
- * @param {string} ossyDir - absolute path to the .ossy directory
13
+ * @param {Array<{ id: string, entry: string }>} tasks
14
+ * @param {string} buildDir - absolute path to the build directory
8
15
  */
9
- export function runWorkerScheduler (tasks, ossyDir) {
16
+ export function runWorkerScheduler (tasks, buildDir) {
10
17
  const sdk = SDK.of({
11
18
  workspaceId: process.env.OSSY_WORKSPACE_ID,
12
19
  apiUrl: process.env.OSSY_API_URL,
@@ -92,20 +99,25 @@ export function runWorkerScheduler (tasks, ossyDir) {
92
99
  console.log(`Processing ${jobList.length} jobs`)
93
100
  for (const job of jobList) {
94
101
  console.log(`Processing job ${job.id}`)
95
- const task = tasks.find((t) => t.type === job.type)
102
+ const task = tasks.find((t) => t.id === job.type)
96
103
  if (!task) {
97
104
  console.log('No handler found for job', job.id)
98
105
  continue
99
106
  }
100
107
 
101
- console.log(`Handler found for ${task.type}`)
108
+ console.log(`Handler found for ${task.id}`)
102
109
  try {
103
- const mod = await import(pathToFileURL(path.resolve(ossyDir, task.module)).href)
110
+ const mod = await import(resolveTaskEntryUrl(task.entry, buildDir))
104
111
  const jobSdk = SDK.of({
105
112
  workspaceId: job.belongsTo,
106
113
  authorization: process.env.OSSY_API_TOKEN,
107
114
  })
108
- await mod.default({ sdk: jobSdk, job }).catch(() => {})
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(() => {})
109
121
  } catch (error) {
110
122
  console.error(error)
111
123
  console.log('Failed to processing job')
package/src/http.js DELETED
@@ -1,85 +0,0 @@
1
- import path from 'path';
2
- import url from 'url'
3
- import express from 'express'
4
- import morgan from 'morgan'
5
- import { Router as OssyRouter } from '@ossy/router'
6
- import cookieParser from 'cookie-parser'
7
-
8
- const app = express();
9
-
10
- const currentDir = path.dirname(url.fileURLToPath(import.meta.url))
11
-
12
- function parsePortFromArgv(argv) {
13
- // Supports: --port 4000, --port=4000, -p 4000
14
- const idx = argv.findIndex(a => a === '--port' || a === '-p')
15
- if (idx !== -1 && argv[idx + 1]) return argv[idx + 1]
16
-
17
- const eq = argv.find(a => a.startsWith('--port='))
18
- if (eq) return eq.split('=')[1]
19
-
20
- return undefined
21
- }
22
-
23
- function normalizePort(value, fallback) {
24
- if (value === undefined || value === null || value === '') return fallback
25
- const n = Number.parseInt(String(value), 10)
26
- if (!Number.isFinite(n) || n <= 0) return fallback
27
- return n
28
- }
29
-
30
- const DEFAULT_PORT = 3000
31
- const port = normalizePort(parsePortFromArgv(process.argv) ?? process.env.PORT, DEFAULT_PORT)
32
-
33
- const middleware = [
34
- morgan('tiny'),
35
- express.json({ strict: false }),
36
- cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'),
37
- ]
38
-
39
- app.use(middleware)
40
-
41
- // const PageRegistry = OssyRouter.of({ pages: [] })
42
- // const ApiRegistry = OssyRouter.of({ api: [] })
43
- // const TaskRegistry = OssyRouter.of({ tasks: [] })
44
- // const StaticRegistry = OssyRouter.of({ static: [] })
45
-
46
- const Registry = OssyRouter.of({ pages: [
47
- {
48
- id: 'home',
49
- type: '@ossy/platform/page',
50
- path: '/',
51
- content: '<h1>Hello World from page route</h1>'
52
- },
53
- {
54
- id: 'home',
55
- type: '@ossy/platform/api',
56
- path: '/api',
57
- handle: (req, res) => {
58
- res.send({ message: 'Hello World from api route' })
59
- }
60
- }
61
- ]})
62
-
63
- app.all('*all', (req, res) => {
64
- const pathname = req.originalUrl
65
-
66
- // Will be replaced with fetching the resource from ossy storage
67
- const resource = Registry.getPageByUrl(pathname)
68
-
69
- if (resource.type === '@ossy/platform/page') {
70
- res.send(resource.content)
71
- }
72
-
73
- else if (resource.type === '@ossy/platform/api') {
74
- resource.handle(req, res)
75
- }
76
-
77
- else {
78
- res.status(404).send('Not found')
79
- }
80
-
81
- })
82
-
83
- app.listen(port, () => {
84
- console.log(`[@ossy/app][server] Running on http://localhost:${port}`);
85
- });