@roj-ai/standalone-server 0.1.35 → 0.2.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.
@@ -9,7 +9,7 @@
9
9
  * pick a more specific code if needed.
10
10
  */
11
11
 
12
- import type { Logger, SessionManager } from '@roj-ai/sdk'
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
- export async function resolveServicePort(
21
- sessionManager: SessionManager,
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<number | null> {
24
- const stats = await sessionManager.getStats()
25
- for (const { id } of stats.sessions) {
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 && entry.status !== 'failed' && entry.status !== 'stopped') {
35
- return entry.port
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
- sessionManager: SessionManager,
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 port = await resolveServicePort(sessionManager, code)
56
- if (!port) {
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
- return new Response(upstream.body, {
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, Preset, SessionId, SessionManager } from '@roj-ai/sdk'
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 = options.config ? { ...envConfig, ...options.config } : envConfig
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 presets = options.llmMiddleware?.length
79
+ const llmMiddleware = options.llmMiddleware
80
+ const presets = llmMiddleware?.length
69
81
  ? options.presets.map(p => ({
70
82
  ...p,
71
- llmMiddleware: [...options.llmMiddleware!, ...(p.llmMiddleware ?? [])],
83
+ llmMiddleware: [...llmMiddleware, ...(p.llmMiddleware ?? [])],
72
84
  }))
73
85
  : options.presets
74
86
 
75
- const services = bootstrap(config, { presets }, createBunPlatform())
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
- const publicHost = config.host === '0.0.0.0' ? 'localhost' : config.host
107
- const publicBaseUrl = `http://${publicHost}:${config.port}`
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
- publicBaseUrl,
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
- try {
173
- await sessionManager.loadAllSessions()
174
- } catch (err) {
175
- logger.error('Failed to load persisted sessions', err instanceof Error ? err : new Error(String(err)))
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
- try {
179
- await transport.start()
180
- } catch (err) {
181
- logger.error('Transport start failed', err instanceof Error ? err : new Error(String(err)))
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: config.port,
219
+ port: publicPort,
187
220
  instanceId: instance.id,
188
- url: `http://${config.host === '0.0.0.0' ? 'localhost' : config.host}:${config.port}`,
221
+ url: getPublicBaseUrl(),
189
222
  })
190
223
 
191
- const shutdown = async () => {
192
- logger.info('Shutting down standalone server...')
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
- process.on('SIGINT', () => {
202
- void shutdown().then(() => process.exit(0))
203
- })
204
- process.on('SIGTERM', () => {
205
- void shutdown().then(() => process.exit(0))
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
- return { config, logger, instance, port: server.port ?? config.port, sessionManager, shutdown }
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
  }