Skip to content

Commit 87c06f9

Browse files
authored
Merge pull request #664 from objectstack-ai/copilot/complete-framework-adapters
2 parents 2a8cd68 + b2fdfd1 commit 87c06f9

35 files changed

Lines changed: 3513 additions & 25 deletions

ROADMAP.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -220,24 +220,21 @@ These are the backbone of ObjectStack's enterprise capabilities.
220220

221221
---
222222

223-
## Phase 5: Framework Adapters (🟡 Mostly Complete)
223+
## Phase 5: Framework Adapters ( Complete)
224224

225225
> **Goal:** First-class integration with popular web frameworks.
226226
227227
### Completed
228228

229-
- [x] **Next.js Adapter** — App Router, Auth/GraphQL/Meta/Data/Storage handlers (9/10)
229+
- [x] **Next.js Adapter** — App Router, Auth/GraphQL/Meta/Data/Storage handlers (10/10)
230230
- [x] **NestJS Adapter** — Full DI module, Express/Fastify support (10/10)
231231
- [x] **Hono Server Plugin** — Production HTTP server with static file serving
232-
233-
### Remaining
234-
235-
- [ ] **Hono Adapter** — Currently a stub (middleware only). Needs full route dispatchers for Auth/GraphQL/Meta/Data/Storage matching Next.js/NestJS completeness
236-
- [ ] **Next.js Server Actions** — Support for React Server Actions pattern
237-
- [ ] **Express Adapter** — Standalone Express integration (currently via NestJS only)
238-
- [ ] **Fastify Adapter** — Standalone Fastify integration (currently via NestJS only)
239-
- [ ] **SvelteKit Adapter** — Community request
240-
- [ ] **Nuxt Adapter** — Community request
232+
- [x] **Hono Adapter** — Full route dispatchers for Auth/GraphQL/Meta/Data/Storage with createHonoApp
233+
- [x] **Next.js Server Actions** — createServerActions with query/getById/create/update/remove/getMetadata
234+
- [x] **Express Adapter** — Standalone Express v5 router with all ObjectStack routes
235+
- [x] **Fastify Adapter** — Fastify plugin with full route dispatchers
236+
- [x] **SvelteKit Adapter** — Web-standard Request/Response based handler for SvelteKit routes
237+
- [x] **Nuxt Adapter** — h3 router integration for Nuxt server routes
241238

242239
---
243240

@@ -400,9 +397,13 @@ These are the backbone of ObjectStack's enterprise capabilities.
400397
| `@objectstack/plugin-dev` | 3.0.2 || ✅ Stable | 10/10 |
401398
| `@objectstack/plugin-hono-server` | 3.0.2 || ✅ Stable | 9/10 |
402399
| `@objectstack/plugin-msw` | 3.0.2 || ✅ Stable | 9/10 |
403-
| `@objectstack/nextjs` | 3.0.2 || ✅ Stable | 9/10 |
400+
| `@objectstack/nextjs` | 3.0.2 || ✅ Stable | 10/10 |
404401
| `@objectstack/nestjs` | 3.0.2 || ✅ Stable | 10/10 |
405-
| `@objectstack/hono` | 3.0.2 || 🔴 Stub | 2/10 |
402+
| `@objectstack/hono` | 3.0.2 || ✅ Stable | 10/10 |
403+
| `@objectstack/express` | 3.0.2 || ✅ Stable | 9/10 |
404+
| `@objectstack/fastify` | 3.0.2 || ✅ Stable | 9/10 |
405+
| `@objectstack/sveltekit` | 3.0.2 || ✅ Stable | 9/10 |
406+
| `@objectstack/nuxt` | 3.0.2 || ✅ Stable | 9/10 |
406407
| `@objectstack/types` | 3.0.2 || 🟡 Minimal | 3/10 |
407408
| `objectstack-vscode` | 0.1.0 || 🟡 Early | 4/10 |
408409
| `create-objectstack` | 3.0.0 || 🟡 Basic | 5/10 |
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# @objectstack/express
2+
3+
The official Express adapter for ObjectStack.
4+
5+
## Features
6+
- Standalone Express router integration
7+
- Full Auth/GraphQL/Metadata/Data/Storage routes
8+
- AuthPlugin service support with Web Request conversion
9+
- Middleware mode for attaching kernel to requests
10+
11+
## Usage
12+
13+
```typescript
14+
import express from 'express';
15+
import { createExpressRouter } from '@objectstack/express';
16+
17+
const app = express();
18+
app.use(express.json());
19+
app.use('/api', createExpressRouter({ kernel }));
20+
21+
app.listen(3000);
22+
```
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "@objectstack/express",
3+
"version": "3.0.2",
4+
"license": "Apache-2.0",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"exports": {
8+
".": {
9+
"types": "./dist/index.d.ts",
10+
"import": "./dist/index.mjs",
11+
"require": "./dist/index.js"
12+
}
13+
},
14+
"scripts": {
15+
"build": "tsup --config ../../../tsup.config.ts",
16+
"test": "vitest run",
17+
"test:watch": "vitest"
18+
},
19+
"peerDependencies": {
20+
"@objectstack/runtime": "workspace:*",
21+
"express": "^5.1.0"
22+
},
23+
"devDependencies": {
24+
"@objectstack/runtime": "workspace:*",
25+
"@types/express": "^5.0.3",
26+
"express": "^5.1.0",
27+
"typescript": "^5.0.0",
28+
"vitest": "^4.0.18"
29+
}
30+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Stub for @objectstack/runtime - replaced by vi.mock in tests
2+
export const HttpDispatcher = class {};
3+
export type ObjectKernel = any;
4+
export type HttpDispatcherResult = any;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
5+
// Mock dispatcher instance
6+
const mockDispatcher = {
7+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }),
8+
handleAuth: vi.fn().mockResolvedValue({ handled: true, response: { body: { ok: true }, status: 200 } }),
9+
handleGraphQL: vi.fn().mockResolvedValue({ data: {} }),
10+
handleMetadata: vi.fn().mockResolvedValue({ handled: true, response: { body: { objects: [] }, status: 200 } }),
11+
handleData: vi.fn().mockResolvedValue({ handled: true, response: { body: { records: [] }, status: 200 } }),
12+
handleStorage: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }),
13+
};
14+
15+
vi.mock('@objectstack/runtime', () => {
16+
return {
17+
HttpDispatcher: function HttpDispatcher() {
18+
return mockDispatcher;
19+
},
20+
};
21+
});
22+
23+
import { createExpressRouter, objectStackMiddleware } from './index';
24+
25+
const mockKernel = { name: 'test-kernel' } as any;
26+
27+
function createMockRes() {
28+
const res: any = {
29+
_status: 200,
30+
_body: null,
31+
_headers: {} as Record<string, string>,
32+
_redirectUrl: null as string | null,
33+
status(code: number) { res._status = code; return res; },
34+
json(body: any) { res._body = body; return res; },
35+
send(body: any) { res._body = body; return res; },
36+
set(k: string, v: string) { res._headers[k] = v; return res; },
37+
redirect(url: string) { res._redirectUrl = url; return res; },
38+
};
39+
return res;
40+
}
41+
42+
function createMockReq(overrides: any = {}) {
43+
return {
44+
method: 'GET',
45+
path: '/',
46+
url: '/',
47+
originalUrl: '/',
48+
body: {},
49+
query: {},
50+
headers: {},
51+
params: {},
52+
protocol: 'http',
53+
get: (key: string) => key === 'host' ? 'localhost' : undefined,
54+
...overrides,
55+
};
56+
}
57+
58+
describe('createExpressRouter', () => {
59+
let router: any;
60+
61+
beforeEach(() => {
62+
vi.clearAllMocks();
63+
router = createExpressRouter({ kernel: mockKernel });
64+
});
65+
66+
it('returns a router with registered routes', () => {
67+
expect(router).toBeDefined();
68+
expect(router.stack).toBeDefined();
69+
expect(router.stack.length).toBeGreaterThan(0);
70+
});
71+
});
72+
73+
describe('objectStackMiddleware', () => {
74+
it('attaches kernel to request', () => {
75+
const middleware = objectStackMiddleware(mockKernel);
76+
const req: any = {};
77+
const res = createMockRes();
78+
const next = vi.fn();
79+
80+
middleware(req, res, next);
81+
82+
expect(req.objectStack).toBe(mockKernel);
83+
expect(next).toHaveBeenCalled();
84+
});
85+
});
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { type Router, type Request, type Response, type NextFunction, Router as createRouter } from 'express';
4+
import { type ObjectKernel, HttpDispatcher, HttpDispatcherResult } from '@objectstack/runtime';
5+
6+
export interface ExpressAdapterOptions {
7+
kernel: ObjectKernel;
8+
prefix?: string;
9+
}
10+
11+
/**
12+
* Auth service interface with handleRequest method
13+
*/
14+
interface AuthService {
15+
handleRequest(request: Request): Promise<globalThis.Response>;
16+
}
17+
18+
/**
19+
* Creates an Express Router with all ObjectStack route dispatchers.
20+
* Provides Auth, GraphQL, Metadata, Data, and Storage routes.
21+
*
22+
* @example
23+
* ```ts
24+
* import express from 'express';
25+
* import { createExpressRouter } from '@objectstack/express';
26+
*
27+
* const app = express();
28+
* app.use('/api', createExpressRouter({ kernel }));
29+
* app.listen(3000);
30+
* ```
31+
*/
32+
export function createExpressRouter(options: ExpressAdapterOptions): Router {
33+
const router = createRouter();
34+
const dispatcher = new HttpDispatcher(options.kernel);
35+
const prefix = options.prefix || '/api';
36+
37+
const sendResult = (result: HttpDispatcherResult, res: Response) => {
38+
if (result.handled) {
39+
if (result.response) {
40+
if (result.response.headers) {
41+
Object.entries(result.response.headers).forEach(([k, v]) => res.set(k, v as string));
42+
}
43+
return res.status(result.response.status).json(result.response.body);
44+
}
45+
if (result.result) {
46+
const response = result.result;
47+
if (response.type === 'redirect' && response.url) {
48+
return res.redirect(response.url);
49+
}
50+
if (response.type === 'stream' && response.stream) {
51+
if (response.headers) {
52+
Object.entries(response.headers).forEach(([k, v]) => res.set(k, v as string));
53+
}
54+
response.stream.pipe(res);
55+
return;
56+
}
57+
return res.status(200).json(response);
58+
}
59+
}
60+
return res.status(404).json({ success: false, error: { message: 'Not Found', code: 404 } });
61+
};
62+
63+
const errorResponse = (err: any, res: Response) => {
64+
return res.status(err.statusCode || 500).json({
65+
success: false,
66+
error: {
67+
message: err.message || 'Internal Server Error',
68+
code: err.statusCode || 500,
69+
},
70+
});
71+
};
72+
73+
// --- Discovery ---
74+
router.get('/', (_req: Request, res: Response) => {
75+
res.json({ data: dispatcher.getDiscoveryInfo(prefix) });
76+
});
77+
78+
// --- Auth ---
79+
router.all('/auth/{*path}', async (req: Request, res: Response) => {
80+
try {
81+
const path = (req.params as any).path;
82+
const method = req.method;
83+
84+
// Try AuthPlugin service first
85+
const authService = typeof options.kernel.getService === 'function'
86+
? options.kernel.getService<AuthService>('auth')
87+
: null;
88+
89+
if (authService && typeof authService.handleRequest === 'function') {
90+
const protocol = req.protocol || 'http';
91+
const host = req.get?.('host') || req.headers?.host || 'localhost';
92+
const url = `${protocol}://${host}${req.originalUrl || req.url}`;
93+
const headers = new Headers();
94+
if (req.headers) {
95+
Object.entries(req.headers).forEach(([k, v]) => {
96+
if (typeof v === 'string') headers.set(k, v);
97+
else if (Array.isArray(v)) headers.set(k, v.join(', '));
98+
});
99+
}
100+
const init: RequestInit = { method, headers };
101+
if (method !== 'GET' && method !== 'HEAD' && req.body) {
102+
init.body = JSON.stringify(req.body);
103+
if (!headers.has('content-type')) {
104+
headers.set('content-type', 'application/json');
105+
}
106+
}
107+
const webRequest = new Request(url, init);
108+
const response = await authService.handleRequest(webRequest);
109+
res.status(response.status);
110+
response.headers.forEach((v: string, k: string) => res.set(k, v));
111+
const text = await response.text();
112+
return res.send(text);
113+
}
114+
115+
// Fallback to dispatcher
116+
const body = method === 'GET' || method === 'HEAD' ? {} : req.body || {};
117+
const result = await dispatcher.handleAuth(path, method, body, { request: req, response: res });
118+
return sendResult(result, res);
119+
} catch (err: any) {
120+
return errorResponse(err, res);
121+
}
122+
});
123+
124+
// --- GraphQL ---
125+
router.post('/graphql', async (req: Request, res: Response) => {
126+
try {
127+
const result = await dispatcher.handleGraphQL(req.body, { request: req });
128+
return res.json(result);
129+
} catch (err: any) {
130+
return errorResponse(err, res);
131+
}
132+
});
133+
134+
// --- Metadata ---
135+
router.all('/meta/{*path}', async (req: Request, res: Response) => {
136+
try {
137+
const subPath = '/' + (req.params as any).path;
138+
const method = req.method;
139+
const body = (method === 'PUT' || method === 'POST') ? req.body : undefined;
140+
const result = await dispatcher.handleMetadata(subPath, { request: req }, method, body);
141+
return sendResult(result, res);
142+
} catch (err: any) {
143+
return errorResponse(err, res);
144+
}
145+
});
146+
147+
router.all('/meta', async (req: Request, res: Response) => {
148+
try {
149+
const method = req.method;
150+
const body = (method === 'PUT' || method === 'POST') ? req.body : undefined;
151+
const result = await dispatcher.handleMetadata('', { request: req }, method, body);
152+
return sendResult(result, res);
153+
} catch (err: any) {
154+
return errorResponse(err, res);
155+
}
156+
});
157+
158+
// --- Data ---
159+
router.all('/data/{*path}', async (req: Request, res: Response) => {
160+
try {
161+
const subPath = '/' + (req.params as any).path;
162+
const method = req.method;
163+
const body = (method === 'POST' || method === 'PATCH') ? req.body : {};
164+
const result = await dispatcher.handleData(subPath, method, body, req.query, { request: req });
165+
return sendResult(result, res);
166+
} catch (err: any) {
167+
return errorResponse(err, res);
168+
}
169+
});
170+
171+
// --- Storage ---
172+
router.all('/storage/{*path}', async (req: Request, res: Response) => {
173+
try {
174+
const subPath = '/' + (req.params as any).path;
175+
const method = req.method;
176+
const file = (req as any).file || (req as any).files?.file;
177+
const result = await dispatcher.handleStorage(subPath, method, file, { request: req });
178+
return sendResult(result, res);
179+
} catch (err: any) {
180+
return errorResponse(err, res);
181+
}
182+
});
183+
184+
return router;
185+
}
186+
187+
/**
188+
* Middleware that attaches the ObjectStack kernel to the request.
189+
*/
190+
export function objectStackMiddleware(kernel: ObjectKernel) {
191+
return (req: Request, _res: Response, next: NextFunction) => {
192+
(req as any).objectStack = kernel;
193+
next();
194+
};
195+
}

0 commit comments

Comments
 (0)