@ossy/platform 1.14.1 → 1.14.2

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.14.2",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,14 +16,15 @@
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/connected-components": "^1.15.2",
26
+ "@ossy/router": "^1.15.2",
27
+ "@ossy/sdk": "^1.15.2",
27
28
  "cookie-parser": "^1.4.7",
28
29
  "express": ">=5.0.0 <6.0.0",
29
30
  "morgan": ">=1.10.1 <2.0.0",
@@ -33,5 +34,5 @@
33
34
  "files": [
34
35
  "src"
35
36
  ],
36
- "gitHead": "6476ba57e0bdf65f55dfd6671a59c7a7ad773327"
37
+ "gitHead": "6a15f3ac4cafdb190beed11e4a93adc0ba28c245"
37
38
  }
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
5
+ export const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
6
+ export const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
7
+ export const RESOURCE_TEMPLATE_FILE_PATTERN = /\.resource\.(mjs|cjs|js)$/
8
+
9
+ function discoverFilesByPattern (srcDir, filePattern) {
10
+ const dir = path.resolve(srcDir)
11
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
12
+ return []
13
+ }
14
+ const files = []
15
+ const walk = (d) => {
16
+ const entries = fs.readdirSync(d, { withFileTypes: true })
17
+ for (const e of entries) {
18
+ const full = path.join(d, e.name)
19
+ if (e.isDirectory()) walk(full)
20
+ else if (filePattern.test(e.name)) files.push(full)
21
+ }
22
+ }
23
+ walk(dir)
24
+ return files
25
+ }
26
+
27
+
28
+ export default async function getPlatformFiles(_folder) {
29
+ const cwd = process.cwd()
30
+ const folder = path.resolve(cwd, _folder)
31
+
32
+ const configPath = fs.existsSync(path.resolve(folder, 'config.js')) ? path.resolve(folder, 'config.js') : null
33
+ const middlewarePath = fs.existsSync(path.resolve(folder, 'middleware.js')) ? path.resolve(folder, 'middleware.js') : null
34
+
35
+ const pageFiles = discoverFilesByPattern(folder, PAGE_FILE_PATTERN)
36
+ const apiFiles = discoverFilesByPattern(folder, API_FILE_PATTERN)
37
+ const taskFiles = discoverFilesByPattern(folder, TASK_FILE_PATTERN)
38
+ const resourceTemplatesFiles = discoverFilesByPattern(folder, TASK_FILE_PATTERN)
39
+
40
+ const entries = [
41
+ ...(middlewarePath ? [middlewarePath] : []),
42
+ ...(configPath ? [configPath] : []),
43
+ ...pageFiles,
44
+ ...apiFiles,
45
+ ...taskFiles,
46
+ ...resourceTemplatesFiles,
47
+ ]
48
+
49
+ return entries
50
+ }
package/src/server.js CHANGED
@@ -1,59 +1,142 @@
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
+ import getPlatformFiles, {
10
+ PAGE_FILE_PATTERN,
11
+ API_FILE_PATTERN,
12
+ } from './getPlatformFiles.task.js'
9
13
 
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
- }
14
+ const DEFAULT_PORT = 3000
15
+
16
+ function parsePortFromArgv (argv) {
17
+ const flagIdx = argv.findIndex((a) => a === '--port' || a === '-p')
18
+ if (flagIdx !== -1 && argv[flagIdx + 1]) return argv[flagIdx + 1]
19
+ const eq = argv.find((a) => a.startsWith('--port='))
20
+ if (eq) return eq.split('=')[1]
21
+ return undefined
22
+ }
23
+
24
+ function resolvePort (explicit) {
25
+ const raw = explicit ?? parsePortFromArgv(process.argv) ?? process.env.PORT
26
+ const parsed = Number.parseInt(String(raw ?? ''), 10)
27
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT
18
28
  }
19
29
 
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
30
+ async function importModule (absPath) {
31
+ return import(pathToFileURL(absPath).href)
32
+ }
33
+
34
+ async function loadRoutes (buildDir) {
35
+ const files = await getPlatformFiles(buildDir)
36
+ const pages = []
37
+ const apis = []
38
+ const pageModules = new Map()
39
+ const apiModules = new Map()
40
+
41
+ for (const file of files) {
42
+ const basename = path.basename(file)
43
+ const isPage = PAGE_FILE_PATTERN.test(basename)
44
+ const isApi = API_FILE_PATTERN.test(basename)
45
+ if (!isPage && !isApi) continue
46
+
47
+ const mod = await importModule(file)
48
+ const metadata = mod.metadata || {}
49
+ const id = metadata.id
50
+ const routePath = metadata.path
51
+ if (!id || !routePath) continue
52
+
53
+ const entry = { id, path: routePath, title: metadata.title }
54
+ if (isPage) {
55
+ pages.push(entry)
56
+ pageModules.set(id, mod)
57
+ } else {
58
+ apis.push(entry)
59
+ apiModules.set(id, mod)
60
+ }
28
61
  }
62
+
63
+ return { pages, apis, pageModules, apiModules }
64
+ }
65
+
66
+ async function loadConfig (buildDir) {
67
+ const configPath = path.resolve(buildDir, 'config.js')
68
+ if (!fs.existsSync(configPath)) return {}
69
+ const mod = await importModule(configPath)
70
+ return mod.default || {}
29
71
  }
30
72
 
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.
73
+ async function loadMiddleware (buildDir) {
74
+ const mwPath = path.resolve(buildDir, 'middleware.js')
75
+ if (!fs.existsSync(mwPath)) return []
76
+ const mod = await importModule(mwPath)
77
+ const value = mod.default
78
+ if (Array.isArray(value)) return value
79
+ if (typeof value === 'function') return [value]
80
+ return []
40
81
  }
41
82
 
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) => {
83
+ function cloneSerializable (value) {
84
+ return value == null ? value : JSON.parse(JSON.stringify(value))
85
+ }
86
+
87
+ async function renderPage ({ Component, metadata, appConfig }) {
88
+ const [{ createElement }, { renderToString }, { App }] = await Promise.all([
89
+ import('react'),
90
+ import('react-dom/server'),
91
+ import('@ossy/connected-components'),
92
+ ])
93
+
94
+ const appProps = {
95
+ ...appConfig,
96
+ theme: cloneSerializable(appConfig.theme),
97
+ themes: cloneSerializable(appConfig.themes),
98
+ resourceTemplates: cloneSerializable(appConfig.resourceTemplates),
99
+ }
100
+
101
+ const element = createElement(
102
+ 'html',
103
+ { lang: appProps.htmlLang || appProps.defaultLanguage || 'en' },
104
+ createElement(
105
+ 'head',
106
+ null,
107
+ createElement('meta', { charSet: 'utf-8' }),
108
+ createElement('title', null, metadata?.title || appProps.documentTitle || ''),
109
+ ),
110
+ createElement(App, appProps, createElement(Component, appProps)),
111
+ )
112
+
113
+ return '<!doctype html>' + renderToString(element)
114
+ }
115
+
116
+ export async function startServer (options = {}) {
117
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd()
118
+ const buildDir = path.resolve(cwd, options.buildDir || 'build')
119
+ const publicDir = path.resolve(buildDir, 'public')
120
+ const port = resolvePort(options.port)
121
+
122
+ if (!fs.existsSync(buildDir)) {
123
+ throw new Error(`[@ossy/platform][server] Build directory not found: ${buildDir}. Run \`app build\` first.`)
124
+ }
125
+
126
+ const [config, userMiddleware, routes] = await Promise.all([
127
+ loadConfig(buildDir),
128
+ loadMiddleware(buildDir),
129
+ loadRoutes(buildDir),
130
+ ])
131
+
132
+ const { pages, apis, pageModules, apiModules } = routes
133
+
134
+ const app = express()
135
+
136
+ app.use(morgan('tiny'))
137
+ app.use(express.json({ strict: false }))
138
+ app.use(cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'))
139
+ app.use((req, _res, next) => {
57
140
  const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
58
141
  req.userAppSettings = userSettings
59
142
  if (userSettings.workspaceId && !req.get('workspaceId')) {
@@ -62,82 +145,102 @@ const middleware = [
62
145
  const cookieHeader = req.headers.cookie
63
146
  req.isAuthenticated = cookieHeader ? cookieHeader.includes('auth=') : false
64
147
  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
- }
148
+ })
149
+ for (const mw of userMiddleware) app.use(mw)
150
+ if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
151
+ app.use(ProxyInternal())
82
152
 
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')
153
+ const apiRouter = OssyRouter.of({ pages: apis })
154
+ const pageRouter = OssyRouter.of({
155
+ pages,
156
+ supportedLanguages: Array.isArray(config.supportedLanguages) ? config.supportedLanguages : [],
157
+ defaultLanguage: config.defaultLanguage,
158
+ })
159
+
160
+ app.all('*all', async (req, res) => {
161
+ const requestUrl = req.originalUrl || '/'
162
+ try {
163
+ const apiRoute = apiRouter.getPageByUrl(requestUrl)
164
+ if (apiRoute) {
165
+ const mod = apiModules.get(apiRoute.id)
166
+ const handler = mod?.default
167
+ if (typeof handler === 'function') {
168
+ await handler(req, res)
169
+ return
170
+ }
171
+ }
172
+
173
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
174
+ res.status(404).send('Not found')
107
175
  return
108
176
  }
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,
177
+
178
+ const pageRoute = pageRouter.getPageByUrl(requestUrl)
179
+ if (pageRoute) {
180
+ const mod = pageModules.get(pageRoute.id)
181
+ const Component = mod?.default
182
+ if (typeof Component !== 'function') {
183
+ res.status(503).type('text').send('SSR runtime unavailable')
184
+ return
185
+ }
186
+ const appConfig = {
187
+ ...config,
188
+ url: requestUrl,
189
+ theme: config?.theme || 'light',
190
+ isAuthenticated: !!req.isAuthenticated,
191
+ workspaceId: config?.workspaceId,
192
+ apiUrl: config?.apiUrl,
193
+ pages: pages.map((page) => ({ id: page?.id, path: page?.path })),
194
+ pageId: pageRoute.id,
195
+ sidebarPrimaryCollapsed: false,
196
+ }
197
+ const html = await renderPage({
198
+ Component,
199
+ metadata: mod.metadata,
200
+ appConfig,
201
+ })
202
+ res.status(200).type('html').send(html)
203
+ return
204
+ }
205
+
206
+ res.status(404).send('Not found')
207
+ } catch (err) {
208
+ console.error('[@ossy/platform][server] Request handling failed:', err)
209
+ if (!res.headersSent) {
210
+ res.status(500).type('text').send('Internal Server Error')
119
211
  }
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
212
  }
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')
213
+ })
214
+
215
+ const server = await new Promise((resolve, reject) => {
216
+ const httpServer = app.listen(port)
217
+ httpServer.once('listening', () => resolve(httpServer))
218
+ httpServer.once('error', reject)
219
+ })
220
+
221
+ console.log(`[@ossy/platform][server] Running on http://localhost:${port}`)
222
+ console.log('[@ossy/platform][server] Press Ctrl+C to stop.')
223
+
224
+ const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
225
+
226
+ let shuttingDown = false
227
+ const handleShutdown = async (signal) => {
228
+ if (shuttingDown) return
229
+ shuttingDown = true
230
+ console.log(`\n[@ossy/platform][server] Received ${signal}, shutting down...`)
231
+ try {
232
+ await closeServer()
233
+ } finally {
234
+ process.exit(0)
137
235
  }
138
236
  }
139
- })
140
237
 
141
- app.listen(port, () => {
142
- console.log(`[@ossy/platform][server] Running on http://localhost:${port}`)
143
- })
238
+ process.on('SIGINT', () => handleShutdown('SIGINT'))
239
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'))
240
+
241
+ const lifetime = new Promise(() => {})
242
+
243
+ return { app, server, port, close: closeServer, lifetime }
244
+ }
245
+
246
+ export default startServer