Skip to content

Commit 19fcee1

Browse files
committed
feat(cloud): implement ObjectOS Cloud Runtime mode with artifact API integration
- Added `ArtifactApiClient` for communication with the control plane to resolve hostnames and fetch project artifacts. - Introduced `ArtifactEnvironmentRegistry` to manage environment drivers without a local control-plane database. - Created `ArtifactKernelFactory` to bootstrap project kernels using artifacts fetched from the control plane. - Implemented `createObjectOSStack` to initialize the ObjectOS runtime with control-plane configuration. - Updated `objectstack.config.ts` to support new control plane URL and API key configurations. - Enhanced `ProjectStackConfigSchema` to include control-plane URL and API key options. - Added tests for `ArtifactApiClient` and `ArtifactEnvironmentRegistry` to ensure correct functionality and error handling.
1 parent e306bc4 commit 19fcee1

10 files changed

Lines changed: 970 additions & 0 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **ObjectOS Cloud Runtime mode** — `apps/server` (and any host using `createBootStack`) can now run as a pure runtime that fetches per-project artifacts from a remote control plane over HTTP, with no embedded control-plane database. Set `OBJECTSTACK_CONTROL_PLANE_URL=https://cp.example.com` (and optional `OBJECTSTACK_CONTROL_PLANE_API_KEY`) and the `project` boot mode delegates to the new `createObjectOSStack()` instead of `createCloudStack()`. New building blocks in `@objectstack/service-cloud`: `ArtifactApiClient` (TTL-cached HTTP client for `GET /api/v1/cloud/resolve-hostname` and `GET /api/v1/cloud/projects/:id/artifact`), `ArtifactEnvironmentRegistry` (replaces `DefaultEnvironmentDriverRegistry`, resolves hostnames via the control-plane API and falls back to the artifact's default datasource when no runtime block is supplied), and `ArtifactKernelFactory` (boots a kernel directly from `artifact.metadata` with `DriverPlugin + ObjectQLPlugin + MetadataPlugin + AppPlugin`, no `ControlPlaneProxyDriver`). `apps/server/objectstack.config.ts` forwards the new `controlPlaneUrl` / `controlPlaneApiKey` knobs through the project block. Closes the "Artifact API loader + local cache durability" item under §7 Missing in the North Star.
12+
1013
### Changed
1114
- **`OBJECTSTACK_MODE` redefined into three values** — Boot-mode selection now accepts `project` (default), `cloud`, and `standalone`. The previous semantics — where `standalone` meant "single-project local dev with full Auth + Studio" — moved under `project`. The new `standalone` value is **runtime-only**: ObjectQL + REST + Driver, no Auth, no control plane, no Studio data. Designed for embedding ObjectStack in other frameworks. Aliases `local` / `single-project` continue to map to `project`; `multi-project` continues to map to `cloud`. Default also changed: an unset `OBJECTSTACK_MODE` now resolves to `project` (was: `standalone`).
1215
- **Migration:** users running with `OBJECTSTACK_MODE=standalone` and expecting Auth/Studio should switch to `OBJECTSTACK_MODE=project` (or unset it).

apps/server/objectstack.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ const config = await createBootStack({
3939
dataDir,
4040
artifactPath: localArtifactPath,
4141
appBundles: createFsAppBundleResolver(),
42+
controlPlaneUrl: process.env.OBJECTSTACK_CONTROL_PLANE_URL,
43+
controlPlaneApiKey: process.env.OBJECTSTACK_CONTROL_PLANE_API_KEY,
4244
},
4345
standalone: {
4446
artifactPath: localArtifactPath,
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Artifact API client.
5+
*
6+
* HTTP client that talks to the ObjectStack control plane (e.g.
7+
* `apps/cloud`) to resolve hostnames to projects and to download a
8+
* project's compiled artifact.
9+
*
10+
* The control plane is expected to expose two endpoints:
11+
*
12+
* GET {controlPlaneUrl}/api/v1/cloud/resolve-hostname?host={hostname}
13+
* → { projectId: string, organizationId?: string, runtime?: ProjectRuntimeConfig }
14+
*
15+
* GET {controlPlaneUrl}/api/v1/cloud/projects/:projectId/artifact
16+
* → ProjectArtifactResponse (ProjectArtifact + optional `runtime` block)
17+
*
18+
* Both endpoints accept an optional `Authorization: Bearer <apiKey>`.
19+
*
20+
* Responses are cached in-memory with a TTL so each kernel-manager
21+
* miss does not produce an extra HTTP round trip. Concurrent callers
22+
* for the same key share a single in-flight promise (singleflight).
23+
*/
24+
25+
import type { ProjectArtifact } from '@objectstack/spec/cloud';
26+
27+
/**
28+
* Per-project runtime config injected by the control plane alongside
29+
* the artifact. Carries the physical database URL the runtime should
30+
* connect to (this is *not* part of the developer-authored compiled
31+
* artifact — the control plane mints it when serving the API).
32+
*/
33+
export interface ProjectRuntimeConfig {
34+
organizationId?: string;
35+
hostname?: string;
36+
/** Driver type — e.g. `sqlite`, `postgres`, `turso`, `memory`. */
37+
databaseDriver: string;
38+
/** Driver-specific connection URL. */
39+
databaseUrl: string;
40+
/** Optional auth token (e.g. for libSQL/Turso). */
41+
databaseAuthToken?: string;
42+
}
43+
44+
/**
45+
* Hostname resolution response.
46+
*/
47+
export interface ResolvedHostname {
48+
projectId: string;
49+
organizationId?: string;
50+
/** Optional runtime config — when present, callers can skip the artifact fetch's runtime block. */
51+
runtime?: ProjectRuntimeConfig;
52+
}
53+
54+
/**
55+
* Artifact response wrapping the spec's `ProjectArtifact` envelope plus
56+
* an optional `runtime` block carrying the project's database
57+
* connection details.
58+
*/
59+
export interface ProjectArtifactResponse extends ProjectArtifact {
60+
runtime?: ProjectRuntimeConfig;
61+
}
62+
63+
export interface ArtifactApiClientConfig {
64+
/** Control-plane base URL (no trailing slash). */
65+
controlPlaneUrl: string;
66+
/** Optional bearer token. */
67+
apiKey?: string;
68+
/** Cache TTL in ms. Default: 5 min. */
69+
cacheTtlMs?: number;
70+
/** Timeout for control-plane HTTP calls in ms. Default: 10s. */
71+
requestTimeoutMs?: number;
72+
/** Optional fetch override (testing). */
73+
fetch?: typeof fetch;
74+
/** Optional logger. */
75+
logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void };
76+
}
77+
78+
interface CacheEntry<T> {
79+
value: T;
80+
expiresAt: number;
81+
}
82+
83+
export class ArtifactApiClient {
84+
private readonly base: string;
85+
private readonly apiKey?: string;
86+
private readonly cacheTtlMs: number;
87+
private readonly requestTimeoutMs: number;
88+
private readonly fetchImpl: typeof fetch;
89+
private readonly logger: NonNullable<ArtifactApiClientConfig['logger']>;
90+
91+
private readonly hostnameCache = new Map<string, CacheEntry<ResolvedHostname>>();
92+
private readonly artifactCache = new Map<string, CacheEntry<ProjectArtifactResponse>>();
93+
private readonly pendingHostname = new Map<string, Promise<ResolvedHostname | null>>();
94+
private readonly pendingArtifact = new Map<string, Promise<ProjectArtifactResponse | null>>();
95+
96+
constructor(config: ArtifactApiClientConfig) {
97+
if (!config.controlPlaneUrl) {
98+
throw new Error('[ArtifactApiClient] controlPlaneUrl is required');
99+
}
100+
this.base = config.controlPlaneUrl.replace(/\/+$/, '');
101+
this.apiKey = config.apiKey;
102+
this.cacheTtlMs = config.cacheTtlMs ?? 5 * 60 * 1000;
103+
this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000;
104+
this.fetchImpl = config.fetch ?? globalThis.fetch;
105+
this.logger = config.logger ?? console;
106+
if (typeof this.fetchImpl !== 'function') {
107+
throw new Error('[ArtifactApiClient] global fetch is not available — provide config.fetch');
108+
}
109+
}
110+
111+
/**
112+
* Resolve a hostname to its project. Returns `null` on 404 or
113+
* malformed responses. Errors (network / 5xx) are thrown so
114+
* upstream callers can retry.
115+
*/
116+
async resolveHostname(host: string): Promise<ResolvedHostname | null> {
117+
const cached = this.hostnameCache.get(host);
118+
if (cached && cached.expiresAt > Date.now()) return cached.value;
119+
120+
const inflight = this.pendingHostname.get(host);
121+
if (inflight) return inflight;
122+
123+
const promise = (async () => {
124+
try {
125+
const url = `${this.base}/api/v1/cloud/resolve-hostname?host=${encodeURIComponent(host)}`;
126+
const res = await this.request(url);
127+
if (res === null) return null;
128+
const body = res.success === false ? null : (res.data ?? res);
129+
if (!body || typeof body.projectId !== 'string' || !body.projectId) return null;
130+
const value: ResolvedHostname = {
131+
projectId: body.projectId,
132+
organizationId: body.organizationId,
133+
runtime: body.runtime,
134+
};
135+
this.hostnameCache.set(host, { value, expiresAt: Date.now() + this.cacheTtlMs });
136+
return value;
137+
} finally {
138+
this.pendingHostname.delete(host);
139+
}
140+
})();
141+
this.pendingHostname.set(host, promise);
142+
return promise;
143+
}
144+
145+
/**
146+
* Fetch the compiled artifact for a project.
147+
*/
148+
async fetchArtifact(projectId: string): Promise<ProjectArtifactResponse | null> {
149+
const cached = this.artifactCache.get(projectId);
150+
if (cached && cached.expiresAt > Date.now()) return cached.value;
151+
152+
const inflight = this.pendingArtifact.get(projectId);
153+
if (inflight) return inflight;
154+
155+
const promise = (async () => {
156+
try {
157+
const url = `${this.base}/api/v1/cloud/projects/${encodeURIComponent(projectId)}/artifact`;
158+
const res = await this.request(url);
159+
if (res === null) return null;
160+
const body = res.success === false ? null : (res.data ?? res);
161+
if (!body || typeof body !== 'object') return null;
162+
if (!body.metadata) {
163+
this.logger.warn?.('[ArtifactApiClient] artifact response missing `metadata`', { projectId });
164+
return null;
165+
}
166+
const value = body as ProjectArtifactResponse;
167+
this.artifactCache.set(projectId, { value, expiresAt: Date.now() + this.cacheTtlMs });
168+
return value;
169+
} finally {
170+
this.pendingArtifact.delete(projectId);
171+
}
172+
})();
173+
this.pendingArtifact.set(projectId, promise);
174+
return promise;
175+
}
176+
177+
/** Drop cached entries for a project (and any matching hostname). */
178+
invalidate(projectId: string): void {
179+
this.artifactCache.delete(projectId);
180+
for (const [host, entry] of this.hostnameCache) {
181+
if (entry.value.projectId === projectId) this.hostnameCache.delete(host);
182+
}
183+
}
184+
185+
/** Drop everything. Used on shutdown / hot-reload. */
186+
clear(): void {
187+
this.hostnameCache.clear();
188+
this.artifactCache.clear();
189+
}
190+
191+
private async request(url: string): Promise<any> {
192+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
193+
const timer = controller ? setTimeout(() => controller.abort(), this.requestTimeoutMs) : null;
194+
try {
195+
const res = await this.fetchImpl(url, {
196+
method: 'GET',
197+
headers: this.buildHeaders(),
198+
signal: controller?.signal,
199+
});
200+
if (res.status === 404) return null;
201+
if (!res.ok) {
202+
throw new Error(`[ArtifactApiClient] ${url} → HTTP ${res.status}`);
203+
}
204+
return await res.json();
205+
} finally {
206+
if (timer) clearTimeout(timer);
207+
}
208+
}
209+
210+
private buildHeaders(): Record<string, string> {
211+
const headers: Record<string, string> = {
212+
'accept': 'application/json',
213+
'user-agent': 'objectos-runtime',
214+
};
215+
if (this.apiKey) headers['authorization'] = `Bearer ${this.apiKey}`;
216+
return headers;
217+
}
218+
}

0 commit comments

Comments
 (0)