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
72 changes: 71 additions & 1 deletion packages/plugins/plugin-hono-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ HTTP Server adapter for ObjectStack using Hono.
- **Fast**: Built on Hono, a high-performance web framework.
- **Full Protocol Support**: Automatically provides all ObjectStack Runtime endpoints (Auth, Data, Metadata, etc.).
- **Middleware**: Supports standard Hono middleware.
- **Wildcard CORS**: Supports wildcard patterns in CORS origins (compatible with better-auth).

## Usage

Expand All @@ -18,7 +19,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
const kernel = new ObjectKernel();

// Register the server plugin
kernel.use(new HonoServerPlugin({
kernel.use(new HonoServerPlugin({
port: 3000,
restConfig: {
api: {
Expand All @@ -30,6 +31,75 @@ kernel.use(new HonoServerPlugin({
await kernel.start();
```

## CORS Configuration

The Hono server plugin supports flexible CORS configuration with wildcard pattern matching.

### Basic CORS

```typescript
kernel.use(new HonoServerPlugin({
port: 3000,
cors: {
origins: ['https://app.example.com'],
credentials: true
}
}));
```

### Wildcard Patterns (better-auth compatible)

```typescript
// Subdomain wildcards
kernel.use(new HonoServerPlugin({
cors: {
origins: ['https://*.objectui.org', 'https://*.objectstack.ai'],
credentials: true
}
}));

// Port wildcards (useful for development)
kernel.use(new HonoServerPlugin({
cors: {
origins: 'http://localhost:*'
}
}));

// Comma-separated patterns
kernel.use(new HonoServerPlugin({
cors: {
origins: 'https://*.objectui.org,https://*.objectstack.ai,http://localhost:*'
}
}));
```

### Environment Variables

CORS can also be configured via environment variables:

```bash
# Single origin
CORS_ORIGIN=https://app.example.com

# Wildcard patterns (comma-separated)
CORS_ORIGIN=https://*.objectui.org,https://*.objectstack.ai

# Disable CORS
CORS_ENABLED=false

# Additional options
CORS_CREDENTIALS=true
CORS_MAX_AGE=86400
```

### Disable CORS

```typescript
kernel.use(new HonoServerPlugin({
cors: false // Completely disable CORS
}));
```

## Architecture

This plugin wraps `@objectstack/hono` to provide a turnkey HTTP server solution for the Runtime. It binds the standard `HttpDispatcher` to a Hono application and starts listening on the configured port.
Expand Down
123 changes: 123 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ vi.mock('./adapter', () => ({
close: vi.fn(),
getRawApp: vi.fn().mockReturnValue({
get: vi.fn(),
use: vi.fn(),
})
};
})
Expand Down Expand Up @@ -110,4 +111,126 @@ describe('HonoServerPlugin', () => {
// Should register SPA fallback middleware
expect(rawApp.get).toHaveBeenCalledWith('/*', expect.anything());
});

describe('CORS wildcard pattern matching', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should enable CORS middleware with wildcard subdomain patterns', async () => {
const plugin = new HonoServerPlugin({
cors: {
origins: ['https://*.objectui.org', 'https://*.objectstack.ai'],
credentials: true
}
});

await plugin.init(context as PluginContext);

const serverInstance = (HonoHttpServer as any).mock.instances[0];
const rawApp = serverInstance.getRawApp();

// CORS middleware should be registered
expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
});

it('should enable CORS middleware with port wildcard patterns', async () => {
const plugin = new HonoServerPlugin({
cors: {
origins: 'http://localhost:*',
}
});

await plugin.init(context as PluginContext);

const serverInstance = (HonoHttpServer as any).mock.instances[0];
const rawApp = serverInstance.getRawApp();

expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
});

it('should support comma-separated wildcard patterns', async () => {
const plugin = new HonoServerPlugin({
cors: {
origins: 'https://*.objectui.org,https://*.objectstack.ai',
}
});

await plugin.init(context as PluginContext);

const serverInstance = (HonoHttpServer as any).mock.instances[0];
const rawApp = serverInstance.getRawApp();

expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
});

it('should support exact origins without wildcards', async () => {
const plugin = new HonoServerPlugin({
cors: {
origins: ['https://app.example.com', 'https://api.example.com'],
}
});

await plugin.init(context as PluginContext);

const serverInstance = (HonoHttpServer as any).mock.instances[0];
const rawApp = serverInstance.getRawApp();

expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
});

it('should support CORS_ORIGIN environment variable with wildcards', async () => {
const originalEnv = process.env.CORS_ORIGIN;
process.env.CORS_ORIGIN = 'https://*.objectui.org,https://*.objectstack.ai';

const plugin = new HonoServerPlugin();
await plugin.init(context as PluginContext);

const serverInstance = (HonoHttpServer as any).mock.instances[0];
const rawApp = serverInstance.getRawApp();

expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));

// Restore environment
if (originalEnv !== undefined) {
process.env.CORS_ORIGIN = originalEnv;
} else {
delete process.env.CORS_ORIGIN;
}
});

it('should disable CORS when cors option is false', async () => {
const plugin = new HonoServerPlugin({
cors: false
});

await plugin.init(context as PluginContext);

const serverInstance = (HonoHttpServer as any).mock.instances[0];
const rawApp = serverInstance.getRawApp();

// CORS middleware should NOT be registered
expect(rawApp.use).not.toHaveBeenCalled();
});

it('should disable CORS when CORS_ENABLED env is false', async () => {
const originalEnv = process.env.CORS_ENABLED;
process.env.CORS_ENABLED = 'false';

const plugin = new HonoServerPlugin();
await plugin.init(context as PluginContext);

const serverInstance = (HonoHttpServer as any).mock.instances[0];
const rawApp = serverInstance.getRawApp();

expect(rawApp.use).not.toHaveBeenCalled();

// Restore environment
if (originalEnv !== undefined) {
process.env.CORS_ENABLED = originalEnv;
} else {
delete process.env.CORS_ENABLED;
}
});
});
});
77 changes: 74 additions & 3 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,62 @@ export interface HonoPluginOptions {
* - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch
* - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc.
*/
/**
* Check if an origin matches a pattern with wildcards.
* Supports patterns like:
* - "https://*.example.com" - matches any subdomain
* - "http://localhost:*" - matches any port
* - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns
*
* @param origin The origin to check (e.g., "https://app.example.com")
* @param pattern The pattern to match against (supports * wildcard)
* @returns true if origin matches the pattern
*/
function matchOriginPattern(origin: string, pattern: string): boolean {
if (pattern === '*') return true;
if (pattern === origin) return true;

// Convert wildcard pattern to regex
// Escape special regex characters except *
const regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
.replace(/\*/g, '.*'); // Convert * to .*

const regex = new RegExp(`^${regexPattern}$`);
return regex.test(origin);
}

/**
* Create a CORS origin matcher function that supports wildcard patterns.
*
* @param patterns Single pattern, array of patterns, or comma-separated patterns
* @returns Function that returns the origin if it matches, or null/undefined
*/
function createOriginMatcher(
patterns: string | string[]
): (origin: string) => string | undefined | null {
// Normalize to array
let patternList: string[];
if (typeof patterns === 'string') {
// Handle comma-separated patterns
patternList = patterns.includes(',')
? patterns.split(',').map(s => s.trim()).filter(Boolean)
: [patterns];
} else {
patternList = patterns;
}

// Return matcher function
return (requestOrigin: string) => {
for (const pattern of patternList) {
if (matchOriginPattern(requestOrigin, pattern)) {
return requestOrigin;
}
}
return null;
};
Comment on lines +100 to +122
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createOriginMatcher() compiles a new RegExp on every request for every pattern (via matchOriginPattern()), which is unnecessary overhead under load. Consider pre-compiling patterns once when building the matcher (e.g., convert patterns to RegExp instances up front) and then only running regex.test() per request.

Copilot uses AI. Check for mistakes.
}
Comment on lines +69 to +123
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matchOriginPattern()/createOriginMatcher() are embedded in hono-plugin.ts, duplicating logic and making it hard to reuse or unit test the real implementation (tests currently copy/paste their own version). Move these helpers into a dedicated module and import them here; this reduces drift risk and keeps hono-plugin.ts focused on plugin wiring.

Copilot uses AI. Check for mistakes.

export class HonoServerPlugin implements Plugin {
name = 'com.objectstack.server.hono';
type = 'server';
Expand Down Expand Up @@ -128,12 +184,27 @@ export class HonoServerPlugin implements Plugin {
const credentials = corsOpts.credentials ?? (process.env.CORS_CREDENTIALS !== 'false');
const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400);

// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
// Use a function to reflect the request's Origin header instead.
// Determine origin handler based on configuration
let origin: string | string[] | ((origin: string) => string | undefined | null);
if (credentials && configuredOrigin === '*') {

// Check if patterns contain wildcards (*, subdomain patterns, port patterns)
const hasWildcard = (patterns: string | string[]): boolean => {
const list = Array.isArray(patterns) ? patterns : [patterns];
return list.some(p => p.includes('*'));
};

// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
// For wildcard patterns (like "https://*.example.com"), always use a matcher function.
// For exact origins, we can pass them directly as string/array.
if (configuredOrigin === '*' && credentials) {
// Credentials mode with '*' - reflect the request origin
origin = (requestOrigin: string) => requestOrigin || '*';
} else if (hasWildcard(configuredOrigin)) {
// Wildcard patterns (including better-auth style patterns like "https://*.objectui.org")
// Use pattern matcher to support subdomain and port wildcards
origin = createOriginMatcher(configuredOrigin);
} else {
// Exact origin(s) - pass through as-is
origin = configuredOrigin;
}

Expand Down
Loading
Loading