Skip to content

Commit 808bade

Browse files
authored
Merge pull request #905 from objectstack-ai/copilot/fix-pnpm-dev-start-issue
2 parents 473e77d + 2600467 commit 808bade

7 files changed

Lines changed: 173 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- **AppPlugin getService crash on missing services**`AppPlugin.start()` and
12+
`loadTranslations()` now wrap `ctx.getService()` in try/catch, since the kernel's
13+
`getService` throws when a service is not registered (rather than returning `undefined`).
14+
This was the direct cause of `plugin.app.com.example.crm failed to start` — the i18n
15+
service was not registered, so `getService('i18n')` threw an unhandled exception.
16+
- **CLI serve: host config AppPlugin mis-wrap**`serve.ts` no longer wraps a host/aggregator config
17+
(one that already contains instantiated plugins in its `plugins` array) with an extra `AppPlugin`.
18+
This prevents the `plugin.app.dev-workspace failed to start` error and eliminates duplicate plugin
19+
registration when running `pnpm dev`.
20+
- **plugin-auth CJS→ESM interop** — Added `module` and `exports` fields to
21+
`@objectstack/plugin-auth` package.json so Node.js resolves the ESM build (`.mjs`) when using
22+
dynamic `import()`, eliminating the `ExperimentalWarning: CommonJS module … is loading ES Module`
23+
warning caused by `better-auth` being ESM-only.
1124
- **i18n service registration & state inconsistency** — Discovery API (`getDiscoveryInfo`) now uses
1225
the same async `resolveService()` fallback chain that request handlers (`handleI18n`) use, ensuring
1326
the reported service status is always consistent with actual runtime availability.

packages/cli/src/commands/serve.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import net from 'net';
77
import chalk from 'chalk';
88
import { bundleRequire } from 'bundle-require';
99
import { loadConfig } from '../utils/config.js';
10+
import { isHostConfig } from '../utils/plugin-detection.js';
1011
import {
1112
printHeader,
1213
printKV,
@@ -190,7 +191,10 @@ export default class Serve extends Command {
190191
}
191192

192193
// 3. Auto-register AppPlugin if config contains app definitions
193-
if (config.objects || config.manifest || config.apps) {
194+
// Skip if config is a host/aggregator config that already contains
195+
// instantiated plugins — wrapping it would cause duplicate registration
196+
// and startup failures (e.g. plugin.app.dev-workspace).
197+
if (!isHostConfig(config) && (config.objects || config.manifest || config.apps)) {
194198
try {
195199
const { AppPlugin } = await import('@objectstack/runtime');
196200
await kernel.use(new AppPlugin(config));
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Detect whether a loaded config is a host/aggregator configuration.
5+
*
6+
* A host config already contains a `plugins` array with instantiated Plugin
7+
* objects (i.e. objects that have an `init` method). Such configs must NOT
8+
* be wrapped again by AppPlugin in the CLI, as that would cause duplicate
9+
* plugin registration and startup failures.
10+
*/
11+
export function isHostConfig(config: any): boolean {
12+
return (
13+
Array.isArray(config.plugins) &&
14+
config.plugins.some((p: any) => typeof p?.init === 'function')
15+
);
16+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { isHostConfig } from '../src/utils/plugin-detection';
3+
4+
/**
5+
* Tests for the host config detection logic used in serve.ts
6+
*
7+
* When the root config is a host/aggregator (i.e. its `plugins` array
8+
* contains already-instantiated Plugin objects), the CLI must NOT wrap
9+
* it again with AppPlugin, as that would cause duplicate registration
10+
* and a `plugin.app.<id> failed to start` error.
11+
*/
12+
describe('Host config detection', () => {
13+
14+
it('should detect host config with instantiated plugins', () => {
15+
const config = {
16+
manifest: { id: 'dev-workspace', name: 'dev_workspace' },
17+
plugins: [
18+
{ name: 'objectql', init: async () => {}, start: async () => {} },
19+
{ name: 'driver', init: async () => {}, start: async () => {} },
20+
],
21+
};
22+
expect(isHostConfig(config)).toBe(true);
23+
});
24+
25+
it('should NOT detect pure app bundle config (no plugins)', () => {
26+
const config = {
27+
manifest: { id: 'my-app', name: 'my_app' },
28+
objects: [{ name: 'task', fields: [] }],
29+
};
30+
expect(isHostConfig(config)).toBe(false);
31+
});
32+
33+
it('should NOT detect config with empty plugins array', () => {
34+
const config = {
35+
manifest: { id: 'my-app', name: 'my_app' },
36+
objects: [{ name: 'task', fields: [] }],
37+
plugins: [],
38+
};
39+
expect(isHostConfig(config)).toBe(false);
40+
});
41+
42+
it('should NOT detect config with string plugin references', () => {
43+
const config = {
44+
manifest: { id: 'my-app', name: 'my_app' },
45+
plugins: ['@objectstack/plugin-auth', '@objectstack/objectql'],
46+
};
47+
expect(isHostConfig(config)).toBe(false);
48+
});
49+
50+
it('should NOT detect config with plain object plugins (no init method)', () => {
51+
const config = {
52+
manifest: { id: 'my-app', name: 'my_app' },
53+
plugins: [
54+
{ name: 'some-plugin', version: '1.0.0' },
55+
],
56+
};
57+
expect(isHostConfig(config)).toBe(false);
58+
});
59+
60+
it('should detect if at least one plugin has init method', () => {
61+
const config = {
62+
manifest: { id: 'dev-workspace' },
63+
plugins: [
64+
{ name: 'plain-bundle', version: '1.0.0' },
65+
{ name: 'real-plugin', init: async () => {}, start: async () => {} },
66+
],
67+
};
68+
expect(isHostConfig(config)).toBe(true);
69+
});
70+
71+
it('should handle config without plugins property', () => {
72+
const config = {
73+
manifest: { id: 'my-app', name: 'my_app' },
74+
};
75+
expect(isHostConfig(config)).toBe(false);
76+
});
77+
});

packages/plugins/plugin-auth/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
"license": "Apache-2.0",
55
"description": "Authentication & Identity Plugin for ObjectStack",
66
"main": "dist/index.js",
7+
"module": "dist/index.mjs",
78
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js",
13+
"types": "./dist/index.d.ts"
14+
}
15+
},
816
"scripts": {
917
"build": "tsup --config ../../../tsup.config.ts",
1018
"test": "vitest run",

packages/runtime/src/app-plugin.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ describe('AppPlugin', () => {
100100
);
101101
});
102102

103+
it('start should handle getService throwing for objectql', async () => {
104+
const bundle = { id: 'com.test.throw' };
105+
const plugin = new AppPlugin(bundle);
106+
107+
vi.mocked(mockContext.getService).mockImplementation(() => {
108+
throw new Error("[Kernel] Service 'objectql' not found");
109+
});
110+
111+
await plugin.start!(mockContext);
112+
113+
expect(mockContext.logger.warn).toHaveBeenCalledWith(
114+
expect.stringContaining('ObjectQL engine service not found'),
115+
expect.any(Object)
116+
);
117+
});
118+
103119
// ═══════════════════════════════════════════════════════════════
104120
// i18n translation auto-loading
105121
// ═══════════════════════════════════════════════════════════════
@@ -173,6 +189,26 @@ describe('AppPlugin', () => {
173189
);
174190
});
175191

192+
it('should skip translation loading when getService throws for i18n', async () => {
193+
vi.mocked(mockContext.getService).mockImplementation((name: string) => {
194+
if (name === 'objectql') return mockQL;
195+
throw new Error("[Kernel] Service 'i18n' not found");
196+
});
197+
198+
const bundle = {
199+
id: 'com.test.i18nthrow',
200+
translations: [{ en: { messages: { hello: 'Hello' } } }],
201+
};
202+
const plugin = new AppPlugin(bundle);
203+
await plugin.start!(mockContext);
204+
205+
// Should log debug but not throw
206+
expect(mockContext.logger.debug).toHaveBeenCalledWith(
207+
expect.stringContaining('No i18n service registered'),
208+
expect.any(Object)
209+
);
210+
});
211+
176212
it('should handle bundle with no translations gracefully', async () => {
177213
const bundle = { id: 'com.test.notrans' };
178214
const plugin = new AppPlugin(bundle);

packages/runtime/src/app-plugin.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,15 @@ export class AppPlugin implements Plugin {
6060

6161
// Execute Runtime Step
6262
// Retrieve ObjectQL engine from services
63-
// We cast to any/ObjectQL because ctx.getService returns unknown
64-
const ql = ctx.getService('objectql') as any;
65-
63+
// ctx.getService throws when a service is not registered, so we
64+
// must use try/catch instead of a null-check.
65+
let ql: any;
66+
try {
67+
ql = ctx.getService('objectql');
68+
} catch {
69+
// Service not registered — handled below
70+
}
71+
6672
if (!ql) {
6773
ctx.logger.warn('ObjectQL engine service not found', {
6874
appName: this.name,
@@ -201,7 +207,15 @@ export class AppPlugin implements Plugin {
201207
* this keeps AppPlugin resilient across server/dev/mock environments.
202208
*/
203209
private loadTranslations(ctx: PluginContext, appId: string): void {
204-
const i18nService = ctx.getService('i18n') as II18nService | undefined;
210+
// ctx.getService throws when a service is not registered, so we
211+
// must use try/catch to gracefully skip when no i18n plugin is loaded.
212+
let i18nService: II18nService | undefined;
213+
try {
214+
i18nService = ctx.getService('i18n') as II18nService;
215+
} catch {
216+
// Service not registered — handled below
217+
}
218+
205219
if (!i18nService) {
206220
ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId });
207221
return;

0 commit comments

Comments
 (0)