Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,24 +220,21 @@ These are the backbone of ObjectStack's enterprise capabilities.

---

## Phase 5: Framework Adapters (🟡 Mostly Complete)
## Phase 5: Framework Adapters ( Complete)

> **Goal:** First-class integration with popular web frameworks.

### Completed

- [x] **Next.js Adapter** — App Router, Auth/GraphQL/Meta/Data/Storage handlers (9/10)
- [x] **Next.js Adapter** — App Router, Auth/GraphQL/Meta/Data/Storage handlers (10/10)
- [x] **NestJS Adapter** — Full DI module, Express/Fastify support (10/10)
- [x] **Hono Server Plugin** — Production HTTP server with static file serving

### Remaining

- [ ] **Hono Adapter** — Currently a stub (middleware only). Needs full route dispatchers for Auth/GraphQL/Meta/Data/Storage matching Next.js/NestJS completeness
- [ ] **Next.js Server Actions** — Support for React Server Actions pattern
- [ ] **Express Adapter** — Standalone Express integration (currently via NestJS only)
- [ ] **Fastify Adapter** — Standalone Fastify integration (currently via NestJS only)
- [ ] **SvelteKit Adapter** — Community request
- [ ] **Nuxt Adapter** — Community request
- [x] **Hono Adapter** — Full route dispatchers for Auth/GraphQL/Meta/Data/Storage with createHonoApp
- [x] **Next.js Server Actions** — createServerActions with query/getById/create/update/remove/getMetadata
- [x] **Express Adapter** — Standalone Express v5 router with all ObjectStack routes
- [x] **Fastify Adapter** — Fastify plugin with full route dispatchers
- [x] **SvelteKit Adapter** — Web-standard Request/Response based handler for SvelteKit routes
- [x] **Nuxt Adapter** — h3 router integration for Nuxt server routes

---

Expand Down Expand Up @@ -400,9 +397,13 @@ These are the backbone of ObjectStack's enterprise capabilities.
| `@objectstack/plugin-dev` | 3.0.2 | — | ✅ Stable | 10/10 |
| `@objectstack/plugin-hono-server` | 3.0.2 | — | ✅ Stable | 9/10 |
| `@objectstack/plugin-msw` | 3.0.2 | — | ✅ Stable | 9/10 |
| `@objectstack/nextjs` | 3.0.2 | ✅ | ✅ Stable | 9/10 |
| `@objectstack/nextjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 |
| `@objectstack/nestjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 |
| `@objectstack/hono` | 3.0.2 | ✅ | 🔴 Stub | 2/10 |
| `@objectstack/hono` | 3.0.2 | ✅ | ✅ Stable | 10/10 |
| `@objectstack/express` | 3.0.2 | ✅ | ✅ Stable | 9/10 |
| `@objectstack/fastify` | 3.0.2 | ✅ | ✅ Stable | 9/10 |
| `@objectstack/sveltekit` | 3.0.2 | ✅ | ✅ Stable | 9/10 |
| `@objectstack/nuxt` | 3.0.2 | ✅ | ✅ Stable | 9/10 |
| `@objectstack/types` | 3.0.2 | — | 🟡 Minimal | 3/10 |
| `objectstack-vscode` | 0.1.0 | — | 🟡 Early | 4/10 |
| `create-objectstack` | 3.0.0 | — | 🟡 Basic | 5/10 |
Expand Down
22 changes: 22 additions & 0 deletions packages/adapters/express/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# @objectstack/express

The official Express adapter for ObjectStack.

## Features
- Standalone Express router integration
- Full Auth/GraphQL/Metadata/Data/Storage routes
- AuthPlugin service support with Web Request conversion
- Middleware mode for attaching kernel to requests

## Usage

```typescript
import express from 'express';
import { createExpressRouter } from '@objectstack/express';

const app = express();
app.use(express.json());
app.use('/api', createExpressRouter({ kernel }));

app.listen(3000);
```
30 changes: 30 additions & 0 deletions packages/adapters/express/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@objectstack/express",
"version": "3.0.2",
"license": "Apache-2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup --config ../../../tsup.config.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@objectstack/runtime": "workspace:*",
"express": "^5.1.0"
},
"devDependencies": {
"@objectstack/runtime": "workspace:*",
"@types/express": "^5.0.3",
"express": "^5.1.0",
"typescript": "^5.0.0",
"vitest": "^4.0.18"
}
}
4 changes: 4 additions & 0 deletions packages/adapters/express/src/__mocks__/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Stub for @objectstack/runtime - replaced by vi.mock in tests
export const HttpDispatcher = class {};
export type ObjectKernel = any;
export type HttpDispatcherResult = any;
85 changes: 85 additions & 0 deletions packages/adapters/express/src/express.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock dispatcher instance
const mockDispatcher = {
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }),
handleAuth: vi.fn().mockResolvedValue({ handled: true, response: { body: { ok: true }, status: 200 } }),
handleGraphQL: vi.fn().mockResolvedValue({ data: {} }),
handleMetadata: vi.fn().mockResolvedValue({ handled: true, response: { body: { objects: [] }, status: 200 } }),
handleData: vi.fn().mockResolvedValue({ handled: true, response: { body: { records: [] }, status: 200 } }),
handleStorage: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }),
};

vi.mock('@objectstack/runtime', () => {
return {
HttpDispatcher: function HttpDispatcher() {
return mockDispatcher;
},
};
});

import { createExpressRouter, objectStackMiddleware } from './index';

const mockKernel = { name: 'test-kernel' } as any;

function createMockRes() {
const res: any = {
_status: 200,
_body: null,
_headers: {} as Record<string, string>,
_redirectUrl: null as string | null,
status(code: number) { res._status = code; return res; },
json(body: any) { res._body = body; return res; },
send(body: any) { res._body = body; return res; },
set(k: string, v: string) { res._headers[k] = v; return res; },
redirect(url: string) { res._redirectUrl = url; return res; },
};
return res;
}

function createMockReq(overrides: any = {}) {
return {
method: 'GET',
path: '/',
url: '/',
originalUrl: '/',
body: {},
query: {},
headers: {},
params: {},
protocol: 'http',
get: (key: string) => key === 'host' ? 'localhost' : undefined,
...overrides,
};
}

describe('createExpressRouter', () => {
let router: any;

beforeEach(() => {
vi.clearAllMocks();
router = createExpressRouter({ kernel: mockKernel });
});

it('returns a router with registered routes', () => {
expect(router).toBeDefined();
expect(router.stack).toBeDefined();
expect(router.stack.length).toBeGreaterThan(0);
});
});

describe('objectStackMiddleware', () => {
it('attaches kernel to request', () => {
const middleware = objectStackMiddleware(mockKernel);
const req: any = {};
const res = createMockRes();
const next = vi.fn();

middleware(req, res, next);

expect(req.objectStack).toBe(mockKernel);
expect(next).toHaveBeenCalled();
});
});
195 changes: 195 additions & 0 deletions packages/adapters/express/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { type Router, type Request, type Response, type NextFunction, Router as createRouter } from 'express';
import { type ObjectKernel, HttpDispatcher, HttpDispatcherResult } from '@objectstack/runtime';

export interface ExpressAdapterOptions {
kernel: ObjectKernel;
prefix?: string;
}

/**
* Auth service interface with handleRequest method
*/
interface AuthService {
handleRequest(request: Request): Promise<globalThis.Response>;
}

/**
* Creates an Express Router with all ObjectStack route dispatchers.
* Provides Auth, GraphQL, Metadata, Data, and Storage routes.
*
* @example
* ```ts
* import express from 'express';
* import { createExpressRouter } from '@objectstack/express';
*
* const app = express();
* app.use('/api', createExpressRouter({ kernel }));
* app.listen(3000);
* ```
*/
export function createExpressRouter(options: ExpressAdapterOptions): Router {
const router = createRouter();
const dispatcher = new HttpDispatcher(options.kernel);
const prefix = options.prefix || '/api';

const sendResult = (result: HttpDispatcherResult, res: Response) => {
if (result.handled) {
if (result.response) {
if (result.response.headers) {
Object.entries(result.response.headers).forEach(([k, v]) => res.set(k, v as string));
}
return res.status(result.response.status).json(result.response.body);
}
if (result.result) {
const response = result.result;
if (response.type === 'redirect' && response.url) {
return res.redirect(response.url);
}
if (response.type === 'stream' && response.stream) {
if (response.headers) {
Object.entries(response.headers).forEach(([k, v]) => res.set(k, v as string));
}
response.stream.pipe(res);
return;
}
return res.status(200).json(response);
}
}
return res.status(404).json({ success: false, error: { message: 'Not Found', code: 404 } });
};

const errorResponse = (err: any, res: Response) => {
return res.status(err.statusCode || 500).json({
success: false,
error: {
message: err.message || 'Internal Server Error',
code: err.statusCode || 500,
},
});
};

// --- Discovery ---
router.get('/', (_req: Request, res: Response) => {
res.json({ data: dispatcher.getDiscoveryInfo(prefix) });
});

// --- Auth ---
router.all('/auth/{*path}', async (req: Request, res: Response) => {
try {
const path = (req.params as any).path;
const method = req.method;

// Try AuthPlugin service first
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService<AuthService>('auth')
: null;

if (authService && typeof authService.handleRequest === 'function') {
const protocol = req.protocol || 'http';
const host = req.get?.('host') || req.headers?.host || 'localhost';
const url = `${protocol}://${host}${req.originalUrl || req.url}`;
const headers = new Headers();
if (req.headers) {
Object.entries(req.headers).forEach(([k, v]) => {
if (typeof v === 'string') headers.set(k, v);
else if (Array.isArray(v)) headers.set(k, v.join(', '));
});
}
const init: RequestInit = { method, headers };
if (method !== 'GET' && method !== 'HEAD' && req.body) {
init.body = JSON.stringify(req.body);
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
}
const webRequest = new Request(url, init);
const response = await authService.handleRequest(webRequest);
res.status(response.status);
response.headers.forEach((v: string, k: string) => res.set(k, v));
const text = await response.text();
return res.send(text);
}

// Fallback to dispatcher
const body = method === 'GET' || method === 'HEAD' ? {} : req.body || {};
const result = await dispatcher.handleAuth(path, method, body, { request: req, response: res });
return sendResult(result, res);
} catch (err: any) {
return errorResponse(err, res);
}
});

// --- GraphQL ---
router.post('/graphql', async (req: Request, res: Response) => {
try {
const result = await dispatcher.handleGraphQL(req.body, { request: req });
return res.json(result);
} catch (err: any) {
return errorResponse(err, res);
}
});

// --- Metadata ---
router.all('/meta/{*path}', async (req: Request, res: Response) => {
try {
const subPath = '/' + (req.params as any).path;
const method = req.method;
const body = (method === 'PUT' || method === 'POST') ? req.body : undefined;
const result = await dispatcher.handleMetadata(subPath, { request: req }, method, body);
return sendResult(result, res);
} catch (err: any) {
return errorResponse(err, res);
}
});

router.all('/meta', async (req: Request, res: Response) => {
try {
const method = req.method;
const body = (method === 'PUT' || method === 'POST') ? req.body : undefined;
const result = await dispatcher.handleMetadata('', { request: req }, method, body);
return sendResult(result, res);
} catch (err: any) {
return errorResponse(err, res);
}
});

// --- Data ---
router.all('/data/{*path}', async (req: Request, res: Response) => {
try {
const subPath = '/' + (req.params as any).path;
const method = req.method;
const body = (method === 'POST' || method === 'PATCH') ? req.body : {};
const result = await dispatcher.handleData(subPath, method, body, req.query, { request: req });
return sendResult(result, res);
} catch (err: any) {
return errorResponse(err, res);
}
});

// --- Storage ---
router.all('/storage/{*path}', async (req: Request, res: Response) => {
try {
const subPath = '/' + (req.params as any).path;
const method = req.method;
const file = (req as any).file || (req as any).files?.file;
const result = await dispatcher.handleStorage(subPath, method, file, { request: req });
return sendResult(result, res);
} catch (err: any) {
return errorResponse(err, res);
}
});

return router;
}

/**
* Middleware that attaches the ObjectStack kernel to the request.
*/
export function objectStackMiddleware(kernel: ObjectKernel) {
return (req: Request, _res: Response, next: NextFunction) => {
(req as any).objectStack = kernel;
next();
};
}
Loading
Loading