@ossy/platform 1.39.3 → 3.0.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 +13 -12
- package/src/Definition.js +1 -1
- package/src/PlatformShell.jsx +5 -5
- package/src/accept-invitation.api.js +46 -0
- package/src/actions/action.service.js +6 -1
- package/src/audit/action-invocation-service.js +1 -2
- package/src/audit/action-invocation.aggregate.js +1 -3
- package/src/audit/task-run-list.aggregate.js +3 -5
- package/src/audit/task-run-service.js +18 -3
- package/src/audit/task-run.aggregate.js +1 -3
- package/src/config.service.js +1 -48
- package/src/directory.schema.js +1 -3
- package/src/file.schema.js +1 -3
- package/src/index.js +0 -1
- package/src/mcp/create-ossy-mcp-server.js +1 -1
- package/src/mcp/mount-platform-mcp.js +1 -1
- package/src/metering/metering-service.js +1 -2
- package/src/push/mount-push-sse.js +51 -3
- package/src/request-diagnostics.js +144 -0
- package/src/request-diagnostics.spec.js +40 -0
- package/src/resources/schema.registry.js +1 -26
- package/src/resources/schema.validation.js +6 -90
- package/src/runtime.js +17 -6
- package/src/select-workspace.api.js +50 -0
- package/src/server.js +166 -48
- package/src/tasks/change-stream.js +69 -9
- package/src/users.middleware.js +52 -20
- package/src/schema-ids.js +0 -9
|
@@ -1,90 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
'textarea',
|
|
8
|
-
'richtext',
|
|
9
|
-
'number',
|
|
10
|
-
'select',
|
|
11
|
-
'multiselect',
|
|
12
|
-
'file',
|
|
13
|
-
'image',
|
|
14
|
-
'boolean',
|
|
15
|
-
'date',
|
|
16
|
-
'timestamp',
|
|
17
|
-
'date-range',
|
|
18
|
-
'date-ranges',
|
|
19
|
-
'id',
|
|
20
|
-
'path',
|
|
21
|
-
])
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Normalize template field `type` to the canonical editor type.
|
|
25
|
-
* `image` is an alias for `file` with default `accept: 'image/*'` when accept is omitted.
|
|
26
|
-
*
|
|
27
|
-
* @param {string | undefined} type
|
|
28
|
-
* @returns {{ type: string, defaultAccept?: string }}
|
|
29
|
-
*/
|
|
30
|
-
export function normalizeFieldType (type) {
|
|
31
|
-
const raw = typeof type === 'string' ? type.trim() : ''
|
|
32
|
-
if (raw === 'image') return { type: 'file', defaultAccept: 'image/*' }
|
|
33
|
-
return { type: raw }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {{ type?: string, accept?: string, min?: number, max?: number } & Record<string, unknown>} field
|
|
38
|
-
*/
|
|
39
|
-
export function resolveFieldDef (field) {
|
|
40
|
-
const { type: canonical, defaultAccept } = normalizeFieldType(field?.type)
|
|
41
|
-
return {
|
|
42
|
-
...field,
|
|
43
|
-
type: canonical,
|
|
44
|
-
accept: field?.accept ?? defaultAccept,
|
|
45
|
-
max: field?.max ?? 1,
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Validates a batch import of workspace schemas.
|
|
51
|
-
*
|
|
52
|
-
* @param {unknown} templates
|
|
53
|
-
* @param {Set<string>} reservedIds - System schema ids that must not be redefined
|
|
54
|
-
* @returns {{ ok: true } | { ok: false, code: string, message: string }}
|
|
55
|
-
*/
|
|
56
|
-
export function validateSchemasForImport (templates, reservedIds) {
|
|
57
|
-
if (!Array.isArray(templates)) {
|
|
58
|
-
return { ok: false, code: 'INVALID_PAYLOAD', message: 'Body must be a JSON array of schemas' }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const engine = Schema.of({ schemas: templates })
|
|
62
|
-
const seenIds = new Set()
|
|
63
|
-
|
|
64
|
-
for (const template of templates) {
|
|
65
|
-
if (reservedIds?.has(template?.id)) {
|
|
66
|
-
return {
|
|
67
|
-
ok: false,
|
|
68
|
-
code: 'RESERVED_SCHEMA_ID',
|
|
69
|
-
message: `Template id "${template.id}" is reserved by a system template`,
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const result = engine.validate(template)
|
|
74
|
-
if (!result.ok) {
|
|
75
|
-
const first = result.errors[0]
|
|
76
|
-
return { ok: false, code: first.code, message: first.message }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (seenIds.has(template.id)) {
|
|
80
|
-
return {
|
|
81
|
-
ok: false,
|
|
82
|
-
code: 'DUPLICATE_SCHEMA_ID',
|
|
83
|
-
message: `Duplicate schema id "${template.id}" in import payload`,
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
seenIds.add(template.id)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return { ok: true }
|
|
90
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
validateSchemasForImport,
|
|
3
|
+
ALLOWED_FIELD_TYPES,
|
|
4
|
+
normalizeFieldType,
|
|
5
|
+
resolveFieldDef,
|
|
6
|
+
} from '@ossy/schema'
|
package/src/runtime.js
CHANGED
|
@@ -4,9 +4,9 @@ import express from 'express'
|
|
|
4
4
|
import cookieParser from 'cookie-parser'
|
|
5
5
|
import morgan from 'morgan'
|
|
6
6
|
import { Router as OssyRouter } from '@ossy/router'
|
|
7
|
-
import { loadManifest, resolveEntryUrl } from './server.js'
|
|
7
|
+
import { loadManifest, resolveEntryUrl, loadLayoutsById, resolvePageLayoutRender } from './server.js'
|
|
8
8
|
import { resolveRequestLocale } from './locale.js'
|
|
9
|
-
import { buildManifestSummary } from '@ossy/
|
|
9
|
+
import { buildManifestSummary } from '@ossy/manifest/build-manifest-summary'
|
|
10
10
|
import { ProxyInternal } from './proxy-internal.js'
|
|
11
11
|
import { loadSite, invalidateSite } from './site-loader.js'
|
|
12
12
|
import { createLogger } from '@ossy/observability'
|
|
@@ -31,7 +31,7 @@ function cloneSerializable (value) {
|
|
|
31
31
|
*/
|
|
32
32
|
const siteContextCache = new Map()
|
|
33
33
|
|
|
34
|
-
function buildSiteContext (manifest, buildDir) {
|
|
34
|
+
function buildSiteContext (manifest, buildDir, layoutsById) {
|
|
35
35
|
const config = manifest.config
|
|
36
36
|
const supportedLanguages = Array.isArray(config.supportedLanguages) ? config.supportedLanguages : []
|
|
37
37
|
|
|
@@ -50,14 +50,15 @@ function buildSiteContext (manifest, buildDir) {
|
|
|
50
50
|
return promise
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
return { pageRouter, apiRouter, manifest, manifestSummary: buildManifestSummary(manifest), loadEntry, buildDir }
|
|
53
|
+
return { pageRouter, apiRouter, manifest, manifestSummary: buildManifestSummary(manifest), loadEntry, buildDir, layoutsById }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function getSiteContext (domain) {
|
|
57
57
|
if (siteContextCache.has(domain)) return siteContextCache.get(domain)
|
|
58
58
|
const buildDir = await loadSite(domain)
|
|
59
59
|
const manifest = loadManifest(buildDir)
|
|
60
|
-
const
|
|
60
|
+
const layoutsById = await loadLayoutsById(manifest.layouts, buildDir, log)
|
|
61
|
+
const context = buildSiteContext(manifest, buildDir, layoutsById)
|
|
61
62
|
siteContextCache.set(domain, context)
|
|
62
63
|
return context
|
|
63
64
|
}
|
|
@@ -129,7 +130,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
129
130
|
return
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
const { pageRouter, apiRouter, manifest, manifestSummary, loadEntry, buildDir } = context
|
|
133
|
+
const { pageRouter, apiRouter, manifest, manifestSummary, loadEntry, buildDir, layoutsById } = context
|
|
133
134
|
|
|
134
135
|
try {
|
|
135
136
|
const apiRoute = apiRouter.getPageByUrl(requestUrl)
|
|
@@ -169,6 +170,11 @@ export async function startRuntime ({ port } = {}) {
|
|
|
169
170
|
|
|
170
171
|
const config = manifest.config
|
|
171
172
|
const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
173
|
+
const {
|
|
174
|
+
Layout,
|
|
175
|
+
layoutEntry,
|
|
176
|
+
layoutSlots,
|
|
177
|
+
} = resolvePageLayoutRender(layoutsById, pageEntry)
|
|
172
178
|
const props = {
|
|
173
179
|
...config,
|
|
174
180
|
...(req.userAppSettings || {}),
|
|
@@ -190,6 +196,11 @@ export async function startRuntime ({ port } = {}) {
|
|
|
190
196
|
...(page.package ? { package: page.package } : {}),
|
|
191
197
|
})),
|
|
192
198
|
pageId: pageRoute.id,
|
|
199
|
+
componentEntries: manifest.components,
|
|
200
|
+
resolveComponentEntry: (entry) => resolveEntryUrl(entry, buildDir),
|
|
201
|
+
layoutSlots,
|
|
202
|
+
Layout,
|
|
203
|
+
layoutEntry,
|
|
193
204
|
}
|
|
194
205
|
const html = await mod.render(props)
|
|
195
206
|
res.status(200).type('html').send(html)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionService,
|
|
3
|
+
mergeUserAppSettingsCookie,
|
|
4
|
+
} from '@ossy/platform'
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
id: 'users.select-workspace',
|
|
8
|
+
path: '/api/v0/users/select-workspace',
|
|
9
|
+
method: 'GET',
|
|
10
|
+
query: ['workspaceId', 'redirect'],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async function handle (req, res) {
|
|
14
|
+
if (req.method !== 'GET') {
|
|
15
|
+
res.setHeader('Allow', 'GET')
|
|
16
|
+
res.status(405).json({ error: 'Method Not Allowed' })
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!req.userId || req.userId === 'anonymous') {
|
|
21
|
+
res.status(401).json({ error: 'Unauthorized' })
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const workspaceId = req.query.workspaceId
|
|
26
|
+
if (!workspaceId || typeof workspaceId !== 'string') {
|
|
27
|
+
res.status(400).json({ message: 'No workspaceId provided' })
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const workspaces = await ActionService.invoke('@ossy/workspaces/actions/list', { req })
|
|
33
|
+
if (!workspaces.some((w) => w.id === workspaceId)) {
|
|
34
|
+
res.status(403).json({ message: 'Forbidden' })
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
mergeUserAppSettingsCookie(req, res, { workspaceId })
|
|
39
|
+
|
|
40
|
+
const redirect = req.query.redirect
|
|
41
|
+
if (redirect && typeof redirect === 'string') {
|
|
42
|
+
res.redirect(302, redirect)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
res.status(200).json('')
|
|
47
|
+
} catch (err) {
|
|
48
|
+
res.status(err.status || 500).json({ message: err.message || 'Internal Server Error' })
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/server.js
CHANGED
|
@@ -7,10 +7,10 @@ import { Router as OssyRouter } from '@ossy/router'
|
|
|
7
7
|
import cookieParser from 'cookie-parser'
|
|
8
8
|
import { ProxyInternal } from './proxy-internal.js'
|
|
9
9
|
import { SDK, resolveActionId } from '@ossy/sdk'
|
|
10
|
-
import { AggregateRebuild, resolveMongoUrl } from '@ossy/event-store'
|
|
10
|
+
import { AggregateRebuild, ensureEventStoreIndexes, Mongo, resolveMongoUrl } from '@ossy/event-store'
|
|
11
11
|
import { TaskService } from './tasks/task-service.js'
|
|
12
12
|
import { ChangeStream } from './tasks/change-stream.js'
|
|
13
|
-
import { buildManifestSummary } from '@ossy/
|
|
13
|
+
import { buildManifestSummary } from '@ossy/manifest/build-manifest-summary'
|
|
14
14
|
import { registerSchema } from './resources/schema.registry.js'
|
|
15
15
|
import { initPlatformSchema } from './resources/schema.service.js'
|
|
16
16
|
import { IntegrationService } from './integration.service.js'
|
|
@@ -26,13 +26,23 @@ import {
|
|
|
26
26
|
buildActionEntitlementIndex,
|
|
27
27
|
createWorkspaceLoader,
|
|
28
28
|
} from './entitlements/action-entitlement.js'
|
|
29
|
-
import { mountPushSse } from './push/mount-push-sse.js'
|
|
29
|
+
import { closePushSseConnections, mountPushSse } from './push/mount-push-sse.js'
|
|
30
|
+
import {
|
|
31
|
+
createSlowRequestLogger,
|
|
32
|
+
createTimedOperation,
|
|
33
|
+
OperationTimeoutError,
|
|
34
|
+
resolveTimeoutMs,
|
|
35
|
+
withTimeout,
|
|
36
|
+
} from './request-diagnostics.js'
|
|
30
37
|
import { mergeWorkspaceSchemas } from '@ossy/workspaces/merge-workspace-schemas'
|
|
31
38
|
import {
|
|
32
39
|
buildEnableablePackageSet,
|
|
33
40
|
setEnableablePackages,
|
|
34
41
|
} from '@ossy/workspaces/entitlements'
|
|
35
42
|
const log = createLogger('@ossy/platform')
|
|
43
|
+
const MONGO_TIMEOUT_MS = resolveTimeoutMs('OSSY_MONGO_TIMEOUT_MS', 10_000)
|
|
44
|
+
const SSR_RENDER_TIMEOUT_MS = resolveTimeoutMs('OSSY_SSR_RENDER_TIMEOUT_MS', 30_000)
|
|
45
|
+
const ACTION_TIMEOUT_MS = resolveTimeoutMs('OSSY_ACTION_TIMEOUT_MS', 60_000)
|
|
36
46
|
|
|
37
47
|
const DEFAULT_PORT = 3000
|
|
38
48
|
const MANIFEST_FILE = 'manifest.json'
|
|
@@ -77,7 +87,8 @@ export function loadManifest (buildDir) {
|
|
|
77
87
|
const actions = Array.isArray(manifest.actions) ? manifest.actions : []
|
|
78
88
|
const emails = Array.isArray(manifest.emails) ? manifest.emails : []
|
|
79
89
|
const layouts = Array.isArray(manifest.layouts) ? manifest.layouts : []
|
|
80
|
-
for (const e of entries) {
|
|
90
|
+
for (const e of entries) {
|
|
91
|
+
if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
|
|
81
92
|
log.warn(`Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
|
|
82
93
|
}
|
|
83
94
|
}
|
|
@@ -117,6 +128,49 @@ function cloneSerializable (value) {
|
|
|
117
128
|
return value == null ? value : JSON.parse(JSON.stringify(value))
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Load all registered layout bundles keyed by canonical layout id.
|
|
133
|
+
*
|
|
134
|
+
* @param {Array<{ id: string, entry: string }>} layoutManifest
|
|
135
|
+
* @param {string} buildDir
|
|
136
|
+
* @param {import('@ossy/observability').Logger} log
|
|
137
|
+
*/
|
|
138
|
+
async function loadLayoutsById (layoutManifest, buildDir, log) {
|
|
139
|
+
/** @type {Map<string, { component: import('react').ComponentType, entry: string }>} */
|
|
140
|
+
const layoutsById = new Map()
|
|
141
|
+
for (const layoutEntry of layoutManifest ?? []) {
|
|
142
|
+
if (!layoutEntry?.id || !layoutEntry?.entry) continue
|
|
143
|
+
try {
|
|
144
|
+
const mod = await import(/* @vite-ignore */ resolveEntryUrl(layoutEntry.entry, buildDir))
|
|
145
|
+
if (typeof mod.default === 'function') {
|
|
146
|
+
layoutsById.set(layoutEntry.id, { component: mod.default, entry: layoutEntry.entry })
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log.warn(`Failed to load layout "${layoutEntry.id}"`, undefined, err)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return layoutsById
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {Map<string, { component: import('react').ComponentType, entry: string }>} layoutsById
|
|
157
|
+
* @param {{ layout?: string, slots?: Record<string, string | null> }} pageEntry
|
|
158
|
+
*/
|
|
159
|
+
function resolvePageLayoutRender (layoutsById, pageEntry) {
|
|
160
|
+
const layoutId = pageEntry?.layout ?? '@ossy/app/layout/default'
|
|
161
|
+
const layoutRecord = layoutsById.get(layoutId) ?? layoutsById.get('@ossy/app/layout/default') ?? null
|
|
162
|
+
const layoutSlots =
|
|
163
|
+
pageEntry?.slots && typeof pageEntry.slots === 'object' && !Array.isArray(pageEntry.slots)
|
|
164
|
+
? pageEntry.slots
|
|
165
|
+
: {}
|
|
166
|
+
return {
|
|
167
|
+
Layout: layoutRecord?.component ?? null,
|
|
168
|
+
layoutEntry: layoutRecord?.entry ?? null,
|
|
169
|
+
layoutSlots,
|
|
170
|
+
layoutId,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
120
174
|
export async function startServer (options = {}) {
|
|
121
175
|
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd()
|
|
122
176
|
const buildDir = path.resolve(cwd, options.buildDir || 'build')
|
|
@@ -169,6 +223,8 @@ export async function startServer (options = {}) {
|
|
|
169
223
|
|
|
170
224
|
// Aggregate registration — mirrors task and schema registration above.
|
|
171
225
|
// AggregateRebuild is provided by @ossy/event-store.
|
|
226
|
+
/** @type {(() => void) | null} */
|
|
227
|
+
let runStartupRebuild = null
|
|
172
228
|
if (AggregateRebuild) {
|
|
173
229
|
for (const agg of manifest.aggregates ?? []) {
|
|
174
230
|
try {
|
|
@@ -179,9 +235,13 @@ export async function startServer (options = {}) {
|
|
|
179
235
|
}
|
|
180
236
|
}
|
|
181
237
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
238
|
+
// Defer full startup rebuild until the server is listening so request handling
|
|
239
|
+
// is not starved while snapshots catch up (332k+ streams on large local DBs).
|
|
240
|
+
runStartupRebuild = () => {
|
|
241
|
+
AggregateRebuild.BuildAndSaveAll().catch((error) => {
|
|
242
|
+
log.error('BuildAndSaveAll failed — is MongoDB reachable?', undefined, error)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
185
245
|
}
|
|
186
246
|
|
|
187
247
|
for (const startup of manifest.startups ?? []) {
|
|
@@ -206,34 +266,9 @@ export async function startServer (options = {}) {
|
|
|
206
266
|
}
|
|
207
267
|
}
|
|
208
268
|
|
|
209
|
-
// Layout loading —
|
|
210
|
-
// layout component decides route-specific chrome (auth, public flows, etc.).
|
|
211
|
-
/** @type {{ component: import('react').ComponentType, entry: string } | null} */
|
|
212
|
-
let appLayout = null
|
|
269
|
+
// Layout loading — registered layouts keyed by id; each page picks layout + merged slots at build time.
|
|
213
270
|
const layoutManifest = manifest.layouts ?? []
|
|
214
|
-
|
|
215
|
-
log.warn(
|
|
216
|
-
`Multiple layouts in manifest (${layoutManifest.length}); only the first is used. Use one *.layout.jsx per app.`,
|
|
217
|
-
)
|
|
218
|
-
}
|
|
219
|
-
const layoutEntry = layoutManifest[0]
|
|
220
|
-
if (layoutEntry) {
|
|
221
|
-
try {
|
|
222
|
-
const mod = await import(/* @vite-ignore */ resolveEntryUrl(layoutEntry.entry, buildDir))
|
|
223
|
-
if (typeof mod.default === 'function') {
|
|
224
|
-
appLayout = { component: mod.default, entry: layoutEntry.entry }
|
|
225
|
-
}
|
|
226
|
-
} catch (err) {
|
|
227
|
-
log.warn(`Failed to load layout "${layoutEntry.id}"`, undefined, err)
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// App-controlled shell slots: `export const slots` in `*.layout.jsx` is recorded
|
|
232
|
-
// on `manifest.layouts[0].slots` at build time and resolved at render in page-runtime.
|
|
233
|
-
const layoutSlots =
|
|
234
|
-
layoutEntry && typeof layoutEntry.slots === 'object' && !Array.isArray(layoutEntry.slots)
|
|
235
|
-
? layoutEntry.slots
|
|
236
|
-
: {}
|
|
271
|
+
const layoutsById = await loadLayoutsById(layoutManifest, buildDir, log)
|
|
237
272
|
|
|
238
273
|
// Register the SDK so all tasks receive it as `sdk`.
|
|
239
274
|
// Priority: explicit options.sdk → SDK.of() from env vars → null (direct-DB fallback in tasks).
|
|
@@ -245,6 +280,9 @@ export async function startServer (options = {}) {
|
|
|
245
280
|
TaskService.startScheduler()
|
|
246
281
|
|
|
247
282
|
if (process.env.DB_URL) {
|
|
283
|
+
await ensureEventStoreIndexes().catch((err) => {
|
|
284
|
+
log.error('Failed to ensure MongoDB indexes — aggregate reads may be very slow', undefined, err)
|
|
285
|
+
})
|
|
248
286
|
ChangeStream.start(resolveMongoUrl(process.env.DB_URL))
|
|
249
287
|
}
|
|
250
288
|
|
|
@@ -267,7 +305,12 @@ export async function startServer (options = {}) {
|
|
|
267
305
|
let loadWorkspaceForEntitlements = null
|
|
268
306
|
try {
|
|
269
307
|
const { Workspace } = await import('@ossy/workspaces/server')
|
|
270
|
-
|
|
308
|
+
const loadWorkspace = createWorkspaceLoader(Workspace)
|
|
309
|
+
loadWorkspaceForEntitlements = (workspaceId) => withTimeout(
|
|
310
|
+
loadWorkspace(workspaceId),
|
|
311
|
+
MONGO_TIMEOUT_MS,
|
|
312
|
+
`loadWorkspace(${workspaceId})`,
|
|
313
|
+
)
|
|
271
314
|
} catch (err) {
|
|
272
315
|
log.warn('[@ossy/platform] Workspace aggregate unavailable — action entitlements disabled', err)
|
|
273
316
|
}
|
|
@@ -288,6 +331,7 @@ export async function startServer (options = {}) {
|
|
|
288
331
|
})
|
|
289
332
|
app.use(UsersMiddleware.AuthenticateUser)
|
|
290
333
|
app.use(WorkspacesMiddleware.ExtractWorkspaceId())
|
|
334
|
+
app.use(createSlowRequestLogger(log))
|
|
291
335
|
if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
|
|
292
336
|
app.use(ProxyInternal())
|
|
293
337
|
|
|
@@ -343,16 +387,33 @@ export async function startServer (options = {}) {
|
|
|
343
387
|
}
|
|
344
388
|
|
|
345
389
|
const actionLog = createLogger(actionId)
|
|
390
|
+
const actionTimer = createTimedOperation(`POST /actions ${actionId}`, { log: actionLog })
|
|
391
|
+
const actionAbort = new AbortController()
|
|
346
392
|
try {
|
|
347
|
-
const result = await
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
393
|
+
const result = await withTimeout(
|
|
394
|
+
ActionService.invoke(actionId, {
|
|
395
|
+
payload,
|
|
396
|
+
sdk: req.sdk ?? null,
|
|
397
|
+
log: actionLog,
|
|
398
|
+
integrations: IntegrationService,
|
|
399
|
+
req,
|
|
400
|
+
signal: actionAbort.signal,
|
|
401
|
+
}),
|
|
402
|
+
ACTION_TIMEOUT_MS,
|
|
403
|
+
`POST /actions ${actionId}`,
|
|
404
|
+
{ abortController: actionAbort },
|
|
405
|
+
)
|
|
406
|
+
actionTimer.finish({ statusCode: 200 })
|
|
354
407
|
res.json(result ?? { ok: true })
|
|
355
408
|
} catch (err) {
|
|
409
|
+
actionTimer.finish({
|
|
410
|
+
statusCode: err instanceof OperationTimeoutError ? 504 : (err?.status ?? 500),
|
|
411
|
+
timedOut: err instanceof OperationTimeoutError,
|
|
412
|
+
})
|
|
413
|
+
if (err instanceof OperationTimeoutError) {
|
|
414
|
+
actionLog.error('Action timed out', { id: actionId, timeoutMs: err.timeoutMs })
|
|
415
|
+
return res.status(504).json({ error: 'Action timed out', code: 'ACTION_TIMEOUT' })
|
|
416
|
+
}
|
|
356
417
|
actionLog.error('Action failed', { id: actionId }, err)
|
|
357
418
|
const status = err?.status ?? 500
|
|
358
419
|
res.status(status).json({ error: err?.message ?? 'Internal error' })
|
|
@@ -368,6 +429,7 @@ export async function startServer (options = {}) {
|
|
|
368
429
|
|
|
369
430
|
app.all('*all', async (req, res) => {
|
|
370
431
|
const requestUrl = req.originalUrl || '/'
|
|
432
|
+
const pageTimer = createTimedOperation(`page ${req.method} ${requestUrl}`, { log })
|
|
371
433
|
try {
|
|
372
434
|
const apiRoute = apiRouter.getPageByUrl(requestUrl)
|
|
373
435
|
if (apiRoute) {
|
|
@@ -393,18 +455,25 @@ export async function startServer (options = {}) {
|
|
|
393
455
|
res.status(404).send('Not found')
|
|
394
456
|
return
|
|
395
457
|
}
|
|
458
|
+
const loadEntryTimer = createTimedOperation(`SSR loadEntry ${pageRoute.id}`, { log })
|
|
396
459
|
const mod = await loadEntry(pageEntry.entry)
|
|
460
|
+
loadEntryTimer.finish()
|
|
397
461
|
if (typeof mod.render !== 'function') {
|
|
398
462
|
res.status(503).type('text').send('SSR runtime unavailable')
|
|
399
463
|
return
|
|
400
464
|
}
|
|
401
|
-
const
|
|
402
|
-
|
|
465
|
+
const {
|
|
466
|
+
Layout,
|
|
467
|
+
layoutEntry,
|
|
468
|
+
layoutSlots,
|
|
469
|
+
} = resolvePageLayoutRender(layoutsById, pageEntry)
|
|
403
470
|
const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
404
471
|
let schemasForPage = cloneSerializable(manifest.schemas || [])
|
|
405
472
|
if (req.workspaceId && loadWorkspaceForEntitlements) {
|
|
473
|
+
const workspaceTimer = createTimedOperation(`SSR loadWorkspace ${req.workspaceId}`, { log })
|
|
406
474
|
try {
|
|
407
475
|
const workspace = await loadWorkspaceForEntitlements(req.workspaceId)
|
|
476
|
+
workspaceTimer.finish({ pageId: pageRoute.id })
|
|
408
477
|
if (workspace?.schemas?.length) {
|
|
409
478
|
schemasForPage = mergeWorkspaceSchemas(
|
|
410
479
|
schemasForPage,
|
|
@@ -412,7 +481,20 @@ export async function startServer (options = {}) {
|
|
|
412
481
|
)
|
|
413
482
|
}
|
|
414
483
|
} catch (err) {
|
|
415
|
-
|
|
484
|
+
workspaceTimer.finish({
|
|
485
|
+
pageId: pageRoute.id,
|
|
486
|
+
error: err?.message ?? String(err),
|
|
487
|
+
timedOut: err instanceof OperationTimeoutError,
|
|
488
|
+
})
|
|
489
|
+
if (err instanceof OperationTimeoutError) {
|
|
490
|
+
log.warn('[@ossy/platform] SSR workspace schema merge timed out — using manifest schemas only', {
|
|
491
|
+
workspaceId: req.workspaceId,
|
|
492
|
+
pageId: pageRoute.id,
|
|
493
|
+
timeoutMs: err.timeoutMs,
|
|
494
|
+
})
|
|
495
|
+
} else {
|
|
496
|
+
log.warn('[@ossy/platform] Failed to merge workspace schemas for SSR', err)
|
|
497
|
+
}
|
|
416
498
|
}
|
|
417
499
|
}
|
|
418
500
|
// Page component → `app:content` is resolved in page-runtime (SSR + hydrate).
|
|
@@ -439,17 +521,44 @@ export async function startServer (options = {}) {
|
|
|
439
521
|
})),
|
|
440
522
|
pageId: pageRoute.id,
|
|
441
523
|
componentEntries: manifest.components,
|
|
524
|
+
resolveComponentEntry: (entry) => resolveEntryUrl(entry, buildDir),
|
|
442
525
|
layoutSlots,
|
|
443
526
|
Layout,
|
|
444
527
|
layoutEntry,
|
|
445
528
|
}
|
|
446
|
-
const
|
|
529
|
+
const renderTimer = createTimedOperation(`SSR render ${pageRoute.id}`, { log })
|
|
530
|
+
let html
|
|
531
|
+
try {
|
|
532
|
+
html = await withTimeout(
|
|
533
|
+
mod.render(props),
|
|
534
|
+
SSR_RENDER_TIMEOUT_MS,
|
|
535
|
+
`SSR render(${pageRoute.id})`,
|
|
536
|
+
)
|
|
537
|
+
} catch (err) {
|
|
538
|
+
renderTimer.finish({ timedOut: err instanceof OperationTimeoutError })
|
|
539
|
+
if (err instanceof OperationTimeoutError) {
|
|
540
|
+
log.error('[@ossy/platform] SSR render timed out', {
|
|
541
|
+
pageId: pageRoute.id,
|
|
542
|
+
url: requestUrl,
|
|
543
|
+
timeoutMs: err.timeoutMs,
|
|
544
|
+
})
|
|
545
|
+
if (!res.headersSent) {
|
|
546
|
+
res.status(504).type('text').send('Page render timed out')
|
|
547
|
+
}
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
throw err
|
|
551
|
+
}
|
|
552
|
+
renderTimer.finish()
|
|
553
|
+
pageTimer.finish({ pageId: pageRoute.id, statusCode: 200 })
|
|
447
554
|
res.status(200).type('html').send(html)
|
|
448
555
|
return
|
|
449
556
|
}
|
|
450
557
|
|
|
558
|
+
pageTimer.finish({ statusCode: 404 })
|
|
451
559
|
res.status(404).send('Not found')
|
|
452
560
|
} catch (err) {
|
|
561
|
+
pageTimer.finish({ error: err?.message ?? String(err) })
|
|
453
562
|
log.error('Request handling failed', undefined, err)
|
|
454
563
|
if (!res.headersSent) {
|
|
455
564
|
res.status(500).type('text').send('Internal Server Error')
|
|
@@ -466,7 +575,12 @@ export async function startServer (options = {}) {
|
|
|
466
575
|
log.info(`Running on http://localhost:${port}`)
|
|
467
576
|
log.info('Press Ctrl+C to stop.')
|
|
468
577
|
|
|
469
|
-
|
|
578
|
+
runStartupRebuild?.()
|
|
579
|
+
|
|
580
|
+
const closeServer = () => new Promise((resolve) => {
|
|
581
|
+
server.closeAllConnections?.()
|
|
582
|
+
server.close(() => resolve())
|
|
583
|
+
})
|
|
470
584
|
|
|
471
585
|
let shuttingDown = false
|
|
472
586
|
const handleShutdown = async (signal) => {
|
|
@@ -474,6 +588,10 @@ export async function startServer (options = {}) {
|
|
|
474
588
|
shuttingDown = true
|
|
475
589
|
log.info(`Received ${signal}, shutting down...`)
|
|
476
590
|
try {
|
|
591
|
+
TaskService.stop()
|
|
592
|
+
closePushSseConnections()
|
|
593
|
+
await ChangeStream.stop()
|
|
594
|
+
await Mongo.closeConnection()
|
|
477
595
|
await closeServer()
|
|
478
596
|
} finally {
|
|
479
597
|
process.exit(0)
|
|
@@ -488,6 +606,7 @@ export async function startServer (options = {}) {
|
|
|
488
606
|
}
|
|
489
607
|
|
|
490
608
|
export default startServer
|
|
609
|
+
export { loadLayoutsById, resolvePageLayoutRender }
|
|
491
610
|
export { ConfigService } from './config.service.js'
|
|
492
611
|
export { ActionService } from './actions/action.service.js'
|
|
493
612
|
export { IntegrationService } from './integration.service.js'
|
|
@@ -496,7 +615,6 @@ export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './stor
|
|
|
496
615
|
export { S3Client } from './storage/s3.client.js'
|
|
497
616
|
export { LocalStorageClient } from './storage/local-storage.client.js'
|
|
498
617
|
export { getSystemSchemas } from './resources/schema.registry.js'
|
|
499
|
-
export { PlatformSchema } from './schema-ids.js'
|
|
500
618
|
export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
|
|
501
619
|
export { validateSchemasForImport } from './resources/schema.validation.js'
|
|
502
620
|
export {
|