@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.
@@ -1,90 +1,6 @@
1
- import { Schema } from '@ossy/schema'
2
-
3
- /** Field input types accepted in schema definitions (UI + API contract). */
4
- export const ALLOWED_FIELD_TYPES = new Set([
5
- 'text',
6
- 'email',
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/app/manifest/build-manifest-summary'
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 context = buildSiteContext(manifest, buildDir)
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/app/manifest/build-manifest-summary'
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) { if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
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
- AggregateRebuild.BuildAndSaveAll().catch((error) => {
183
- log.error('BuildAndSaveAll failed is MongoDB reachable?', undefined, error)
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 — one `*.layout.jsx` per app wraps every page render. The
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
- if (layoutManifest.length > 1) {
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
- loadWorkspaceForEntitlements = createWorkspaceLoader(Workspace)
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 ActionService.invoke(actionId, {
348
- payload,
349
- sdk: req.sdk ?? null,
350
- log: actionLog,
351
- integrations: IntegrationService,
352
- req,
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 Layout = appLayout?.component ?? null
402
- const layoutEntry = appLayout?.entry ?? null
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
- log.warn('[@ossy/platform] Failed to merge workspace schemas for SSR', err)
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 html = await mod.render(props)
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
- const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
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 {