@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 +23 -0
- package/package.json +8 -5
- package/src/runtime.js +213 -0
- package/src/site-loader.js +162 -0
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.
|
|
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.
|
|
26
|
-
"@ossy/sdk": "^1.17.
|
|
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": "
|
|
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
|
+
}
|