@roj-ai/standalone-server 0.1.36 → 0.2.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/dist/main.js +1 -4
- package/dist/main.js.map +1 -1
- package/dist/platform-api.d.ts +26 -5
- package/dist/platform-api.d.ts.map +1 -1
- package/dist/platform-api.js +331 -55
- package/dist/platform-api.js.map +1 -1
- package/dist/preview-proxy.d.ts +28 -3
- package/dist/preview-proxy.d.ts.map +1 -1
- package/dist/preview-proxy.js +69 -16
- package/dist/preview-proxy.js.map +1 -1
- package/dist/server.d.ts +5 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +83 -37
- package/dist/server.js.map +1 -1
- package/dist/user-config-loader.d.ts.map +1 -1
- package/dist/user-config-loader.js +2 -1
- package/dist/user-config-loader.js.map +1 -1
- package/package.json +9 -7
- package/src/main.ts +1 -4
- package/src/platform-api.ts +445 -111
- package/src/preview-proxy.ts +86 -18
- package/src/server.ts +82 -39
- package/src/user-config-loader.ts +2 -1
package/src/preview-proxy.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* pick a more specific code if needed.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { Logger,
|
|
12
|
+
import type { Logger, RuntimeLeaseRelease, SessionState } from '@roj-ai/sdk'
|
|
13
13
|
import { selectPluginState } from '@roj-ai/sdk'
|
|
14
14
|
|
|
15
15
|
interface ServiceEntry {
|
|
@@ -17,23 +17,50 @@ interface ServiceEntry {
|
|
|
17
17
|
status?: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** The slice of a session runtime this proxy needs — `Session` satisfies it. */
|
|
21
|
+
export interface PreviewSession {
|
|
22
|
+
readonly state: SessionState
|
|
23
|
+
tryAcquireRuntimeLease(reason: string): RuntimeLeaseRelease | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The slice of the manager this proxy needs — `SessionManager` satisfies it. */
|
|
27
|
+
export interface PreviewSessionSource {
|
|
28
|
+
listResidentSessions(): Promise<PreviewSession[]>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PreviewTarget {
|
|
32
|
+
port: number
|
|
33
|
+
/** Runtime lease held for this request — release it once the response is done. */
|
|
34
|
+
release: RuntimeLeaseRelease
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find a resident session running `serviceType` and lease its runtime.
|
|
39
|
+
*
|
|
40
|
+
* Resident runtimes only: a session is not kept resident by a healthy service any
|
|
41
|
+
* more, and loading one here would let a proxied asset GET resurrect an evicted
|
|
42
|
+
* session and reset every runtime's idle clock.
|
|
43
|
+
*
|
|
44
|
+
* The lease is what keeps the service alive for the request. Without it, idle
|
|
45
|
+
* eviction stops the dev server mid-response — an HMR or SSE stream outlives the
|
|
46
|
+
* idle window on its own, since it never produces a new request to refresh it.
|
|
47
|
+
*/
|
|
48
|
+
export async function acquirePreviewTarget(
|
|
49
|
+
sessions: PreviewSessionSource,
|
|
22
50
|
serviceType: string,
|
|
23
|
-
): Promise<
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const result = await sessionManager.getSession(id)
|
|
27
|
-
if (!result.ok) continue
|
|
28
|
-
const services = selectPluginState<Map<string, ServiceEntry>>(result.value.state, 'services')
|
|
51
|
+
): Promise<PreviewTarget | null> {
|
|
52
|
+
for (const session of await sessions.listResidentSessions()) {
|
|
53
|
+
const services = selectPluginState<Map<string, ServiceEntry>>(session.state, 'services')
|
|
29
54
|
const entry = services?.get(serviceType)
|
|
30
55
|
// Accept any non-failed status as long as a port is assigned. The
|
|
31
56
|
// services plugin emits `ready` (not `running`); the state projection
|
|
32
57
|
// can also briefly lag the WS `serviceStatus` broadcast that clients
|
|
33
58
|
// use to decide when to render the preview iframe.
|
|
34
|
-
if (entry?.port
|
|
35
|
-
|
|
36
|
-
|
|
59
|
+
if (!entry?.port || entry.status === 'failed' || entry.status === 'stopped') continue
|
|
60
|
+
// Losing the race with an eviction that already started is normal — skip
|
|
61
|
+
// that session rather than serving a port that is about to close.
|
|
62
|
+
const release = session.tryAcquireRuntimeLease(`preview:${serviceType}`)
|
|
63
|
+
if (release) return { port: entry.port, release }
|
|
37
64
|
}
|
|
38
65
|
return null
|
|
39
66
|
}
|
|
@@ -41,7 +68,7 @@ export async function resolveServicePort(
|
|
|
41
68
|
export async function proxyPreview(
|
|
42
69
|
req: Request,
|
|
43
70
|
prefix: string,
|
|
44
|
-
|
|
71
|
+
sessions: PreviewSessionSource,
|
|
45
72
|
logger: Logger,
|
|
46
73
|
): Promise<Response> {
|
|
47
74
|
const url = new URL(req.url)
|
|
@@ -52,13 +79,22 @@ export async function proxyPreview(
|
|
|
52
79
|
return new Response('preview code required', { status: 404 })
|
|
53
80
|
}
|
|
54
81
|
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
82
|
+
const target = await acquirePreviewTarget(sessions, code)
|
|
83
|
+
if (!target) {
|
|
57
84
|
return new Response(`No service running for code "${code}"`, { status: 503 })
|
|
58
85
|
}
|
|
59
86
|
|
|
87
|
+
let released = false
|
|
88
|
+
const release = () => {
|
|
89
|
+
if (released) return
|
|
90
|
+
released = true
|
|
91
|
+
target.release()
|
|
92
|
+
}
|
|
93
|
+
// A client that walks away mid-stream never reaches the flush below.
|
|
94
|
+
req.signal.addEventListener('abort', release, { once: true })
|
|
95
|
+
|
|
60
96
|
const targetPath = '/' + restParts.join('/')
|
|
61
|
-
const targetUrl = `http://127.0.0.1:${port}${targetPath}${url.search}`
|
|
97
|
+
const targetUrl = `http://127.0.0.1:${target.port}${targetPath}${url.search}`
|
|
62
98
|
|
|
63
99
|
const headers = new Headers(req.headers)
|
|
64
100
|
headers.delete('host')
|
|
@@ -70,15 +106,47 @@ export async function proxyPreview(
|
|
|
70
106
|
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body,
|
|
71
107
|
redirect: 'manual',
|
|
72
108
|
})
|
|
73
|
-
|
|
109
|
+
if (!upstream.body) {
|
|
110
|
+
release()
|
|
111
|
+
return new Response(null, {
|
|
112
|
+
status: upstream.status,
|
|
113
|
+
statusText: upstream.statusText,
|
|
114
|
+
headers: upstream.headers,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
// Pump manually so the lease is released on every ending the body can have:
|
|
118
|
+
// last chunk, upstream failure, or a consumer that walks away.
|
|
119
|
+
const reader = upstream.body.getReader()
|
|
120
|
+
const tracked = new ReadableStream<Uint8Array>({
|
|
121
|
+
async pull(controller) {
|
|
122
|
+
try {
|
|
123
|
+
const { done, value } = await reader.read()
|
|
124
|
+
if (done) {
|
|
125
|
+
controller.close()
|
|
126
|
+
release()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
controller.enqueue(value)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
controller.error(error)
|
|
132
|
+
release()
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
cancel(reason) {
|
|
136
|
+
release()
|
|
137
|
+
return reader.cancel(reason)
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
return new Response(tracked, {
|
|
74
141
|
status: upstream.status,
|
|
75
142
|
statusText: upstream.statusText,
|
|
76
143
|
headers: upstream.headers,
|
|
77
144
|
})
|
|
78
145
|
} catch (err) {
|
|
146
|
+
release()
|
|
79
147
|
logger.warn('Preview proxy upstream failed', {
|
|
80
148
|
code,
|
|
81
|
-
port,
|
|
149
|
+
port: target.port,
|
|
82
150
|
error: err instanceof Error ? err.message : String(err),
|
|
83
151
|
})
|
|
84
152
|
return new Response('Preview upstream unreachable', { status: 502 })
|
package/src/server.ts
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
* GET /health — health check
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { Config, LLMMiddleware, LocalResource, Logger,
|
|
15
|
+
import type { Config, LLMMiddleware, LocalResource, Logger, SessionDefaults, SessionId, SessionManager } from '@roj-ai/sdk'
|
|
16
16
|
import { bootstrap, createSystemFromServices, loadConfig, validateConfig } from '@roj-ai/sdk'
|
|
17
17
|
import { createApp } from '@roj-ai/sdk/transport/http/app'
|
|
18
18
|
import { createAgentTransport, ServerAdapter } from '@roj-ai/sdk/transport/adapter'
|
|
19
|
-
import { createBunPlatform } from '@roj-ai/sdk/bun-platform'
|
|
19
|
+
import { createBunPlatform, createServerLifecycle, runServerStartup, shutdownFromSignal } from '@roj-ai/sdk/bun-platform'
|
|
20
20
|
import { createBunWebSocketHandlers } from '@roj-ai/transport/bun'
|
|
21
21
|
import { Hono } from 'hono'
|
|
22
22
|
import { cors } from 'hono/cors'
|
|
@@ -29,8 +29,7 @@ import { proxyPreview } from './preview-proxy.js'
|
|
|
29
29
|
import { createSessionFileRoute } from './session-file-route.js'
|
|
30
30
|
import { generateTokenSecret } from './signed-token.js'
|
|
31
31
|
|
|
32
|
-
export interface StartStandaloneOptions {
|
|
33
|
-
presets: Preset[]
|
|
32
|
+
export interface StartStandaloneOptions extends SessionDefaults {
|
|
34
33
|
config?: Partial<Config>
|
|
35
34
|
instanceId?: string
|
|
36
35
|
instanceName?: string
|
|
@@ -56,24 +55,39 @@ export interface StandaloneHandle {
|
|
|
56
55
|
shutdown(): Promise<void>
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
/** Evict a session runtime after this long idle. Override with SESSION_IDLE_TIMEOUT_MS. */
|
|
59
|
+
const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 600_000
|
|
60
|
+
|
|
59
61
|
export async function startStandaloneServer(options: StartStandaloneOptions): Promise<StandaloneHandle> {
|
|
60
62
|
const envConfig = loadConfig()
|
|
61
|
-
const config: Config =
|
|
63
|
+
const config: Config = {
|
|
64
|
+
...envConfig,
|
|
65
|
+
...options.config,
|
|
66
|
+
host: resolveStandaloneHost(options.config?.host, process.env.HOST),
|
|
67
|
+
// Standalone is a long-lived single-instance runtime, so it evicts idle
|
|
68
|
+
// session runtimes by default. SESSION_IDLE_TIMEOUT_MS=0 turns it off.
|
|
69
|
+
sessionIdleTimeoutMs: options.config?.sessionIdleTimeoutMs
|
|
70
|
+
?? envConfig.sessionIdleTimeoutMs
|
|
71
|
+
?? DEFAULT_SESSION_IDLE_TIMEOUT_MS,
|
|
72
|
+
}
|
|
62
73
|
|
|
63
74
|
const errors = validateConfig(config)
|
|
64
75
|
if (errors.length > 0) {
|
|
65
76
|
throw new Error(`Configuration errors:\n${errors.map(e => ` - ${e}`).join('\n')}`)
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
const
|
|
79
|
+
const llmMiddleware = options.llmMiddleware
|
|
80
|
+
const presets = llmMiddleware?.length
|
|
69
81
|
? options.presets.map(p => ({
|
|
70
82
|
...p,
|
|
71
|
-
llmMiddleware: [...
|
|
83
|
+
llmMiddleware: [...llmMiddleware, ...(p.llmMiddleware ?? [])],
|
|
72
84
|
}))
|
|
73
85
|
: options.presets
|
|
74
86
|
|
|
75
|
-
|
|
87
|
+
// Forwarded whole: bootstrap folds the SessionDefaults, so nothing is dropped here.
|
|
88
|
+
const services = bootstrap(config, { ...options, presets }, createBunPlatform())
|
|
76
89
|
const { logger } = services
|
|
90
|
+
warnIfStandaloneExposed(config.host, logger)
|
|
77
91
|
|
|
78
92
|
// Reap service processes left behind by a previous agent, before any session can load
|
|
79
93
|
// and start its own. At boot this agent owns nothing, so every survivor is an orphan.
|
|
@@ -103,8 +117,8 @@ export async function startStandaloneServer(options: StartStandaloneOptions): Pr
|
|
|
103
117
|
})
|
|
104
118
|
|
|
105
119
|
const tokenSecret = generateTokenSecret()
|
|
106
|
-
|
|
107
|
-
const
|
|
120
|
+
let publicPort = config.port
|
|
121
|
+
const getPublicBaseUrl = () => `http://${formatPublicHost(config.host)}:${publicPort}`
|
|
108
122
|
|
|
109
123
|
const registry = new LocalRegistry(config.dataPath, logger)
|
|
110
124
|
await registry.init()
|
|
@@ -123,7 +137,7 @@ export async function startStandaloneServer(options: StartStandaloneOptions): Pr
|
|
|
123
137
|
registry,
|
|
124
138
|
gitFs,
|
|
125
139
|
tokenSecret,
|
|
126
|
-
|
|
140
|
+
getPublicBaseUrl,
|
|
127
141
|
})
|
|
128
142
|
|
|
129
143
|
const outerApp = new Hono()
|
|
@@ -168,44 +182,74 @@ export async function startStandaloneServer(options: StartStandaloneOptions): Pr
|
|
|
168
182
|
})
|
|
169
183
|
|
|
170
184
|
const server = startBunServer(config, outerApp, serverAdapter)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
185
|
+
publicPort = server.port ?? config.port
|
|
186
|
+
let transportStopPromise: Promise<void> | undefined
|
|
187
|
+
let transportStopErrorReported = false
|
|
188
|
+
const stopTransportOnce = async () => {
|
|
189
|
+
transportStopPromise ??= Promise.resolve().then(() => transport.stop())
|
|
190
|
+
try {
|
|
191
|
+
await transportStopPromise
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (transportStopErrorReported) return
|
|
194
|
+
transportStopErrorReported = true
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
176
197
|
}
|
|
177
198
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
199
|
+
const lifecycle = createServerLifecycle({
|
|
200
|
+
stopIngress: () => server.stop(true),
|
|
201
|
+
cleanupSteps: [
|
|
202
|
+
...options.onShutdown ? [options.onShutdown] : [],
|
|
203
|
+
() => sessionManager.shutdown(),
|
|
204
|
+
stopTransportOnce,
|
|
205
|
+
],
|
|
206
|
+
runSignalShutdown: shutdown => {
|
|
207
|
+
void shutdownFromSignal(shutdown)
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await runServerStartup(lifecycle, [
|
|
212
|
+
{ run: () => sessionManager.loadAllSessions() },
|
|
213
|
+
// A pending connection is canceled before the remaining ordered cleanup.
|
|
214
|
+
{ run: () => transport.start(), cancel: stopTransportOnce },
|
|
215
|
+
])
|
|
183
216
|
|
|
184
217
|
logger.info('Standalone server started', {
|
|
185
218
|
host: config.host,
|
|
186
|
-
port:
|
|
219
|
+
port: publicPort,
|
|
187
220
|
instanceId: instance.id,
|
|
188
|
-
url:
|
|
221
|
+
url: getPublicBaseUrl(),
|
|
189
222
|
})
|
|
190
223
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (options.onShutdown) {
|
|
194
|
-
await options.onShutdown()
|
|
195
|
-
}
|
|
196
|
-
await sessionManager.shutdown()
|
|
197
|
-
await transport.stop()
|
|
198
|
-
server.stop()
|
|
199
|
-
}
|
|
224
|
+
return { config, logger, instance, port: publicPort, sessionManager, shutdown: lifecycle.shutdown }
|
|
225
|
+
}
|
|
200
226
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
227
|
+
export function resolveStandaloneHost(configHost: string | undefined, envHost: string | undefined): string {
|
|
228
|
+
return configHost ?? envHost ?? '127.0.0.1'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function isLoopbackHost(host: string): boolean {
|
|
232
|
+
const normalized = host.toLowerCase().replace(/^\[|\]$/g, '')
|
|
233
|
+
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true
|
|
234
|
+
if (normalized.startsWith('::ffff:')) return isLoopbackHost(normalized.slice('::ffff:'.length))
|
|
235
|
+
const ipv4 = normalized.split('.')
|
|
236
|
+
return ipv4.length === 4
|
|
237
|
+
&& ipv4.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
238
|
+
&& ipv4[0] === '127'
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function warnIfStandaloneExposed(host: string, logger: Pick<Logger, 'warn'>): void {
|
|
242
|
+
if (isLoopbackHost(host)) return
|
|
243
|
+
logger.warn('Standalone server is listening on a non-loopback host without authentication', {
|
|
244
|
+
host,
|
|
245
|
+
action: 'Bind to 127.0.0.1 unless network access is intentional and protected externally',
|
|
206
246
|
})
|
|
247
|
+
}
|
|
207
248
|
|
|
208
|
-
|
|
249
|
+
function formatPublicHost(host: string): string {
|
|
250
|
+
if (host === '0.0.0.0' || host === '::' || host === '[::]') return 'localhost'
|
|
251
|
+
const unwrapped = host.replace(/^\[|\]$/g, '')
|
|
252
|
+
return unwrapped.includes(':') ? `[${unwrapped}]` : unwrapped
|
|
209
253
|
}
|
|
210
254
|
|
|
211
255
|
interface WSData {
|
|
@@ -251,4 +295,3 @@ function startBunServer(
|
|
|
251
295
|
websocket: wsHandlers ?? { open() {}, close() {}, message() {} },
|
|
252
296
|
})
|
|
253
297
|
}
|
|
254
|
-
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { LocalResource, Preset, RojConfig } from '@roj-ai/sdk'
|
|
9
|
-
import { validatePreset } from '@roj-ai/sdk'
|
|
9
|
+
import { parseExtraBinds, validatePreset } from '@roj-ai/sdk'
|
|
10
10
|
import { existsSync } from 'node:fs'
|
|
11
11
|
import { dirname, isAbsolute, resolve } from 'node:path'
|
|
12
12
|
|
|
@@ -70,6 +70,7 @@ export async function loadUserConfig(configPath: string): Promise<RojConfig> {
|
|
|
70
70
|
presets,
|
|
71
71
|
sandboxed: typedConfig.sandboxed as boolean | undefined,
|
|
72
72
|
snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'],
|
|
73
|
+
extraBinds: parseExtraBinds(typedConfig.extraBinds, configDir, absolutePath),
|
|
73
74
|
localResources,
|
|
74
75
|
}
|
|
75
76
|
}
|