@ossy/platform 1.39.3 → 3.0.1
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 +12 -12
- package/src/Definition.js +1 -1
- package/src/PlatformShell.jsx +5 -5
- 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/directory.schema.js +1 -3
- package/src/file.schema.js +1 -3
- package/src/index.js +0 -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/runtime.js +16 -5
- package/src/server.js +165 -47
- package/src/tasks/change-stream.js +69 -9
- package/src/users.middleware.js +52 -20
- package/src/schema-ids.js +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -44,16 +44,16 @@
|
|
|
44
44
|
"@aws-sdk/util-create-request": "^3.972.26",
|
|
45
45
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
47
|
-
"@ossy/app": "^
|
|
48
|
-
"@ossy/event-store": "^
|
|
49
|
-
"@ossy/locale": "^
|
|
50
|
-
"@ossy/observability": "^
|
|
51
|
-
"@ossy/policies": "^
|
|
52
|
-
"@ossy/schema": "^
|
|
53
|
-
"@ossy/sdk": "^
|
|
54
|
-
"@ossy/tokens": "^
|
|
55
|
-
"@ossy/users": "^
|
|
56
|
-
"@ossy/workspaces": "^
|
|
47
|
+
"@ossy/app": "^3.0.1",
|
|
48
|
+
"@ossy/event-store": "^3.0.1",
|
|
49
|
+
"@ossy/locale": "^3.0.1",
|
|
50
|
+
"@ossy/observability": "^3.0.1",
|
|
51
|
+
"@ossy/policies": "^3.0.1",
|
|
52
|
+
"@ossy/schema": "^3.0.1",
|
|
53
|
+
"@ossy/sdk": "^3.0.1",
|
|
54
|
+
"@ossy/tokens": "^3.0.1",
|
|
55
|
+
"@ossy/users": "^3.0.1",
|
|
56
|
+
"@ossy/workspaces": "^3.0.1",
|
|
57
57
|
"cookie-parser": "^1.4.7",
|
|
58
58
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
59
59
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -73,5 +73,5 @@
|
|
|
73
73
|
"src",
|
|
74
74
|
"Dockerfile"
|
|
75
75
|
],
|
|
76
|
-
"gitHead": "
|
|
76
|
+
"gitHead": "4a70ec216448680d2fddc57b2a8a0077489720ae"
|
|
77
77
|
}
|
package/src/Definition.js
CHANGED
package/src/PlatformShell.jsx
CHANGED
|
@@ -25,12 +25,12 @@ import { ComponentSlotsProvider, Slot } from '@ossy/design-system'
|
|
|
25
25
|
export function PlatformShell({ slots = {}, children }) {
|
|
26
26
|
return (
|
|
27
27
|
<ComponentSlotsProvider slots={slots}>
|
|
28
|
-
<Slot
|
|
29
|
-
<Slot
|
|
30
|
-
<Slot
|
|
31
|
-
<Slot
|
|
28
|
+
<Slot view="app:system-messages" />
|
|
29
|
+
<Slot view="app:header" />
|
|
30
|
+
<Slot view="app:sidebar" />
|
|
31
|
+
<Slot view="app:toolbar" />
|
|
32
32
|
{children}
|
|
33
|
-
<Slot
|
|
33
|
+
<Slot view="app:notifications" />
|
|
34
34
|
</ComponentSlotsProvider>
|
|
35
35
|
)
|
|
36
36
|
}
|
|
@@ -62,7 +62,7 @@ export const ActionService = {
|
|
|
62
62
|
* Invoke an action by id — records audit, runs the derived primary task, dispatches on_action follow-ups.
|
|
63
63
|
*
|
|
64
64
|
* @param {string} id
|
|
65
|
-
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
|
|
65
|
+
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown, signal?: AbortSignal }} context
|
|
66
66
|
* @returns {Promise<unknown>}
|
|
67
67
|
*/
|
|
68
68
|
async invoke (id, context = {}) {
|
|
@@ -92,6 +92,11 @@ export const ActionService = {
|
|
|
92
92
|
|
|
93
93
|
try {
|
|
94
94
|
const result = await TaskService.invoke(taskId, invokeContext)
|
|
95
|
+
if (context.signal?.aborted) {
|
|
96
|
+
throw context.signal.reason instanceof Error
|
|
97
|
+
? context.signal.reason
|
|
98
|
+
: new Error(`[ActionService] Action aborted: "${id}"`)
|
|
99
|
+
}
|
|
95
100
|
const taskRunId = invokeContext.audit?.taskRunId ?? null
|
|
96
101
|
|
|
97
102
|
if (!skipAudit) {
|
|
@@ -3,7 +3,6 @@ import { Aggregate } from '@ossy/event-store'
|
|
|
3
3
|
import { createLogger } from '@ossy/observability'
|
|
4
4
|
import { taskIdFromActionId } from '@ossy/schema'
|
|
5
5
|
import { ActionInvocation } from './action-invocation.aggregate.js'
|
|
6
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
7
6
|
import { MeteringService } from '../metering/metering-service.js'
|
|
8
7
|
import { detectChannel } from './detect-channel.js'
|
|
9
8
|
import {
|
|
@@ -42,7 +41,7 @@ export const ActionInvocationService = {
|
|
|
42
41
|
try {
|
|
43
42
|
await Aggregate.Of(ActionInvocation, {
|
|
44
43
|
resourceId: invocationId,
|
|
45
|
-
type:
|
|
44
|
+
type: '@ossy/platform/schema/action-invocation',
|
|
46
45
|
event: 'Invoked',
|
|
47
46
|
createdBy: actorId ?? undefined,
|
|
48
47
|
payload: {
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* ADR 0008 entity — audit record for each action invocation (POST /actions, MCP, SDK).
|
|
5
3
|
*/
|
|
6
4
|
export class ActionInvocation {
|
|
7
5
|
|
|
8
6
|
static kind = 'entity'
|
|
9
|
-
static SchemaId =
|
|
7
|
+
static SchemaId = '@ossy/platform/schema/action-invocation'
|
|
10
8
|
static AggregateType = 'ActionInvocation'
|
|
11
9
|
|
|
12
10
|
static View (events, state = {}) {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* ADR 0008 projection — workspace-scoped task run list for UI and billing queries.
|
|
5
3
|
*/
|
|
@@ -9,9 +7,9 @@ export class TaskRunListProjection {
|
|
|
9
7
|
static ProjectionId = '@ossy/platform/data/task-run-list'
|
|
10
8
|
|
|
11
9
|
static sources = [
|
|
12
|
-
{ type:
|
|
13
|
-
{ type:
|
|
14
|
-
{ type:
|
|
10
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Started' },
|
|
11
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Completed' },
|
|
12
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Failed' },
|
|
15
13
|
]
|
|
16
14
|
|
|
17
15
|
static scopeFromEvent (event) {
|
|
@@ -2,7 +2,6 @@ import { nanoid } from 'nanoid'
|
|
|
2
2
|
import { Aggregate } from '@ossy/event-store'
|
|
3
3
|
import { createLogger, metrics } from '@ossy/observability'
|
|
4
4
|
import { TaskRun } from './task-run.aggregate.js'
|
|
5
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
6
5
|
import { MeteringService } from '../metering/metering-service.js'
|
|
7
6
|
import { detectChannel } from './detect-channel.js'
|
|
8
7
|
import {
|
|
@@ -12,9 +11,18 @@ import {
|
|
|
12
11
|
summarizeError,
|
|
13
12
|
summarizeResult,
|
|
14
13
|
} from './audit-helpers.js'
|
|
14
|
+
import { rejectOnAbort } from '../request-diagnostics.js'
|
|
15
15
|
|
|
16
16
|
const log = createLogger('platform/task-run')
|
|
17
17
|
|
|
18
|
+
function assertNotAborted (context) {
|
|
19
|
+
if (context.signal?.aborted) {
|
|
20
|
+
throw context.signal.reason instanceof Error
|
|
21
|
+
? context.signal.reason
|
|
22
|
+
: new Error('Operation aborted')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
function recordTaskMetrics (taskId, durationMs) {
|
|
19
27
|
try { metrics.timing('task.duration', durationMs, { task: taskId }) } catch {}
|
|
20
28
|
try { metrics.increment('task.run', { task: taskId }) } catch {}
|
|
@@ -49,6 +57,10 @@ export const TaskRunService = {
|
|
|
49
57
|
} = opts
|
|
50
58
|
|
|
51
59
|
if (audit === false) {
|
|
60
|
+
assertNotAborted(context)
|
|
61
|
+
if (context.signal) {
|
|
62
|
+
return Promise.race([handler(context), rejectOnAbort(context.signal)])
|
|
63
|
+
}
|
|
52
64
|
return handler(context)
|
|
53
65
|
}
|
|
54
66
|
|
|
@@ -72,7 +84,10 @@ export const TaskRunService = {
|
|
|
72
84
|
})
|
|
73
85
|
|
|
74
86
|
try {
|
|
75
|
-
|
|
87
|
+
assertNotAborted(context)
|
|
88
|
+
const result = context.signal
|
|
89
|
+
? await Promise.race([handler(context), rejectOnAbort(context.signal)])
|
|
90
|
+
: await handler(context)
|
|
76
91
|
const durationMs = Date.now() - startMs
|
|
77
92
|
await TaskRunService._appendCompleted({
|
|
78
93
|
runId,
|
|
@@ -145,7 +160,7 @@ export const TaskRunService = {
|
|
|
145
160
|
try {
|
|
146
161
|
await Aggregate.Of(TaskRun, {
|
|
147
162
|
resourceId: runId,
|
|
148
|
-
type:
|
|
163
|
+
type: '@ossy/platform/schema/task-run',
|
|
149
164
|
event: 'Started',
|
|
150
165
|
createdBy: actorId ?? undefined,
|
|
151
166
|
payload: {
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* ADR 0008 entity — one stream per task execution (sync invoke, changestream, cron, on_action).
|
|
5
3
|
*/
|
|
6
4
|
export class TaskRun {
|
|
7
5
|
|
|
8
6
|
static kind = 'entity'
|
|
9
|
-
static SchemaId =
|
|
7
|
+
static SchemaId = '@ossy/platform/schema/task-run'
|
|
10
8
|
static AggregateType = 'TaskRun'
|
|
11
9
|
|
|
12
10
|
static View (events, state = {}) {
|
package/src/directory.schema.js
CHANGED
package/src/file.schema.js
CHANGED
package/src/index.js
CHANGED
|
@@ -4,7 +4,6 @@ export { ChangeStream } from './tasks/change-stream.js'
|
|
|
4
4
|
export { registerSchema, getSystemSchemas } from './resources/schema.registry.js'
|
|
5
5
|
export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
|
|
6
6
|
export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, resolveFieldDef } from './resources/schema.validation.js'
|
|
7
|
-
export { PlatformSchema } from './schema-ids.js'
|
|
8
7
|
export { IntegrationService } from './integration.service.js'
|
|
9
8
|
export { ConfigService } from './config.service.js'
|
|
10
9
|
export { ActionService } from './actions/action.service.js'
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
2
|
import { EventStore } from '@ossy/event-store'
|
|
3
3
|
import { createLogger, metrics } from '@ossy/observability'
|
|
4
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
5
4
|
import { moduleIdFromTaskId } from '../audit/audit-helpers.js'
|
|
6
5
|
|
|
7
6
|
const log = createLogger('platform/metering')
|
|
@@ -73,7 +72,7 @@ export const MeteringService = {
|
|
|
73
72
|
try {
|
|
74
73
|
await EventStore.AppendResourceEvent({
|
|
75
74
|
id: nanoid(),
|
|
76
|
-
type:
|
|
75
|
+
type: '@ossy/platform/schema/meter-event',
|
|
77
76
|
resourceId: nanoid(),
|
|
78
77
|
event: 'Recorded',
|
|
79
78
|
version: 1,
|
|
@@ -5,6 +5,46 @@ const log = createLogger('@ossy/platform')
|
|
|
5
5
|
|
|
6
6
|
const HEARTBEAT_MS = 25_000
|
|
7
7
|
|
|
8
|
+
/** @type {Map<string, number>} */
|
|
9
|
+
const activeConnectionsByWorkspace = new Map()
|
|
10
|
+
|
|
11
|
+
/** @type {Set<{ res: import('express').Response, heartbeat: ReturnType<typeof setInterval>, unsubscribe: () => void, workspaceId: string }>} */
|
|
12
|
+
const activeConnections = new Set()
|
|
13
|
+
|
|
14
|
+
function trackConnection (workspaceId, delta) {
|
|
15
|
+
const next = Math.max(0, (activeConnectionsByWorkspace.get(workspaceId) ?? 0) + delta)
|
|
16
|
+
if (next === 0) {
|
|
17
|
+
activeConnectionsByWorkspace.delete(workspaceId)
|
|
18
|
+
} else {
|
|
19
|
+
activeConnectionsByWorkspace.set(workspaceId, next)
|
|
20
|
+
}
|
|
21
|
+
return next
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function releaseConnection (connection) {
|
|
25
|
+
if (!activeConnections.delete(connection)) return
|
|
26
|
+
|
|
27
|
+
clearInterval(connection.heartbeat)
|
|
28
|
+
connection.unsubscribe()
|
|
29
|
+
const remaining = trackConnection(connection.workspaceId, -1)
|
|
30
|
+
log.debug('[push] SSE closed', {
|
|
31
|
+
workspaceId: connection.workspaceId,
|
|
32
|
+
activeForWorkspace: remaining,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tear down all open SSE connections so HTTP server shutdown is not blocked.
|
|
38
|
+
*/
|
|
39
|
+
export function closePushSseConnections () {
|
|
40
|
+
for (const connection of [...activeConnections]) {
|
|
41
|
+
releaseConnection(connection)
|
|
42
|
+
if (!connection.res.writableEnded) {
|
|
43
|
+
connection.res.end()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
8
48
|
/**
|
|
9
49
|
* ADR 0008 §9 — workspace-scoped SSE invalidation bus.
|
|
10
50
|
*
|
|
@@ -30,14 +70,22 @@ export function mountPushSse (app) {
|
|
|
30
70
|
send({ kind: 'connected', scope: { workspaceId } })
|
|
31
71
|
|
|
32
72
|
const unsubscribe = PushInvalidation.subscribe(workspaceId, send)
|
|
73
|
+
const activeForWorkspace = trackConnection(workspaceId, 1)
|
|
74
|
+
log.debug('[push] SSE opened', {
|
|
75
|
+
workspaceId,
|
|
76
|
+
activeForWorkspace,
|
|
77
|
+
userId: req.userId,
|
|
78
|
+
})
|
|
79
|
+
|
|
33
80
|
const heartbeat = setInterval(() => {
|
|
34
81
|
res.write(': heartbeat\n\n')
|
|
35
82
|
}, HEARTBEAT_MS)
|
|
36
83
|
|
|
84
|
+
const connection = { res, heartbeat, unsubscribe, workspaceId }
|
|
85
|
+
activeConnections.add(connection)
|
|
86
|
+
|
|
37
87
|
req.on('close', () => {
|
|
38
|
-
|
|
39
|
-
unsubscribe()
|
|
40
|
-
log.debug(`[push] SSE closed for workspace ${workspaceId}`)
|
|
88
|
+
releaseConnection(connection)
|
|
41
89
|
})
|
|
42
90
|
})
|
|
43
91
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
|
|
3
|
+
const log = createLogger('@ossy/platform/request-diagnostics')
|
|
4
|
+
|
|
5
|
+
export class OperationTimeoutError extends Error {
|
|
6
|
+
constructor (label, timeoutMs) {
|
|
7
|
+
super(`Operation timed out after ${timeoutMs}ms: ${label}`)
|
|
8
|
+
this.name = 'OperationTimeoutError'
|
|
9
|
+
this.label = label
|
|
10
|
+
this.timeoutMs = timeoutMs
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveTimeoutMs (envKey, fallback) {
|
|
15
|
+
const raw = process.env[envKey]
|
|
16
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10)
|
|
17
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reject when `promise` does not settle within `timeoutMs`.
|
|
22
|
+
* Pass `timeoutMs <= 0` to disable (returns `promise` unchanged).
|
|
23
|
+
*
|
|
24
|
+
* When `abortController` is supplied, aborts its signal on timeout so callers
|
|
25
|
+
* can propagate cancellation into long-running work.
|
|
26
|
+
*
|
|
27
|
+
* @template T
|
|
28
|
+
* @param {Promise<T>} promise
|
|
29
|
+
* @param {number} timeoutMs
|
|
30
|
+
* @param {string} label
|
|
31
|
+
* @param {{ abortController?: AbortController }} [options]
|
|
32
|
+
* @returns {Promise<T>}
|
|
33
|
+
*/
|
|
34
|
+
export function withTimeout (promise, timeoutMs, label, options = {}) {
|
|
35
|
+
if (!timeoutMs || timeoutMs <= 0) return promise
|
|
36
|
+
|
|
37
|
+
const { abortController } = options
|
|
38
|
+
let timer
|
|
39
|
+
|
|
40
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
const err = new OperationTimeoutError(label, timeoutMs)
|
|
43
|
+
abortController?.abort(err)
|
|
44
|
+
reject(err)
|
|
45
|
+
}, timeoutMs)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const abortPromise = abortController
|
|
49
|
+
? new Promise((_, reject) => {
|
|
50
|
+
const { signal } = abortController
|
|
51
|
+
if (signal.aborted) {
|
|
52
|
+
reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
signal.addEventListener('abort', () => {
|
|
56
|
+
reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
|
|
57
|
+
}, { once: true })
|
|
58
|
+
})
|
|
59
|
+
: null
|
|
60
|
+
|
|
61
|
+
const races = abortPromise ? [promise, timeoutPromise, abortPromise] : [promise, timeoutPromise]
|
|
62
|
+
|
|
63
|
+
return Promise.race(races).finally(() => {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reject when a signal aborts (used to race cooperative task handlers).
|
|
70
|
+
*
|
|
71
|
+
* @param {AbortSignal} signal
|
|
72
|
+
* @returns {Promise<never>}
|
|
73
|
+
*/
|
|
74
|
+
export function rejectOnAbort (signal) {
|
|
75
|
+
return new Promise((_, reject) => {
|
|
76
|
+
if (signal.aborted) {
|
|
77
|
+
reject(signal.reason ?? new Error('Operation aborted'))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
signal.addEventListener('abort', () => {
|
|
81
|
+
reject(signal.reason ?? new Error('Operation aborted'))
|
|
82
|
+
}, { once: true })
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Log when an operation exceeds the slow threshold.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} label
|
|
90
|
+
* @param {{ log?: import('@ossy/observability').Logger, warnThresholdMs?: number }} [options]
|
|
91
|
+
*/
|
|
92
|
+
export function createTimedOperation (label, options = {}) {
|
|
93
|
+
const logger = options.log ?? log
|
|
94
|
+
const warnThresholdMs = options.warnThresholdMs
|
|
95
|
+
?? resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
|
|
96
|
+
const start = performance.now()
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
finish (detail) {
|
|
100
|
+
const elapsedMs = Math.round(performance.now() - start)
|
|
101
|
+
const payload = { label, elapsedMs, ...(detail ?? {}) }
|
|
102
|
+
if (elapsedMs >= warnThresholdMs) {
|
|
103
|
+
logger.warn('[slow-operation]', payload)
|
|
104
|
+
} else {
|
|
105
|
+
logger.debug('[timing]', payload)
|
|
106
|
+
}
|
|
107
|
+
return elapsedMs
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Express middleware — logs requests that exceed OSSY_REQUEST_SLOW_MS (default 5s).
|
|
114
|
+
*
|
|
115
|
+
* @param {import('@ossy/observability').Logger} [logger]
|
|
116
|
+
*/
|
|
117
|
+
export function createSlowRequestLogger (logger = log) {
|
|
118
|
+
const slowThresholdMs = resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
|
|
119
|
+
|
|
120
|
+
return (req, res, next) => {
|
|
121
|
+
const start = performance.now()
|
|
122
|
+
const requestUrl = req.originalUrl || req.url || '/'
|
|
123
|
+
|
|
124
|
+
const onFinished = () => {
|
|
125
|
+
res.removeListener('finish', onFinished)
|
|
126
|
+
res.removeListener('close', onFinished)
|
|
127
|
+
const elapsedMs = Math.round(performance.now() - start)
|
|
128
|
+
if (elapsedMs < slowThresholdMs) return
|
|
129
|
+
|
|
130
|
+
logger.warn('[slow-request]', {
|
|
131
|
+
method: req.method,
|
|
132
|
+
url: requestUrl,
|
|
133
|
+
elapsedMs,
|
|
134
|
+
statusCode: res.statusCode,
|
|
135
|
+
userId: req.userId,
|
|
136
|
+
workspaceId: req.workspaceId,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
res.on('finish', onFinished)
|
|
141
|
+
res.on('close', onFinished)
|
|
142
|
+
next()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { OperationTimeoutError, rejectOnAbort, withTimeout } from './request-diagnostics.js'
|
|
3
|
+
|
|
4
|
+
describe('request-diagnostics', () => {
|
|
5
|
+
it('withTimeout resolves when promise settles in time', async () => {
|
|
6
|
+
const result = await withTimeout(Promise.resolve('ok'), 100, 'fast')
|
|
7
|
+
expect(result).toBe('ok')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('withTimeout rejects with OperationTimeoutError when promise is slow', async () => {
|
|
11
|
+
await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toMatchObject({
|
|
12
|
+
label: 'slow-op',
|
|
13
|
+
timeoutMs: 20,
|
|
14
|
+
})
|
|
15
|
+
await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toBeInstanceOf(OperationTimeoutError)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('withTimeout passes through when timeout is disabled', async () => {
|
|
19
|
+
const result = await withTimeout(Promise.resolve(42), 0, 'disabled')
|
|
20
|
+
expect(result).toBe(42)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('withTimeout aborts the supplied controller on timeout', async () => {
|
|
24
|
+
const abortController = new AbortController()
|
|
25
|
+
let aborted = false
|
|
26
|
+
abortController.signal.addEventListener('abort', () => { aborted = true })
|
|
27
|
+
|
|
28
|
+
await expect(
|
|
29
|
+
withTimeout(new Promise(() => {}), 20, 'abortable', { abortController }),
|
|
30
|
+
).rejects.toBeInstanceOf(OperationTimeoutError)
|
|
31
|
+
expect(aborted).toBe(true)
|
|
32
|
+
expect(abortController.signal.aborted).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejectOnAbort rejects when signal is already aborted', async () => {
|
|
36
|
+
const abortController = new AbortController()
|
|
37
|
+
abortController.abort(new Error('already gone'))
|
|
38
|
+
await expect(rejectOnAbort(abortController.signal)).rejects.toThrow(/already gone/)
|
|
39
|
+
})
|
|
40
|
+
})
|
package/src/runtime.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
9
|
import { buildManifestSummary } from '@ossy/app/manifest/build-manifest-summary'
|
|
10
10
|
import { ProxyInternal } from './proxy-internal.js'
|
|
@@ -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)
|
package/src/server.js
CHANGED
|
@@ -7,7 +7,7 @@ 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
13
|
import { buildManifestSummary } from '@ossy/app/manifest/build-manifest-summary'
|
|
@@ -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 {
|
|
@@ -17,6 +17,10 @@ export class ChangeStream {
|
|
|
17
17
|
static _reconnectAttempts = 0
|
|
18
18
|
static _dbUrl = null
|
|
19
19
|
static _client = null
|
|
20
|
+
/** @type {import('mongodb').ChangeStream | null} */
|
|
21
|
+
static _changeStream = null
|
|
22
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
23
|
+
static _reconnectTimer = null
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* Opens the MongoDB changestream and wires up reconnect logic.
|
|
@@ -33,12 +37,35 @@ export class ChangeStream {
|
|
|
33
37
|
})
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
static stop() {
|
|
40
|
+
static async stop () {
|
|
37
41
|
ChangeStream._stopped = true
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
ChangeStream.
|
|
42
|
+
|
|
43
|
+
if (ChangeStream._reconnectTimer) {
|
|
44
|
+
clearTimeout(ChangeStream._reconnectTimer)
|
|
45
|
+
ChangeStream._reconnectTimer = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stream = ChangeStream._changeStream
|
|
49
|
+
ChangeStream._changeStream = null
|
|
50
|
+
if (stream) {
|
|
51
|
+
try {
|
|
52
|
+
await stream.close()
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore — stream may already be closed
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const client = ChangeStream._client
|
|
59
|
+
ChangeStream._client = null
|
|
60
|
+
if (client) {
|
|
61
|
+
try {
|
|
62
|
+
await client.close()
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
41
66
|
}
|
|
67
|
+
|
|
68
|
+
log.info('[ChangeStream] Stopped')
|
|
42
69
|
}
|
|
43
70
|
|
|
44
71
|
static async _getMongoClient() {
|
|
@@ -46,7 +73,10 @@ export class ChangeStream {
|
|
|
46
73
|
return MongoClient
|
|
47
74
|
}
|
|
48
75
|
|
|
49
|
-
static async _getClient() {
|
|
76
|
+
static async _getClient () {
|
|
77
|
+
if (ChangeStream._stopped) {
|
|
78
|
+
throw new Error('ChangeStream stopped')
|
|
79
|
+
}
|
|
50
80
|
if (!ChangeStream._client) {
|
|
51
81
|
const MongoClient = await ChangeStream._getMongoClient()
|
|
52
82
|
ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
|
|
@@ -56,7 +86,8 @@ export class ChangeStream {
|
|
|
56
86
|
return ChangeStream._client
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
static async _resetClient() {
|
|
89
|
+
static async _resetClient () {
|
|
90
|
+
if (ChangeStream._stopped) return
|
|
60
91
|
const MongoClient = await ChangeStream._getMongoClient()
|
|
61
92
|
const prev = ChangeStream._client
|
|
62
93
|
ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
|
|
@@ -68,7 +99,7 @@ export class ChangeStream {
|
|
|
68
99
|
log.info('[ChangeStream] New MongoClient instance created')
|
|
69
100
|
}
|
|
70
101
|
|
|
71
|
-
static _scheduleReconnect() {
|
|
102
|
+
static _scheduleReconnect () {
|
|
72
103
|
if (ChangeStream._stopped) return
|
|
73
104
|
|
|
74
105
|
const delaySecs = Math.min(Math.pow(2, ChangeStream._reconnectAttempts), 30)
|
|
@@ -76,7 +107,12 @@ export class ChangeStream {
|
|
|
76
107
|
|
|
77
108
|
log.info(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
|
|
78
109
|
|
|
79
|
-
|
|
110
|
+
if (ChangeStream._reconnectTimer) {
|
|
111
|
+
clearTimeout(ChangeStream._reconnectTimer)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ChangeStream._reconnectTimer = setTimeout(() => {
|
|
115
|
+
ChangeStream._reconnectTimer = null
|
|
80
116
|
if (ChangeStream._stopped) return
|
|
81
117
|
ChangeStream._open().catch((error) => {
|
|
82
118
|
log.error('[ChangeStream] Change stream could not be opened', undefined, error)
|
|
@@ -85,16 +121,30 @@ export class ChangeStream {
|
|
|
85
121
|
}, delaySecs * 1000)
|
|
86
122
|
}
|
|
87
123
|
|
|
88
|
-
static async _open() {
|
|
124
|
+
static async _open () {
|
|
125
|
+
if (ChangeStream._stopped) return
|
|
126
|
+
|
|
89
127
|
log.info('[ChangeStream] Watching for changes')
|
|
90
128
|
|
|
91
129
|
const dbName = process.env.DB_NAME || 'test'
|
|
92
130
|
const client = await ChangeStream._getClient()
|
|
131
|
+
if (ChangeStream._stopped) return
|
|
132
|
+
|
|
93
133
|
const collection = client.db(dbName).collection('eventstore')
|
|
94
134
|
|
|
135
|
+
if (ChangeStream._changeStream) {
|
|
136
|
+
try {
|
|
137
|
+
await ChangeStream._changeStream.close()
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
ChangeStream._changeStream = null
|
|
142
|
+
}
|
|
143
|
+
|
|
95
144
|
const changeStream = collection.watch([
|
|
96
145
|
{ $match: { operationType: 'insert' } },
|
|
97
146
|
])
|
|
147
|
+
ChangeStream._changeStream = changeStream
|
|
98
148
|
|
|
99
149
|
const wasReconnecting = ChangeStream._reconnectAttempts > 0
|
|
100
150
|
ChangeStream._reconnectAttempts = 0
|
|
@@ -105,6 +155,7 @@ export class ChangeStream {
|
|
|
105
155
|
// Ensures only one reconnect is scheduled if both 'error' and 'close' fire for the same failure.
|
|
106
156
|
let reconnectScheduled = false
|
|
107
157
|
const scheduleOnce = () => {
|
|
158
|
+
if (ChangeStream._stopped) return
|
|
108
159
|
if (!reconnectScheduled) {
|
|
109
160
|
reconnectScheduled = true
|
|
110
161
|
ChangeStream._scheduleReconnect()
|
|
@@ -112,6 +163,7 @@ export class ChangeStream {
|
|
|
112
163
|
}
|
|
113
164
|
|
|
114
165
|
changeStream.on('error', (error) => {
|
|
166
|
+
if (ChangeStream._stopped) return
|
|
115
167
|
log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
|
|
116
168
|
if (isMongoTopologyClosedError(error)) {
|
|
117
169
|
ChangeStream._resetClient().catch(() => {})
|
|
@@ -132,11 +184,19 @@ export class ChangeStream {
|
|
|
132
184
|
})
|
|
133
185
|
|
|
134
186
|
changeStream.on('close', () => {
|
|
187
|
+
if (ChangeStream._changeStream === changeStream) {
|
|
188
|
+
ChangeStream._changeStream = null
|
|
189
|
+
}
|
|
190
|
+
if (ChangeStream._stopped) return
|
|
135
191
|
log.info('[ChangeStream] close detected')
|
|
136
192
|
scheduleOnce()
|
|
137
193
|
})
|
|
138
194
|
|
|
139
195
|
changeStream.on('end', () => {
|
|
196
|
+
if (ChangeStream._changeStream === changeStream) {
|
|
197
|
+
ChangeStream._changeStream = null
|
|
198
|
+
}
|
|
199
|
+
if (ChangeStream._stopped) return
|
|
140
200
|
log.info('[ChangeStream] end detected')
|
|
141
201
|
scheduleOnce()
|
|
142
202
|
})
|
package/src/users.middleware.js
CHANGED
|
@@ -5,8 +5,14 @@ import { User } from '@ossy/users/server'
|
|
|
5
5
|
import { Token } from '@ossy/tokens/server'
|
|
6
6
|
import { ConfigService } from './config.service.js'
|
|
7
7
|
import { PoliciesQueries } from '@ossy/policies/server'
|
|
8
|
+
import {
|
|
9
|
+
OperationTimeoutError,
|
|
10
|
+
resolveTimeoutMs,
|
|
11
|
+
withTimeout,
|
|
12
|
+
} from './request-diagnostics.js'
|
|
8
13
|
|
|
9
14
|
const log = createLogger('users')
|
|
15
|
+
const AUTH_TIMEOUT_MS = resolveTimeoutMs('OSSY_AUTH_TIMEOUT_MS', 10_000)
|
|
10
16
|
|
|
11
17
|
function normalizeAuthToken (token) {
|
|
12
18
|
const trimmed = String(token ?? '').trim()
|
|
@@ -58,29 +64,55 @@ export class UsersMiddleware {
|
|
|
58
64
|
log.info('[UsersMiddleware] Authenticating')
|
|
59
65
|
log.debug('[UsersMiddleware] authToken', { authToken })
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.then(({ sub }) => Aggregate.Of(User, sub))
|
|
69
|
-
.then(Aggregate.View())
|
|
70
|
-
.then(user => {
|
|
71
|
-
const workspaces = user.workspaces ?? []
|
|
67
|
+
const authStarted = performance.now()
|
|
68
|
+
let authFinished = false
|
|
69
|
+
const finishAuth = (apply) => {
|
|
70
|
+
if (authFinished) return
|
|
71
|
+
authFinished = true
|
|
72
|
+
apply()
|
|
73
|
+
}
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
withTimeout(
|
|
76
|
+
TokenService.verify(authToken)
|
|
77
|
+
.then(UsersMiddleware.assertApiTokenActive)
|
|
78
|
+
.then(payload => {
|
|
79
|
+
req.authPayload = payload
|
|
80
|
+
req.tokenScopes = payload?.type === 'Api' ? (payload.scopes ?? ['*']) : null
|
|
81
|
+
return payload
|
|
82
|
+
})
|
|
83
|
+
.then(({ sub }) => Aggregate.Of(User, sub))
|
|
84
|
+
.then(Aggregate.View())
|
|
85
|
+
.then(user => {
|
|
86
|
+
const workspaces = user.workspaces ?? []
|
|
87
|
+
|
|
88
|
+
return PoliciesQueries.GetPoliciesForUser(user.id)
|
|
89
|
+
.then(policies => {
|
|
90
|
+
finishAuth(() => {
|
|
91
|
+
log.info('[UsersMiddleware] Resolved user', {
|
|
92
|
+
elapsedMs: Math.round(performance.now() - authStarted),
|
|
93
|
+
})
|
|
94
|
+
req.userId = user.id
|
|
95
|
+
req.user = { ...user, workspaces, policies }
|
|
96
|
+
next()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}),
|
|
100
|
+
AUTH_TIMEOUT_MS,
|
|
101
|
+
'UsersMiddleware.AuthenticateUser',
|
|
102
|
+
)
|
|
81
103
|
.catch(error => {
|
|
104
|
+
if (error instanceof OperationTimeoutError) {
|
|
105
|
+
log.warn('[UsersMiddleware] Auth timed out', {
|
|
106
|
+
timeoutMs: error.timeoutMs,
|
|
107
|
+
elapsedMs: Math.round(performance.now() - authStarted),
|
|
108
|
+
})
|
|
109
|
+
finishAuth(() => {
|
|
110
|
+
res.status(504).json({ error: 'Authentication timed out', code: 'AUTH_TIMEOUT' })
|
|
111
|
+
})
|
|
112
|
+
return
|
|
113
|
+
}
|
|
82
114
|
log.debug('[UsersMiddleware] Auth failed; anonymous', { error })
|
|
83
|
-
asAnonymous
|
|
115
|
+
finishAuth(asAnonymous)
|
|
84
116
|
})
|
|
85
117
|
}
|
|
86
118
|
|
package/src/schema-ids.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/** Platform envelope schemas (ADR 0008). */
|
|
2
|
-
export const PlatformSchema = Object.freeze({
|
|
3
|
-
config: '@ossy/platform/schema/config',
|
|
4
|
-
directory: '@ossy/platform/schema/directory',
|
|
5
|
-
file: '@ossy/platform/schema/file',
|
|
6
|
-
taskRun: '@ossy/platform/schema/task-run',
|
|
7
|
-
actionInvocation: '@ossy/platform/schema/action-invocation',
|
|
8
|
-
meterEvent: '@ossy/platform/schema/meter-event',
|
|
9
|
-
})
|