From af776483637c41f2a78bdbd2cf4595678bc954a5 Mon Sep 17 00:00:00 2001 From: Flussen Date: Tue, 7 Apr 2026 18:26:20 +0200 Subject: [PATCH] feat: add remote export support to IExports and improve bootstrap error handling for autoload modules --- README.md | 38 +++++++++++++++++++++ package.json | 2 +- src/adapters/contracts/IExports.ts | 55 ++++++++++++++++++++++++++++++ src/adapters/node/node-exports.ts | 23 +++++++++++++ src/runtime/server/bootstrap.ts | 27 ++++++++++++++- 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4b3ebe7..d8d39e8 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,44 @@ export class ExampleNetController { - `@Throttle(limit, windowMs)` - `@RequiresState({ missing: [...] })` +### Exports + +`@Export()` defines a public resource API. Adapters may expose both direct/local access through `getResource()` and an optional explicit async helper layer through `getRemoteResource()` / `waitForRemoteResource()`. + +```ts +import { Controller, Export } from '@open-core/framework/server' +import { IExports } from '@open-core/framework/contracts/server' + +@Controller() +export class DatabaseController { + @Export('pingDatabase') + async pingDatabase() { + return { success: true } + } +} + +interface DatabaseExports { + pingDatabase(): Promise<{ success: boolean }> +} + +class ExampleConsumer { + constructor(private readonly exportsService: IExports) {} + + async ping() { + const database = await this.exportsService.waitForRemoteResource('database', { + exportName: 'pingDatabase', + }) + + return database.pingDatabase() + } +} +``` + +Guidance: + +- `getResource()` is for local/synchronous resolution used by framework internals. +- `waitForRemoteResource()` / `getRemoteResource()` are optional adapter utilities for explicit async resource-to-resource calls. + ### Library events Use library wrappers to emit domain events and `@OnLibraryEvent()` to observe them. diff --git a/package.json b/package.json index adc574d..a43f385 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-core/framework", - "version": "1.0.8", + "version": "1.0.9", "description": "Secure, event-driven TypeScript Framework & Runtime engine for CitizenFX (Cfx).", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/adapters/contracts/IExports.ts b/src/adapters/contracts/IExports.ts index c31d685..68cb129 100644 --- a/src/adapters/contracts/IExports.ts +++ b/src/adapters/contracts/IExports.ts @@ -1,4 +1,59 @@ export abstract class IExports { + /** + * Registers a local export handler for the current resource. + * + * @remarks + * This is called by the framework during metadata processing when it discovers + * methods decorated with `@Export()`. + */ abstract register(exportName: string, handler: (...args: unknown[]) => unknown): void + + /** + * Resolves exports for a resource using the adapter's direct/local mechanism. + * + * @remarks + * Framework internals rely on this method remaining synchronous and side-effect free. + * Adapters should return `undefined` when the resource is not directly resolvable. + */ abstract getResource(resourceName: string): T | undefined + + /** + * Returns an async proxy for resource exports when the adapter provides a remote helper layer. + * + * @remarks + * This is optional and should not change the semantics of `getResource()`. + * Consumers should treat methods on the returned proxy as async. + */ + getRemoteResource(_resourceName: string): T { + throw new Error('[OpenCore] Remote exports are not supported by the active adapter.') + } + + /** + * Calls a single exported method through the adapter's optional remote helper layer. + */ + callRemoteExport( + _resourceName: string, + _exportName: string, + ..._args: unknown[] + ): Promise { + return Promise.reject( + new Error('[OpenCore] Remote exports are not supported by the active adapter.'), + ) + } + + /** + * Waits until a resource exposes exports compatible with the adapter's remote helper layer. + * + * @param _options.exportName Optional export name that must be present before resolving. + * @param _options.timeoutMs Maximum time to wait before failing. + * @param _options.intervalMs Polling interval used by adapters that implement polling. + */ + waitForRemoteResource( + _resourceName: string, + _options?: { exportName?: string; timeoutMs?: number; intervalMs?: number }, + ): Promise { + return Promise.reject( + new Error('[OpenCore] Remote exports are not supported by the active adapter.'), + ) + } } diff --git a/src/adapters/node/node-exports.ts b/src/adapters/node/node-exports.ts index 55852ef..f4a2d39 100644 --- a/src/adapters/node/node-exports.ts +++ b/src/adapters/node/node-exports.ts @@ -26,6 +26,29 @@ export class NodeExports implements IExports { ) } + getRemoteResource(_resourceName: string): T { + throw new Error('[OpenCore] Remote exports are not supported in Node.js runtime.') + } + + callRemoteExport( + _resourceName: string, + _exportName: string, + ..._args: unknown[] + ): Promise { + return Promise.reject( + new Error('[OpenCore] Remote exports are not supported in Node.js runtime.'), + ) + } + + waitForRemoteResource( + _resourceName: string, + _options?: { exportName?: string; timeoutMs?: number; intervalMs?: number }, + ): Promise { + return Promise.reject( + new Error('[OpenCore] Remote exports are not supported in Node.js runtime.'), + ) + } + /** * Get all registered exports as an object */ diff --git a/src/runtime/server/bootstrap.ts b/src/runtime/server/bootstrap.ts index 25bc2cb..6ac7919 100644 --- a/src/runtime/server/bootstrap.ts +++ b/src/runtime/server/bootstrap.ts @@ -344,14 +344,39 @@ async function tryImportAutoLoad() { try { await import('./.opencore/autoload.server.controllers') } catch (err) { - if (err instanceof Error && err.message.includes('Cannot find module')) { + if (isAutoloadModuleNotFound(err)) { loggers.bootstrap.warn(`[Bootstrap] No server controllers autoload file found, skipping.`) return } + + const message = err instanceof Error ? err.message : String(err) + loggers.bootstrap.error(`[Bootstrap] Failed to import server controllers autoload file.`, { + error: message, + }) throw err } } +function isAutoloadModuleNotFound(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false + } + + const error = err as NodeJS.ErrnoException & { requireStack?: string[] } + const message = typeof error.message === 'string' ? error.message : '' + const requireStack = Array.isArray(error.requireStack) ? error.requireStack : [] + + if (error.code !== 'MODULE_NOT_FOUND' && !message.includes('Cannot find module')) { + return false + } + + if (message.includes('autoload.server.controllers')) { + return true + } + + return requireStack.some((entry) => entry.includes('autoload.server.controllers')) +} + /** * Runs session recovery to restore sessions for players already connected. *