@ossy/platform 1.16.5 → 1.16.7

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/Dockerfile ADDED
@@ -0,0 +1,23 @@
1
+ # Build context: ossy/packages/platform directory.
2
+ # From repo root: docker build -f ossy/packages/platform/Dockerfile ossy/packages/platform
3
+ # In CI, this image is built and pushed to ghcr.io/ossy-se/runtime:latest by the Publish workflow.
4
+ FROM node:24-bookworm-slim
5
+
6
+ WORKDIR /app
7
+
8
+ COPY package.json ./
9
+
10
+ # Install production deps only.
11
+ # @ossy/router and @ossy/sdk must be resolvable — in production these are
12
+ # published npm packages; in monorepo CI use npm pack + install.
13
+ RUN npm install --omit=dev --no-audit --no-fund --loglevel=error
14
+
15
+ COPY src ./src
16
+
17
+ EXPOSE 3000
18
+
19
+ # Required env vars at runtime:
20
+ # OSSY_API_KEY — Ossy API JWT for CMS reads
21
+ # OSSY_API_URL — (optional) override API base, default https://api.ossy.se/api/v0
22
+ # PORT — (optional) override listen port, default 3000
23
+ CMD ["node", "src/runtime.js"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.16.5",
3
+ "version": "1.16.7",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,8 @@
12
12
  ".": "./src/server.js",
13
13
  "./server": "./src/server.js",
14
14
  "./proxy-internal": "./src/proxy-internal.js",
15
+ "./runtime": "./src/runtime.js",
16
+ "./site-loader": "./src/site-loader.js",
15
17
  "./worker": "./src/worker-entry.js",
16
18
  "./worker-runtime": "./src/worker-runtime.js"
17
19
  },
@@ -22,15 +24,16 @@
22
24
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
23
25
  "license": "MIT",
24
26
  "dependencies": {
25
- "@ossy/router": "^1.17.5",
26
- "@ossy/sdk": "^1.17.5",
27
+ "@ossy/router": "^1.17.7",
28
+ "@ossy/sdk": "^1.17.7",
27
29
  "cookie-parser": "^1.4.7",
28
30
  "dotenv": ">=16.0.0 <17.0.0",
29
31
  "express": ">=5.0.0 <6.0.0",
30
32
  "morgan": ">=1.10.1 <2.0.0"
31
33
  },
32
34
  "files": [
33
- "src"
35
+ "src",
36
+ "Dockerfile"
34
37
  ],
35
- "gitHead": "81baa12dad845ec3f48438d2c4ba139a3a271770"
38
+ "gitHead": "09617a4222cd31cc0e8cf867d42310fa5c73d6cc"
36
39
  }
package/src/runtime.js ADDED
@@ -0,0 +1,213 @@
1
+ import path from 'path'
2
+ import fs from 'node:fs'
3
+ import express from 'express'
4
+ import cookieParser from 'cookie-parser'
5
+ import morgan from 'morgan'
6
+ import { Router as OssyRouter } from '@ossy/router'
7
+ import { loadManifest, resolveEntryUrl } from './server.js'
8
+ import { ProxyInternal } from './proxy-internal.js'
9
+ import { loadSite, invalidateSite } from './site-loader.js'
10
+
11
+ const DEFAULT_PORT = 3000
12
+
13
+ function resolvePort () {
14
+ const raw = process.env.PORT
15
+ const parsed = Number.parseInt(String(raw ?? ''), 10)
16
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT
17
+ }
18
+
19
+ function cloneSerializable (value) {
20
+ return value == null ? value : JSON.parse(JSON.stringify(value))
21
+ }
22
+
23
+ /**
24
+ * Per-domain SSR context, cached after first successful site load.
25
+ * @type {Map<string, { pageRouter: any, apiRouter: any, manifest: any, loadEntry: (url: string) => Promise<any> }>}
26
+ */
27
+ const siteContextCache = new Map()
28
+
29
+ function buildSiteContext (manifest, buildDir) {
30
+ const config = manifest.config
31
+ const supportedLanguages = Array.isArray(config.supportedLanguages) ? config.supportedLanguages : []
32
+
33
+ const pageRouter = OssyRouter.of({
34
+ pages: manifest.pages,
35
+ supportedLanguages,
36
+ defaultLanguage: config.defaultLanguage,
37
+ })
38
+ const apiRouter = OssyRouter.of({ pages: manifest.apis })
39
+
40
+ const moduleCache = new Map()
41
+ function loadEntry (entryUrl) {
42
+ if (moduleCache.has(entryUrl)) return moduleCache.get(entryUrl)
43
+ const promise = import(resolveEntryUrl(entryUrl, buildDir))
44
+ moduleCache.set(entryUrl, promise)
45
+ return promise
46
+ }
47
+
48
+ return { pageRouter, apiRouter, manifest, loadEntry }
49
+ }
50
+
51
+ async function getSiteContext (domain) {
52
+ if (siteContextCache.has(domain)) return siteContextCache.get(domain)
53
+ const buildDir = await loadSite(domain)
54
+ const manifest = loadManifest(buildDir)
55
+ const context = buildSiteContext(manifest, buildDir)
56
+ siteContextCache.set(domain, context)
57
+ return context
58
+ }
59
+
60
+ /**
61
+ * Start the multi-tenant SSR runtime. Serves any published Ossy app by hostname,
62
+ * loading build artifacts from the CMS on first request per domain.
63
+ *
64
+ * Environment variables:
65
+ * OSSY_API_KEY — API JWT for CMS reads (required)
66
+ * OSSY_API_URL — Override API base URL (default: https://api.ossy.se/api/v0)
67
+ * PORT — Override listen port (default: 3000)
68
+ */
69
+ export async function startRuntime ({ port } = {}) {
70
+ const resolvedPort = port ?? resolvePort()
71
+
72
+ const app = express()
73
+ app.use(morgan('tiny'))
74
+ app.use(express.json({ strict: false }))
75
+ app.use(cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'))
76
+
77
+ app.use((req, _res, next) => {
78
+ const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
79
+ req.userAppSettings = userSettings
80
+ if (userSettings.workspaceId && !req.get('workspaceId')) {
81
+ req.headers.workspaceid = userSettings.workspaceId
82
+ }
83
+ req.isAuthenticated = req.headers.cookie ? req.headers.cookie.includes('auth=') : false
84
+ next()
85
+ })
86
+
87
+ // Proxy /@ossy/* to the upstream Ossy API
88
+ app.use(ProxyInternal())
89
+
90
+ // Force a fresh site download without waiting for the TTL to expire
91
+ app.post('/_ossy/reload', (req, res) => {
92
+ const domain = req.query.domain || req.hostname
93
+ invalidateSite(String(domain))
94
+ siteContextCache.delete(String(domain))
95
+ res.json({ ok: true, domain })
96
+ })
97
+
98
+ // Static file serving — download site on first request, then serve from local /tmp
99
+ app.use(async (req, res, next) => {
100
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next()
101
+ const domain = req.hostname
102
+ let buildDir
103
+ try {
104
+ buildDir = await loadSite(domain)
105
+ } catch {
106
+ return next()
107
+ }
108
+ const publicDir = path.join(buildDir, 'public')
109
+ if (!fs.existsSync(publicDir)) return next()
110
+ express.static(publicDir)(req, res, next)
111
+ })
112
+
113
+ // SSR + API route handler
114
+ app.all('*all', async (req, res) => {
115
+ const domain = req.hostname
116
+ const requestUrl = req.originalUrl || '/'
117
+
118
+ let context
119
+ try {
120
+ context = await getSiteContext(domain)
121
+ } catch (err) {
122
+ console.error(`[@ossy/platform] Failed to load site for ${domain}:`, err.message)
123
+ res.status(503).type('text').send(`Site not available: ${domain}`)
124
+ return
125
+ }
126
+
127
+ const { pageRouter, apiRouter, manifest, loadEntry } = context
128
+
129
+ try {
130
+ const apiRoute = apiRouter.getPageByUrl(requestUrl)
131
+ if (apiRoute) {
132
+ const apiEntry = manifest.apis.find((a) => a.id === apiRoute.id)
133
+ if (apiEntry) {
134
+ const mod = await loadEntry(apiEntry.entry)
135
+ if (typeof mod.handle === 'function') {
136
+ await mod.handle(req, res)
137
+ return
138
+ }
139
+ }
140
+ }
141
+
142
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
143
+ res.status(404).send('Not found')
144
+ return
145
+ }
146
+
147
+ const pageRoute = pageRouter.getPageByUrl(requestUrl)
148
+ if (!pageRoute) {
149
+ res.status(404).send('Not found')
150
+ return
151
+ }
152
+
153
+ const pageEntry = manifest.pages.find((p) => p.id === pageRoute.id)
154
+ if (!pageEntry) {
155
+ res.status(404).send('Not found')
156
+ return
157
+ }
158
+
159
+ const mod = await loadEntry(pageEntry.entry)
160
+ if (typeof mod.render !== 'function') {
161
+ res.status(503).type('text').send('SSR runtime unavailable')
162
+ return
163
+ }
164
+
165
+ const config = manifest.config
166
+ const props = {
167
+ ...config,
168
+ ...(req.userAppSettings || {}),
169
+ theme: req.userAppSettings?.theme || cloneSerializable(config?.theme),
170
+ themes: cloneSerializable(config?.themes),
171
+ resourceTemplates: cloneSerializable(config?.resourceTemplates),
172
+ url: requestUrl,
173
+ isAuthenticated: !!req.isAuthenticated,
174
+ pages: manifest.pages.map((page) => ({ id: page.id, path: page.path })),
175
+ pageId: pageRoute.id,
176
+ }
177
+ const html = await mod.render(props)
178
+ res.status(200).type('html').send(html)
179
+ } catch (err) {
180
+ console.error(`[@ossy/platform] Request error for ${domain}${requestUrl}:`, err)
181
+ if (!res.headersSent) res.status(500).type('text').send('Internal Server Error')
182
+ }
183
+ })
184
+
185
+ const server = await new Promise((resolve, reject) => {
186
+ const httpServer = app.listen(resolvedPort)
187
+ httpServer.once('listening', () => resolve(httpServer))
188
+ httpServer.once('error', reject)
189
+ })
190
+
191
+ console.log(`[@ossy/platform] Runtime running on http://localhost:${resolvedPort}`)
192
+ console.log('[@ossy/platform] Press Ctrl+C to stop.')
193
+
194
+ const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
195
+
196
+ let shuttingDown = false
197
+ const handleShutdown = async (signal) => {
198
+ if (shuttingDown) return
199
+ shuttingDown = true
200
+ console.log(`\n[@ossy/platform] Received ${signal}, shutting down…`)
201
+ try { await closeServer() } finally { process.exit(0) }
202
+ }
203
+ process.on('SIGINT', () => handleShutdown('SIGINT'))
204
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'))
205
+
206
+ return { app, server, port: resolvedPort, close: closeServer }
207
+ }
208
+
209
+ // Run directly: node src/runtime.js
210
+ startRuntime().catch((err) => {
211
+ console.error('[@ossy/platform] Runtime failed to start:', err)
212
+ process.exit(1)
213
+ })
@@ -0,0 +1,162 @@
1
+ import path from 'path'
2
+ import os from 'os'
3
+ import { mkdir, writeFile } from 'fs/promises'
4
+ import { SDK, ResourcesList } from '@ossy/sdk'
5
+
6
+ const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
7
+ const DOWNLOAD_CONCURRENCY = 10
8
+ const BASE_DIR = path.join(os.tmpdir(), 'ossy-rt')
9
+
10
+ const API_URL = process.env.OSSY_API_URL || 'https://api.ossy.se/api/v0'
11
+ const API_KEY = process.env.OSSY_API_KEY
12
+
13
+ /** @type {Map<string, { buildDir: string, workspaceId: string, loadedAt: number }>} */
14
+ const cache = new Map()
15
+
16
+ /**
17
+ * Resolve workspaceId for a domain by calling the /apps/ask endpoint.
18
+ * Result is cached inside the site cache entry — this function is always
19
+ * called as part of loadSite(), which manages its own TTL.
20
+ *
21
+ * @param {string} domain e.g. `ossy.se`
22
+ * @returns {Promise<string>} workspaceId
23
+ */
24
+ async function resolveWorkspaceId (domain) {
25
+ const url = `${API_URL}/apps/ask?domain=${encodeURIComponent(domain)}`
26
+ const res = await fetch(url)
27
+ if (res.status === 404) {
28
+ throw new Error(`[@ossy/platform] Domain not registered: ${domain}`)
29
+ }
30
+ if (!res.ok) {
31
+ throw new Error(`[@ossy/platform] /apps/ask failed for ${domain}: HTTP ${res.status}`)
32
+ }
33
+ const body = await res.json()
34
+ if (!body?.workspaceId) {
35
+ throw new Error(`[@ossy/platform] /apps/ask returned no workspaceId for ${domain}`)
36
+ }
37
+ return body.workspaceId
38
+ }
39
+
40
+ /**
41
+ * BFS walk of CMS resources under `location`, collecting all file resources recursively.
42
+ * Directories have `type === 'directory'`; files have `content.src`.
43
+ *
44
+ * @param {ReturnType<typeof SDK.of>} sdk
45
+ * @param {string} location e.g. `/@ossy/apps/ossy.se`
46
+ * @returns {Promise<Array<{ resource: object, relPath: string }>>}
47
+ */
48
+ async function collectFiles (sdk, location) {
49
+ const results = []
50
+ const queue = [{ location, prefix: '' }]
51
+
52
+ while (queue.length > 0) {
53
+ const { location: loc, prefix } = queue.shift()
54
+ const resources = await sdk.makeRequest(ResourcesList)({
55
+ search: new URLSearchParams({ location: loc }).toString(),
56
+ })
57
+
58
+ if (!Array.isArray(resources)) continue
59
+
60
+ for (const r of resources) {
61
+ const relPath = prefix ? `${prefix}/${r.name}` : r.name
62
+ if (r.type === 'directory') {
63
+ queue.push({ location: `${loc}/${r.name}`, prefix: relPath })
64
+ } else if (r.content?.src) {
65
+ results.push({ resource: r, relPath })
66
+ }
67
+ }
68
+ }
69
+
70
+ return results
71
+ }
72
+
73
+ /**
74
+ * Run async tasks with a concurrency limit.
75
+ * @param {Array<() => Promise<void>>} tasks
76
+ * @param {number} concurrency
77
+ */
78
+ async function runWithConcurrency (tasks, concurrency) {
79
+ const queue = [...tasks]
80
+ const workers = Array.from(
81
+ { length: Math.min(concurrency, tasks.length) },
82
+ async () => {
83
+ while (queue.length > 0) {
84
+ const task = queue.shift()
85
+ if (task) await task()
86
+ }
87
+ }
88
+ )
89
+ await Promise.all(workers)
90
+ }
91
+
92
+ /**
93
+ * Download a file from a presigned URL to an absolute local path.
94
+ * @param {string} src presigned S3 URL
95
+ * @param {string} absPath local destination
96
+ */
97
+ async function downloadFile (src, absPath) {
98
+ await mkdir(path.dirname(absPath), { recursive: true })
99
+ const res = await fetch(src)
100
+ if (!res.ok) {
101
+ throw new Error(`[@ossy/platform] Download failed for ${path.basename(absPath)}: HTTP ${res.status}`)
102
+ }
103
+ const buf = await res.arrayBuffer()
104
+ await writeFile(absPath, Buffer.from(buf))
105
+ }
106
+
107
+ /**
108
+ * Load (and cache) a site's build artifacts from the CMS into `/tmp/ossy-rt/{domain}/`.
109
+ * Returns the local `buildDir` path once all files are downloaded.
110
+ *
111
+ * The workspaceId is resolved dynamically via GET /api/v0/apps/ask?domain=X so
112
+ * the runtime can host apps from any workspace without a global OSSY_WORKSPACE_ID.
113
+ *
114
+ * @param {string} domain e.g. `ossy.se`
115
+ * @returns {Promise<string>} absolute path to the local buildDir
116
+ */
117
+ export async function loadSite (domain) {
118
+ const cached = cache.get(domain)
119
+ if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
120
+ return cached.buildDir
121
+ }
122
+
123
+ const workspaceId = await resolveWorkspaceId(domain)
124
+
125
+ const buildDir = path.join(BASE_DIR, domain)
126
+ const cmsLocation = `/@ossy/apps/${domain}`
127
+
128
+ console.log(`[@ossy/platform] Loading site for ${domain} (workspace ${workspaceId}) from CMS ${cmsLocation}…`)
129
+
130
+ const sdk = SDK.of({
131
+ apiUrl: API_URL,
132
+ workspaceId,
133
+ authorization: API_KEY,
134
+ })
135
+
136
+ const files = await collectFiles(sdk, cmsLocation)
137
+
138
+ if (files.length === 0) {
139
+ throw new Error(`[@ossy/platform] No published build found for ${domain} at ${cmsLocation}`)
140
+ }
141
+
142
+ const tasks = files.map(({ resource, relPath }) => async () => {
143
+ const absPath = path.join(buildDir, relPath)
144
+ await downloadFile(resource.content.src, absPath)
145
+ })
146
+
147
+ await runWithConcurrency(tasks, DOWNLOAD_CONCURRENCY)
148
+
149
+ console.log(`[@ossy/platform] Site ${domain} ready — ${files.length} file(s) in ${buildDir}`)
150
+
151
+ cache.set(domain, { buildDir, workspaceId, loadedAt: Date.now() })
152
+ return buildDir
153
+ }
154
+
155
+ /**
156
+ * Invalidate the in-memory cache for a domain, forcing a fresh download on next request.
157
+ * @param {string} domain
158
+ */
159
+ export function invalidateSite (domain) {
160
+ cache.delete(domain)
161
+ console.log(`[@ossy/platform] Cache invalidated for ${domain}`)
162
+ }