@roj-ai/standalone-server 0.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.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @roj-ai/standalone-server — run a single roj instance on your machine.
3
+ *
4
+ * Exposes the same REST + WebSocket URL shape as the Cloudflare-hosted
5
+ * platform (the subset that makes sense for a local single-instance setup),
6
+ * so the same client SDK and React hooks work unchanged.
7
+ */
8
+ export { startStandaloneServer } from './server.js';
9
+ export type { StartStandaloneOptions, StandaloneHandle } from './server.js';
10
+ export type { InstanceState } from './instance.js';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACnD,YAAY,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAC3E,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Singleton instance state for the standalone server.
3
+ *
4
+ * A standalone server hosts exactly one "instance" — a workspace containing
5
+ * sessions. The instance id is either supplied via config or auto-generated
6
+ * on startup and remains stable for the process lifetime.
7
+ */
8
+ export interface InstanceState {
9
+ id: string;
10
+ name: string;
11
+ createdAt: string;
12
+ presetIds: string[];
13
+ }
14
+ export declare function createInstance(options: {
15
+ id?: string;
16
+ name?: string;
17
+ presetIds: string[];
18
+ }): InstanceState;
19
+ //# sourceMappingURL=instance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../src/instance.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,WAAW,aAAa;IAC7B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,aAAa,CAO1G"}
package/dist/main.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CLI entry: run the standalone roj server from a `roj.config.ts` file.
4
+ *
5
+ * Usage:
6
+ * bun standalone-server [config-path] (default: roj.config.ts)
7
+ * bunx roj-standalone [config-path]
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AACA;;;;;;GAMG"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Platform REST API for the standalone server.
3
+ *
4
+ * Implements the subset of @roj-ai/client/platform method contract that
5
+ * makes sense for a single-machine, single-instance deployment:
6
+ *
7
+ * - instances.* — singleton, always returns the one instance
8
+ * - sessions.* — delegates to the SDK session manager
9
+ * - tokens.create — noop (no auth)
10
+ *
11
+ * NOT implemented (return method_not_found):
12
+ * - bundles.* — presets are imported directly, not uploaded
13
+ * - sessions.publish — publishing requires the CF platform
14
+ * - instances.archive — no-op; shutdown the server instead
15
+ */
16
+ import type { Logger, SessionManager } from '@roj-ai/sdk';
17
+ import { Hono } from 'hono';
18
+ import type { InstanceState } from './instance.js';
19
+ interface Deps {
20
+ instance: InstanceState;
21
+ sessionManager: SessionManager;
22
+ logger: Logger;
23
+ }
24
+ export declare function createPlatformApi(deps: Deps): Hono;
25
+ export {};
26
+ //# sourceMappingURL=platform-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform-api.d.ts","sourceRoot":"","sources":["../src/platform-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAC3B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAElD,UAAU,IAAI;IACb,QAAQ,EAAE,aAAa,CAAA;IACvB,cAAc,EAAE,cAAc,CAAA;IAC9B,MAAM,EAAE,MAAM,CAAA;CACd;AAQD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CAuBlD"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Preview proxy — forwards requests from
3
+ * /api/v1/instances/{id}/preview/{code}/{...path}
4
+ * to the localhost port of a running dev service.
5
+ *
6
+ * The {code} segment is treated as a service type (e.g. "dev"). The first
7
+ * running session that has a service of that type wins. For single-session
8
+ * flows this is deterministic; for multi-session flows the caller should
9
+ * pick a more specific code if needed.
10
+ */
11
+ import type { Logger, SessionManager } from '@roj-ai/sdk';
12
+ export declare function resolveServicePort(sessionManager: SessionManager, serviceType: string): Promise<number | null>;
13
+ export declare function proxyPreview(req: Request, prefix: string, sessionManager: SessionManager, logger: Logger): Promise<Response>;
14
+ //# sourceMappingURL=preview-proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview-proxy.d.ts","sourceRoot":"","sources":["../src/preview-proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAQzD,wBAAsB,kBAAkB,CACvC,cAAc,EAAE,cAAc,EAC9B,WAAW,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgBxB;AAED,wBAAsB,YAAY,CACjC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,cAAc,EAC9B,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,QAAQ,CAAC,CAwCnB"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Standalone roj server — hosts the SDK agent and exposes a platform-
3
+ * compatible REST + WebSocket surface for a single instance on localhost.
4
+ *
5
+ * URL shape (matches @roj-ai/client/platform):
6
+ * POST /api/v1/rpc — platform RPC (singleton)
7
+ * POST /api/v1/instances/{id}/rpc — agent RPC
8
+ * POST /api/v1/instances/{id}/sessions/{sid}/upload — file upload (session)
9
+ * WS /api/v1/instances/{id}/ws — live events
10
+ * ANY /api/v1/instances/{id}/preview/{code}/* — dev service proxy
11
+ * POST /api/v1/instances/{id}/exchange — noop (no auth locally)
12
+ * GET /health — health check
13
+ */
14
+ import type { Config, LLMMiddleware, Logger, Preset, SessionManager } from '@roj-ai/sdk';
15
+ import { type InstanceState } from './instance.js';
16
+ export interface StartStandaloneOptions {
17
+ presets: Preset[];
18
+ config?: Partial<Config>;
19
+ instanceId?: string;
20
+ instanceName?: string;
21
+ llmMiddleware?: LLMMiddleware[];
22
+ onShutdown?: () => Promise<void> | void;
23
+ onBeforeStart?: (ctx: {
24
+ config: Config;
25
+ logger: Logger;
26
+ }) => void | Promise<void>;
27
+ }
28
+ export interface StandaloneHandle {
29
+ config: Config;
30
+ logger: Logger;
31
+ instance: InstanceState;
32
+ /** Resolved listen port (useful when config.port was 0 — OS-assigned). */
33
+ port: number;
34
+ /** Underlying session manager. Exposed for tests that need to assert on session state. */
35
+ sessionManager: SessionManager;
36
+ shutdown(): Promise<void>;
37
+ }
38
+ export declare function startStandaloneServer(options: StartStandaloneOptions): Promise<StandaloneHandle>;
39
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,EAAa,cAAc,EAAE,MAAM,aAAa,CAAA;AAQnG,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAIlE,MAAM,WAAW,sBAAsB;IACtC,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,aAAa,EAAE,CAAA;IAC/B,UAAU,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;IACvC,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACjF;AAED,MAAM,WAAW,gBAAgB;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,aAAa,CAAA;IACvB,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAA;IACZ,0FAA0F;IAC1F,cAAc,EAAE,cAAc,CAAA;IAC9B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACzB;AAED,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAgHtG"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Runtime loader for the user `roj.config.ts` file.
3
+ *
4
+ * Uses dynamic `import()` — kept out of `@roj-ai/sdk` (which stays free of
5
+ * runtime-specific I/O) and out of `@roj-ai/sandbox-runtime` (Bun-specific).
6
+ */
7
+ import type { RojConfig } from '@roj-ai/sdk';
8
+ export declare function loadUserConfig(configPath: string): Promise<RojConfig>;
9
+ //# sourceMappingURL=user-config-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-config-loader.d.ts","sourceRoot":"","sources":["../src/user-config-loader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAU,SAAS,EAAE,MAAM,aAAa,CAAA;AAKpD,wBAAsB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CA0D3E"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@roj-ai/standalone-server",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "bin": {
7
+ "roj-standalone": "./src/main.ts"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./src/index.ts",
13
+ "default": "./src/index.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "src",
18
+ "dist"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "type-check": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@roj-ai/client": "^0.0.2",
28
+ "@roj-ai/sdk": "^0.0.2",
29
+ "@roj-ai/shared": "^0.0.2",
30
+ "@roj-ai/transport": "^0.0.2",
31
+ "hono": "^4.10.7"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "^1.3.3",
35
+ "typescript": "^5.9.3"
36
+ },
37
+ "types": "./dist/index.d.ts"
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @roj-ai/standalone-server — run a single roj instance on your machine.
3
+ *
4
+ * Exposes the same REST + WebSocket URL shape as the Cloudflare-hosted
5
+ * platform (the subset that makes sense for a local single-instance setup),
6
+ * so the same client SDK and React hooks work unchanged.
7
+ */
8
+
9
+ export { startStandaloneServer } from './server.js'
10
+ export type { StartStandaloneOptions, StandaloneHandle } from './server.js'
11
+ export type { InstanceState } from './instance.js'
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Singleton instance state for the standalone server.
3
+ *
4
+ * A standalone server hosts exactly one "instance" — a workspace containing
5
+ * sessions. The instance id is either supplied via config or auto-generated
6
+ * on startup and remains stable for the process lifetime.
7
+ */
8
+
9
+ export interface InstanceState {
10
+ id: string
11
+ name: string
12
+ createdAt: string
13
+ presetIds: string[]
14
+ }
15
+
16
+ export function createInstance(options: { id?: string; name?: string; presetIds: string[] }): InstanceState {
17
+ return {
18
+ id: options.id ?? 'default',
19
+ name: options.name ?? 'standalone',
20
+ createdAt: new Date().toISOString(),
21
+ presetIds: options.presetIds,
22
+ }
23
+ }
package/src/main.ts ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CLI entry: run the standalone roj server from a `roj.config.ts` file.
4
+ *
5
+ * Usage:
6
+ * bun standalone-server [config-path] (default: roj.config.ts)
7
+ * bunx roj-standalone [config-path]
8
+ */
9
+
10
+ import { resolve } from 'node:path'
11
+ import { startStandaloneServer } from './server.js'
12
+ import { loadUserConfig } from './user-config-loader.js'
13
+
14
+ async function main() {
15
+ const configPath = process.argv[2] ?? 'roj.config.ts'
16
+ const absoluteConfigPath = resolve(process.cwd(), configPath)
17
+
18
+ let userConfig: Awaited<ReturnType<typeof loadUserConfig>>
19
+ try {
20
+ userConfig = await loadUserConfig(absoluteConfigPath)
21
+ console.log(`Loaded config from: ${absoluteConfigPath}`)
22
+ console.log(` Presets: ${userConfig.presets.map(p => p.id).join(', ')}`)
23
+ } catch (error) {
24
+ console.error(`Failed to load config from ${absoluteConfigPath}:`)
25
+ console.error(error instanceof Error ? error.message : String(error))
26
+ process.exit(1)
27
+ }
28
+
29
+ await startStandaloneServer({ presets: userConfig.presets })
30
+ }
31
+
32
+ main().catch((error) => {
33
+ console.error('Fatal error:', error)
34
+ process.exit(1)
35
+ })
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Platform REST API for the standalone server.
3
+ *
4
+ * Implements the subset of @roj-ai/client/platform method contract that
5
+ * makes sense for a single-machine, single-instance deployment:
6
+ *
7
+ * - instances.* — singleton, always returns the one instance
8
+ * - sessions.* — delegates to the SDK session manager
9
+ * - tokens.create — noop (no auth)
10
+ *
11
+ * NOT implemented (return method_not_found):
12
+ * - bundles.* — presets are imported directly, not uploaded
13
+ * - sessions.publish — publishing requires the CF platform
14
+ * - instances.archive — no-op; shutdown the server instead
15
+ */
16
+
17
+ import type { Logger, SessionManager } from '@roj-ai/sdk'
18
+ import { Hono } from 'hono'
19
+ import type { InstanceState } from './instance.js'
20
+
21
+ interface Deps {
22
+ instance: InstanceState
23
+ sessionManager: SessionManager
24
+ logger: Logger
25
+ }
26
+
27
+ interface RpcEnvelope {
28
+ method?: string
29
+ input?: unknown
30
+ batch?: Array<{ method: string; input?: unknown }>
31
+ }
32
+
33
+ export function createPlatformApi(deps: Deps): Hono {
34
+ const app = new Hono()
35
+
36
+ app.post('/rpc', async (c) => {
37
+ const body = await c.req.json<RpcEnvelope>().catch(() => ({} as RpcEnvelope))
38
+
39
+ if (Array.isArray(body.batch)) {
40
+ const results = []
41
+ for (const call of body.batch) {
42
+ results.push(await dispatch(deps, call.method, call.input))
43
+ }
44
+ return c.json({ results })
45
+ }
46
+
47
+ if (typeof body.method !== 'string') {
48
+ return c.json({ ok: false, error: { type: 'invalid_request', message: 'Missing method' } }, 400)
49
+ }
50
+
51
+ const result = await dispatch(deps, body.method, body.input)
52
+ return c.json(result)
53
+ })
54
+
55
+ return app
56
+ }
57
+
58
+ async function dispatch(
59
+ deps: Deps,
60
+ method: string,
61
+ input: unknown,
62
+ ): Promise<{ ok: true; value: unknown } | { ok: false; error: { type: string; message: string } }> {
63
+ const handler = handlers[method]
64
+ if (!handler) {
65
+ return { ok: false, error: { type: 'method_not_found', message: `Method not supported in standalone: ${method}` } }
66
+ }
67
+
68
+ try {
69
+ const value = await handler(deps, input ?? {})
70
+ return { ok: true, value }
71
+ } catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err)
73
+ deps.logger.error(`Platform RPC handler failed: ${method}`, err instanceof Error ? err : new Error(message))
74
+ return { ok: false, error: { type: 'handler_error', message } }
75
+ }
76
+ }
77
+
78
+ type Handler = (deps: Deps, input: any) => Promise<unknown>
79
+
80
+ const handlers: Record<string, Handler> = {
81
+ 'instances.create': async ({ instance }) => ({
82
+ instanceId: instance.id,
83
+ status: 'ready',
84
+ }),
85
+
86
+ 'instances.list': async ({ instance }) => ({
87
+ instances: [instanceSummary(instance)],
88
+ total: 1,
89
+ }),
90
+
91
+ 'instances.get': async ({ instance }, _input) => instanceSummary(instance),
92
+
93
+ 'instances.status': async ({ instance, sessionManager }) => {
94
+ const stats = await sessionManager.getStats()
95
+ return {
96
+ instanceId: instance.id,
97
+ status: 'ready',
98
+ sandbox: { state: 'running' },
99
+ sessions: stats.sessions.map(s => ({
100
+ id: s.id,
101
+ presetId: s.presetId,
102
+ status: s.status,
103
+ createdAt: new Date().toISOString(),
104
+ })),
105
+ lifecycleEvents: [],
106
+ serviceUrls: [],
107
+ }
108
+ },
109
+
110
+ 'instances.archive': async () => ({ ok: true }),
111
+
112
+ 'sessions.create': async ({ sessionManager }, input: { presetId: string; initialPrompt?: string }) => {
113
+ const result = await sessionManager.callManagerMethod('sessions.create', {
114
+ presetId: input.presetId,
115
+ })
116
+ if (!result.ok) throw new Error(result.error.message)
117
+ return result.value as { sessionId: string }
118
+ },
119
+
120
+ 'sessions.list': async ({ sessionManager }) => {
121
+ const result = await sessionManager.callManagerMethod('sessions.list', {})
122
+ if (!result.ok) throw new Error(result.error.message)
123
+ return result.value as { sessions: unknown[]; total: number }
124
+ },
125
+
126
+ 'tokens.create': async () => ({ token: '' }),
127
+ }
128
+
129
+ function instanceSummary(instance: InstanceState) {
130
+ return {
131
+ instanceId: instance.id,
132
+ name: instance.name,
133
+ status: 'ready',
134
+ templateSlug: 'standalone',
135
+ bundleSlug: 'standalone',
136
+ bundleRevisionId: '',
137
+ vcsType: 'none',
138
+ metadata: null,
139
+ createdAt: instance.createdAt,
140
+ }
141
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Preview proxy — forwards requests from
3
+ * /api/v1/instances/{id}/preview/{code}/{...path}
4
+ * to the localhost port of a running dev service.
5
+ *
6
+ * The {code} segment is treated as a service type (e.g. "dev"). The first
7
+ * running session that has a service of that type wins. For single-session
8
+ * flows this is deterministic; for multi-session flows the caller should
9
+ * pick a more specific code if needed.
10
+ */
11
+
12
+ import type { Logger, SessionManager } from '@roj-ai/sdk'
13
+ import { selectPluginState } from '@roj-ai/sdk'
14
+
15
+ interface ServiceEntry {
16
+ port?: number
17
+ status?: string
18
+ }
19
+
20
+ export async function resolveServicePort(
21
+ sessionManager: SessionManager,
22
+ 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')
29
+ const entry = services?.get(serviceType)
30
+ // Accept any non-failed status as long as a port is assigned. The
31
+ // services plugin emits `ready` (not `running`); the state projection
32
+ // can also briefly lag the WS `serviceStatus` broadcast that clients
33
+ // use to decide when to render the preview iframe.
34
+ if (entry?.port && entry.status !== 'failed' && entry.status !== 'stopped') {
35
+ return entry.port
36
+ }
37
+ }
38
+ return null
39
+ }
40
+
41
+ export async function proxyPreview(
42
+ req: Request,
43
+ prefix: string,
44
+ sessionManager: SessionManager,
45
+ logger: Logger,
46
+ ): Promise<Response> {
47
+ const url = new URL(req.url)
48
+ const rest = url.pathname.slice(prefix.length)
49
+ const [code, ...restParts] = rest.split('/').filter(Boolean)
50
+
51
+ if (!code) {
52
+ return new Response('preview code required', { status: 404 })
53
+ }
54
+
55
+ const port = await resolveServicePort(sessionManager, code)
56
+ if (!port) {
57
+ return new Response(`No service running for code "${code}"`, { status: 503 })
58
+ }
59
+
60
+ const targetPath = '/' + restParts.join('/')
61
+ const targetUrl = `http://127.0.0.1:${port}${targetPath}${url.search}`
62
+
63
+ const headers = new Headers(req.headers)
64
+ headers.delete('host')
65
+
66
+ try {
67
+ const upstream = await fetch(targetUrl, {
68
+ method: req.method,
69
+ headers,
70
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body,
71
+ redirect: 'manual',
72
+ })
73
+ return new Response(upstream.body, {
74
+ status: upstream.status,
75
+ statusText: upstream.statusText,
76
+ headers: upstream.headers,
77
+ })
78
+ } catch (err) {
79
+ logger.warn('Preview proxy upstream failed', {
80
+ code,
81
+ port,
82
+ error: err instanceof Error ? err.message : String(err),
83
+ })
84
+ return new Response('Preview upstream unreachable', { status: 502 })
85
+ }
86
+ }
package/src/server.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Standalone roj server — hosts the SDK agent and exposes a platform-
3
+ * compatible REST + WebSocket surface for a single instance on localhost.
4
+ *
5
+ * URL shape (matches @roj-ai/client/platform):
6
+ * POST /api/v1/rpc — platform RPC (singleton)
7
+ * POST /api/v1/instances/{id}/rpc — agent RPC
8
+ * POST /api/v1/instances/{id}/sessions/{sid}/upload — file upload (session)
9
+ * WS /api/v1/instances/{id}/ws — live events
10
+ * ANY /api/v1/instances/{id}/preview/{code}/* — dev service proxy
11
+ * POST /api/v1/instances/{id}/exchange — noop (no auth locally)
12
+ * GET /health — health check
13
+ */
14
+
15
+ import type { Config, LLMMiddleware, Logger, Preset, SessionId, SessionManager } from '@roj-ai/sdk'
16
+ import { bootstrap, createSystemFromServices, loadConfig, validateConfig } from '@roj-ai/sdk'
17
+ import { createApp } from '@roj-ai/sdk/transport/http/app'
18
+ import { createAgentTransport, ServerAdapter } from '@roj-ai/sdk/transport/adapter'
19
+ import { createBunPlatform } from '@roj-ai/sdk/bun-platform'
20
+ import { createBunWebSocketHandlers } from '@roj-ai/transport/bun'
21
+ import { Hono } from 'hono'
22
+ import { cors } from 'hono/cors'
23
+ import { createInstance, type InstanceState } from './instance.js'
24
+ import { createPlatformApi } from './platform-api.js'
25
+ import { proxyPreview } from './preview-proxy.js'
26
+
27
+ export interface StartStandaloneOptions {
28
+ presets: Preset[]
29
+ config?: Partial<Config>
30
+ instanceId?: string
31
+ instanceName?: string
32
+ llmMiddleware?: LLMMiddleware[]
33
+ onShutdown?: () => Promise<void> | void
34
+ onBeforeStart?: (ctx: { config: Config; logger: Logger }) => void | Promise<void>
35
+ }
36
+
37
+ export interface StandaloneHandle {
38
+ config: Config
39
+ logger: Logger
40
+ instance: InstanceState
41
+ /** Resolved listen port (useful when config.port was 0 — OS-assigned). */
42
+ port: number
43
+ /** Underlying session manager. Exposed for tests that need to assert on session state. */
44
+ sessionManager: SessionManager
45
+ shutdown(): Promise<void>
46
+ }
47
+
48
+ export async function startStandaloneServer(options: StartStandaloneOptions): Promise<StandaloneHandle> {
49
+ const envConfig = loadConfig()
50
+ const config: Config = options.config ? { ...envConfig, ...options.config } : envConfig
51
+
52
+ const errors = validateConfig(config)
53
+ if (errors.length > 0) {
54
+ throw new Error(`Configuration errors:\n${errors.map(e => ` - ${e}`).join('\n')}`)
55
+ }
56
+
57
+ const presets = options.llmMiddleware?.length
58
+ ? options.presets.map(p => ({
59
+ ...p,
60
+ llmMiddleware: [...options.llmMiddleware!, ...(p.llmMiddleware ?? [])],
61
+ }))
62
+ : options.presets
63
+
64
+ const services = bootstrap(config, { presets }, createBunPlatform())
65
+ const { logger } = services
66
+
67
+ const instance = createInstance({
68
+ id: options.instanceId,
69
+ name: options.instanceName,
70
+ presetIds: presets.map(p => p.id),
71
+ })
72
+
73
+ if (options.onBeforeStart) {
74
+ await options.onBeforeStart({ config, logger })
75
+ }
76
+
77
+ const transport = createAgentTransport({ mode: 'standalone', logger })
78
+ const serverAdapter = transport instanceof ServerAdapter ? transport : null
79
+
80
+ const system = createSystemFromServices(services, {
81
+ onUserOutput: (message) => transport.broadcast(message),
82
+ })
83
+ const sessionManager = system.sessionManager
84
+
85
+ const agentApp = createApp({
86
+ ...services,
87
+ sessionRuntime: sessionManager,
88
+ })
89
+
90
+ const platformApp = createPlatformApi({ instance, sessionManager, logger })
91
+
92
+ const outerApp = new Hono()
93
+ // Reflect the request origin and allow credentials — the SPA uses
94
+ // `credentials: 'include'` on its RPC fetches, which requires a specific
95
+ // origin header (not `*`) plus `Access-Control-Allow-Credentials: true`.
96
+ outerApp.use('*', cors({ origin: (origin) => origin ?? '*', credentials: true }))
97
+
98
+ outerApp.get('/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }))
99
+ outerApp.route('/api/v1', platformApp)
100
+
101
+ // Path-based preview proxy (must come BEFORE the catch-all instance route)
102
+ outerApp.all('/api/v1/instances/:id/preview/*', (c) => {
103
+ const id = c.req.param('id')
104
+ const prefix = `/api/v1/instances/${id}/preview/`
105
+ return proxyPreview(c.req.raw, prefix, sessionManager, logger)
106
+ })
107
+
108
+ // Noop auth-exchange (platform has real cookies; standalone is open)
109
+ outerApp.post('/api/v1/instances/:id/exchange', (c) => c.json({ ok: true }))
110
+
111
+ // Instance-scoped agent routes — strip the instance prefix and delegate to createApp
112
+ outerApp.all('/api/v1/instances/:id/*', async (c) => {
113
+ const id = c.req.param('id')
114
+ const prefix = `/api/v1/instances/${id}`
115
+ const url = new URL(c.req.url)
116
+ const innerPath = url.pathname.slice(prefix.length) || '/'
117
+ const innerUrl = `${url.origin}${innerPath}${url.search}`
118
+ return agentApp.fetch(new Request(innerUrl, c.req.raw))
119
+ })
120
+
121
+ const server = startBunServer(config, outerApp, serverAdapter)
122
+
123
+ try {
124
+ await sessionManager.loadAllSessions()
125
+ } catch (err) {
126
+ logger.error('Failed to load persisted sessions', err instanceof Error ? err : new Error(String(err)))
127
+ }
128
+
129
+ try {
130
+ await transport.start()
131
+ } catch (err) {
132
+ logger.error('Transport start failed', err instanceof Error ? err : new Error(String(err)))
133
+ }
134
+
135
+ logger.info('Standalone server started', {
136
+ host: config.host,
137
+ port: config.port,
138
+ instanceId: instance.id,
139
+ url: `http://${config.host === '0.0.0.0' ? 'localhost' : config.host}:${config.port}`,
140
+ })
141
+
142
+ const shutdown = async () => {
143
+ logger.info('Shutting down standalone server...')
144
+ if (options.onShutdown) {
145
+ await options.onShutdown()
146
+ }
147
+ await sessionManager.shutdown()
148
+ await transport.stop()
149
+ server.stop()
150
+ }
151
+
152
+ process.on('SIGINT', () => {
153
+ void shutdown().then(() => process.exit(0))
154
+ })
155
+ process.on('SIGTERM', () => {
156
+ void shutdown().then(() => process.exit(0))
157
+ })
158
+
159
+ return { config, logger, instance, port: server.port ?? config.port, sessionManager, shutdown }
160
+ }
161
+
162
+ interface WSData {
163
+ clientId: string
164
+ sessionId: string | null
165
+ subscribedSessions: Set<SessionId>
166
+ }
167
+
168
+ function startBunServer(
169
+ config: Config,
170
+ app: Hono,
171
+ serverAdapter: ServerAdapter | null,
172
+ ) {
173
+ const wsHandlers = serverAdapter
174
+ ? createBunWebSocketHandlers<WSData>({
175
+ onOpen: (ws) => serverAdapter.handleOpen(ws, ws.data.sessionId ?? undefined),
176
+ onClose: (ws, code, reason) => serverAdapter.handleClose(ws, code, reason),
177
+ onMessage: (ws, message) => serverAdapter.handleMessage(ws, message),
178
+ onError: (ws, error) => serverAdapter.handleError(ws, error),
179
+ getData: (ws) => ws.data,
180
+ })
181
+ : null
182
+
183
+ return Bun.serve<WSData>({
184
+ port: config.port,
185
+ hostname: config.host,
186
+ fetch(req, server) {
187
+ const url = new URL(req.url)
188
+ const wsMatch = url.pathname.match(/^\/api\/v1\/instances\/[^/]+\/ws$/)
189
+ if (wsMatch && serverAdapter) {
190
+ const sessionId = url.searchParams.get('sessionId')
191
+ const upgraded = server.upgrade(req, {
192
+ data: {
193
+ clientId: crypto.randomUUID(),
194
+ sessionId,
195
+ subscribedSessions: new Set<SessionId>(),
196
+ } satisfies WSData,
197
+ })
198
+ return upgraded ? undefined : new Response('WebSocket upgrade failed', { status: 400 })
199
+ }
200
+ return app.fetch(req)
201
+ },
202
+ websocket: wsHandlers ?? { open() {}, close() {}, message() {} },
203
+ })
204
+ }
205
+
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Runtime loader for the user `roj.config.ts` file.
3
+ *
4
+ * Uses dynamic `import()` — kept out of `@roj-ai/sdk` (which stays free of
5
+ * runtime-specific I/O) and out of `@roj-ai/sandbox-runtime` (Bun-specific).
6
+ */
7
+
8
+ import type { Preset, RojConfig } from '@roj-ai/sdk'
9
+ import { validatePreset } from '@roj-ai/sdk'
10
+ import { existsSync } from 'node:fs'
11
+ import { resolve } from 'node:path'
12
+
13
+ export async function loadUserConfig(configPath: string): Promise<RojConfig> {
14
+ const absolutePath = resolve(process.cwd(), configPath)
15
+
16
+ if (!existsSync(absolutePath)) {
17
+ throw new Error(`Configuration file not found: ${absolutePath}`)
18
+ }
19
+
20
+ let mod: unknown
21
+ try {
22
+ mod = await import(absolutePath)
23
+ } catch (error) {
24
+ throw new Error(
25
+ `Failed to load configuration file: ${absolutePath}\n${error instanceof Error ? error.message : String(error)}`,
26
+ )
27
+ }
28
+
29
+ const config = (mod as { default?: unknown }).default
30
+ if (!config) {
31
+ throw new Error(`Configuration file must have a default export: ${absolutePath}`)
32
+ }
33
+ if (typeof config !== 'object' || config === null) {
34
+ throw new Error(`Configuration must be an object: ${absolutePath}`)
35
+ }
36
+
37
+ const typedConfig = config as Record<string, unknown>
38
+ if (!Array.isArray(typedConfig.presets)) {
39
+ throw new Error(`Configuration must have a 'presets' array: ${absolutePath}`)
40
+ }
41
+
42
+ const presets = typedConfig.presets as Preset[]
43
+ const allErrors: string[] = []
44
+
45
+ for (const preset of presets) {
46
+ if (!preset.id || typeof preset.id !== 'string') {
47
+ allErrors.push(`Preset missing required 'id' field`)
48
+ continue
49
+ }
50
+ const errors = validatePreset(preset)
51
+ if (errors.length > 0) {
52
+ allErrors.push(`Preset '${preset.id}': ${errors.join(', ')}`)
53
+ }
54
+ }
55
+
56
+ if (allErrors.length > 0) {
57
+ throw new Error(`Preset validation errors:\n${allErrors.map(e => ` - ${e}`).join('\n')}`)
58
+ }
59
+
60
+ const ids = presets.map(p => p.id)
61
+ const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index)
62
+ if (duplicates.length > 0) {
63
+ throw new Error(`Duplicate preset IDs: ${[...new Set(duplicates)].join(', ')}`)
64
+ }
65
+
66
+ return {
67
+ presets,
68
+ sandboxed: typedConfig.sandboxed as boolean | undefined,
69
+ snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'],
70
+ }
71
+ }