@ossy/platform 1.14.0 → 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.0",
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.0",
26
- "@ossy/sdk": "^1.15.0",
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": "6a7aac8937521487dc9a032a8fe71f02f2a86367"
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,76 +1,142 @@
1
1
  import fs from 'node:fs'
2
- import path from 'path'
3
- import url from 'url'
2
+ import path from 'node:path'
4
3
  import { pathToFileURL } from 'node:url'
5
4
  import express from 'express'
6
5
  import morgan from 'morgan'
7
6
  import { Router as OssyRouter } from '@ossy/router'
8
7
  import cookieParser from 'cookie-parser'
9
8
  import { ProxyInternal } from './proxy-internal.js'
9
+ import getPlatformFiles, {
10
+ PAGE_FILE_PATTERN,
11
+ API_FILE_PATTERN,
12
+ } from './getPlatformFiles.task.js'
10
13
 
11
- import buildTimeConfig from './.ossy/server-config.runtime.mjs'
12
- import { BuildPage, buildPrerenderAppConfig } from './.ossy/render-page.task.js'
13
- import Middleware from './.ossy/middleware.runtime.js'
14
+ const DEFAULT_PORT = 3000
14
15
 
15
- const __ossyDir = path.dirname(url.fileURLToPath(import.meta.url)) + '/.ossy'
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
28
+ }
16
29
 
17
- function readOssyJson (name) {
18
- return JSON.parse(fs.readFileSync(path.join(__ossyDir, name), 'utf8'))
30
+ async function importModule (absPath) {
31
+ return import(pathToFileURL(absPath).href)
19
32
  }
20
33
 
21
- const apiRouteList = readOssyJson('api.generated.json') ?? []
22
- const sitePageList = readOssyJson('pages.generated.json') ?? []
23
-
24
- function pageRouterLanguageOptions (config, pages) {
25
- let supported = config?.supportedLanguages
26
- let defaultLanguage = config?.defaultLanguage
27
- if ((!supported || supported.length <= 1) && pages.length > 0) {
28
- const p0 = pages[0]
29
- if (p0 && typeof p0.path === 'object' && p0.path != null) {
30
- supported = Object.keys(p0.path)
31
- defaultLanguage = defaultLanguage || supported[0]
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)
32
60
  }
33
61
  }
34
- return {
35
- supportedLanguages: Array.isArray(supported) ? supported : [],
36
- defaultLanguage,
37
- }
62
+
63
+ return { pages, apis, pageModules, apiModules }
38
64
  }
39
65
 
40
- const app = express()
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 || {}
71
+ }
41
72
 
42
- const currentDir = path.dirname(url.fileURLToPath(import.meta.url))
43
- const ROOT_PATH = path.resolve(currentDir, 'public')
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 []
81
+ }
44
82
 
45
- function parsePortFromArgv (argv) {
46
- const idx = argv.findIndex((a) => a === '--port' || a === '-p')
47
- if (idx !== -1 && argv[idx + 1]) return argv[idx + 1]
83
+ function cloneSerializable (value) {
84
+ return value == null ? value : JSON.parse(JSON.stringify(value))
85
+ }
48
86
 
49
- const eq = argv.find((a) => a.startsWith('--port='))
50
- if (eq) return eq.split('=')[1]
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
+ }
51
100
 
52
- return undefined
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)
53
114
  }
54
115
 
55
- function normalizePort (value, fallback) {
56
- if (value === undefined || value === null || value === '') return fallback
57
- const n = Number.parseInt(String(value), 10)
58
- if (!Number.isFinite(n) || n <= 0) return fallback
59
- return n
60
- }
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)
61
121
 
62
- const DEFAULT_PORT = 3000
63
- const port = normalizePort(parsePortFromArgv(process.argv) ?? process.env.PORT, DEFAULT_PORT)
122
+ if (!fs.existsSync(buildDir)) {
123
+ throw new Error(`[@ossy/platform][server] Build directory not found: ${buildDir}. Run \`app build\` first.`)
124
+ }
64
125
 
65
- if (Middleware !== undefined) {
66
- console.log(`[@ossy/platform][server] ${Middleware?.length || 0} custom middleware loaded`)
67
- }
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
68
133
 
69
- const middleware = [
70
- morgan('tiny'),
71
- express.json({ strict: false }),
72
- cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'),
73
- (req, _res, next) => {
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) => {
74
140
  const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
75
141
  req.userAppSettings = userSettings
76
142
  if (userSettings.workspaceId && !req.get('workspaceId')) {
@@ -79,64 +145,102 @@ const middleware = [
79
145
  const cookieHeader = req.headers.cookie
80
146
  req.isAuthenticated = cookieHeader ? cookieHeader.includes('auth=') : false
81
147
  next()
82
- },
83
- ...(Middleware || []),
84
- express.static(ROOT_PATH),
85
- ProxyInternal(),
86
- ]
87
-
88
- app.use(middleware)
89
-
90
- const apiRouter = OssyRouter.of({
91
- pages: apiRouteList,
92
- })
93
-
94
- const { supportedLanguages, defaultLanguage } = pageRouterLanguageOptions(
95
- buildTimeConfig,
96
- sitePageList
97
- )
98
-
99
- const pageRouter = OssyRouter.of({
100
- pages: sitePageList,
101
- defaultLanguage,
102
- supportedLanguages,
103
- })
104
-
105
- app.all('*all', async (req, res) => {
106
- const requestUrl = req.originalUrl || '/'
107
- try {
108
- const apiRoute = apiRouter.getPageByUrl(requestUrl)
109
- if (apiRoute?.module) {
110
- const mod = await import(pathToFileURL(path.resolve(__ossyDir, apiRoute.module)).href)
111
- await mod.default(req, res)
112
- return
113
- }
114
- if (req.method !== 'GET' && req.method !== 'HEAD') {
148
+ })
149
+ for (const mw of userMiddleware) app.use(mw)
150
+ if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
151
+ app.use(ProxyInternal())
152
+
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')
175
+ return
176
+ }
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
+
115
206
  res.status(404).send('Not found')
116
- return
117
- }
118
- const pageRoute = pageRouter.getPageByUrl(requestUrl)
119
- if (pageRoute) {
120
- const appConfig = buildPrerenderAppConfig({
121
- buildTimeConfig,
122
- pageList: sitePageList,
123
- activeRouteId: pageRoute.id,
124
- urlPath: requestUrl,
125
- isAuthenticated: !!req.isAuthenticated,
126
- })
127
- const html = await BuildPage.handle({ route: pageRoute, appConfig })
128
- res.status(200).type('html').send(html)
129
- return
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')
211
+ }
130
212
  }
131
- res.status(404).send('Not found')
132
- } catch (err) {
133
- console.error('[@ossy/platform][server] Request handling failed:', err)
134
- if (!res.headersSent) {
135
- 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)
136
235
  }
137
236
  }
138
- })
139
237
 
140
- app.listen(port, () => {
141
- console.log(`[@ossy/platform][server] Running on http://localhost:${port}`)
142
- })
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