diff --git a/examples/helloworld-service-worker/README.md b/examples/helloworld-service-worker/README.md new file mode 100644 index 0000000000..6f3ecea89b --- /dev/null +++ b/examples/helloworld-service-worker/README.md @@ -0,0 +1,43 @@ +# helloworld-service-worker + +Minimal example of `@eggjs/service-worker`: a tegg module served through the +standalone service worker runtime — HTTP controllers and MCP tools over the +same fetch event loop, no egg application required. + +## Run + +```bash +# from the monorepo root +ut install --from pnpm +node --import=@oxc-node/core/register examples/helloworld-service-worker/main.ts +``` + +Then: + +```bash +curl 'http://127.0.0.1:7001/hello/?name=you' +# {"message":"hello, you"} + +curl -X POST 'http://127.0.0.1:7001/mcp/calc/stream' \ + -H 'accept: application/json, text/event-stream' \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":1,"b":41}}}' +``` + +## What's inside + +- `app/` — the tegg module (its `package.json` declares `eggModule.name`): + - `HelloController.ts` — an `@HTTPController` bound to `GET /hello/`. + - `CalcMCPController.ts` — an `@MCPController` exposing an `add` tool over + MCP stateless streamable HTTP at `/mcp/calc`. + - `HelloService.ts` — a `@ContextProto` service injected into both. +- `main.ts` — boots `ServiceWorkerApp` on the module dir and serves it over + `node:http`. The entry lives outside `app/` so the module scan doesn't + execute it. + +## Test + +```bash +# from the monorepo root +utx vitest run examples/helloworld-service-worker --config examples/helloworld-service-worker/vitest.config.ts +``` diff --git a/examples/helloworld-service-worker/app/CalcMCPController.ts b/examples/helloworld-service-worker/app/CalcMCPController.ts new file mode 100644 index 0000000000..4e2c520c7f --- /dev/null +++ b/examples/helloworld-service-worker/app/CalcMCPController.ts @@ -0,0 +1,27 @@ +import { Inject, MCPController, MCPTool, ToolArgsSchema } from '@eggjs/tegg'; +import { z } from 'zod'; + +import { HelloService } from './HelloService.ts'; + +const AddArgsSchema = { + a: z.number(), + b: z.number(), +}; + +@MCPController({ name: 'calc' }) +export class CalcMCPController { + @Inject() + private readonly helloService: HelloService; + + @MCPTool({ description: 'add two numbers' }) + async add(@ToolArgsSchema(AddArgsSchema) args: { a: number; b: number }) { + return { + content: [ + { + type: 'text' as const, + text: `${this.helloService.hello('mcp')}: ${args.a + args.b}`, + }, + ], + }; + } +} diff --git a/examples/helloworld-service-worker/app/HelloController.ts b/examples/helloworld-service-worker/app/HelloController.ts new file mode 100644 index 0000000000..604d36834e --- /dev/null +++ b/examples/helloworld-service-worker/app/HelloController.ts @@ -0,0 +1,14 @@ +import { HTTPController, HTTPMethod, HTTPMethodEnum, HTTPQuery, Inject } from '@eggjs/tegg'; + +import { HelloService } from './HelloService.ts'; + +@HTTPController({ path: '/hello' }) +export class HelloController { + @Inject() + private readonly helloService: HelloService; + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/' }) + async hello(@HTTPQuery({ name: 'name' }) name: string) { + return { message: this.helloService.hello(name ?? 'service worker') }; + } +} diff --git a/examples/helloworld-service-worker/app/HelloService.ts b/examples/helloworld-service-worker/app/HelloService.ts new file mode 100644 index 0000000000..956b6e5e2f --- /dev/null +++ b/examples/helloworld-service-worker/app/HelloService.ts @@ -0,0 +1,8 @@ +import { ContextProto } from '@eggjs/tegg'; + +@ContextProto() +export class HelloService { + hello(name: string): string { + return `hello, ${name}`; + } +} diff --git a/examples/helloworld-service-worker/app/package.json b/examples/helloworld-service-worker/app/package.json new file mode 100644 index 0000000000..91a8a20001 --- /dev/null +++ b/examples/helloworld-service-worker/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "helloworld-service-worker-app", + "type": "module", + "eggModule": { + "name": "helloWorldServiceWorker" + } +} diff --git a/examples/helloworld-service-worker/main.ts b/examples/helloworld-service-worker/main.ts new file mode 100644 index 0000000000..b1d92291fe --- /dev/null +++ b/examples/helloworld-service-worker/main.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ServiceWorkerApp } from '@eggjs/service-worker'; + +const app = new ServiceWorkerApp(path.join(path.dirname(fileURLToPath(import.meta.url)), 'app')); +const server = await app.serve({ port: 7001 }); +console.log('service worker listening on http://127.0.0.1:7001'); +console.log(' GET /hello/?name=you'); +console.log(' POST /mcp/calc (MCP streamable http)'); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + console.log(`received ${signal}, shutting down`); + app + .destroy() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); + }); +} + +void server; diff --git a/examples/helloworld-service-worker/package.json b/examples/helloworld-service-worker/package.json new file mode 100644 index 0000000000..8591d91e15 --- /dev/null +++ b/examples/helloworld-service-worker/package.json @@ -0,0 +1,27 @@ +{ + "name": "helloworld-service-worker", + "version": "1.0.0", + "private": true, + "description": "Hello World example using the standalone service worker", + "type": "module", + "scripts": { + "start": "node --import=@oxc-node/core/register main.ts", + "test": "vitest run", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/service-worker": "workspace:*", + "@eggjs/tegg": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@eggjs/tsconfig": "workspace:*", + "@oxc-node/core": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/examples/helloworld-service-worker/test/index.test.ts b/examples/helloworld-service-worker/test/index.test.ts new file mode 100644 index 0000000000..cec2ff867b --- /dev/null +++ b/examples/helloworld-service-worker/test/index.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import path from 'node:path'; + +import { ServiceWorkerApp } from '@eggjs/service-worker'; +import { afterAll, beforeAll, describe, it } from 'vitest'; + +describe('examples/helloworld-service-worker', () => { + let app: ServiceWorkerApp; + let base: string; + + beforeAll(async () => { + app = new ServiceWorkerApp(path.join(__dirname, '../app')); + const server = await app.serve(); + const { address, port } = server.address() as AddressInfo; + base = `http://${address}:${port}`; + }); + + afterAll(async () => { + await app.destroy(); + }); + + it('should say hello over http', async () => { + const res = await fetch(`${base}/hello/?name=egg`); + assert.deepEqual(await res.json(), { message: 'hello, egg' }); + }); + + it('should serve the calc MCP tool', async () => { + const res = await fetch(`${base}/mcp/calc/stream`, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 41 } }, + }), + }); + assert.equal(res.status, 200); + const text = await res.text(); + assert.match(text, /hello, mcp: 42/); + }); +}); diff --git a/examples/helloworld-service-worker/tsconfig.json b/examples/helloworld-service-worker/tsconfig.json new file mode 100644 index 0000000000..376b902478 --- /dev/null +++ b/examples/helloworld-service-worker/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@eggjs/tsconfig" +} diff --git a/examples/helloworld-service-worker/vitest.config.ts b/examples/helloworld-service-worker/vitest.config.ts new file mode 100644 index 0000000000..b4ce27ae5f --- /dev/null +++ b/examples/helloworld-service-worker/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + include: ['test/**/*.test.ts'], + }, +}); diff --git a/tegg/core/controller-runtime/package.json b/tegg/core/controller-runtime/package.json new file mode 100644 index 0000000000..75e24658ff --- /dev/null +++ b/tegg/core/controller-runtime/package.json @@ -0,0 +1,64 @@ +{ + "name": "@eggjs/controller-runtime", + "version": "4.0.2-beta.20", + "description": "tegg host-agnostic controller runtime", + "keywords": [ + "controller", + "egg", + "runtime", + "tegg", + "typescript" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/tegg/core/controller-runtime", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "author": "killagu ", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "tegg/core/controller-runtime" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/controller-decorator": "workspace:*", + "@eggjs/core-decorator": "workspace:*", + "@eggjs/lifecycle": "workspace:*", + "@eggjs/metadata": "workspace:*", + "@eggjs/router": "workspace:*", + "@eggjs/tegg-common-util": "workspace:*", + "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*", + "@modelcontextprotocol/sdk": "^1.23.0", + "egg-errors": "catalog:", + "path-to-regexp": "catalog:path-to-regexp1" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/tegg/core/controller-runtime/src/index.ts b/tegg/core/controller-runtime/src/index.ts new file mode 100644 index 0000000000..cca4ea6e45 --- /dev/null +++ b/tegg/core/controller-runtime/src/index.ts @@ -0,0 +1,19 @@ +// tegg host-agnostic controller runtime surface: both the egg plugin host and +// the standalone service-worker host subclass/consume these. +export * from './lib/ControllerLoadUnit.ts'; +export * from './lib/ControllerLoadUnitHook.ts'; +export * from './lib/ControllerModule.ts'; +export * from './lib/ControllerLoadUnitInstance.ts'; +export * from './lib/ControllerMetadataManager.ts'; +export * from './lib/ControllerPrototypeHook.ts'; +export * from './lib/ControllerRegister.ts'; +export * from './lib/ControllerRegisterDefaults.ts'; +export * from './lib/ControllerRegisterFactory.ts'; +export * from './lib/MiddlewareGraphHook.ts'; +export * from './lib/RootProtoManager.ts'; +export * from './lib/errors.ts'; +export * from './lib/impl/http/HTTPControllerRegisterBase.ts'; +export * from './lib/impl/http/HTTPMethodRegisterBase.ts'; +export * from './lib/impl/mcp/McpRouter.ts'; +export * from './lib/impl/mcp/MCPServerHelper.ts'; +export * from './lib/impl/mcp/MCPControllerRegister.ts'; diff --git a/tegg/plugin/controller/src/lib/ControllerLoadUnit.ts b/tegg/core/controller-runtime/src/lib/ControllerLoadUnit.ts similarity index 100% rename from tegg/plugin/controller/src/lib/ControllerLoadUnit.ts rename to tegg/core/controller-runtime/src/lib/ControllerLoadUnit.ts diff --git a/tegg/plugin/controller/src/lib/AppLoadUnitControllerHook.ts b/tegg/core/controller-runtime/src/lib/ControllerLoadUnitHook.ts similarity index 73% rename from tegg/plugin/controller/src/lib/AppLoadUnitControllerHook.ts rename to tegg/core/controller-runtime/src/lib/ControllerLoadUnitHook.ts index b0b4483de1..afe82895af 100644 --- a/tegg/plugin/controller/src/lib/AppLoadUnitControllerHook.ts +++ b/tegg/core/controller-runtime/src/lib/ControllerLoadUnitHook.ts @@ -3,14 +3,18 @@ import type { LifecycleHook } from '@eggjs/lifecycle'; import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; import { ControllerMetadataManager } from './ControllerMetadataManager.ts'; -import { ControllerRegisterFactory } from './ControllerRegisterFactory.ts'; -import { RootProtoManager } from './RootProtoManager.ts'; +import type { ControllerRegisterFactory } from './ControllerRegisterFactory.ts'; +import type { RootProtoManager } from './RootProtoManager.ts'; -export class AppLoadUnitControllerHook implements LifecycleHook { - private readonly controllerRegisterFactory: ControllerRegisterFactory; +/** + * Host-agnostic load-unit hook: for every controller proto in a created load + * unit, resolve the register for its controller type and run it. + */ +export class ControllerLoadUnitHook implements LifecycleHook { + private readonly controllerRegisterFactory: ControllerRegisterFactory; private readonly rootProtoManager: RootProtoManager; - constructor(controllerRegisterFactory: ControllerRegisterFactory, rootProtoManager: RootProtoManager) { + constructor(controllerRegisterFactory: ControllerRegisterFactory, rootProtoManager: RootProtoManager) { this.controllerRegisterFactory = controllerRegisterFactory; this.rootProtoManager = rootProtoManager; } diff --git a/tegg/plugin/controller/src/lib/ControllerLoadUnitInstance.ts b/tegg/core/controller-runtime/src/lib/ControllerLoadUnitInstance.ts similarity index 100% rename from tegg/plugin/controller/src/lib/ControllerLoadUnitInstance.ts rename to tegg/core/controller-runtime/src/lib/ControllerLoadUnitInstance.ts diff --git a/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts b/tegg/core/controller-runtime/src/lib/ControllerMetadataManager.ts similarity index 100% rename from tegg/plugin/controller/src/lib/ControllerMetadataManager.ts rename to tegg/core/controller-runtime/src/lib/ControllerMetadataManager.ts diff --git a/tegg/core/controller-runtime/src/lib/ControllerModule.ts b/tegg/core/controller-runtime/src/lib/ControllerModule.ts new file mode 100644 index 0000000000..b5ad832ce2 --- /dev/null +++ b/tegg/core/controller-runtime/src/lib/ControllerModule.ts @@ -0,0 +1,51 @@ +import { EggPrototypeLifecycleProto, Inject, InnerObjectProto, LoadUnitLifecycleProto } from '@eggjs/core-decorator'; +import { LifecyclePostInject } from '@eggjs/lifecycle'; +import { AccessLevel } from '@eggjs/tegg-types'; + +import { ControllerLoadUnitHook } from './ControllerLoadUnitHook.ts'; +import { ControllerPrototypeHook } from './ControllerPrototypeHook.ts'; +import { ControllerRegisterDefaults } from './ControllerRegisterDefaults.ts'; +import { ControllerRegisterFactory } from './ControllerRegisterFactory.ts'; +import { RootProtoManager } from './RootProtoManager.ts'; + +/** + * The controller plugin AS a module: the same declarative hook set for BOTH + * hosts (egg discovers it through the framework scan + plugin promotion, the + * service worker through its package dependency). Only host-EQUIVALENT + * pieces live here — egg-only transport wiring stays imperative in app.ts + * (creators enqueue through ControllerRegisterDefaults), fetch transport + * providers live in @eggjs/service-worker. The `egg` import is type-only so + * the scan stays host-safe. + */ +@InnerObjectProto({ name: 'rootProtoManager', accessLevel: AccessLevel.PUBLIC }) +export class EggRootProtoManager extends RootProtoManager {} + +@InnerObjectProto({ name: 'controllerRegisterFactory', accessLevel: AccessLevel.PUBLIC }) +export class EggControllerRegisterFactory extends ControllerRegisterFactory { + // No host is threaded through the DI graph: egg's transport creators close + // over `app` imperatively (see app.ts), the fetch creators are container + // citizens. Both register through ControllerRegisterDefaults / the injected + // factory directly. + + /** + * Apply the transport creators the host enqueued imperatively before this + * proto existed (egg's HTTP/MCP registers close over boot-time state). + */ + @LifecyclePostInject() + applyDefaultRegisters(): void { + ControllerRegisterDefaults.drain(this); + } +} + +@LoadUnitLifecycleProto() +export class EggControllerLoadUnitHook extends ControllerLoadUnitHook { + constructor( + @Inject() controllerRegisterFactory: EggControllerRegisterFactory, + @Inject() rootProtoManager: EggRootProtoManager, + ) { + super(controllerRegisterFactory, rootProtoManager); + } +} + +@EggPrototypeLifecycleProto() +export class EggControllerPrototypeLifecycleHook extends ControllerPrototypeHook {} diff --git a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts b/tegg/core/controller-runtime/src/lib/ControllerPrototypeHook.ts similarity index 67% rename from tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts rename to tegg/core/controller-runtime/src/lib/ControllerPrototypeHook.ts index e6f5743c4a..0efd657452 100644 --- a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts +++ b/tegg/core/controller-runtime/src/lib/ControllerPrototypeHook.ts @@ -2,7 +2,11 @@ import { ControllerMetaBuilderFactory, ControllerMetadataUtil } from '@eggjs/con import type { LifecycleHook } from '@eggjs/lifecycle'; import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/metadata'; -export class EggControllerPrototypeHook implements LifecycleHook { +/** + * Host-agnostic prototype hook: build controller metadata from the decorated + * class when its prototype is created. + */ +export class ControllerPrototypeHook implements LifecycleHook { async postCreate(ctx: EggPrototypeLifecycleContext): Promise { const metadata = ControllerMetaBuilderFactory.build(ctx.clazz); if (metadata) { diff --git a/tegg/plugin/controller/src/lib/ControllerRegister.ts b/tegg/core/controller-runtime/src/lib/ControllerRegister.ts similarity index 73% rename from tegg/plugin/controller/src/lib/ControllerRegister.ts rename to tegg/core/controller-runtime/src/lib/ControllerRegister.ts index f03f0486ae..4c0b335535 100644 --- a/tegg/plugin/controller/src/lib/ControllerRegister.ts +++ b/tegg/core/controller-runtime/src/lib/ControllerRegister.ts @@ -1,6 +1,6 @@ import type { LoadUnit } from '@eggjs/metadata'; -import { RootProtoManager } from './RootProtoManager.ts'; +import type { RootProtoManager } from './RootProtoManager.ts'; export interface ControllerRegister { register(rootProtoManager: RootProtoManager, loadUnit?: LoadUnit): Promise; diff --git a/tegg/core/controller-runtime/src/lib/ControllerRegisterDefaults.ts b/tegg/core/controller-runtime/src/lib/ControllerRegisterDefaults.ts new file mode 100644 index 0000000000..ee6027340f --- /dev/null +++ b/tegg/core/controller-runtime/src/lib/ControllerRegisterDefaults.ts @@ -0,0 +1,32 @@ +import type { ControllerTypeLike } from '@eggjs/controller-decorator'; +import { TeggScope } from '@eggjs/tegg-types'; + +import type { ControllerRegisterFactory, RegisterCreator } from './ControllerRegisterFactory.ts'; + +const DEFAULT_REGISTERS_SLOT = Symbol('tegg:controller:defaultRegisterCreators'); + +/** + * Per-app pre-registration queue for transport register creators. + * + * A host whose transport creator needs host objects available only + * imperatively (the egg host: creators close over `app`) enqueues them during + * early boot (before the InnerObjectLoadUnit exists); the shared + * controller-plugin factory proto drains the queue when it materializes. + * Hosts whose creators are container citizens (service worker fetch + * providers) keep registering on the injected factory directly. + */ +export class ControllerRegisterDefaults { + static #queue(): [ControllerTypeLike, RegisterCreator][] { + return TeggScope.resolve(DEFAULT_REGISTERS_SLOT, () => [], 'ControllerRegisterDefaults.queue'); + } + + static enqueue(type: ControllerTypeLike, creator: RegisterCreator): void { + ControllerRegisterDefaults.#queue().push([type, creator]); + } + + static drain(factory: ControllerRegisterFactory): void { + for (const [type, creator] of ControllerRegisterDefaults.#queue()) { + factory.registerControllerRegister(type, creator); + } + } +} diff --git a/tegg/plugin/controller/src/lib/ControllerRegisterFactory.ts b/tegg/core/controller-runtime/src/lib/ControllerRegisterFactory.ts similarity index 60% rename from tegg/plugin/controller/src/lib/ControllerRegisterFactory.ts rename to tegg/core/controller-runtime/src/lib/ControllerRegisterFactory.ts index 28f7a79dfe..aa9a5115bf 100644 --- a/tegg/plugin/controller/src/lib/ControllerRegisterFactory.ts +++ b/tegg/core/controller-runtime/src/lib/ControllerRegisterFactory.ts @@ -1,25 +1,29 @@ import type { ControllerMetadata, ControllerTypeLike } from '@eggjs/controller-decorator'; import type { EggPrototype } from '@eggjs/metadata'; -import type { Application } from 'egg'; import type { ControllerRegister } from './ControllerRegister.ts'; -export type RegisterCreator = ( +/** + * `THost` is whatever the host wants to thread through to its register + * creators (the egg plugin passes the Application; standalone runtimes + * typically pass nothing). + */ +export type RegisterCreator = ( proto: EggPrototype, controllerMeta: ControllerMetadata, - app: Application, + host: THost, ) => ControllerRegister; -export class ControllerRegisterFactory { - private readonly app: Application; - private registerCreatorMap: Map; +export class ControllerRegisterFactory { + private readonly host: THost; + private registerCreatorMap: Map>; - constructor(app: Application) { - this.app = app; + constructor(host?: THost) { + this.host = host as THost; this.registerCreatorMap = new Map(); } - registerControllerRegister(type: ControllerTypeLike, creator: RegisterCreator): void { + registerControllerRegister(type: ControllerTypeLike, creator: RegisterCreator): void { this.registerCreatorMap.set(type, creator); } @@ -28,6 +32,6 @@ export class ControllerRegisterFactory { if (!creator) { return; } - return creator(proto, metadata, this.app); + return creator(proto, metadata, this.host); } } diff --git a/tegg/plugin/controller/src/lib/MiddlewareGraphHook.ts b/tegg/core/controller-runtime/src/lib/MiddlewareGraphHook.ts similarity index 97% rename from tegg/plugin/controller/src/lib/MiddlewareGraphHook.ts rename to tegg/core/controller-runtime/src/lib/MiddlewareGraphHook.ts index 893058bcd7..3161392621 100644 --- a/tegg/plugin/controller/src/lib/MiddlewareGraphHook.ts +++ b/tegg/core/controller-runtime/src/lib/MiddlewareGraphHook.ts @@ -1,6 +1,6 @@ +import { ControllerInfoUtil, MethodInfoUtil } from '@eggjs/controller-decorator'; import type { GlobalGraph, ProtoDependencyMeta, ProtoNode } from '@eggjs/metadata'; import { ClassProtoDescriptor as ClassProtoDescriptorImpl } from '@eggjs/metadata'; -import { ControllerInfoUtil, MethodInfoUtil } from '@eggjs/tegg'; import type { GraphNode } from '@eggjs/tegg-common-util'; import type { EggProtoImplClass, IAdvice } from '@eggjs/tegg-types'; diff --git a/tegg/plugin/controller/src/lib/RootProtoManager.ts b/tegg/core/controller-runtime/src/lib/RootProtoManager.ts similarity index 69% rename from tegg/plugin/controller/src/lib/RootProtoManager.ts rename to tegg/core/controller-runtime/src/lib/RootProtoManager.ts index 439fe5d28d..6b866c72f2 100644 --- a/tegg/plugin/controller/src/lib/RootProtoManager.ts +++ b/tegg/core/controller-runtime/src/lib/RootProtoManager.ts @@ -1,8 +1,17 @@ import type { EggPrototype } from '@eggjs/metadata'; import { MapUtil } from '@eggjs/tegg-common-util'; -import type { Context } from 'egg'; -export type GetRootProtoCallback = (ctx: Context) => EggPrototype | undefined; +/** + * The structural request shape RootProtoManager needs. Both the egg Context + * and fetch-style contexts satisfy it. + */ +export interface RootProtoRequestContext { + method: string; + host: string; + path: string; +} + +export type GetRootProtoCallback = (ctx: RootProtoRequestContext) => EggPrototype | undefined; export class RootProtoManager { // @@ -14,7 +23,7 @@ export class RootProtoManager { cbList.push(cb); } - getRootProto(ctx: Context): EggPrototype | undefined { + getRootProto(ctx: RootProtoRequestContext): EggPrototype | undefined { const hostCbList = this.protoMap.get(ctx.method + ctx.host); if (hostCbList) { for (const cb of hostCbList) { diff --git a/tegg/plugin/controller/src/lib/errors.ts b/tegg/core/controller-runtime/src/lib/errors.ts similarity index 100% rename from tegg/plugin/controller/src/lib/errors.ts rename to tegg/core/controller-runtime/src/lib/errors.ts diff --git a/tegg/core/controller-runtime/src/lib/impl/http/HTTPControllerRegisterBase.ts b/tegg/core/controller-runtime/src/lib/impl/http/HTTPControllerRegisterBase.ts new file mode 100644 index 0000000000..c321be376a --- /dev/null +++ b/tegg/core/controller-runtime/src/lib/impl/http/HTTPControllerRegisterBase.ts @@ -0,0 +1,100 @@ +import { CONTROLLER_META_DATA, type HTTPControllerMeta, type HTTPMethodMeta } from '@eggjs/controller-decorator'; +import type { EggPrototype } from '@eggjs/metadata'; +import type { Router } from '@eggjs/router'; +import type { EggContainerFactory } from '@eggjs/tegg-runtime'; + +import type { ControllerRegister } from '../../ControllerRegister.ts'; +import type { RootProtoManager } from '../../RootProtoManager.ts'; +import type { HTTPMethodRegister } from './HTTPMethodRegisterBase.ts'; + +export type HTTPMethodRegisterCreator = ( + proto: EggPrototype, + controllerMeta: HTTPControllerMeta, + methodMeta: HTTPMethodMeta, + router: Router, + checkRouters: Map, + eggContainerFactory: typeof EggContainerFactory, +) => HTTPMethodRegister; + +/** + * Host-agnostic HTTP controller registration skeleton: accumulate controller + * protos while load units are created, then `doRegister()` wires all methods + * onto the router in priority order (a duplicate-check pass, then the real + * registration pass). Hosts subclass/instantiate with their own router and + * method-register creator (i.e. their param-binding strategy). + */ +export class HTTPControllerRegister implements ControllerRegister { + protected readonly router: Router; + protected readonly checkRouters: Map; + protected readonly eggContainerFactory: typeof EggContainerFactory; + protected readonly methodRegisterCreator: HTTPMethodRegisterCreator; + protected controllerProtos: EggPrototype[] = []; + + constructor( + router: Router, + eggContainerFactory: typeof EggContainerFactory, + methodRegisterCreator: HTTPMethodRegisterCreator, + ) { + this.router = router; + this.checkRouters = new Map(); + this.checkRouters.set('default', router); + this.eggContainerFactory = eggContainerFactory; + this.methodRegisterCreator = methodRegisterCreator; + } + + addControllerProto(proto: EggPrototype): void { + this.controllerProtos.push(proto); + } + + register(): Promise { + // do noting + return Promise.resolve(); + } + + clear(): void { + this.controllerProtos = []; + this.checkRouters.clear(); + } + + doRegister(rootProtoManager: RootProtoManager): void { + const methodMap = new Map(); + for (const proto of this.controllerProtos) { + const metadata = proto.getMetaData(CONTROLLER_META_DATA) as HTTPControllerMeta; + for (const method of metadata.methods) { + methodMap.set(method, proto); + } + } + const allMethods = Array.from(methodMap.keys()).sort((a, b) => b.priority - a.priority); + + // FIXME: why init method register twice? + for (const method of allMethods) { + const controllerProto = methodMap.get(method)!; + const controllerMeta = controllerProto.getMetaData(CONTROLLER_META_DATA) as HTTPControllerMeta; + const methodRegister = this.methodRegisterCreator( + controllerProto, + controllerMeta, + method, + this.router, + this.checkRouters, + this.eggContainerFactory, + ); + methodRegister.checkDuplicate(); + } + + for (const method of allMethods) { + const controllerProto = methodMap.get(method)!; + const controllerMeta = controllerProto.getMetaData(CONTROLLER_META_DATA) as HTTPControllerMeta; + const methodRegister = this.methodRegisterCreator( + controllerProto, + controllerMeta, + method, + this.router, + this.checkRouters, + this.eggContainerFactory, + ); + // Error: framework.RouterConflictError: register http controller GET AppController2.get failed, GET /apps/:id is conflict with exists rule /apps/:id [ https://eggjs.org/faq/TEGG_ROUTER_CONFLICT ] + // methodRegister.checkDuplicate(); + methodRegister.register(rootProtoManager); + } + } +} diff --git a/tegg/core/controller-runtime/src/lib/impl/http/HTTPMethodRegisterBase.ts b/tegg/core/controller-runtime/src/lib/impl/http/HTTPMethodRegisterBase.ts new file mode 100644 index 0000000000..3d7a4e9d52 --- /dev/null +++ b/tegg/core/controller-runtime/src/lib/impl/http/HTTPMethodRegisterBase.ts @@ -0,0 +1,129 @@ +import type { HTTPControllerMeta, HTTPMethodMeta } from '@eggjs/controller-decorator'; +import type { EggPrototype } from '@eggjs/metadata'; +import { Router } from '@eggjs/router'; +import type { EggContainerFactory } from '@eggjs/tegg-runtime'; +import { FrameworkErrorFormater } from 'egg-errors'; +import pathToRegexp from 'path-to-regexp'; + +import { RouterConflictError } from '../../errors.ts'; +import type { RootProtoManager, RootProtoRequestContext } from '../../RootProtoManager.ts'; + +const noop = () => { + // ... +}; + +/** + * Loosely-typed koa-style middleware so both the egg host (egg MiddlewareFunc) + * and fetch-style hosts can flow through the shared skeleton. + */ +export type HTTPHandlerFunc = (ctx: any, next: () => Promise) => Promise | void; + +/** + * Host-agnostic HTTP method registration skeleton. Both routers derive from + * `@eggjs/router`'s Router, so route registration / duplicate checking / + * root-proto wiring is shared; only `createHandler` (how request I/O binds to + * method args and how the return value is written back) differs per host. + */ +export abstract class HTTPMethodRegister { + protected readonly router: Router; + protected readonly checkRouters: Map; + protected readonly controllerMeta: HTTPControllerMeta; + protected readonly methodMeta: HTTPMethodMeta; + protected readonly proto: EggPrototype; + protected readonly eggContainerFactory: typeof EggContainerFactory; + + constructor( + proto: EggPrototype, + controllerMeta: HTTPControllerMeta, + methodMeta: HTTPMethodMeta, + router: Router, + checkRouters: Map, + eggContainerFactory: typeof EggContainerFactory, + ) { + this.proto = proto; + this.controllerMeta = controllerMeta; + this.router = router; + this.methodMeta = methodMeta; + this.checkRouters = checkRouters; + this.eggContainerFactory = eggContainerFactory; + } + + /** Bind the host request to method args and write the return value back. */ + protected abstract createHandler(methodMeta: HTTPMethodMeta, host: string | undefined): HTTPHandlerFunc; + + /** Host hook for extra method middlewares (e.g. the egg acl middleware). */ + protected getExtraMethodMiddlewares(): HTTPHandlerFunc[] { + return []; + } + + checkDuplicate(): void { + // 1. check duplicate with host router + this.checkDuplicateInRouter(this.router); + + // 2. check duplicate with host tegg controller + let hostRouter: Router | undefined; + const hosts = this.controllerMeta.getMethodHosts(this.methodMeta) || []; + hosts.forEach((h) => { + if (h) { + hostRouter = this.checkRouters.get(h); + if (!hostRouter) { + hostRouter = new Router({ sensitive: true }); + this.checkRouters.set(h, hostRouter!); + } + } + if (hostRouter) { + this.checkDuplicateInRouter(hostRouter); + this.registerToRouter(hostRouter); + } + }); + } + + private registerToRouter(router: Router) { + const routerFunc = router[this.methodMeta.method.toLowerCase() as keyof Router] as Function; + const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); + const methodName = this.controllerMeta.getMethodName(this.methodMeta); + Reflect.apply(routerFunc, router, [methodName, methodRealPath, noop]); + } + + private checkDuplicateInRouter(router: Router) { + const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); + const matched = router.match(methodRealPath, this.methodMeta.method); + const methodName = this.controllerMeta.getMethodName(this.methodMeta); + if (matched.route) { + const [layer] = matched.path; + const err = new RouterConflictError( + `register http controller ${methodName} failed, ${this.methodMeta.method} ${methodRealPath} is conflict with exists rule ${layer.path}`, + ); + throw FrameworkErrorFormater.format(err); + } + } + + /** + * register method to router + */ + register(rootProtoManager: RootProtoManager): void { + const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); + const methodName = this.controllerMeta.getMethodName(this.methodMeta); + const routerFunc = this.router[this.methodMeta.method.toLowerCase() as keyof Router] as Function; + const methodMiddlewares: HTTPHandlerFunc[] = this.controllerMeta.getMethodMiddlewares(this.methodMeta); + methodMiddlewares.push(...this.getExtraMethodMiddlewares()); + const hosts = this.controllerMeta.getMethodHosts(this.methodMeta) ?? [undefined]; + hosts.forEach((host) => { + const handler = this.createHandler(this.methodMeta, host); + Reflect.apply(routerFunc, this.router, [methodName, methodRealPath, ...methodMiddlewares, handler]); + // https://github.com/eggjs/egg-core/blob/0af6178022e7734c4a8b17bb56d592b315207883/lib/egg.js#L279 + const regExp = pathToRegexp(methodRealPath, { + sensitive: true, + }); + rootProtoManager.registerRootProto( + this.methodMeta.method, + (ctx: RootProtoRequestContext) => { + if (regExp.test(ctx.path)) { + return this.proto; + } + }, + host || '', + ); + }); + } +} diff --git a/tegg/core/controller-runtime/src/lib/impl/mcp/MCPControllerRegister.ts b/tegg/core/controller-runtime/src/lib/impl/mcp/MCPControllerRegister.ts new file mode 100644 index 0000000000..35ba2bbd4c --- /dev/null +++ b/tegg/core/controller-runtime/src/lib/impl/mcp/MCPControllerRegister.ts @@ -0,0 +1,87 @@ +import type { MCPControllerMeta } from '@eggjs/controller-decorator'; +import type { EggPrototype } from '@eggjs/metadata'; +import { EggContainerFactory } from '@eggjs/tegg-runtime'; +import { CONTROLLER_META_DATA } from '@eggjs/tegg-types'; + +import type { ControllerRegister } from '../../ControllerRegister.ts'; +import type { McpRouter, ServerRegisterRecord } from './McpRouter.ts'; + +/** + * Host-agnostic, COLLECT-ONLY MCP controller register. + * + * It accumulates controller protos and, on `register()`, reads each proto's + * MCP metadata and collects tool/resource/prompt records into a per-server-name + * map. All transport (route mounting, sessions, SSE/stream/stateless handling, + * ping, proxy hooks) lives behind the injected {@link McpRouter}: the first + * time a server name is seen the register hands the router the LIVE record + * arrays via `registerServer`, and the router mounts transport once and reads + * those arrays lazily at request time. + */ +export class MCPControllerRegister implements ControllerRegister { + readonly eggContainerFactory: typeof EggContainerFactory; + private readonly controllerMeta: MCPControllerMeta; + private readonly mcpRouter: McpRouter; + private controllerProtos: EggPrototype[] = []; + private registeredControllerProtos: EggPrototype[] = []; + + registerMap: Record< + string, + { + tools: ServerRegisterRecord[]; + prompts: ServerRegisterRecord[]; + resources: ServerRegisterRecord[]; + } + > = {}; + + constructor(controllerMeta: MCPControllerMeta, mcpRouter: McpRouter) { + // Direct import, not a read off the (possibly proxied) app — see + // HTTPControllerRegister.create. + this.eggContainerFactory = EggContainerFactory; + this.controllerMeta = controllerMeta; + this.mcpRouter = mcpRouter; + } + + addControllerProto(proto: EggPrototype): void { + this.controllerProtos.push(proto); + } + + async register(): Promise { + for (const proto of this.controllerProtos) { + if (this.registeredControllerProtos.includes(proto)) { + continue; + } + const metadata = proto.getMetaData(CONTROLLER_META_DATA) as MCPControllerMeta; + const serverName = metadata.name ?? 'default'; + + // The first time a server name appears, create its (live) record entry + // and hand it to the host router so the router mounts transport once. + const isNew = !this.registerMap[serverName]; + const entry = (this.registerMap[serverName] ??= { + prompts: [], + resources: [], + tools: [], + }); + if (isNew) { + this.mcpRouter.registerServer({ + serverName, + controllerMeta: this.controllerMeta, + tools: entry.tools, + resources: entry.resources, + prompts: entry.prompts, + }); + } + + const getOrCreateEggObject = this.eggContainerFactory.getOrCreateEggObject.bind(this.eggContainerFactory); + for (const prompt of metadata.prompts) { + entry.prompts.push({ getOrCreateEggObject, proto, meta: prompt }); + } + for (const resource of metadata.resources) { + entry.resources.push({ getOrCreateEggObject, proto, meta: resource }); + } + for (const tool of metadata.tools) { + entry.tools.push({ getOrCreateEggObject, proto, meta: tool }); + } + this.registeredControllerProtos.push(proto); + } + } +} diff --git a/tegg/plugin/controller/src/lib/impl/mcp/MCPServerHelper.ts b/tegg/core/controller-runtime/src/lib/impl/mcp/MCPServerHelper.ts similarity index 80% rename from tegg/plugin/controller/src/lib/impl/mcp/MCPServerHelper.ts rename to tegg/core/controller-runtime/src/lib/impl/mcp/MCPServerHelper.ts index 9164428f31..1ecd46ca3e 100644 --- a/tegg/plugin/controller/src/lib/impl/mcp/MCPServerHelper.ts +++ b/tegg/core/controller-runtime/src/lib/impl/mcp/MCPServerHelper.ts @@ -1,21 +1,30 @@ -import type { MCPControllerMeta, MCPPromptMeta, MCPResourceMeta, MCPToolMeta } from '@eggjs/tegg'; +import type { MCPControllerMeta, MCPPromptMeta, MCPResourceMeta, MCPToolMeta } from '@eggjs/controller-decorator'; import { CONTROLLER_META_DATA } from '@eggjs/tegg-types'; import type { EggObject, EggObjectName, EggPrototype } from '@eggjs/tegg-types'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ReadResourceCallback, ToolCallback, PromptCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { MCPControllerHook } from './MCPControllerRegister.ts'; -import { MCPControllerRegister } from './MCPControllerRegister.ts'; +/** + * The host-agnostic slice of the MCP controller hooks: a schema loader that + * resolves tool/prompt args schemas when the decorated metadata has none. + * The egg host passes its (per-app, live) EggMcpRouter.hooks list. + */ +export interface MCPSchemaLoaderHook { + schemaLoader?: ( + controllerMeta: MCPControllerMeta, + meta: MCPPromptMeta | MCPToolMeta, + ) => Promise['2'] | undefined>; +} export interface MCPServerHelperOptions { name: string; version: string; - hooks: MCPControllerHook[]; + hooks?: readonly MCPSchemaLoaderHook[]; } export class MCPServerHelper { server: McpServer; - hooks: MCPControllerHook[]; + hooks: readonly MCPSchemaLoaderHook[]; constructor(opts: MCPServerHelperOptions) { this.server = new McpServer( { @@ -24,7 +33,19 @@ export class MCPServerHelper { }, { capabilities: { logging: {} } }, ); - this.hooks = opts.hooks; + this.hooks = opts.hooks ?? []; + } + + private async loadSchema( + controllerMeta: MCPControllerMeta, + meta: MCPPromptMeta | MCPToolMeta, + ): Promise['2'] | undefined> { + for (const hook of this.hooks) { + const schema = await hook.schemaLoader?.(controllerMeta, meta); + if (schema) { + return schema; + } + } } async mcpResourceRegister( @@ -59,13 +80,8 @@ export class MCPServerHelper { let schema: NonNullable<(typeof toolMeta)['detail']>['argsSchema'] | undefined; if (toolMeta.detail?.argsSchema) { schema = toolMeta.detail?.argsSchema; - } else if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { - schema = await hook.schemaLoader?.(controllerMeta, toolMeta); - if (schema) { - break; - } - } + } else { + schema = await this.loadSchema(controllerMeta, toolMeta); } const handler = async (...args: any[]) => { const eggObj = await getOrCreateEggObject(controllerProto, controllerProto.name); @@ -104,13 +120,8 @@ export class MCPServerHelper { let schema: NonNullable<(typeof promptMeta)['detail']>['argsSchema'] | undefined; if (promptMeta.detail?.argsSchema) { schema = promptMeta.detail?.argsSchema; - } else if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { - schema = await hook.schemaLoader?.(controllerMeta, promptMeta); - if (schema) { - break; - } - } + } else { + schema = await this.loadSchema(controllerMeta, promptMeta); } const handler = async (...args: any[]) => { const eggObj = await getOrCreateEggObject(controllerProto, controllerProto.name); diff --git a/tegg/core/controller-runtime/src/lib/impl/mcp/McpRouter.ts b/tegg/core/controller-runtime/src/lib/impl/mcp/McpRouter.ts new file mode 100644 index 0000000000..2d6eda8aa7 --- /dev/null +++ b/tegg/core/controller-runtime/src/lib/impl/mcp/McpRouter.ts @@ -0,0 +1,47 @@ +import type { MCPControllerMeta, MCPPromptMeta, MCPResourceMeta, MCPToolMeta } from '@eggjs/controller-decorator'; +import type { EggObject, EggObjectName, EggPrototype } from '@eggjs/tegg-types'; + +/** + * The shared DI object name for the host-specific MCP router. Both hosts + * (egg controller plugin, service worker) provide their own implementation + * under this name; because the two host plugins are never used together the + * names do not collide. + */ +export const MCP_ROUTER_NAME = 'mcpRouter'; + +/** + * A single collected controller record: the (lazy) egg-object resolver, the + * owning controller proto, and the tool/resource/prompt metadata. The host + * router turns these into MCP server registrations at request time. + */ +export interface ServerRegisterRecord { + getOrCreateEggObject: (proto: EggPrototype, name?: EggObjectName) => Promise; + proto: EggPrototype; + meta: T; +} + +/** + * One MCP server's collected records. The tool/resource/prompt arrays are + * LIVE references owned by the collect-only register: the router mounts its + * transport once (per server name) and reads these arrays lazily at request + * time, so records collected after the mount are still served. + */ +export interface McpServerRegistration { + /** `'default'` for the unnamed server, otherwise the multiple-server name. */ + serverName: string; + controllerMeta: MCPControllerMeta; + tools: ServerRegisterRecord[]; + resources: ServerRegisterRecord[]; + prompts: ServerRegisterRecord[]; +} + +/** + * The transport boundary between the host-agnostic MCP controller register + * (which only COLLECTS records) and the host-specific transport wiring (egg + * node HTTP routes, service worker fetch routes). The register calls + * `registerServer` once the first time it sees a server name; the router owns + * everything about how requests reach the collected records. + */ +export interface McpRouter { + registerServer(reg: McpServerRegistration): void; +} diff --git a/tegg/core/controller-runtime/tsconfig.json b/tegg/core/controller-runtime/tsconfig.json new file mode 100644 index 0000000000..618c6c3e97 --- /dev/null +++ b/tegg/core/controller-runtime/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/tegg/core/standalone-decorator/package.json b/tegg/core/standalone-decorator/package.json index c12b4d7a9f..9773fa576b 100644 --- a/tegg/core/standalone-decorator/package.json +++ b/tegg/core/standalone-decorator/package.json @@ -41,7 +41,11 @@ "scripts": { "typecheck": "tsgo --noEmit" }, - "dependencies": {}, + "dependencies": { + "@eggjs/core-decorator": "workspace:*", + "@eggjs/dynamic-inject": "workspace:*", + "@eggjs/tegg-types": "workspace:*" + }, "devDependencies": { "@types/node": "catalog:", "typescript": "catalog:" diff --git a/tegg/core/standalone-decorator/src/event/EventHandler.ts b/tegg/core/standalone-decorator/src/event/EventHandler.ts new file mode 100644 index 0000000000..b81ea10505 --- /dev/null +++ b/tegg/core/standalone-decorator/src/event/EventHandler.ts @@ -0,0 +1,26 @@ +import { SingletonProto } from '@eggjs/core-decorator'; +import { QualifierImplDecoratorUtil } from '@eggjs/dynamic-inject'; +import type { EggProtoImplClass, ImplDecorator, SingletonProtoParams } from '@eggjs/tegg-types'; + +/** + * Standalone event dispatch abstraction: implementations handle one event + * type (e.g. 'fetch') and are resolved at runtime via + * `eggObjectFactory.getEggObject(AbstractEventHandler, event.type)`. + */ +export abstract class AbstractEventHandler { + abstract handleEvent(event: E): Promise; +} + +export const EVENT_HANDLER_ATTRIBUTE = Symbol.for('EggPrototype#eventHandler'); + +export type EventType = Record; + +export const EventHandler: ImplDecorator = + QualifierImplDecoratorUtil.generatorDecorator(AbstractEventHandler, EVENT_HANDLER_ATTRIBUTE); + +export const EventHandlerProto = (type: EventType[keyof EventType], params?: SingletonProtoParams) => { + return (clazz: EggProtoImplClass) => { + EventHandler(type)(clazz); + SingletonProto(params)(clazz); + }; +}; diff --git a/tegg/core/standalone-decorator/src/index.ts b/tegg/core/standalone-decorator/src/index.ts index a283767273..585203f92d 100644 --- a/tegg/core/standalone-decorator/src/index.ts +++ b/tegg/core/standalone-decorator/src/index.ts @@ -1,3 +1,4 @@ export * from './typing.ts'; export * from './util/index.ts'; export * from './decorator/index.ts'; +export * from './event/EventHandler.ts'; diff --git a/tegg/plugin/controller/package.json b/tegg/plugin/controller/package.json index f777731c2c..d8e68646a3 100644 --- a/tegg/plugin/controller/package.json +++ b/tegg/plugin/controller/package.json @@ -33,25 +33,15 @@ "./config/config.default": "./src/config/config.default.ts", "./lib/AgentControllerObject": "./src/lib/AgentControllerObject.ts", "./lib/AgentControllerProto": "./src/lib/AgentControllerProto.ts", - "./lib/AppLoadUnitControllerHook": "./src/lib/AppLoadUnitControllerHook.ts", - "./lib/ControllerLoadUnit": "./src/lib/ControllerLoadUnit.ts", "./lib/ControllerLoadUnitHandler": "./src/lib/ControllerLoadUnitHandler.ts", - "./lib/ControllerLoadUnitInstance": "./src/lib/ControllerLoadUnitInstance.ts", - "./lib/ControllerMetadataManager": "./src/lib/ControllerMetadataManager.ts", - "./lib/ControllerRegister": "./src/lib/ControllerRegister.ts", - "./lib/ControllerRegisterFactory": "./src/lib/ControllerRegisterFactory.ts", + "./lib/ControllerModule": "./src/lib/ControllerModule.ts", "./lib/EggControllerLoader": "./src/lib/EggControllerLoader.ts", - "./lib/EggControllerPrototypeHook": "./src/lib/EggControllerPrototypeHook.ts", - "./lib/errors": "./src/lib/errors.ts", "./lib/impl/http/Acl": "./src/lib/impl/http/Acl.ts", "./lib/impl/http/HTTPControllerRegister": "./src/lib/impl/http/HTTPControllerRegister.ts", "./lib/impl/http/HTTPMethodRegister": "./src/lib/impl/http/HTTPMethodRegister.ts", "./lib/impl/http/Req": "./src/lib/impl/http/Req.ts", + "./lib/impl/mcp/EggMcpRouter": "./src/lib/impl/mcp/EggMcpRouter.ts", "./lib/impl/mcp/MCPConfig": "./src/lib/impl/mcp/MCPConfig.ts", - "./lib/impl/mcp/MCPControllerRegister": "./src/lib/impl/mcp/MCPControllerRegister.ts", - "./lib/impl/mcp/MCPServerHelper": "./src/lib/impl/mcp/MCPServerHelper.ts", - "./lib/MiddlewareGraphHook": "./src/lib/MiddlewareGraphHook.ts", - "./lib/RootProtoManager": "./src/lib/RootProtoManager.ts", "./types": "./src/types.ts", "./package.json": "./package.json" }, @@ -65,25 +55,15 @@ "./config/config.default": "./dist/config/config.default.js", "./lib/AgentControllerObject": "./dist/lib/AgentControllerObject.js", "./lib/AgentControllerProto": "./dist/lib/AgentControllerProto.js", - "./lib/AppLoadUnitControllerHook": "./dist/lib/AppLoadUnitControllerHook.js", - "./lib/ControllerLoadUnit": "./dist/lib/ControllerLoadUnit.js", "./lib/ControllerLoadUnitHandler": "./dist/lib/ControllerLoadUnitHandler.js", - "./lib/ControllerLoadUnitInstance": "./dist/lib/ControllerLoadUnitInstance.js", - "./lib/ControllerMetadataManager": "./dist/lib/ControllerMetadataManager.js", - "./lib/ControllerRegister": "./dist/lib/ControllerRegister.js", - "./lib/ControllerRegisterFactory": "./dist/lib/ControllerRegisterFactory.js", + "./lib/ControllerModule": "./dist/lib/ControllerModule.js", "./lib/EggControllerLoader": "./dist/lib/EggControllerLoader.js", - "./lib/EggControllerPrototypeHook": "./dist/lib/EggControllerPrototypeHook.js", - "./lib/errors": "./dist/lib/errors.js", "./lib/impl/http/Acl": "./dist/lib/impl/http/Acl.js", "./lib/impl/http/HTTPControllerRegister": "./dist/lib/impl/http/HTTPControllerRegister.js", "./lib/impl/http/HTTPMethodRegister": "./dist/lib/impl/http/HTTPMethodRegister.js", "./lib/impl/http/Req": "./dist/lib/impl/http/Req.js", + "./lib/impl/mcp/EggMcpRouter": "./dist/lib/impl/mcp/EggMcpRouter.js", "./lib/impl/mcp/MCPConfig": "./dist/lib/impl/mcp/MCPConfig.js", - "./lib/impl/mcp/MCPControllerRegister": "./dist/lib/impl/mcp/MCPControllerRegister.js", - "./lib/impl/mcp/MCPServerHelper": "./dist/lib/impl/mcp/MCPServerHelper.js", - "./lib/MiddlewareGraphHook": "./dist/lib/MiddlewareGraphHook.js", - "./lib/RootProtoManager": "./dist/lib/RootProtoManager.js", "./types": "./dist/types.js", "./package.json": "./package.json" } @@ -94,6 +74,7 @@ "dependencies": { "@eggjs/agent-runtime": "workspace:*", "@eggjs/controller-decorator": "workspace:*", + "@eggjs/controller-runtime": "workspace:*", "@eggjs/core-decorator": "workspace:*", "@eggjs/lifecycle": "workspace:*", "@eggjs/metadata": "workspace:*", @@ -132,6 +113,9 @@ "engines": { "node": ">=22.18.0" }, + "eggModule": { + "name": "teggController" + }, "eggPlugin": { "name": "teggController", "strict": false, diff --git a/tegg/plugin/controller/src/app.ts b/tegg/plugin/controller/src/app.ts index 54932ff72c..2fb019b34c 100644 --- a/tegg/plugin/controller/src/app.ts +++ b/tegg/plugin/controller/src/app.ts @@ -1,24 +1,31 @@ import assert from 'node:assert'; -import { ControllerMetaBuilderFactory, ControllerType } from '@eggjs/controller-decorator'; -import { GlobalGraph, type LoadUnitLifecycleContext } from '@eggjs/metadata'; -import { type LoadUnitInstanceLifecycleContext, ModuleLoadUnitInstance } from '@eggjs/tegg-runtime'; +import { ControllerMetaBuilderFactory, ControllerType, type MCPControllerMeta } from '@eggjs/controller-decorator'; +import { + CONTROLLER_LOAD_UNIT, + ControllerLoadUnit, + ControllerMetadataManager, + ControllerRegisterDefaults, + type ControllerRegisterFactory, + MCPControllerRegister, + middlewareGraphHook, + type RootProtoManager, +} from '@eggjs/controller-runtime'; +import { EggPrototypeFactory, GlobalGraph, type LoadUnitLifecycleContext } from '@eggjs/metadata'; +import { + EggContainerFactory, + type LoadUnitInstanceLifecycleContext, + ModuleLoadUnitInstance, +} from '@eggjs/tegg-runtime'; import { AGENT_CONTROLLER_PROTO_IMPL_TYPE, TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { AgentControllerObject } from './lib/AgentControllerObject.ts'; import { AgentControllerProto } from './lib/AgentControllerProto.ts'; -import { AppLoadUnitControllerHook } from './lib/AppLoadUnitControllerHook.ts'; -import { CONTROLLER_LOAD_UNIT, ControllerLoadUnit } from './lib/ControllerLoadUnit.ts'; import { ControllerLoadUnitHandler } from './lib/ControllerLoadUnitHandler.ts'; -import { ControllerMetadataManager } from './lib/ControllerMetadataManager.ts'; -import { ControllerRegisterFactory } from './lib/ControllerRegisterFactory.ts'; import { EggControllerLoader } from './lib/EggControllerLoader.ts'; -import { EggControllerPrototypeHook } from './lib/EggControllerPrototypeHook.ts'; import { HTTPControllerRegister } from './lib/impl/http/HTTPControllerRegister.ts'; -import { MCPControllerRegister } from './lib/impl/mcp/MCPControllerRegister.ts'; -import { middlewareGraphHook } from './lib/MiddlewareGraphHook.ts'; -import { RootProtoManager } from './lib/RootProtoManager.ts'; +import { EggMcpRouter } from './lib/impl/mcp/EggMcpRouter.ts'; // Load Controller process // 1. await add load unit is ready, controller may depend other load unit @@ -27,19 +34,15 @@ import { RootProtoManager } from './lib/RootProtoManager.ts'; export default class ControllerAppBootHook implements ILifecycleBoot { private readonly app: Application; - private readonly loadUnitHook: AppLoadUnitControllerHook; - private readonly controllerRegisterFactory: ControllerRegisterFactory; private controllerLoadUnitHandler: ControllerLoadUnitHandler; - private readonly controllerPrototypeHook: EggControllerPrototypeHook; constructor(app: Application) { this.app = app; - this.controllerRegisterFactory = new ControllerRegisterFactory(this.app); - this.app.rootProtoManager = new RootProtoManager(); - this.app.controllerRegisterFactory = this.controllerRegisterFactory; + // rootProtoManager / controllerRegisterFactory / the controller hooks are + // declared by the controller MODULE (lib/ControllerModule.ts) and + // instantiated in the InnerObjectLoadUnit — didLoad() below backfills the + // per-app instances onto the app surface. this.app.controllerMetaBuilderFactory = ControllerMetaBuilderFactory; - this.loadUnitHook = new AppLoadUnitControllerHook(this.controllerRegisterFactory, this.app.rootProtoManager); - this.controllerPrototypeHook = new EggControllerPrototypeHook(); this.app.eggPrototypeCreatorFactory.registerPrototypeCreator( AGENT_CONTROLLER_PROTO_IMPL_TYPE, AgentControllerProto.createProto, @@ -56,13 +59,18 @@ export default class ControllerAppBootHook implements ILifecycleBoot { } private doConfigWillLoad(): void { - this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitHook); - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.controllerPrototypeHook); this.app.eggObjectFactory.registerEggObjectCreateMethod(AgentControllerProto, AgentControllerObject.createObject); this.app.loaderFactory.registerLoader(CONTROLLER_LOAD_UNIT, (unitPath) => { return new EggControllerLoader(unitPath); }); - this.controllerRegisterFactory.registerControllerRegister(ControllerType.HTTP, HTTPControllerRegister.create); + // Drained by the module factory proto when the InnerObjectLoadUnit + // materializes (before any business load unit). The creators close over + // `this.app` (rather than the DI-threaded host) so the egg app never has to + // be provided as an inner object — HTTP registers mount on `app.router`, + // the MCP router captures the app imperatively. + ControllerRegisterDefaults.enqueue(ControllerType.HTTP, (proto, meta) => + HTTPControllerRegister.create(proto, meta, this.app), + ); this.app.loadUnitFactory.registerLoadUnitCreator( CONTROLLER_LOAD_UNIT, (ctx: LoadUnitLifecycleContext): ControllerLoadUnit => { @@ -98,7 +106,18 @@ export default class ControllerAppBootHook implements ILifecycleBoot { // init http root proto middleware this.prepareMiddleware(this.app.config.coreMiddleware); if (this.mcpEnable()) { - this.controllerRegisterFactory.registerControllerRegister(ControllerType.MCP, MCPControllerRegister.create); + // One EggMcpRouter + one collect-only MCPControllerRegister per app, + // created lazily on the first MCP controller proto (during didLoad, when + // app.router/app.config.mcp are ready). The router owns all egg transport; + // the register only collects records and delegates to the router. + let eggMcpRouter: EggMcpRouter | undefined; + let mcpRegister: MCPControllerRegister | undefined; + ControllerRegisterDefaults.enqueue(ControllerType.MCP, (proto, meta) => { + eggMcpRouter ??= new EggMcpRouter(this.app); + mcpRegister ??= new MCPControllerRegister(meta as MCPControllerMeta, eggMcpRouter); + mcpRegister.addControllerProto(proto); + return mcpRegister; + }); // Don't let the mcp's body be consumed this.app.config.coreMiddleware.unshift('mcpBodyMiddleware'); @@ -150,6 +169,16 @@ export default class ControllerAppBootHook implements ILifecycleBoot { // that touches the per-app factories), and the HTTP/MCP registers below are // per-app — run the whole flow inside this app's scope. await TeggScope.run(this.app._teggScopeBag, async () => { + // The controller module owns these protos; expose the per-app instances + // on the app surface for the teggRootProto middleware and downstream + // consumers. + const factoryProto = EggPrototypeFactory.instance.getPrototype('controllerRegisterFactory'); + this.app.controllerRegisterFactory = (await EggContainerFactory.getOrCreateEggObject(factoryProto)) + .obj as ControllerRegisterFactory; + const rootProtoManagerProto = EggPrototypeFactory.instance.getPrototype('rootProtoManager'); + this.app.rootProtoManager = (await EggContainerFactory.getOrCreateEggObject(rootProtoManagerProto)) + .obj as RootProtoManager; + this.controllerLoadUnitHandler = new ControllerLoadUnitHandler(this.app); await this.controllerLoadUnitHandler.ready(); @@ -159,7 +188,7 @@ export default class ControllerAppBootHook implements ILifecycleBoot { // and register methods after collect is done. HTTPControllerRegister.instance?.doRegister(this.app.rootProtoManager); - this.app.config.mcp.hooks = MCPControllerRegister.hooks; + this.app.config.mcp.hooks = EggMcpRouter.hooks; }); } @@ -177,11 +206,12 @@ export default class ControllerAppBootHook implements ILifecycleBoot { if (this.controllerLoadUnitHandler) { await this.controllerLoadUnitHandler.destroy(); } - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitHook); - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.controllerPrototypeHook); + // The module-declared controller hooks deregister with the + // InnerObjectLoadUnit teardown. ControllerMetadataManager.instance.clear(); HTTPControllerRegister.clean(); - MCPControllerRegister.clean(); + // The MCP register/router are per-boot closures (no static instance to + // clean); the scope-backed hook list is torn down with the app bag. }); } } diff --git a/tegg/plugin/controller/src/index.ts b/tegg/plugin/controller/src/index.ts index 0050f52d81..00a7f262ab 100644 --- a/tegg/plugin/controller/src/index.ts +++ b/tegg/plugin/controller/src/index.ts @@ -1 +1,5 @@ import './types.ts'; +// Host-agnostic controller runtime surface (including the teggController module +// that provides the DI inner objects) now lives in @eggjs/controller-runtime; +// re-export it so existing egg-side consumers keep importing from this plugin. +export * from '@eggjs/controller-runtime'; diff --git a/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts b/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts index c08352af73..357cdb17f0 100644 --- a/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts +++ b/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts @@ -1,12 +1,11 @@ import path from 'node:path'; +import { CONTROLLER_LOAD_UNIT } from '@eggjs/controller-runtime'; import type { EggLoadUnitType, LoadUnit } from '@eggjs/metadata'; import type { LoadUnitInstance } from '@eggjs/tegg-runtime'; import type { Application } from 'egg'; import { Base } from 'sdk-base'; -import { CONTROLLER_LOAD_UNIT } from './ControllerLoadUnit.ts'; - export class ControllerLoadUnitHandler extends Base { private readonly app: Application; controllerLoadUnit: LoadUnit; diff --git a/tegg/plugin/controller/src/lib/ControllerModule.ts b/tegg/plugin/controller/src/lib/ControllerModule.ts new file mode 100644 index 0000000000..6d10ca35ed --- /dev/null +++ b/tegg/plugin/controller/src/lib/ControllerModule.ts @@ -0,0 +1,15 @@ +// The teggController module's DI inner objects (rootProtoManager, +// controllerRegisterFactory) and its lifecycle hooks are defined ONCE, host +// agnostically, in @eggjs/controller-runtime — which is a plain library, NOT an +// eggModule. This file re-exports those decorated prototypes so the EGG host's +// `teggController` eggModule (this plugin package) registers them — +// `LoaderUtil.loadFile` collects re-exported `@*Proto` classes. The standalone +// service-worker host re-exports the same prototypes into its own `serviceWorker` +// eggModule (see @eggjs/service-worker/src/ControllerModule.ts). The scanned +// module is always a host package; the runtime library is never scanned. +export { + EggRootProtoManager, + EggControllerRegisterFactory, + EggControllerLoadUnitHook, + EggControllerPrototypeLifecycleHook, +} from '@eggjs/controller-runtime'; diff --git a/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts b/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts index 2b11d24d5d..6f50d584a2 100644 --- a/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts +++ b/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts @@ -1,24 +1,17 @@ import assert from 'node:assert/strict'; -import { - CONTROLLER_META_DATA, - type ControllerMetadata, - ControllerType, - HTTPControllerMeta, - HTTPMethodMeta, -} from '@eggjs/controller-decorator'; +import { type ControllerMetadata, ControllerType } from '@eggjs/controller-decorator'; +import { HTTPControllerRegister as BaseHTTPControllerRegister } from '@eggjs/controller-runtime'; import type { EggPrototype } from '@eggjs/metadata'; import { EggContainerFactory } from '@eggjs/tegg-runtime'; import { TeggScope } from '@eggjs/tegg-types'; import type { Application, Router } from 'egg'; -import type { ControllerRegister } from '../../ControllerRegister.ts'; -import { RootProtoManager } from '../../RootProtoManager.ts'; import { HTTPMethodRegister } from './HTTPMethodRegister.ts'; const HTTP_CONTROLLER_REGISTER_SLOT = Symbol('tegg:controller:httpControllerRegister'); -export class HTTPControllerRegister implements ControllerRegister { +export class HTTPControllerRegister extends BaseHTTPControllerRegister { // Per-app: the register accumulates protos and binds to one app's router, so // it must be per-app (resolved from the active TeggScope bag). static get instance(): HTTPControllerRegister | undefined { @@ -29,79 +22,31 @@ export class HTTPControllerRegister implements ControllerRegister { TeggScope.set(HTTP_CONTROLLER_REGISTER_SLOT, value); } - private readonly router: Router; - private readonly checkRouters: Map; - private readonly eggContainerFactory: typeof EggContainerFactory; - private controllerProtos: EggPrototype[] = []; - static create(proto: EggPrototype, controllerMeta: ControllerMetadata, app: Application): HTTPControllerRegister { assert(controllerMeta.type === ControllerType.HTTP, 'controller meta type is not HTTP'); if (!HTTPControllerRegister.instance) { - HTTPControllerRegister.instance = new HTTPControllerRegister(app.router, app.eggContainerFactory); + // Import the container factory directly: `app` may arrive through the + // inject proxy, whose property reads bind function values — a bound + // class loses its statics. + HTTPControllerRegister.instance = new HTTPControllerRegister(app.router, EggContainerFactory); } - HTTPControllerRegister.instance.controllerProtos.push(proto); + HTTPControllerRegister.instance.addControllerProto(proto); return HTTPControllerRegister.instance; } constructor(router: Router, eggContainerFactory: typeof EggContainerFactory) { - this.router = router; - this.checkRouters = new Map(); - this.checkRouters.set('default', router); - this.eggContainerFactory = eggContainerFactory; - } - - register(): Promise { - // do noting - return Promise.resolve(); + super( + router, + eggContainerFactory, + (proto, controllerMeta, methodMeta, methodRouter, checkRouters, containerFactory) => + new HTTPMethodRegister(proto, controllerMeta, methodMeta, methodRouter, checkRouters, containerFactory), + ); } static clean(): void { if (this.instance) { - this.instance.controllerProtos = []; - this.instance.checkRouters.clear(); + this.instance.clear(); } this.instance = undefined; } - - doRegister(rootProtoManager: RootProtoManager): void { - const methodMap = new Map(); - for (const proto of this.controllerProtos) { - const metadata = proto.getMetaData(CONTROLLER_META_DATA) as HTTPControllerMeta; - for (const method of metadata.methods) { - methodMap.set(method, proto); - } - } - const allMethods = Array.from(methodMap.keys()).sort((a, b) => b.priority - a.priority); - - // FIXME: why init method register twice? - for (const method of allMethods) { - const controllerProto = methodMap.get(method)!; - const controllerMeta = controllerProto.getMetaData(CONTROLLER_META_DATA) as HTTPControllerMeta; - const methodRegister = new HTTPMethodRegister( - controllerProto, - controllerMeta, - method, - this.router, - this.checkRouters, - this.eggContainerFactory, - ); - methodRegister.checkDuplicate(); - } - - for (const method of allMethods) { - const controllerProto = methodMap.get(method)!; - const controllerMeta = controllerProto.getMetaData(CONTROLLER_META_DATA) as HTTPControllerMeta; - const methodRegister = new HTTPMethodRegister( - controllerProto, - controllerMeta, - method, - this.router, - this.checkRouters, - this.eggContainerFactory, - ); - // Error: framework.RouterConflictError: register http controller GET AppController2.get failed, GET /apps/:id is conflict with exists rule /apps/:id [ https://eggjs.org/faq/TEGG_ROUTER_CONFLICT ] - // methodRegister.checkDuplicate(); - methodRegister.register(rootProtoManager); - } - } } diff --git a/tegg/plugin/controller/src/lib/impl/http/HTTPMethodRegister.ts b/tegg/plugin/controller/src/lib/impl/http/HTTPMethodRegister.ts index f84744b95f..451cc0b1a2 100644 --- a/tegg/plugin/controller/src/lib/impl/http/HTTPMethodRegister.ts +++ b/tegg/plugin/controller/src/lib/impl/http/HTTPMethodRegister.ts @@ -1,57 +1,28 @@ import assert from 'node:assert'; import { - type EggContext, - HTTPControllerMeta, - HTTPMethodMeta, HTTPParamType, - PathParamMeta, - QueriesParamMeta, - QueryParamMeta, + type PathParamMeta, + type QueriesParamMeta, + type QueryParamMeta, Cookies, + type HTTPMethodMeta, } from '@eggjs/controller-decorator'; -import type { EggPrototype } from '@eggjs/metadata'; -import { EggRouter } from '@eggjs/router'; +import { HTTPMethodRegister as BaseHTTPMethodRegister, type HTTPHandlerFunc } from '@eggjs/controller-runtime'; import { TimerUtil } from '@eggjs/tegg-common-util'; -import { EggContainerFactory } from '@eggjs/tegg-runtime'; -import type { Router, MiddlewareFunc } from 'egg'; -import { FrameworkErrorFormater } from 'egg-errors'; -import pathToRegexp from 'path-to-regexp'; +import type { MiddlewareFunc } from 'egg'; -import { RouterConflictError } from '../../errors.ts'; -import { RootProtoManager } from '../../RootProtoManager.ts'; import { aclMiddlewareFactory } from './Acl.ts'; import { initRequest } from './Req.ts'; -const noop = () => { - // ... -}; - -export class HTTPMethodRegister { - private readonly router: Router; - private readonly checkRouters: Map; - private readonly controllerMeta: HTTPControllerMeta; - private readonly methodMeta: HTTPMethodMeta; - private readonly proto: EggPrototype; - private readonly eggContainerFactory: typeof EggContainerFactory; - - constructor( - proto: EggPrototype, - controllerMeta: HTTPControllerMeta, - methodMeta: HTTPMethodMeta, - router: Router, - checkRouters: Map, - eggContainerFactory: typeof EggContainerFactory, - ) { - this.proto = proto; - this.controllerMeta = controllerMeta; - this.router = router; - this.methodMeta = methodMeta; - this.checkRouters = checkRouters; - this.eggContainerFactory = eggContainerFactory; - } - - private createHandler(methodMeta: HTTPMethodMeta, host: string | undefined): MiddlewareFunc { +/** + * The egg host's HTTP method register: the shared skeleton + * (route registration / duplicate check / root proto wiring) lives in + * the controller plugin runtime; this subclass binds the egg ctx request shape + * to method args and writes the return value back to `ctx.body`. + */ +export class HTTPMethodRegister extends BaseHTTPMethodRegister { + protected createHandler(methodMeta: HTTPMethodMeta, host: string | undefined): HTTPHandlerFunc { const argsLength = methodMeta.paramMap.size; const hasContext = methodMeta.contextParamIndex !== undefined; const contextIndex = methodMeta.contextParamIndex; @@ -59,7 +30,7 @@ export class HTTPMethodRegister { const timeout = this.controllerMeta.getMethodTimeout(methodMeta); // oxlint-disable-next-line no-this-alias const methodRegister = this; - return async function (ctx, next) { + const handler: MiddlewareFunc = async function (ctx, next) { // if hosts is not empty and host is not matched, not execute if (host && host !== ctx.host) { return await next(); @@ -141,79 +112,11 @@ export class HTTPMethodRegister { ctx.body = body; } }; + return handler as HTTPHandlerFunc; } - checkDuplicate(): void { - // 1. check duplicate with egg controller - this.checkDuplicateInRouter(this.router); - - // 2. check duplicate with host tegg controller - let hostRouter: Router | undefined; - const hosts = this.controllerMeta.getMethodHosts(this.methodMeta) || []; - hosts.forEach((h) => { - if (h) { - hostRouter = this.checkRouters.get(h); - if (!hostRouter) { - hostRouter = new EggRouter({ sensitive: true }, this.router.app); - this.checkRouters.set(h, hostRouter!); - } - } - if (hostRouter) { - this.checkDuplicateInRouter(hostRouter); - this.registerToRouter(hostRouter); - } - }); - } - - private registerToRouter(router: Router) { - const routerFunc = router[this.methodMeta.method.toLowerCase() as keyof Router] as Function; - const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); - const methodName = this.controllerMeta.getMethodName(this.methodMeta); - Reflect.apply(routerFunc, router, [methodName, methodRealPath, noop]); - } - - private checkDuplicateInRouter(router: Router) { - const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); - const matched = router.match(methodRealPath, this.methodMeta.method); - const methodName = this.controllerMeta.getMethodName(this.methodMeta); - if (matched.route) { - const [layer] = matched.path; - const err = new RouterConflictError( - `register http controller ${methodName} failed, ${this.methodMeta.method} ${methodRealPath} is conflict with exists rule ${layer.path}`, - ); - throw FrameworkErrorFormater.format(err); - } - } - - /** - * register method to router - */ - register(rootProtoManager: RootProtoManager): void { - const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); - const methodName = this.controllerMeta.getMethodName(this.methodMeta); - const routerFunc = this.router[this.methodMeta.method.toLowerCase() as keyof Router] as Function; - const methodMiddlewares = this.controllerMeta.getMethodMiddlewares(this.methodMeta); + protected getExtraMethodMiddlewares(): HTTPHandlerFunc[] { const aclMiddleware = aclMiddlewareFactory(this.controllerMeta, this.methodMeta); - if (aclMiddleware) { - methodMiddlewares.push(aclMiddleware); - } - const hosts = this.controllerMeta.getMethodHosts(this.methodMeta) ?? [undefined]; - hosts.forEach((host) => { - const handler = this.createHandler(this.methodMeta, host); - Reflect.apply(routerFunc, this.router, [methodName, methodRealPath, ...methodMiddlewares, handler]); - // https://github.com/eggjs/egg-core/blob/0af6178022e7734c4a8b17bb56d592b315207883/lib/egg.js#L279 - const regExp = pathToRegexp(methodRealPath, { - sensitive: true, - }); - rootProtoManager.registerRootProto( - this.methodMeta.method, - (ctx: EggContext) => { - if (regExp.test(ctx.path)) { - return this.proto; - } - }, - host || '', - ); - }); + return aclMiddleware ? [aclMiddleware as HTTPHandlerFunc] : []; } } diff --git a/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts b/tegg/plugin/controller/src/lib/impl/mcp/EggMcpRouter.ts similarity index 73% rename from tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts rename to tegg/plugin/controller/src/lib/impl/mcp/EggMcpRouter.ts index 0d26f4bb06..cf21a3e0d4 100644 --- a/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts +++ b/tegg/plugin/controller/src/lib/impl/mcp/EggMcpRouter.ts @@ -1,20 +1,10 @@ -import assert from 'node:assert'; import http, { IncomingMessage, ServerResponse } from 'node:http'; import { Socket } from 'node:net'; -import type { EggPrototype } from '@eggjs/metadata'; -import { CONTROLLER_META_DATA, ControllerType, MCPProtocols } from '@eggjs/tegg'; -import type { - ControllerMetadata, - MCPControllerMeta, - MCPPromptMeta, - MCPToolMeta, - EggContext, - EggObjectName, - MCPResourceMeta, -} from '@eggjs/tegg'; -import { EggContainerFactory } from '@eggjs/tegg-runtime'; -import type { EggObject } from '@eggjs/tegg-runtime'; +import type { McpRouter, McpServerRegistration } from '@eggjs/controller-runtime'; +import { MCPServerHelper } from '@eggjs/controller-runtime'; +import { MCPProtocols } from '@eggjs/tegg'; +import type { MCPControllerMeta, MCPPromptMeta, MCPToolMeta, EggContext } from '@eggjs/tegg'; import { TeggScope } from '@eggjs/tegg-types'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -29,16 +19,13 @@ import type { Application, Context, Router } from 'egg'; import compose from 'koa-compose'; import getRawBody from 'raw-body'; -import type { ControllerRegister } from '../../ControllerRegister.ts'; import { MCPConfig } from './MCPConfig.ts'; -import { MCPServerHelper } from './MCPServerHelper.ts'; -const MCP_CONTROLLER_REGISTER_SLOT = Symbol('tegg:controller:mcpControllerRegister'); const MCP_HOOKS_SLOT = Symbol('tegg:controller:mcpControllerHooks'); export interface MCPControllerHook { // SSE - preSSEInitHandle?: (ctx: Context, transport: SSEServerTransport, register: MCPControllerRegister) => Promise; + preSSEInitHandle?: (ctx: Context, transport: SSEServerTransport, router: EggMcpRouter) => Promise; preHandleInitHandle?: (ctx: Context) => Promise; // STREAM @@ -47,7 +34,7 @@ export interface MCPControllerHook { ctx: Context, transport: StreamableHTTPServerTransport, server: McpServer, - register: MCPControllerRegister, + router: EggMcpRouter, ) => Promise; // COMMON @@ -64,16 +51,10 @@ export interface MCPControllerHook { middlewareError?: (ctx: Context, e: Error) => Promise; } -interface ServerRegisterRecord { - getOrCreateEggObject: (proto: EggPrototype, name?: EggObjectName) => Promise; - proto: EggPrototype; - meta: T; -} - class InnerSSEServerTransport extends SSEServerTransport { - // Capture the owning per-app register so send() (driven by the MCP SDK, often + // Capture the owning per-app router so send() (driven by the MCP SDK, often // outside any ALS frame) resolves the correct app's request map directly. - register?: MCPControllerRegister; + router?: EggMcpRouter; async send(message: JSONRPCMessage): Promise { let err: null | Error = null; @@ -82,7 +63,7 @@ class InnerSSEServerTransport extends SSEServerTransport { } catch (e) { err = e as Error; } finally { - const map = this.register?.sseTransportsRequestMap.get(this); + const map = this.router?.sseTransportsRequestMap.get(this); if (map && 'id' in message) { const { resolve, reject } = map[message.id!] ?? {}; if (resolve) { @@ -94,29 +75,39 @@ class InnerSSEServerTransport extends SSEServerTransport { } } -export class MCPControllerRegister implements ControllerRegister { - // Per-app: holds this app's MCP transports/servers/timers, so it is resolved - // from the active TeggScope bag rather than a process-global singleton. - static get instance(): MCPControllerRegister | undefined { - return TeggScope.getOr(MCP_CONTROLLER_REGISTER_SLOT, () => undefined, 'MCPControllerRegister.instance'); +/** + * The egg host's MCP transport boundary: holds this app's MCP + * transports/servers/timers and mounts node HTTP routes (SSE, streamable HTTP, + * stateless streamable HTTP) on `app.router`. It is created imperatively per + * app during controller boot (see `app.ts`) so the egg `app` never has to be + * threaded through the DI graph as an inner object. + * + * The host-agnostic {@link MCPControllerRegister} only COLLECTS records; this + * router turns each server name's live records into per-request MCP servers. + */ +export class EggMcpRouter implements McpRouter { + // Per-app hook list (mcp-proxy registers its hook here at agent boot). Each + // app gets its own list (scope-backed) so concurrent apps do not accumulate + // each other's hooks. Kept static so mcp-proxy can register a hook before any + // router instance exists. + static get hooks(): MCPControllerHook[] { + return TeggScope.resolve(MCP_HOOKS_SLOT, () => [], 'EggMcpRouter.hooks'); } - static set instance(value: MCPControllerRegister | undefined) { - TeggScope.set(MCP_CONTROLLER_REGISTER_SLOT, value); + static addHook(hook: MCPControllerHook): void { + EggMcpRouter.hooks.push(hook); } readonly app: Application; - readonly eggContainerFactory: typeof EggContainerFactory; private readonly router: Router; - private controllerProtos: EggPrototype[] = []; - private registeredControllerProtos: EggPrototype[] = []; + mcpConfig: MCPConfig; + transports: Record = {}; sseConnections: Map = new Map(); mcpServerHelperMap: Record MCPServerHelper> = {}; mcpServerMap: Record = {}; - private controllerMeta: MCPControllerMeta; - mcpConfig: MCPConfig; streamTransports: Record = {}; + pingIntervals: Record = {}; sseTransportsRequestMap: Map< InnerSSEServerTransport, Record< @@ -128,61 +119,40 @@ export class MCPControllerRegister implements ControllerRegister { > > = new Map(); - // Per-app hook list (mcp-proxy registers its hook here at agent boot). Each app - // gets its own list so concurrent apps do not accumulate each other's hooks. - static get hooks(): MCPControllerHook[] { - return TeggScope.resolve(MCP_HOOKS_SLOT, () => [], 'MCPControllerRegister.hooks'); - } + // The live collected records, keyed by server name; the route handlers read + // these lazily at request time. + private registrations: Record = {}; // Optional: resolved + composed lazily on the first request (see // `composeGlobalMiddleware`), so it stays undefined until then. globalMiddlewares?: compose.ComposedMiddleware; - registerMap: Record< - string, - { - tools: ServerRegisterRecord[]; - prompts: ServerRegisterRecord[]; - resources: ServerRegisterRecord[]; - } - > = {}; - - pingIntervals: Record = {}; - - static create(proto: EggPrototype, controllerMeta: ControllerMetadata, app: Application): MCPControllerRegister { - assert(controllerMeta.type === ControllerType.MCP, 'controller meta type is not MCP'); - if (!MCPControllerRegister.instance) { - MCPControllerRegister.instance = new MCPControllerRegister(proto, controllerMeta as MCPControllerMeta, app); - } - MCPControllerRegister.instance.controllerProtos.push(proto); - return MCPControllerRegister.instance; - } - - constructor(_proto: EggPrototype, controllerMeta: MCPControllerMeta, app: Application) { + constructor(app: Application) { this.app = app; - this.eggContainerFactory = app.eggContainerFactory; this.router = app.router; - - this.controllerMeta = controllerMeta; - this.mcpConfig = new MCPConfig(app.config.mcp); } - static addHook(hook: MCPControllerHook): void { - MCPControllerRegister.hooks.push(hook); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - static async connectStatelessStreamTransport(_name?: string): Promise { - // No-op: MCP SDK >= 1.26 requires stateless transports to be single-use, - // so transports are now created per-request in the route handler. - } - - static clean(): void { - if (this.instance) { - this.instance.controllerProtos = []; + registerServer(reg: McpServerRegistration): void { + const serverName = reg.serverName; + // The unnamed ('default') server uses the base MCP paths (name === undefined); + // named servers use the multiple-server paths. + const name = serverName === 'default' ? undefined : serverName; + this.registrations[serverName] = reg; + this.mcpServerHelperMap[serverName] = () => { + return new MCPServerHelper({ + name: reg.controllerMeta.name ?? `chair-mcp-${name ?? this.app.name}-server`, + version: reg.controllerMeta.version ?? '1.0.0', + hooks: EggMcpRouter.hooks, + }); + }; + this.mcpStatelessStreamServerInit(name); + this.mcpStreamServerInit(name); + this.mcpServerInit(name); + this.mcpServerRegister(name); + if (name) { + this.mcpConfig.setMultipleServerPath(this.app, name); } - this.instance = undefined; } mcpStatelessStreamServerInit(name?: string): void { @@ -196,7 +166,7 @@ export class MCPControllerRegister implements ControllerRegister { sessionIdGenerator: undefined, }); const mcpServerHelper = self.mcpServerHelperMap[name ?? 'default'](); - const registerEntry = self.registerMap[name ?? 'default']; + const registerEntry = self.registrations[name ?? 'default']; if (registerEntry) { for (const tool of registerEntry.tools) { await mcpServerHelper.mcpToolRegister(tool.getOrCreateEggObject, tool.proto, tool.meta); @@ -216,8 +186,8 @@ export class MCPControllerRegister implements ControllerRegister { } onmessage && (await onmessage(message, extra)); }; - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preHandle?.(self.app.currentContext!); } } @@ -272,8 +242,8 @@ export class MCPControllerRegister implements ControllerRegister { const mw = self.composeGlobalMiddleware(() => (self.app.middleware as any).teggCtxLifecycleMiddleware()); const initHandler = async (ctx: Context) => { ctx.respond = false; - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preHandle?.(self.app.currentContext!); } } @@ -307,21 +277,21 @@ export class MCPControllerRegister implements ControllerRegister { ctx.respond = false; const eventStore = this.mcpConfig.getEventStore(); const mcpServerHelper = self.mcpServerHelperMap[name ?? 'default'](); - for (const tool of self.registerMap[name ?? 'default'].tools) { + for (const tool of self.registrations[name ?? 'default'].tools) { await mcpServerHelper.mcpToolRegister(tool.getOrCreateEggObject, tool.proto, tool.meta); } - for (const resource of self.registerMap[name ?? 'default'].resources) { + for (const resource of self.registrations[name ?? 'default'].resources) { await mcpServerHelper.mcpResourceRegister(resource.getOrCreateEggObject, resource.proto, resource.meta); } - for (const prompt of self.registerMap[name ?? 'default'].prompts) { + for (const prompt of self.registrations[name ?? 'default'].prompts) { await mcpServerHelper.mcpPromptRegister(prompt.getOrCreateEggObject, prompt.proto, prompt.meta); } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => this.mcpConfig.getSessionIdGenerator(name)(ctx), eventStore, onsessioninitialized: async (sessionId) => { - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.onStreamSessionInitialized?.( self.app.currentContext!, transport, @@ -379,8 +349,8 @@ export class MCPControllerRegister implements ControllerRegister { } else if (sessionId) { const transport = self.streamTransports[sessionId]; if (transport) { - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preHandle?.(self.app.currentContext!); } } @@ -397,8 +367,8 @@ export class MCPControllerRegister implements ControllerRegister { }); return; } - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { const checked = await hook.checkAndRunProxy?.(self.app.currentContext!, MCPProtocols.STREAM, sessionId); if (checked) { return; @@ -421,10 +391,10 @@ export class MCPControllerRegister implements ControllerRegister { const self = this; const initHandler = async (ctx: Context) => { const transport = new InnerSSEServerTransport(self.mcpConfig.getSseMessagePath(name), ctx.res); - transport.register = self; + transport.router = self; const id = transport.sessionId; - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preSSEInitHandle?.(self.app.currentContext!, transport, self); } } @@ -443,13 +413,13 @@ export class MCPControllerRegister implements ControllerRegister { }); ctx.respond = false; const mcpServerHelper = self.mcpServerHelperMap[name ?? 'default'](); - for (const tool of self.registerMap[name ?? 'default'].tools) { + for (const tool of self.registrations[name ?? 'default'].tools) { mcpServerHelper.mcpToolRegister(tool.getOrCreateEggObject, tool.proto, tool.meta); } - for (const resource of self.registerMap[name ?? 'default'].resources) { + for (const resource of self.registrations[name ?? 'default'].resources) { mcpServerHelper.mcpResourceRegister(resource.getOrCreateEggObject, resource.proto, resource.meta); } - for (const prompt of self.registerMap[name ?? 'default'].prompts) { + for (const prompt of self.registrations[name ?? 'default'].prompts) { mcpServerHelper.mcpPromptRegister(prompt.getOrCreateEggObject, prompt.proto, prompt.meta); } await mcpServerHelper.server.connect(transport); @@ -502,8 +472,8 @@ export class MCPControllerRegister implements ControllerRegister { const newCtx = self.app.createContext(req, res) as unknown as Context; await ctx.app.ctxStorage.run(newCtx, async () => { await mw(newCtx, async () => { - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preHandle?.(newCtx); } } @@ -531,8 +501,8 @@ export class MCPControllerRegister implements ControllerRegister { const sessionId = ctx.query.sessionId as string; if (self.transports[sessionId]) { - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preHandleInitHandle?.(self.app.currentContext!); } } @@ -564,8 +534,8 @@ export class MCPControllerRegister implements ControllerRegister { } return; } - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { const checked = await hook.checkAndRunProxy?.(self.app.currentContext!, MCPProtocols.SSE, sessionId); if (checked) { return; @@ -661,72 +631,4 @@ export class MCPControllerRegister implements ControllerRegister { this.pingIntervals[sessionId] = timerId; } - - async register(): Promise { - for (const proto of this.controllerProtos) { - if (this.registeredControllerProtos.includes(proto)) { - continue; - } - const metadata = proto.getMetaData(CONTROLLER_META_DATA) as MCPControllerMeta; - if (!this.mcpServerHelperMap[metadata.name ?? 'default']) { - this.mcpServerHelperMap[metadata.name ?? 'default'] = () => { - return new MCPServerHelper({ - name: this.controllerMeta.name ?? `chair-mcp-${metadata.name ?? this.app.name}-server`, - version: this.controllerMeta.version ?? '1.0.0', - hooks: MCPControllerRegister.hooks, - }); - }; - this.mcpStatelessStreamServerInit(metadata.name); - this.mcpStreamServerInit(metadata.name); - this.mcpServerInit(metadata.name); - this.mcpServerRegister(metadata.name); - if (metadata.name) { - this.mcpConfig.setMultipleServerPath(this.app, metadata.name); - } - } - for (const prompt of metadata.prompts) { - if (!this.registerMap[metadata.name ?? 'default']) { - this.registerMap[metadata.name ?? 'default'] = { - prompts: [], - resources: [], - tools: [], - }; - } - this.registerMap[metadata.name ?? 'default'].prompts.push({ - getOrCreateEggObject: this.eggContainerFactory.getOrCreateEggObject.bind(this.eggContainerFactory), - proto, - meta: prompt, - }); - } - for (const resource of metadata.resources) { - if (!this.registerMap[metadata.name ?? 'default']) { - this.registerMap[metadata.name ?? 'default'] = { - prompts: [], - resources: [], - tools: [], - }; - } - this.registerMap[metadata.name ?? 'default'].resources.push({ - getOrCreateEggObject: this.eggContainerFactory.getOrCreateEggObject.bind(this.eggContainerFactory), - proto, - meta: resource, - }); - } - for (const tool of metadata.tools) { - if (!this.registerMap[metadata.name ?? 'default']) { - this.registerMap[metadata.name ?? 'default'] = { - prompts: [], - resources: [], - tools: [], - }; - } - this.registerMap[metadata.name ?? 'default'].tools.push({ - getOrCreateEggObject: this.eggContainerFactory.getOrCreateEggObject.bind(this.eggContainerFactory), - proto, - meta: tool, - }); - } - this.registeredControllerProtos.push(proto); - } - } } diff --git a/tegg/plugin/controller/src/types.ts b/tegg/plugin/controller/src/types.ts index ee933a4ac6..a7aa206acd 100644 --- a/tegg/plugin/controller/src/types.ts +++ b/tegg/plugin/controller/src/types.ts @@ -1,8 +1,6 @@ import '@eggjs/tegg-plugin/types'; import type { ControllerMetaBuilderFactory } from '@eggjs/controller-decorator'; - -import type { ControllerRegisterFactory } from './lib/ControllerRegisterFactory.ts'; -import type { RootProtoManager } from './lib/RootProtoManager.ts'; +import type { ControllerRegisterFactory, RootProtoManager } from '@eggjs/controller-runtime'; declare module 'egg' { interface Application { diff --git a/tegg/plugin/controller/test/lib/HTTPMethodRegister.test.ts b/tegg/plugin/controller/test/lib/HTTPMethodRegister.test.ts index 75e01bdd8a..8b09cb4924 100644 --- a/tegg/plugin/controller/test/lib/HTTPMethodRegister.test.ts +++ b/tegg/plugin/controller/test/lib/HTTPMethodRegister.test.ts @@ -1,5 +1,10 @@ import path from 'node:path'; +import { + CONTROLLER_LOAD_UNIT, + ControllerLoadUnit, + ControllerPrototypeHook as EggControllerPrototypeHook, +} from '@eggjs/controller-runtime'; import { EggPrototypeCreatorFactory, EggPrototypeFactory, @@ -12,9 +17,7 @@ import { CONTROLLER_META_DATA, HTTPControllerMeta } from '@eggjs/tegg'; import { EggContainerFactory } from '@eggjs/tegg-runtime'; import { describe, it, beforeAll, afterAll, expect } from 'vitest'; -import { CONTROLLER_LOAD_UNIT, ControllerLoadUnit } from '../../src/lib/ControllerLoadUnit.ts'; import { EggControllerLoader } from '../../src/lib/EggControllerLoader.ts'; -import { EggControllerPrototypeHook } from '../../src/lib/EggControllerPrototypeHook.ts'; import { HTTPMethodRegister } from '../../src/lib/impl/http/HTTPMethodRegister.ts'; import { getFixtures } from '../utils.ts'; diff --git a/tegg/plugin/controller/test/lib/MCPGlobalMiddleware.test.ts b/tegg/plugin/controller/test/lib/MCPGlobalMiddleware.test.ts index 52d2546a67..88f84bd360 100644 --- a/tegg/plugin/controller/test/lib/MCPGlobalMiddleware.test.ts +++ b/tegg/plugin/controller/test/lib/MCPGlobalMiddleware.test.ts @@ -3,16 +3,16 @@ import assert from 'node:assert/strict'; import compose from 'koa-compose'; import { describe, it } from 'vitest'; -import { MCPControllerRegister } from '../../src/lib/impl/mcp/MCPControllerRegister.ts'; +import { EggMcpRouter } from '../../src/lib/impl/mcp/EggMcpRouter.ts'; // Unit tests for the lazy MCP global-middleware resolution. // -// MCPControllerRegister.register() runs during the tegg load-unit init -// (postCreate), which happens before egg's `loadMiddleware` populates -// `app.middlewares`. The middleware named in `config.mcp.middleware` must -// therefore be resolved lazily — on the first request — rather than at -// registration time, otherwise booting an app that configures `mcp.middleware` -// throws `Middleware not found`. +// The MCP routes are mounted during the tegg load-unit init (postCreate), +// which happens before egg's `loadMiddleware` populates `app.middlewares`. The +// middleware named in `config.mcp.middleware` must therefore be resolved lazily +// — on the first request — rather than at registration time, otherwise booting +// an app that configures `mcp.middleware` throws `Middleware not found`. +// This lazy wiring lives on the egg transport router (EggMcpRouter). function createRegister(mcp: any, middlewares: any) { const app: any = { eggContainerFactory: {}, @@ -20,7 +20,7 @@ function createRegister(mcp: any, middlewares: any) { config: { mcp }, middlewares, }; - const register = new (MCPControllerRegister as any)({}, {}, app); + const register = new (EggMcpRouter as any)(app); return { register, app }; } @@ -134,7 +134,7 @@ describe('plugin/controller/test/lib/MCPGlobalMiddleware.test.ts', () => { config: { mcp: { middleware: ['trace'] } }, middlewares: {}, // 'trace' not loaded yet }; - const register = new (MCPControllerRegister as any)({}, {}, app); + const register = new (EggMcpRouter as any)(app); assert.doesNotThrow(() => register.mcpStatelessStreamServerInit()); assert.doesNotThrow(() => register.mcpStreamServerInit()); diff --git a/tegg/plugin/mcp-proxy/src/app.ts b/tegg/plugin/mcp-proxy/src/app.ts index adf2a33425..e0bac2f485 100644 --- a/tegg/plugin/mcp-proxy/src/app.ts +++ b/tegg/plugin/mcp-proxy/src/app.ts @@ -1,4 +1,4 @@ -import { MCPControllerRegister } from '@eggjs/controller-plugin/lib/impl/mcp/MCPControllerRegister'; +import { EggMcpRouter } from '@eggjs/controller-plugin/lib/impl/mcp/EggMcpRouter'; import { TeggScope } from '@eggjs/tegg-types'; import type { Application } from 'egg'; @@ -13,7 +13,7 @@ export default class AppHook { configWillLoad(): void { // hooks is per-app (scope-backed); register into this app's scope. - TeggScope.run(this.agent._teggScopeBag, () => MCPControllerRegister.addHook(MCPProxyHook)); + TeggScope.run(this.agent._teggScopeBag, () => EggMcpRouter.addHook(MCPProxyHook)); } async didLoad(): Promise { diff --git a/tegg/plugin/mcp-proxy/src/index.ts b/tegg/plugin/mcp-proxy/src/index.ts index dbe50d2fdf..c565df3f7d 100644 --- a/tegg/plugin/mcp-proxy/src/index.ts +++ b/tegg/plugin/mcp-proxy/src/index.ts @@ -4,8 +4,8 @@ import querystring from 'node:querystring'; import { Readable } from 'node:stream'; import url from 'node:url'; -import { MCPControllerRegister } from '@eggjs/controller-plugin/lib/impl/mcp/MCPControllerRegister'; -import type { MCPControllerHook } from '@eggjs/controller-plugin/lib/impl/mcp/MCPControllerRegister'; +import { EggMcpRouter } from '@eggjs/controller-plugin/lib/impl/mcp/EggMcpRouter'; +import type { MCPControllerHook } from '@eggjs/controller-plugin/lib/impl/mcp/EggMcpRouter'; import { MCPProtocols, TeggScope, type TeggScopeBag } from '@eggjs/tegg-types'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; @@ -64,8 +64,8 @@ export const MCPProxyHook: MCPControllerHook = { // owning app's scope before reading the scope-backed hook list. const bag = (self.app as { _teggScopeBag?: TeggScopeBag })._teggScopeBag; await TeggScope.runMaybe(bag, async () => { - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preProxy?.(ctx, req, res); } } @@ -144,8 +144,8 @@ export const MCPProxyHook: MCPControllerHook = { // reading the scope-backed hook list. const bag = (self.app as { _teggScopeBag?: TeggScopeBag })._teggScopeBag; await TeggScope.runMaybe(bag, async () => { - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { + if (EggMcpRouter.hooks.length > 0) { + for (const hook of EggMcpRouter.hooks) { await hook.preProxy?.(ctx, req, res); } } diff --git a/tegg/standalone/service-worker-controller/package.json b/tegg/standalone/service-worker-controller/package.json new file mode 100644 index 0000000000..cdd7a40bd6 --- /dev/null +++ b/tegg/standalone/service-worker-controller/package.json @@ -0,0 +1,66 @@ +{ + "name": "@eggjs/service-worker-controller", + "version": "4.0.2-beta.19", + "description": "tegg standalone service worker fetch controller transport: fetch HTTP/MCP controller support for the service worker runtime", + "keywords": [ + "controller", + "egg", + "fetch", + "service worker", + "standalone", + "tegg", + "typescript" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/tegg/standalone/service-worker-controller", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "tegg/standalone/service-worker-controller" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/controller-decorator": "workspace:*", + "@eggjs/controller-runtime": "workspace:*", + "@eggjs/router": "workspace:*", + "@eggjs/service-worker-runtime": "workspace:*", + "@eggjs/tegg": "workspace:*", + "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*", + "@modelcontextprotocol/sdk": "^1.23.0" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "zod": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + }, + "eggModule": { + "name": "serviceWorker" + } +} diff --git a/tegg/standalone/service-worker-controller/src/ControllerModule.ts b/tegg/standalone/service-worker-controller/src/ControllerModule.ts new file mode 100644 index 0000000000..425f80f296 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/ControllerModule.ts @@ -0,0 +1,13 @@ +// The controller module's DI inner objects (rootProtoManager, +// controllerRegisterFactory) and lifecycle hooks are defined once, host +// agnostically, in @eggjs/controller-runtime (a plain library — it is NOT an +// eggModule). Re-export the decorated prototypes here so the service worker +// host's own `serviceWorker` eggModule scan registers them +// (`LoaderUtil.loadFile` collects re-exported `@*Proto` classes). The egg host +// re-exports the same prototypes from its own `teggController` plugin module. +export { + EggRootProtoManager, + EggControllerRegisterFactory, + EggControllerLoadUnitHook, + EggControllerPrototypeLifecycleHook, +} from '@eggjs/controller-runtime'; diff --git a/tegg/standalone/service-worker-controller/src/controller/ServiceWorkerContext.ts b/tegg/standalone/service-worker-controller/src/controller/ServiceWorkerContext.ts new file mode 100644 index 0000000000..6c759641f0 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/controller/ServiceWorkerContext.ts @@ -0,0 +1,21 @@ +import type { ServiceWorkerContext, ServiceWorkerContextInit } from '../types.ts'; + +export abstract class BaseServiceWorkerContextImpl implements ServiceWorkerContext { + event: Event; + #response: Response; + + constructor(init: ServiceWorkerContextInit) { + this.event = init.event; + } + + get response(): Response | undefined { + return this.#response; + } + + set response(response: Response) { + this.#response = response; + } + + abstract get body(): any | undefined; + abstract set body(body: any); +} diff --git a/tegg/standalone/service-worker-controller/src/event/FetchEventImpl.ts b/tegg/standalone/service-worker-controller/src/event/FetchEventImpl.ts new file mode 100644 index 0000000000..6ff0a7b4d0 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/event/FetchEventImpl.ts @@ -0,0 +1,35 @@ +import type { FetchEvent } from '../types.ts'; + +/** + * Minimal FetchEvent implementation for hosts that drive the service worker + * from node (the serve() bridge, tests, faas adapters). + */ +export class FetchEventImpl extends Event implements FetchEvent { + readonly request: Request; + readonly #waitUntilPromises: Promise[] = []; + #responsePromise?: Promise; + + constructor(request: Request) { + super('fetch'); + this.request = request; + } + + respondWith(r: Response | PromiseLike): void { + this.#responsePromise = Promise.resolve(r); + } + + waitUntil(f: Promise): void { + this.#waitUntilPromises.push(f); + } + + get responsePromise(): Promise | undefined { + return this.#responsePromise; + } + + async waitUntilSettled(): Promise { + if (this.#waitUntilPromises.length === 0) { + return; + } + await Promise.allSettled(this.#waitUntilPromises); + } +} diff --git a/tegg/standalone/service-worker-controller/src/http/FetchEventHandler.ts b/tegg/standalone/service-worker-controller/src/http/FetchEventHandler.ts new file mode 100644 index 0000000000..abbc467b95 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/http/FetchEventHandler.ts @@ -0,0 +1,100 @@ +import type { RootProtoManager } from '@eggjs/controller-runtime'; +import { BackgroundTaskHelper } from '@eggjs/service-worker-runtime'; +import { AccessLevel, Inject } from '@eggjs/tegg'; +import { EggContainerFactory } from '@eggjs/tegg-runtime'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; +import { AbstractEventHandler, EventHandlerProto } from '@eggjs/tegg/standalone'; + +import { MCPRegisterProvider } from '../mcp/MCPRegisterProvider.ts'; +import type { FetchEvent } from '../types.ts'; +import { ResponseUtils } from '../utils/ResponseUtils.ts'; +import { FetchRouter } from './FetchRouter.ts'; +import { HTTPRegisterProvider } from './HTTPRegisterProvider.ts'; +import { ServiceWorkerFetchContext } from './ServiceWorkerFetchContext.ts'; + +type RouterMiddleware = (ctx: ServiceWorkerFetchContext, next: () => Promise) => Promise; + +@EventHandlerProto('fetch', { accessLevel: AccessLevel.PUBLIC }) +export class FetchEventHandler extends AbstractEventHandler { + @Inject() + private readonly fetchRouter: FetchRouter; + + @Inject() + private readonly rootProtoManager: RootProtoManager; + + @Inject() + private readonly httpRegisterProvider: HTTPRegisterProvider; + + @Inject() + private readonly mcpRegisterProvider: MCPRegisterProvider; + + #routes?: RouterMiddleware; + #initPromise?: Promise; + + private async initRoutes(): Promise { + if (this.#routes) { + return; + } + this.#initPromise ??= this.doInitRoutes().catch((err) => { + this.#initPromise = undefined; + throw err; + }); + await this.#initPromise; + } + + private async doInitRoutes(): Promise { + // Routes land on the router lazily at the first event: every load unit has + // been created by now, so all controller protos are collected. + this.httpRegisterProvider.doRegister(this.rootProtoManager); + await this.mcpRegisterProvider.doRegister(); + this.#routes = this.fetchRouter.middleware() as unknown as RouterMiddleware; + } + + async handleEvent(event: FetchEvent): Promise { + await this.initRoutes(); + const ctx = new ServiceWorkerFetchContext({ event }); + try { + await this.#routes!(ctx, async () => { + /* noop */ + }); + const response = ctx.response; + if (!response) { + return ResponseUtils.createErrorResponse(404, 'NOT_FOUND', `${ctx.method} ${ctx.path} not found`); + } + for (const [key, value] of ctx.responseHeaders.entries()) { + response.headers.set(key, value); + } + return await this.#guardResponseStream(response); + } catch (e: any) { + console.error('[service-worker] handle fetch event failed:', e); + return ResponseUtils.createErrorResponse(500, 'INTERNAL_SERVER_ERROR', e?.message ?? 'internal error'); + } + } + + /** + * The tegg context is destroyed as soon as the runner returns, but a + * streaming body keeps pulling from ContextProto objects afterwards. Route + * the body through a passthrough and register the drain as a background + * task: ctx destroy then waits (bounded by `config.backgroundTask.timeout`) + * until the client has fully consumed the stream. Client aborts are a + * normal way for the drain to end, not an error. + */ + async #guardResponseStream(response: Response): Promise { + if (!response.body) { + return response; + } + const { readable, writable } = new TransformStream(); + const drained = response.body.pipeTo(writable).catch(() => { + /* client abort / stream error: consumption is over either way */ + }); + const eggObject = await EggContainerFactory.getOrCreateEggObjectFromClazz( + BackgroundTaskHelper as unknown as EggProtoImplClass, + ); + (eggObject.obj as BackgroundTaskHelper).run(() => drained); + return new Response(readable, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } +} diff --git a/tegg/standalone/service-worker-controller/src/http/FetchHTTPMethodRegister.ts b/tegg/standalone/service-worker-controller/src/http/FetchHTTPMethodRegister.ts new file mode 100644 index 0000000000..bd02850169 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/http/FetchHTTPMethodRegister.ts @@ -0,0 +1,89 @@ +import type { IncomingHttpHeaders } from 'node:http'; + +import type { HTTPMethodMeta, PathParamMeta, QueriesParamMeta, QueryParamMeta } from '@eggjs/controller-decorator'; +import { HTTPParamType } from '@eggjs/controller-decorator'; +import { HTTPMethodRegister, type HTTPHandlerFunc } from '@eggjs/controller-runtime'; + +import { RequestUtils } from '../utils/RequestUtils.ts'; +import type { ServiceWorkerFetchContext } from './ServiceWorkerFetchContext.ts'; + +/** + * The fetch host's HTTP method register: the shared skeleton lives in + * the controller plugin runtime; this subclass binds the Fetch Request shape to + * method args and writes the return value back as a Response. + */ +export class FetchHTTPMethodRegister extends HTTPMethodRegister { + protected createHandler(methodMeta: HTTPMethodMeta, host: string | undefined): HTTPHandlerFunc { + const argsLength = methodMeta.paramMap.size; + const hasContext = methodMeta.contextParamIndex !== undefined; + const contextIndex = methodMeta.contextParamIndex; + const methodArgsLength = argsLength + (hasContext ? 1 : 0); + // oxlint-disable-next-line no-this-alias + const methodRegister = this; + return async function (ctx: ServiceWorkerFetchContext, next: () => Promise) { + // if hosts is not empty and host is not matched, not execute + if (host && host !== ctx.host) { + return await next(); + } + // HTTP decorator core implement + // use controller metadata map http request to function arguments + const eggObj = await methodRegister.eggContainerFactory.getOrCreateEggObject( + methodRegister.proto, + methodRegister.proto.name, + ); + const realObj = eggObj.obj; + const realMethod = realObj[methodMeta.name]; + const args: Array = Array.from({ + length: methodArgsLength, + }); + if (hasContext) { + args[contextIndex!] = ctx; + } + for (const [index, param] of methodMeta.paramMap) { + switch (param.type) { + case HTTPParamType.BODY: { + args[index] = await RequestUtils.getRequestBody(ctx.event.request); + break; + } + case HTTPParamType.PARAM: { + const pathParam: PathParamMeta = param as PathParamMeta; + args[index] = ctx.params[pathParam.name]; + break; + } + case HTTPParamType.QUERY: { + const queryParam: QueryParamMeta = param as QueryParamMeta; + args[index] = ctx.url.searchParams.get(queryParam.name) as string; + break; + } + case HTTPParamType.QUERIES: { + const queryParam: QueriesParamMeta = param as QueriesParamMeta; + args[index] = ctx.url.searchParams.getAll(queryParam.name); + break; + } + case HTTPParamType.HEADERS: { + const headers: IncomingHttpHeaders = {}; + for (const [k, v] of ctx.event.request.headers.entries()) { + headers[k] = v; + } + args[index] = headers; + break; + } + case HTTPParamType.REQUEST: { + args[index] = ctx.event.request; + break; + } + default: + throw new Error( + `unsupported param type ${param.type} in method ${methodRegister.controllerMeta.controllerName}.${String(methodMeta.name)} under the service worker runtime`, + ); + } + } + const res = await Reflect.apply(realMethod, realObj, args); + if (res instanceof Response) { + ctx.response = res; + } else { + ctx.body = res; + } + } as HTTPHandlerFunc; + } +} diff --git a/tegg/standalone/service-worker-controller/src/http/FetchRouter.ts b/tegg/standalone/service-worker-controller/src/http/FetchRouter.ts new file mode 100644 index 0000000000..7497f54f1f --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/http/FetchRouter.ts @@ -0,0 +1,6 @@ +import { Router } from '@eggjs/router'; +import { InnerObjectProto } from '@eggjs/tegg'; +import { AccessLevel } from '@eggjs/tegg-types'; + +@InnerObjectProto({ accessLevel: AccessLevel.PUBLIC }) +export class FetchRouter extends Router {} diff --git a/tegg/standalone/service-worker-controller/src/http/HTTPRegisterProvider.ts b/tegg/standalone/service-worker-controller/src/http/HTTPRegisterProvider.ts new file mode 100644 index 0000000000..04633b32fe --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/http/HTTPRegisterProvider.ts @@ -0,0 +1,48 @@ +import { ControllerType } from '@eggjs/controller-decorator'; +import { HTTPControllerRegister, type RootProtoManager } from '@eggjs/controller-runtime'; +import type { ControllerRegisterFactory } from '@eggjs/controller-runtime'; +import { Inject, InnerObjectProto, LifecyclePostInject } from '@eggjs/tegg'; +import { EggContainerFactory } from '@eggjs/tegg-runtime'; +import { AccessLevel } from '@eggjs/tegg-types'; + +import { FetchHTTPMethodRegister } from './FetchHTTPMethodRegister.ts'; +import type { FetchRouter } from './FetchRouter.ts'; + +/** + * Owns the fetch host's HTTPControllerRegister (no per-app statics: the + * provider is an inner object, scoped to its app by the InnerObjectLoadUnit) + * and plugs the HTTP register creator into the controller register factory. + */ +@InnerObjectProto({ name: 'httpRegisterProvider', accessLevel: AccessLevel.PUBLIC }) +export class HTTPRegisterProvider { + @Inject() + private readonly fetchRouter: FetchRouter; + + @Inject() + private readonly controllerRegisterFactory: ControllerRegisterFactory; + + #register?: HTTPControllerRegister; + + @LifecyclePostInject() + protected init(): void { + this.controllerRegisterFactory.registerControllerRegister(ControllerType.HTTP, (proto) => { + const register = this.getOrCreateRegister(); + register.addControllerProto(proto); + return register; + }); + } + + getOrCreateRegister(): HTTPControllerRegister { + this.#register ??= new HTTPControllerRegister( + this.fetchRouter, + EggContainerFactory, + (proto, controllerMeta, methodMeta, router, checkRouters, containerFactory) => + new FetchHTTPMethodRegister(proto, controllerMeta, methodMeta, router, checkRouters, containerFactory), + ); + return this.#register; + } + + doRegister(rootProtoManager: RootProtoManager): void { + this.#register?.doRegister(rootProtoManager); + } +} diff --git a/tegg/standalone/service-worker-controller/src/http/ServiceWorkerFetchContext.ts b/tegg/standalone/service-worker-controller/src/http/ServiceWorkerFetchContext.ts new file mode 100644 index 0000000000..69c1c6713a --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/http/ServiceWorkerFetchContext.ts @@ -0,0 +1,42 @@ +import { BaseServiceWorkerContextImpl } from '../controller/ServiceWorkerContext.ts'; +import type { FetchEvent, ServiceWorkerContextInit } from '../types.ts'; +import { ResponseUtils } from '../utils/ResponseUtils.ts'; + +export class ServiceWorkerFetchContext extends BaseServiceWorkerContextImpl { + url: URL; + method: string; + path: string; + host: string; + // params will be set in @eggjs/router + params: Record = {}; + /** Headers set by middlewares/controllers, merged onto the final response. */ + readonly responseHeaders = new Headers(); + #body?: any; + + constructor(init: ServiceWorkerContextInit) { + super(init); + + this.url = new URL(this.event.request.url); + this.method = this.event.request.method; + this.path = this.url.pathname; + this.host = this.url.hostname; + } + + get response(): Response | undefined { + return super.response; + } + + set response(response: Response) { + super.response = response; + this.#body = response.body; + } + + get body(): any | undefined { + return this.#body; + } + + set body(body: any) { + this.response = ResponseUtils.createResponseByBody(body); + this.#body = body; + } +} diff --git a/tegg/standalone/service-worker-controller/src/index.ts b/tegg/standalone/service-worker-controller/src/index.ts new file mode 100644 index 0000000000..263b7f53c6 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/index.ts @@ -0,0 +1,13 @@ +export * from './controller/ServiceWorkerContext.ts'; +export * from './event/FetchEventImpl.ts'; +export * from './http/FetchEventHandler.ts'; +export * from './http/FetchHTTPMethodRegister.ts'; +export * from './http/FetchRouter.ts'; +export * from './http/HTTPRegisterProvider.ts'; +export * from './http/ServiceWorkerFetchContext.ts'; +export * from './mcp/AbstractControllerAdvice.ts'; +export * from './mcp/MCPRegisterProvider.ts'; +export * from './mcp/ServiceWorkerMcpRouter.ts'; +export * from './types.ts'; +export * from './utils/RequestUtils.ts'; +export * from './utils/ResponseUtils.ts'; diff --git a/tegg/standalone/service-worker-controller/src/mcp/AbstractControllerAdvice.ts b/tegg/standalone/service-worker-controller/src/mcp/AbstractControllerAdvice.ts new file mode 100644 index 0000000000..913054fce4 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/mcp/AbstractControllerAdvice.ts @@ -0,0 +1,17 @@ +import type { AdviceContext, IAdvice } from '@eggjs/tegg-types'; + +import type { ServiceWorkerFetchContext } from '../http/ServiceWorkerFetchContext.ts'; + +/** + * Base class for MCP controller AOP middlewares under the service worker + * runtime: `@Middleware(SomeAdvice)` classes extending this run as koa-style + * middlewares around the MCP transport handler. + */ +export abstract class AbstractControllerAdvice implements IAdvice { + // Default no-op around to satisfy IAdvice structural type check + async around(_ctx: AdviceContext, next: () => Promise): Promise { + return next(); + } + + abstract middleware(ctx: ServiceWorkerFetchContext, next: () => Promise): Promise; +} diff --git a/tegg/standalone/service-worker-controller/src/mcp/MCPRegisterProvider.ts b/tegg/standalone/service-worker-controller/src/mcp/MCPRegisterProvider.ts new file mode 100644 index 0000000000..e89f1c5535 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/mcp/MCPRegisterProvider.ts @@ -0,0 +1,35 @@ +import { ControllerType, type MCPControllerMeta } from '@eggjs/controller-decorator'; +import { MCPControllerRegister, type ControllerRegisterFactory } from '@eggjs/controller-runtime'; +import { Inject, InnerObjectProto, LifecyclePostInject } from '@eggjs/tegg'; +import { AccessLevel } from '@eggjs/tegg-types'; + +import { ServiceWorkerMcpRouter } from './ServiceWorkerMcpRouter.ts'; + +/** + * Plugs the shared, host-agnostic MCP collect-register into the controller + * register factory, bound to the fetch host's {@link ServiceWorkerMcpRouter}. + * The register only collects records; the router owns the fetch transport. + */ +@InnerObjectProto({ name: 'mcpRegisterProvider', accessLevel: AccessLevel.PUBLIC }) +export class MCPRegisterProvider { + @Inject() + private readonly mcpRouter: ServiceWorkerMcpRouter; + + @Inject() + private readonly controllerRegisterFactory: ControllerRegisterFactory; + + #register?: MCPControllerRegister; + + @LifecyclePostInject() + protected init(): void { + this.controllerRegisterFactory.registerControllerRegister(ControllerType.MCP, (proto, controllerMeta) => { + this.#register ??= new MCPControllerRegister(controllerMeta as MCPControllerMeta, this.mcpRouter); + this.#register.addControllerProto(proto); + return this.#register; + }); + } + + async doRegister(): Promise { + await this.mcpRouter.doRegister(); + } +} diff --git a/tegg/standalone/service-worker-controller/src/mcp/ServiceWorkerMcpRouter.ts b/tegg/standalone/service-worker-controller/src/mcp/ServiceWorkerMcpRouter.ts new file mode 100644 index 0000000000..1350999f50 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/mcp/ServiceWorkerMcpRouter.ts @@ -0,0 +1,172 @@ +import { CONTROLLER_META_DATA, type MCPControllerMeta } from '@eggjs/controller-decorator'; +import { + MCPServerHelper, + MCP_ROUTER_NAME, + type McpRouter, + type McpServerRegistration, +} from '@eggjs/controller-runtime'; +import { Inject, InnerObjectProto } from '@eggjs/tegg'; +import { EggContainerFactory } from '@eggjs/tegg-runtime'; +import { AccessLevel, CONTROLLER_AOP_MIDDLEWARES } from '@eggjs/tegg-types'; +import type { EggProtoImplClass, EggPrototype } from '@eggjs/tegg-types'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; + +import type { FetchRouter } from '../http/FetchRouter.ts'; +import type { ServiceWorkerFetchContext } from '../http/ServiceWorkerFetchContext.ts'; +import type { MCPAuthHandler } from '../types.ts'; +import type { AbstractControllerAdvice } from './AbstractControllerAdvice.ts'; + +type MCPMiddleware = (ctx: ServiceWorkerFetchContext, next: () => Promise) => Promise; + +/** + * The fetch host's MCP transport boundary: stateless streamable HTTP under + * `/mcp[/name]/stream` (POST only). The service worker speaks Fetch natively, + * so the SDK's web-standard transport handles Request/Response directly — no + * node req/res bridging. In stateless mode the SDK forbids transport reuse, so + * each request gets a fresh MCPServerHelper + transport built from the live + * records the host-agnostic register collected. + * + * The register collects and calls {@link registerServer} once per server name; + * the actual fetch routes are mounted lazily in {@link doRegister} (after every + * load unit exists, so all controller protos — and their middlewares — are + * present). + */ +@InnerObjectProto({ name: MCP_ROUTER_NAME, accessLevel: AccessLevel.PUBLIC }) +export class ServiceWorkerMcpRouter implements McpRouter { + @Inject() + private readonly fetchRouter: FetchRouter; + + @Inject() + private readonly mcpAuthHandler: MCPAuthHandler; + + #registrations: McpServerRegistration[] = []; + #mounted = false; + + registerServer(reg: McpServerRegistration): void { + this.#registrations.push(reg); + } + + async doRegister(): Promise { + if (this.#mounted) { + return; + } + this.#mounted = true; + for (const reg of this.#registrations) { + const name = reg.serverName === 'default' ? undefined : reg.serverName; + this.mountServer(reg, name); + } + } + + /** + * Build a fresh MCP server + transport for a single request. The SDK's + * stateless transport is single-shot (reuse throws), so this runs per + * request against the live records; registration is in-memory callback + * wiring and the callbacks resolve egg objects lazily. + */ + private async createServerTransport( + reg: McpServerRegistration, + helperFactory: () => MCPServerHelper, + ): Promise { + const mcpServerHelper = helperFactory(); + for (const tool of reg.tools) { + await mcpServerHelper.mcpToolRegister(tool.getOrCreateEggObject, tool.proto, tool.meta); + } + for (const resource of reg.resources) { + await mcpServerHelper.mcpResourceRegister(resource.getOrCreateEggObject, resource.proto, resource.meta); + } + for (const prompt of reg.prompts) { + await mcpServerHelper.mcpPromptRegister(prompt.getOrCreateEggObject, prompt.proto, prompt.meta); + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await mcpServerHelper.server.connect(transport); + return transport; + } + + /** + * Resolve the per-server middlewares from every controller proto that + * contributed to this server: function-type middlewares from the controller + * metadata plus AOP advice classes declared on the proto. + */ + private buildMiddlewares(reg: McpServerRegistration): MCPMiddleware[] { + const middlewares: MCPMiddleware[] = []; + const seen = new Set(); + for (const record of [...reg.tools, ...reg.resources, ...reg.prompts]) { + if (seen.has(record.proto)) { + continue; + } + seen.add(record.proto); + const metadata = record.proto.getMetaData(CONTROLLER_META_DATA) as MCPControllerMeta; + // Function-type middlewares from MCPControllerMeta + const classMiddlewares = metadata.middlewares ?? []; + for (const mw of classMiddlewares) { + middlewares.push(mw as unknown as MCPMiddleware); + } + // AOP-type middlewares from class metadata + const aopMiddlewareClasses = (record.proto.getMetaData(CONTROLLER_AOP_MIDDLEWARES) ?? + []) as EggProtoImplClass[]; + for (const clazz of aopMiddlewareClasses) { + middlewares.push(async (ctx, next) => { + const eggObj = await EggContainerFactory.getOrCreateEggObjectFromClazz(clazz); + await (eggObj.obj as AbstractControllerAdvice).middleware(ctx, next); + }); + } + } + return middlewares; + } + + private mountServer(reg: McpServerRegistration, name?: string): void { + const router = this.fetchRouter; + const helperFactory = () => + new MCPServerHelper({ + name: reg.controllerMeta.name ?? `mcp-${reg.serverName}-server`, + version: reg.controllerMeta.version ?? '1.0.0', + }); + const middlewares = this.buildMiddlewares(reg); + + const postRouterFunc = router.post; + const initHandler = async (ctx: ServiceWorkerFetchContext) => { + const denied = await this.mcpAuthHandler.authenticate(ctx.event.request); + if (denied) { + ctx.response = denied; + return; + } + const transport = await this.createServerTransport(reg, helperFactory); + ctx.response = await transport.handleRequest(ctx.event.request); + }; + + const streamPath = `/mcp${name ? `/${name}` : ''}/stream`; + const basePath = `/mcp${name ? `/${name}` : ''}`; + const paths = [streamPath, basePath]; + for (const path of paths) { + Reflect.apply(postRouterFunc, router, ['mcpStatelessStreamInit', path, ...middlewares, initHandler]); + } + + // Only POST is allowed for stateless streamable HTTP + const notAllowedHandler = async (ctx: ServiceWorkerFetchContext) => { + ctx.response = new Response( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed.', + }, + id: null, + }), + { + status: 405, + headers: { + 'content-type': 'application/json', + }, + }, + ); + }; + const getRouterFunc = router.get; + const delRouterFunc = router.del; + for (const path of paths) { + Reflect.apply(getRouterFunc, router, ['mcpStatelessStreamNotAllowed', path, notAllowedHandler]); + Reflect.apply(delRouterFunc, router, ['mcpStatelessStreamNotAllowed', path, notAllowedHandler]); + } + } +} diff --git a/tegg/standalone/service-worker-controller/src/types.ts b/tegg/standalone/service-worker-controller/src/types.ts new file mode 100644 index 0000000000..965da3c268 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/types.ts @@ -0,0 +1,28 @@ +export interface FetchEvent extends Event { + request: Request; + waitUntil(f: Promise): void; + respondWith(r: Response | PromiseLike): void; +} + +/** + * The auth extension point for MCP routes. The host provides an + * implementation via `ServiceWorkerAppOptions.mcpAuthHandler` (backed by the + * `mcpAuthHandler` inner object); the default passes every request through. + * Return a Response to reject the request, or undefined to let it in. + */ +export interface MCPAuthHandler { + authenticate(request: Request): Promise; +} + +export interface ServiceWorkerContextInit { + event: T; +} + +export interface ServiceWorkerContext { + event: Event; + get response(): Response | undefined; + set response(response: Response); + + get body(): any | undefined; + set body(body: any); +} diff --git a/tegg/standalone/service-worker-controller/src/utils/RequestUtils.ts b/tegg/standalone/service-worker-controller/src/utils/RequestUtils.ts new file mode 100644 index 0000000000..9f98e477e7 --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/utils/RequestUtils.ts @@ -0,0 +1,35 @@ +export class RequestUtils { + static ContentTypes = { + json: [ + 'application/json', + 'application/json-patch+json', + 'application/vnd.api+json', + 'application/csp-report', + 'application/scim+json', + ], + form: ['application/x-www-form-urlencoded'], + text: ['text/plain'], + }; + + static async getRequestBody(request: Request): Promise { + if (RequestUtils.matchContentTypes(request, RequestUtils.ContentTypes.json)) { + return await request.json(); + } + if (RequestUtils.matchContentTypes(request, RequestUtils.ContentTypes.text)) { + return await request.text(); + } + if (RequestUtils.matchContentTypes(request, RequestUtils.ContentTypes.form)) { + return await request.formData(); + } + } + + static matchContentTypes(request: Request, types: string[]): boolean { + const contentType = request.headers.get('content-type'); + if (!contentType) { + return false; + } + // strip parameters like `; charset=utf-8` and a trailing semicolon + const value = contentType.split(';')[0].trim().toLowerCase(); + return types.includes(value); + } +} diff --git a/tegg/standalone/service-worker-controller/src/utils/ResponseUtils.ts b/tegg/standalone/service-worker-controller/src/utils/ResponseUtils.ts new file mode 100644 index 0000000000..c5e4fbf55d --- /dev/null +++ b/tegg/standalone/service-worker-controller/src/utils/ResponseUtils.ts @@ -0,0 +1,30 @@ +export class ResponseUtils { + /** + * The unified error shape for framework-generated failures (routing 404, + * unhandled controller errors): `{ code, message }` JSON. Controller-crafted + * Responses pass through untouched. + */ + static createErrorResponse(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ code, message }), { + status, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + static createResponseByBody(body: any): Response { + if (typeof body === 'undefined' || body === null) { + return new Response(null, { status: 204 }); + } + if (Buffer.isBuffer(body) || typeof body === 'string' || body instanceof ReadableStream) { + return new Response(body as BodyInit, { status: 200 }); + } + return new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } +} diff --git a/tegg/standalone/service-worker-controller/tsconfig.json b/tegg/standalone/service-worker-controller/tsconfig.json new file mode 100644 index 0000000000..618c6c3e97 --- /dev/null +++ b/tegg/standalone/service-worker-controller/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/tegg/standalone/service-worker-controller/tsdown.config.ts b/tegg/standalone/service-worker-controller/tsdown.config.ts new file mode 100644 index 0000000000..6dfb63bdb8 --- /dev/null +++ b/tegg/standalone/service-worker-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, +}); diff --git a/tegg/standalone/service-worker-runtime/README.md b/tegg/standalone/service-worker-runtime/README.md new file mode 100644 index 0000000000..7e4a5e7531 --- /dev/null +++ b/tegg/standalone/service-worker-runtime/README.md @@ -0,0 +1,28 @@ +# @eggjs/service-worker-runtime + +The protocol-agnostic half of the standalone service worker: an event-driven +runner on top of `@eggjs/standalone` that dispatches incoming events to +handlers by `event.type`. + +Protocol packages (e.g. `@eggjs/service-worker` for fetch/HTTP/MCP) build on +this by contributing: + +- an event handler: a class extending `AbstractEventHandler`, registered with + `@EventHandlerProto('')`; +- whatever inner objects / lifecycle hooks their protocol needs, declared with + the module plugin decorators (`@InnerObjectProto`, `@LoadUnitLifecycleProto`, + …). + +What this package provides: + +- `ServiceWorkerRunner` — the `@Runner()` entry: resolves the handler for + `event.type` and dispatches. +- `ContextProtoLoadUnitHook` / `ContextProtoProperty` — injects the current + event into ContextProto objects (`@Inject() event`). +- `StandaloneEggObjectFactory` — qualifier-based handler resolution. +- `BackgroundTaskHelper` (re-exported from `@eggjs/background-task`) — + request-scoped background tasks drained at ctx destroy. The host must + provide `logger` and `config` inner objects (`ServiceWorkerApp` does). + +Most applications should depend on `@eggjs/service-worker` instead; this +package is the extension surface for new event protocols. diff --git a/tegg/standalone/service-worker-runtime/package.json b/tegg/standalone/service-worker-runtime/package.json new file mode 100644 index 0000000000..1c3426b8da --- /dev/null +++ b/tegg/standalone/service-worker-runtime/package.json @@ -0,0 +1,62 @@ +{ + "name": "@eggjs/service-worker-runtime", + "version": "4.0.2-beta.19", + "description": "protocol-agnostic tegg service worker runtime: @Runner event dispatch, context event injection and background task wiring", + "keywords": [ + "egg", + "service worker", + "standalone", + "tegg", + "typescript" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/tegg/standalone/service-worker-runtime", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "tegg/standalone/service-worker-runtime" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/background-task": "workspace:*", + "@eggjs/dynamic-inject-runtime": "workspace:*", + "@eggjs/lifecycle": "workspace:*", + "@eggjs/metadata": "workspace:*", + "@eggjs/tegg": "workspace:*", + "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + }, + "eggModule": { + "name": "serviceWorkerRuntime" + } +} diff --git a/tegg/standalone/service-worker-runtime/src/BackgroundTask.ts b/tegg/standalone/service-worker-runtime/src/BackgroundTask.ts new file mode 100644 index 0000000000..ef226a4b96 --- /dev/null +++ b/tegg/standalone/service-worker-runtime/src/BackgroundTask.ts @@ -0,0 +1,6 @@ +// Re-exported so the module scan picks BackgroundTaskHelper up as a member of +// this module — service worker contexts then support `@Inject() +// backgroundTaskHelper` with ctx-destroy-time draining out of the box. The +// host must provide `logger` and `config` inner objects (ServiceWorkerApp +// does). +export { BackgroundTaskHelper } from '@eggjs/background-task'; diff --git a/tegg/standalone/service-worker-runtime/src/ContextProtoLoadUnitHook.ts b/tegg/standalone/service-worker-runtime/src/ContextProtoLoadUnitHook.ts new file mode 100644 index 0000000000..237afd1a7f --- /dev/null +++ b/tegg/standalone/service-worker-runtime/src/ContextProtoLoadUnitHook.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert'; + +import { IdenticalUtil } from '@eggjs/lifecycle'; +import { EggPrototypeFactory, type LoadUnit, type LoadUnitLifecycleContext } from '@eggjs/metadata'; +import { type LifecycleHook, LoadUnitLifecycleProto } from '@eggjs/tegg'; +import { ContextHandler, ProvidedInnerObjectProto } from '@eggjs/tegg-runtime'; +import { AccessLevel, ObjectInitType } from '@eggjs/tegg-types'; + +import { type ContextProtoMeta, ContextProtoProperty } from './constants.ts'; + +/** + * Registers context-level protos resolved from the current tegg context (e.g. + * `event`), so any module can `@Inject() event` in a request scope. + */ +@LoadUnitLifecycleProto() +export class ContextProtoLoadUnitHook implements LifecycleHook { + async preCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + if (loadUnit.name === 'serviceWorkerRuntime') { + // can `@Inject() event` + ContextProtoLoadUnitHook.registerPrototype(ContextProtoProperty.Event, loadUnit); + } + } + + static registerPrototype(protoMeta: ContextProtoMeta, loadUnit: LoadUnit): void { + const proto = new ProvidedInnerObjectProto( + IdenticalUtil.createProtoId(loadUnit.id, protoMeta.protoName), + protoMeta.protoName, + () => { + const ctx = ContextHandler.getContext(); + assert(ctx, 'context should not be null'); + return ctx.get(protoMeta.contextKey) as object; + }, + ObjectInitType.CONTEXT, + loadUnit.id, + [], + AccessLevel.PUBLIC, + ); + EggPrototypeFactory.instance.registerPrototype(proto, loadUnit); + } +} diff --git a/tegg/standalone/service-worker-runtime/src/ServiceWorkerRunner.ts b/tegg/standalone/service-worker-runtime/src/ServiceWorkerRunner.ts new file mode 100644 index 0000000000..53d03590a2 --- /dev/null +++ b/tegg/standalone/service-worker-runtime/src/ServiceWorkerRunner.ts @@ -0,0 +1,23 @@ +import type { EggObjectFactory } from '@eggjs/dynamic-inject-runtime'; +import { Inject, SingletonProto } from '@eggjs/tegg'; +import { AbstractEventHandler, type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +/** + * The standalone service worker entry runner: resolve the event handler + * implementation by `event.type` and dispatch. Protocol packages contribute + * handlers via `@EventHandlerProto('')`. + */ +@Runner() +@SingletonProto() +export class ServiceWorkerRunner implements MainRunner { + @Inject() + private readonly event: Event; + + @Inject() + private readonly eggObjectFactory: EggObjectFactory; + + async main(): Promise { + const handler = await this.eggObjectFactory.getEggObject(AbstractEventHandler, this.event.type); + return await handler.handleEvent(this.event); + } +} diff --git a/tegg/standalone/service-worker-runtime/src/StandaloneEggObjectFactory.ts b/tegg/standalone/service-worker-runtime/src/StandaloneEggObjectFactory.ts new file mode 100644 index 0000000000..ea012a7cdc --- /dev/null +++ b/tegg/standalone/service-worker-runtime/src/StandaloneEggObjectFactory.ts @@ -0,0 +1,8 @@ +import { EGG_OBJECT_FACTORY_PROTO_IMPLE_TYPE, EggObjectFactory } from '@eggjs/dynamic-inject-runtime'; +import { AccessLevel, SingletonProto } from '@eggjs/tegg'; + +@SingletonProto({ + protoImplType: EGG_OBJECT_FACTORY_PROTO_IMPLE_TYPE, + accessLevel: AccessLevel.PRIVATE, +}) +export class StandaloneEggObjectFactory extends EggObjectFactory {} diff --git a/tegg/standalone/service-worker-runtime/src/constants.ts b/tegg/standalone/service-worker-runtime/src/constants.ts new file mode 100644 index 0000000000..3c48e623d2 --- /dev/null +++ b/tegg/standalone/service-worker-runtime/src/constants.ts @@ -0,0 +1,11 @@ +export interface ContextProtoMeta { + protoName: PropertyKey; + contextKey: symbol; +} + +export class ContextProtoProperty { + static readonly Event: ContextProtoMeta = { + protoName: 'event', + contextKey: Symbol.for('tegg:serviceWorker:context#event'), + }; +} diff --git a/tegg/standalone/service-worker-runtime/src/index.ts b/tegg/standalone/service-worker-runtime/src/index.ts new file mode 100644 index 0000000000..8149ff09cc --- /dev/null +++ b/tegg/standalone/service-worker-runtime/src/index.ts @@ -0,0 +1,5 @@ +export * from './BackgroundTask.ts'; +export * from './constants.ts'; +export * from './ContextProtoLoadUnitHook.ts'; +export * from './ServiceWorkerRunner.ts'; +export * from './StandaloneEggObjectFactory.ts'; diff --git a/tegg/standalone/service-worker-runtime/tsconfig.json b/tegg/standalone/service-worker-runtime/tsconfig.json new file mode 100644 index 0000000000..618c6c3e97 --- /dev/null +++ b/tegg/standalone/service-worker-runtime/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/tegg/standalone/service-worker-runtime/tsdown.config.ts b/tegg/standalone/service-worker-runtime/tsdown.config.ts new file mode 100644 index 0000000000..6dfb63bdb8 --- /dev/null +++ b/tegg/standalone/service-worker-runtime/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, +}); diff --git a/tegg/standalone/service-worker/README.md b/tegg/standalone/service-worker/README.md new file mode 100644 index 0000000000..fd6a677027 --- /dev/null +++ b/tegg/standalone/service-worker/README.md @@ -0,0 +1,90 @@ +# @eggjs/service-worker + +Serve a tegg module through service-worker semantics: fetch events in, +`Response` objects out. HTTP controllers (`@HTTPController`) and MCP tools +(`@MCPController`) run over the same event loop with full tegg dependency +injection — no egg application required. + +The heavy lifting is shared with the egg host: controller metadata comes from +`@eggjs/controller-decorator` and the registration runtime from +the controller plugin's host-agnostic runtime (`@eggjs/controller-plugin`); +only the parameter binding and transport are +fetch-specific. + +## Usage + +```ts +import { ServiceWorkerApp } from '@eggjs/service-worker'; + +const app = new ServiceWorkerApp('/path/to/module'); + +// over node:http +const server = await app.serve({ port: 7001 }); + +// or embedded: hand it a fetch event, get a Response back +import { FetchEventImpl } from '@eggjs/service-worker'; +const response = await app.handleEvent(new FetchEventImpl(new Request('http://localhost/hello/'))); + +await app.destroy(); +``` + +A module is a plain tegg module: decorated classes plus a `package.json` with +`eggModule.name` (and optionally a `module.yml` for `ModuleConfigs`). See +`examples/helloworld-service-worker` for a runnable example. + +## HTTP controllers + +`@HTTPController` / `@HTTPMethod` work as in the egg host. Parameter binding +supports `@HTTPBody`, `@HTTPParam`, `@HTTPQuery`, `@HTTPQueries`, +`@HTTPHeaders`, and `@Request` (the raw fetch `Request`); `@Cookies` is not +supported under the fetch runtime and fails with an explicit error. A handler +may return a plain value (serialized to JSON), a string/Buffer/ReadableStream, +or a fetch `Response` (passed through as-is). + +- Errors surface in a unified shape: `{ code, message }` JSON with + `NOT_FOUND` (404) or `INTERNAL_SERVER_ERROR` (500). +- `ctx.responseHeaders` set by middlewares/controllers are merged onto the + final response. +- Streaming/SSE responses keep the request's ContextProto objects alive until + the client drains the body (bounded by `config.backgroundTask.timeout`; + set it to `0` to wait indefinitely). + +## MCP controllers + +`@MCPController` / `@MCPTool` / `@MCPPrompt` / `@MCPResource` are served as +MCP **stateless streamable HTTP** under `POST /mcp[/name]/stream` (and +`/mcp[/name]`), using the MCP SDK's web-standard transport. Non-POST methods +get a 405 jsonrpc error. Each request builds a fresh server + transport, as +the SDK requires in stateless mode. + +Authentication is an extension point (default: allow all): + +```ts +const app = new ServiceWorkerApp(cwd, { + mcpAuthHandler: { + async authenticate(request) { + if (request.headers.get('x-token') !== 'secret') { + return new Response('unauthorized', { status: 401 }); + } + return undefined; // let it through + }, + }, +}); +``` + +## Host-provided inner objects + +`ServiceWorkerApp` accepts `innerObjectHandlers` (from +`@eggjs/standalone`) to provide or override injectable singletons: + +- `config` — also settable via the `config` option; read by + `BackgroundTaskHelper` (`config.backgroundTask.timeout`). +- `logger` — defaults to `console`. +- `mcpAuthHandler` — also settable via the `mcpAuthHandler` option. +- `httpclient` is intentionally not bundled; provide your own via + `innerObjectHandlers` if modules inject one. + +## Background tasks + +`BackgroundTaskHelper` from `@eggjs/background-task` is available in every +request context; ctx destroy drains pending tasks, same as the egg host. diff --git a/tegg/standalone/service-worker/package.json b/tegg/standalone/service-worker/package.json new file mode 100644 index 0000000000..2637f1e083 --- /dev/null +++ b/tegg/standalone/service-worker/package.json @@ -0,0 +1,58 @@ +{ + "name": "@eggjs/service-worker", + "version": "4.0.2-beta.19", + "description": "tegg standalone service worker runtime: fetch event model, HTTP controller support and a node:http serve() bridge", + "keywords": [ + "egg", + "fetch", + "service worker", + "standalone", + "tegg", + "typescript" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/tegg/standalone/service-worker", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "tegg/standalone/service-worker" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/service-worker-controller": "workspace:*", + "@eggjs/service-worker-runtime": "workspace:*", + "@eggjs/standalone": "workspace:*" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.23.0", + "@types/node": "catalog:", + "typescript": "catalog:", + "zod": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/tegg/standalone/service-worker/src/ServiceWorkerApp.ts b/tegg/standalone/service-worker/src/ServiceWorkerApp.ts new file mode 100644 index 0000000000..3fec4ff318 --- /dev/null +++ b/tegg/standalone/service-worker/src/ServiceWorkerApp.ts @@ -0,0 +1,185 @@ +import http from 'node:http'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; + +import { FetchEventImpl, type MCPAuthHandler } from '@eggjs/service-worker-controller'; +import { ContextProtoProperty } from '@eggjs/service-worker-runtime'; +import { + StandaloneApp, + StandaloneContext, + type InitStandaloneAppOptions, + type StandaloneAppOptions, +} from '@eggjs/standalone'; + +export interface ServiceWorkerAppOptions extends StandaloneAppOptions { + /** Injected as the `config` inner object (BackgroundTaskHelper reads `config.backgroundTask.timeout`). */ + config?: Record; + /** Auth hook for MCP routes; the default lets every request through. */ + mcpAuthHandler?: MCPAuthHandler; +} + +const PASS_THROUGH_MCP_AUTH_HANDLER: MCPAuthHandler = { + async authenticate() { + return undefined; + }, +}; + +export interface ServeOptions { + port?: number; + hostname?: string; +} + +/** + * The service worker facade over StandaloneApp: loads the two service worker + * framework packages (runtime + fetch adapter) as frameworkDeps ahead of the + * app's own modules, then serves events either embedded (`handleEvent`) or + * over node:http (`serve`). + */ +export class ServiceWorkerApp { + readonly #app: StandaloneApp; + readonly #initOptions: InitStandaloneAppOptions; + readonly #servers: http.Server[] = []; + #initialized = false; + + constructor(cwd: string, options?: ServiceWorkerAppOptions) { + const { config, mcpAuthHandler, ...standaloneOptions } = options ?? {}; + // The @eggjs/service-worker-controller package root alone would discover + // BOTH modules through the node_modules convention (its package.json + // depends on @eggjs/service-worker-runtime, which declares `eggModule`), + // but that yields [serviceWorker, serviceWorkerRuntime] and reference order + // is currently load-bearing: with the runtime module scanned second, the + // ServiceWorkerRunner's `eggObjectFactory` inject fails to resolve + // (EggPrototypeNotFound in LOAD_UNIT:serviceWorkerRuntime). Keep the + // runtime entry explicitly FIRST, then the service-worker-controller + // (`serviceWorker` eggModule) entry, until reference order stops affecting + // resolution. `!test/**` keeps the packages' test fixture modules out of + // the scan in workspace layouts (src/ in dev, dist/ when published — the + // package root either way). + const frameworkDeps: StandaloneAppOptions['frameworkDeps'] = [ + { + baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/service-worker-runtime/package.json'))), + extraFilePattern: ['!test/**'], + }, + { + baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/service-worker-controller/package.json'))), + extraFilePattern: ['!test/**'], + }, + ...(standaloneOptions.frameworkDeps ?? []), + ]; + // Construction-time wiring (capabilities + provided objects); the app + // binding (baseDir/name/env and scan sources) goes to init() below — + // the StandaloneAppInit/InitStandaloneAppOptions split. + this.#app = new StandaloneApp({ + frameworkDeps, + dump: standaloneOptions.dump, + logger: standaloneOptions.logger, + innerObjects: { + config: [{ obj: config ?? {} }], + mcpAuthHandler: [{ obj: mcpAuthHandler ?? PASS_THROUGH_MCP_AUTH_HANDLER }], + ...standaloneOptions.innerObjectHandlers, + }, + }); + this.#initOptions = { + baseDir: cwd, + name: standaloneOptions.name, + env: standaloneOptions.env, + dependencies: standaloneOptions.dependencies, + manifest: standaloneOptions.manifest, + loaderFS: standaloneOptions.loaderFS, + }; + } + + get app(): StandaloneApp { + return this.#app; + } + + async init(): Promise { + if (this.#initialized) { + return; + } + await this.#app.init(this.#initOptions); + this.#initialized = true; + } + + async handleEvent(event: Event): Promise { + const context = new StandaloneContext(); + context.set(ContextProtoProperty.Event.contextKey, event); + + return await this.#app.run(context); + } + + async serve(options?: ServeOptions): Promise { + await this.init(); + const server = http.createServer((req, res) => { + this.#handleHttpRequest(req, res).catch((e) => { + console.error('[service-worker] serve request failed:', e); + if (!res.headersSent) { + res.writeHead(500); + } + res.end(); + }); + }); + this.#servers.push(server); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(options?.port ?? 0, options?.hostname ?? '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + return server; + } + + async #handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const url = `http://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`; + const method = (req.method ?? 'GET').toUpperCase(); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') { + headers.set(key, value); + } else if (Array.isArray(value)) { + for (const v of value) { + headers.append(key, v); + } + } + } + const hasBody = method !== 'GET' && method !== 'HEAD'; + const request = new Request(url, { + method, + headers, + body: hasBody ? (Readable.toWeb(req) as unknown as BodyInit) : undefined, + // @ts-expect-error duplex is required for stream bodies but missing from the lib type + duplex: hasBody ? 'half' : undefined, + }); + const event = new FetchEventImpl(request); + const response = await this.handleEvent(event); + res.statusCode = response.status; + for (const [key, value] of response.headers.entries()) { + res.setHeader(key, value); + } + if (response.body) { + await pipeline(Readable.fromWeb(response.body as any), res); + } else { + res.end(); + } + event.waitUntilSettled().catch(() => { + /* logged by the tasks themselves */ + }); + } + + async destroy(): Promise { + await Promise.all( + this.#servers.map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections?.(); + }), + ), + ); + this.#servers.length = 0; + await this.#app.destroy(); + } +} diff --git a/tegg/standalone/service-worker/src/index.ts b/tegg/standalone/service-worker/src/index.ts new file mode 100644 index 0000000000..bc7c963cb5 --- /dev/null +++ b/tegg/standalone/service-worker/src/index.ts @@ -0,0 +1,2 @@ +export * from '@eggjs/service-worker-controller'; +export * from './ServiceWorkerApp.ts'; diff --git a/tegg/standalone/service-worker/test/MCP.test.ts b/tegg/standalone/service-worker/test/MCP.test.ts new file mode 100644 index 0000000000..ebdffd8d19 --- /dev/null +++ b/tegg/standalone/service-worker/test/MCP.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import type http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import path from 'node:path'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { afterAll, beforeAll, describe, it } from 'vitest'; + +import { ServiceWorkerApp } from '../src/index.ts'; + +const MCP_HEADERS = { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', +}; + +function parseSSEMessage(text: string): any { + // stateless streamable http replies as SSE frames: `event: message\ndata: {...}` + const dataLine = text + .split('\n') + .reverse() + .find((line) => line.startsWith('data:')); + assert(dataLine, `no data line in: ${text}`); + return JSON.parse(dataLine.slice('data:'.length).trim()); +} + +describe('standalone/service-worker/test/MCP.test.ts', () => { + let app: ServiceWorkerApp; + let server: http.Server; + let base: string; + + beforeAll(async () => { + app = new ServiceWorkerApp(path.join(__dirname, 'fixtures/hello-app')); + server = await app.serve(); + const { address, port } = server.address() as AddressInfo; + base = `http://${address}:${port}`; + }); + + afterAll(async () => { + await app.destroy(); + }); + + it('should list tools over stateless streamable http', async () => { + const res = await fetch(`${base}/mcp/calc/stream`, { + method: 'POST', + headers: MCP_HEADERS, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + }); + assert.equal(res.status, 200); + const message = parseSSEMessage(await res.text()); + const tools = message.result.tools as Array<{ name: string; description?: string }>; + assert.deepEqual( + tools.map((t) => t.name), + ['add'], + ); + assert.equal(tools[0].description, 'add two numbers'); + }); + + it('should call a tool with schema-bound args', async () => { + const res = await fetch(`${base}/mcp/calc/stream`, { + method: 'POST', + headers: MCP_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'add', arguments: { a: 40, b: 2 } }, + }), + }); + assert.equal(res.status, 200); + const message = parseSSEMessage(await res.text()); + assert.deepEqual(message.result.content, [{ type: 'text', text: 'hello, mcp: 42' }]); + }); + + it('should reject non-POST with 405 jsonrpc error', async () => { + const res = await fetch(`${base}/mcp/calc/stream`); + assert.equal(res.status, 405); + const body = (await res.json()) as { error: { code: number } }; + assert.equal(body.error.code, -32000); + }); + + it('should serve a full MCP SDK client round-trip', async () => { + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL(`${base}/mcp/calc`)); + await client.connect(transport); + try { + const { tools } = await client.listTools(); + assert.deepEqual( + tools.map((t) => t.name), + ['add'], + ); + const result = await client.callTool({ name: 'add', arguments: { a: 1, b: 2 } }); + assert.deepEqual(result.content, [{ type: 'text', text: 'hello, mcp: 3' }]); + } finally { + await client.close(); + } + }); +}); + +describe('standalone/service-worker/test/MCP.test.ts mcpAuthHandler', () => { + let app: ServiceWorkerApp; + let base: string; + + beforeAll(async () => { + app = new ServiceWorkerApp(path.join(__dirname, 'fixtures/hello-app'), { + mcpAuthHandler: { + async authenticate(request: Request) { + if (request.headers.get('x-mcp-token') !== 'secret') { + return new Response(JSON.stringify({ code: 'UNAUTHORIZED', message: 'missing x-mcp-token' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); + } + return undefined; + }, + }, + }); + const server = await app.serve(); + const { address, port } = server.address() as AddressInfo; + base = `http://${address}:${port}`; + }); + + afterAll(async () => { + await app.destroy(); + }); + + it('should reject MCP requests failing the auth hook', async () => { + const res = await fetch(`${base}/mcp/calc/stream`, { + method: 'POST', + headers: MCP_HEADERS, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + }); + assert.equal(res.status, 401); + const body = (await res.json()) as { code: string }; + assert.equal(body.code, 'UNAUTHORIZED'); + }); + + it('should pass MCP requests satisfying the auth hook', async () => { + const res = await fetch(`${base}/mcp/calc/stream`, { + method: 'POST', + headers: { ...MCP_HEADERS, 'x-mcp-token': 'secret' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + }); + assert.equal(res.status, 200); + const message = parseSSEMessage(await res.text()); + assert.equal(message.result.tools.length, 1); + }); +}); diff --git a/tegg/standalone/service-worker/test/MultiApp.test.ts b/tegg/standalone/service-worker/test/MultiApp.test.ts new file mode 100644 index 0000000000..19b62bf030 --- /dev/null +++ b/tegg/standalone/service-worker/test/MultiApp.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import path from 'node:path'; + +import { afterAll, beforeAll, describe, it } from 'vitest'; + +import { ServiceWorkerApp } from '../src/index.ts'; + +describe('standalone/service-worker/test/MultiApp.test.ts', () => { + let app1: ServiceWorkerApp; + let app2: ServiceWorkerApp; + let base1: string; + let base2: string; + + beforeAll(async () => { + // Two apps loading the SAME fixture concurrently: routers, registries and + // singletons must stay per-app (TeggScope isolation). + app1 = new ServiceWorkerApp(path.join(__dirname, 'fixtures/hello-app')); + app2 = new ServiceWorkerApp(path.join(__dirname, 'fixtures/hello-app')); + const [server1, server2] = await Promise.all([app1.serve(), app2.serve()]); + base1 = `http://127.0.0.1:${(server1.address() as AddressInfo).port}`; + base2 = `http://127.0.0.1:${(server2.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await Promise.all([app1.destroy(), app2.destroy()]); + }); + + it('should serve both apps independently', async () => { + const [res1, res2] = await Promise.all([fetch(`${base1}/hello/`), fetch(`${base2}/hello/`)]); + assert.equal(res1.status, 200); + assert.equal(res2.status, 200); + assert.deepEqual(await res1.json(), { message: 'hello, tegg' }); + assert.deepEqual(await res2.json(), { message: 'hello, tegg' }); + }); + + it('should keep per-app event injection isolated under concurrent requests', async () => { + const [res1, res2] = await Promise.all([fetch(`${base1}/hello/event`), fetch(`${base2}/hello/event`)]); + const url1 = ((await res1.json()) as { url: string }).url; + const url2 = ((await res2.json()) as { url: string }).url; + assert.match(url1, new RegExp(`^${base1}`)); + assert.match(url2, new RegExp(`^${base2}`)); + }); +}); diff --git a/tegg/standalone/service-worker/test/ServiceWorkerApp.test.ts b/tegg/standalone/service-worker/test/ServiceWorkerApp.test.ts new file mode 100644 index 0000000000..352587319a --- /dev/null +++ b/tegg/standalone/service-worker/test/ServiceWorkerApp.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import type http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import path from 'node:path'; + +import { afterAll, beforeAll, describe, it } from 'vitest'; + +import { FetchEventImpl, ServiceWorkerApp } from '../src/index.ts'; +import { backgroundFlags } from './fixtures/hello-app/HelloController.ts'; + +describe('standalone/service-worker/test/ServiceWorkerApp.test.ts', () => { + let app: ServiceWorkerApp; + let server: http.Server; + let base: string; + + beforeAll(async () => { + app = new ServiceWorkerApp(path.join(__dirname, 'fixtures/hello-app')); + server = await app.serve(); + const { address, port } = server.address() as AddressInfo; + base = `http://${address}:${port}`; + }); + + afterAll(async () => { + await app.destroy(); + }); + + it('should serve a controller over node:http', async () => { + const res = await fetch(`${base}/hello/`); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { message: 'hello, tegg' }); + }); + + it('should bind path params and query', async () => { + const res = await fetch(`${base}/hello/users/42?role=admin`); + assert.deepEqual(await res.json(), { id: '42', role: 'admin' }); + }); + + it('should bind json body', async () => { + const res = await fetch(`${base}/hello/echo`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ foo: 'bar' }), + }); + assert.deepEqual(await res.json(), { received: { foo: 'bar' } }); + }); + + it('should pass through a returned Response as-is', async () => { + const res = await fetch(`${base}/hello/raw`); + assert.equal(res.status, 201); + assert.equal(res.headers.get('x-raw'), '1'); + assert.equal(await res.text(), 'raw-body'); + }); + + it('should run method middlewares', async () => { + const res = await fetch(`${base}/hello/middleware`); + assert.deepEqual(await res.json(), { fromMiddleware: 'yes' }); + }); + + it('should inject the fetch event into context protos', async () => { + const res = await fetch(`${base}/hello/event`); + const { url } = (await res.json()) as { url: string }; + assert.match(url, /\/hello\/event$/); + }); + + it('should support BackgroundTaskHelper and drain on ctx destroy', async () => { + const res = await fetch(`${base}/hello/background`); + assert.deepEqual(await res.json(), { started: true }); + // ctx destroy drains background tasks; give the event loop a tick + for (let i = 0; i < 50 && backgroundFlags.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.deepEqual(backgroundFlags, ['done']); + }); + + it('should load module.yml as moduleConfig', async () => { + const res = await fetch(`${base}/hello/module-config`); + assert.deepEqual(await res.json(), { features: { greeting: 'howdy' } }); + }); + + it('should return the unified error shape for unknown routes', async () => { + const res = await fetch(`${base}/nope`); + assert.equal(res.status, 404); + const body = (await res.json()) as { code: string; message: string }; + assert.equal(body.code, 'NOT_FOUND'); + assert.match(body.message, /GET \/nope/); + }); + + it('should return the unified error shape for controller errors', async () => { + const res = await fetch(`${base}/stream/boom`); + assert.equal(res.status, 500); + assert.deepEqual(await res.json(), { + code: 'INTERNAL_SERVER_ERROR', + message: 'stream controller boom', + }); + }); + + it('should merge ctx.responseHeaders onto the final response', async () => { + const res = await fetch(`${base}/stream/headers`); + assert.equal(res.headers.get('x-service-worker'), 'on'); + assert.deepEqual(await res.json(), { ok: true }); + }); + + it('should keep context protos alive until a streaming response is drained', async () => { + const res = await fetch(`${base}/stream/sse`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'text/event-stream'); + // chunks are produced 30ms apart, well after handleEvent has returned; + // without the stream guard the probe is destroyed first and emits DEAD + const text = await res.text(); + assert.equal(text, 'data: chunk-0\n\ndata: chunk-1\n\ndata: chunk-2\n\n'); + }); + + it('should handle embedded events without a server', async () => { + const event = new FetchEventImpl(new Request('http://embedded.local/hello/')); + const response = await app.handleEvent(event); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { message: 'hello, tegg' }); + }); +}); diff --git a/tegg/standalone/service-worker/test/fixtures/hello-app/CalcMCPController.ts b/tegg/standalone/service-worker/test/fixtures/hello-app/CalcMCPController.ts new file mode 100644 index 0000000000..4e2c520c7f --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/hello-app/CalcMCPController.ts @@ -0,0 +1,27 @@ +import { Inject, MCPController, MCPTool, ToolArgsSchema } from '@eggjs/tegg'; +import { z } from 'zod'; + +import { HelloService } from './HelloService.ts'; + +const AddArgsSchema = { + a: z.number(), + b: z.number(), +}; + +@MCPController({ name: 'calc' }) +export class CalcMCPController { + @Inject() + private readonly helloService: HelloService; + + @MCPTool({ description: 'add two numbers' }) + async add(@ToolArgsSchema(AddArgsSchema) args: { a: number; b: number }) { + return { + content: [ + { + type: 'text' as const, + text: `${this.helloService.hello('mcp')}: ${args.a + args.b}`, + }, + ], + }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/hello-app/EventEchoService.ts b/tegg/standalone/service-worker/test/fixtures/hello-app/EventEchoService.ts new file mode 100644 index 0000000000..db7aeffd17 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/hello-app/EventEchoService.ts @@ -0,0 +1,11 @@ +import { AccessLevel, ContextProto, Inject } from '@eggjs/tegg'; + +@ContextProto({ accessLevel: AccessLevel.PUBLIC }) +export class EventEchoService { + @Inject() + private readonly event: Event; + + requestUrl(): string { + return (this.event as any).request.url; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/hello-app/HelloController.ts b/tegg/standalone/service-worker/test/fixtures/hello-app/HelloController.ts new file mode 100644 index 0000000000..13ed24b0fc --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/hello-app/HelloController.ts @@ -0,0 +1,85 @@ +import { BackgroundTaskHelper } from '@eggjs/background-task'; +import { + InjectContext, + HTTPBody, + HTTPController, + HTTPMethod, + HTTPMethodEnum, + HTTPParam, + HTTPQuery, + Inject, + Middleware, + type ModuleConfigs, +} from '@eggjs/tegg'; + +import { EventEchoService } from './EventEchoService.ts'; +import { HelloService } from './HelloService.ts'; + +export const backgroundFlags: string[] = []; + +async function markMiddleware(ctx: any, next: () => Promise): Promise { + ctx.fromMiddleware = 'yes'; + await next(); +} + +@HTTPController({ path: '/hello' }) +export class HelloController { + @Inject() + private readonly helloService: HelloService; + + @Inject() + private readonly eventEchoService: EventEchoService; + + @Inject() + private readonly backgroundTaskHelper: BackgroundTaskHelper; + + @Inject() + private readonly moduleConfigs: ModuleConfigs; + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/' }) + async index() { + return { message: this.helloService.hello('tegg') }; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/users/:id' }) + async user(@HTTPParam({ name: 'id' }) id: string, @HTTPQuery({ name: 'role' }) role: string) { + return { id, role: role ?? null }; + } + + @HTTPMethod({ method: HTTPMethodEnum.POST, path: '/echo' }) + async echo(@HTTPBody() body: Record) { + return { received: body }; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/raw' }) + async raw() { + return new Response('raw-body', { + status: 201, + headers: { 'x-raw': '1' }, + }); + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/middleware' }) + @Middleware(markMiddleware) + async middleware(@InjectContext() ctx: any) { + return { fromMiddleware: ctx.fromMiddleware ?? null }; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/event' }) + async event() { + return { url: this.eventEchoService.requestUrl() }; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/module-config' }) + async moduleConfig() { + return this.moduleConfigs.get('helloApp'); + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/background' }) + async background() { + this.backgroundTaskHelper.run(async () => { + backgroundFlags.push('done'); + }); + return { started: true }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/hello-app/HelloService.ts b/tegg/standalone/service-worker/test/fixtures/hello-app/HelloService.ts new file mode 100644 index 0000000000..fbecb074e5 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/hello-app/HelloService.ts @@ -0,0 +1,8 @@ +import { AccessLevel, SingletonProto } from '@eggjs/tegg'; + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class HelloService { + hello(name: string): string { + return `hello, ${name}`; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/hello-app/StreamController.ts b/tegg/standalone/service-worker/test/fixtures/hello-app/StreamController.ts new file mode 100644 index 0000000000..e90dcd0ba0 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/hello-app/StreamController.ts @@ -0,0 +1,56 @@ +import { ContextProto, HTTPController, HTTPMethod, HTTPMethodEnum, Inject, InjectContext } from '@eggjs/tegg'; +import type { EggObjectLifecycle } from '@eggjs/tegg-types'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +@ContextProto() +export class StreamProbeService implements EggObjectLifecycle { + destroyed = false; + + chunk(index: number): string { + return this.destroyed ? 'DEAD' : `chunk-${index}`; + } + + async preDestroy(): Promise { + this.destroyed = true; + } +} + +@HTTPController({ path: '/stream' }) +export class StreamController { + @Inject() + private readonly streamProbeService: StreamProbeService; + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/sse' }) + async sse() { + const probe = this.streamProbeService; + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + for (let i = 0; i < 3; i++) { + // stay slower than the ctx-destroy tick so a missing stream guard + // surfaces as DEAD chunks + await sleep(30); + controller.enqueue(encoder.encode(`data: ${probe.chunk(i)}\n\n`)); + } + controller.close(); + }, + }); + return new Response(stream, { + headers: { 'content-type': 'text/event-stream' }, + }); + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/boom' }) + async boom() { + throw new Error('stream controller boom'); + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/headers' }) + async headers(@InjectContext() ctx: any) { + ctx.responseHeaders.set('x-service-worker', 'on'); + return { ok: true }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/hello-app/module.yml b/tegg/standalone/service-worker/test/fixtures/hello-app/module.yml new file mode 100644 index 0000000000..341ce88370 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/hello-app/module.yml @@ -0,0 +1,2 @@ +features: + greeting: 'howdy' diff --git a/tegg/standalone/service-worker/test/fixtures/hello-app/package.json b/tegg/standalone/service-worker/test/fixtures/hello-app/package.json new file mode 100644 index 0000000000..6607fdfaf3 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/hello-app/package.json @@ -0,0 +1,7 @@ +{ + "name": "hello-app", + "type": "module", + "eggModule": { + "name": "helloApp" + } +} diff --git a/tegg/standalone/service-worker/tsconfig.json b/tegg/standalone/service-worker/tsconfig.json new file mode 100644 index 0000000000..618c6c3e97 --- /dev/null +++ b/tegg/standalone/service-worker/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/tegg/standalone/service-worker/tsdown.config.ts b/tegg/standalone/service-worker/tsdown.config.ts new file mode 100644 index 0000000000..6dfb63bdb8 --- /dev/null +++ b/tegg/standalone/service-worker/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, +}); diff --git a/wiki/index.md b/wiki/index.md index ac5f0533cf..4afc3da1db 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -27,6 +27,7 @@ Read this file before exploring raw sources. - [Egg Bundler](./packages/egg-bundler.md) - Tooling package that bundles Egg applications and backs `egg-bin bundle`. - [Loader FS Package](./packages/loader-fs.md) - Shared loader-facing filesystem boundary for Egg loaders and future bundled runtimes. - [Onerror Plugin](./packages/onerror.md) - Default Egg error-handling plugin and configurable response negotiation layer. +- [Standalone Service Worker](./packages/service-worker.md) - Fetch-semantics standalone runtime serving HTTP controllers and MCP tools from a tegg module without an egg application. - [Typings Package](./packages/typings.md) - Shared TypeScript type surface for cross-package Egg typings. - [Utils Package](./packages/utils.md) - Shared utility package for module loading and bundled module-loader integration. diff --git a/wiki/log.md b/wiki/log.md index e0df9a006e..5507b81830 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2,6 +2,18 @@ Dates use the workspace-local Asia/Shanghai calendar date. +## [2026-07-10] package | four-package controller layering (runtime library + per-host plugins) + +- sources touched: `tegg/core/controller-runtime/*` (new), `tegg/standalone/service-worker-controller/*` (new), `tegg/plugin/controller/src/{index.ts,lib/ControllerModule.ts}`, `tegg/standalone/service-worker/src/{ServiceWorkerApp.ts,index.ts,ControllerModule.ts}` (moved) +- pages updated: `wiki/packages/service-worker.md`, `wiki/log.md` +- note: Split the controller stack into four packages mirroring the egg/standalone host boundary. Extracted the egg-free host-agnostic runtime into `@eggjs/controller-runtime` — a plain LIBRARY, NOT an eggModule (it defines the base register classes, collect-only `MCPControllerRegister`, `McpRouter`/`Router` abstractions, `MCPServerHelper`, and the controller inner-object prototypes, but is never scanned). The scanned eggModule stays a HOST package: the egg host's `teggController` plugin (`@eggjs/controller-plugin`) and the fetch host's `serviceWorker` module (extracted into a new `@eggjs/service-worker-controller` package) each re-export the runtime's protos into their own module (`ControllerModule.ts`, collected by `LoaderUtil.loadFile`). `@eggjs/service-worker` is now just the `ServiceWorkerApp` host facade, depending on the egg-free runtime + the fetch controller package — never on the egg plugin. Package-identity module binding (the C2/C3 reconcile) keeps the egg host promoting its own `teggController`. Regression green across controller/service-worker/mcp-proxy/example/MultiApp; typecheck clean; the runtime and fetch-controller packages carry no `egg` dependency. + +## [2026-07-10] package | host-agnostic MCP register via McpRouter boundary + +- sources touched: `tegg/plugin/controller/src/lib/impl/mcp/{McpRouter,EggMcpRouter,MCPControllerRegister}.ts`, `tegg/plugin/controller/src/{app.ts,lib/ControllerModule.ts}`, `tegg/plugin/tegg/src/lib/ModuleHandler.ts`, `tegg/standalone/service-worker/src/mcp/{ServiceWorkerMcpRouter,MCPRegisterProvider}.ts`, `tegg/plugin/mcp-proxy/src/{app,index}.ts` +- pages updated: `wiki/packages/service-worker.md`, `wiki/log.md` +- note: Fixed the C4 host-boundary leak — the MCP controller register mixed record collection with egg-specific transport and the egg host threaded its `Application` into the module inner-object DI graph as a PRIVATE `eggApp` provided object. Extracted a `McpRouter` transport boundary: the shared `MCPControllerRegister` now only collects tool/resource/prompt records and calls `mcpRouter.registerServer(reg)`; egg node-HTTP transport moved to `EggMcpRouter` (built in `app.ts` with `app`), SW fetch transport to `ServiceWorkerMcpRouter`. Both provide the `mcpRouter` DI name (host plugins never coexist). `eggApp` provided object removed from `ModuleHandler`; `EggControllerRegisterFactory` dropped its host generic/injection. `MCPServerHelper` was already host-agnostic and is unchanged. Regression green (controller/service-worker/mcp-proxy/example/MultiApp) modulo a pre-existing controller boot-error test that only times out under the 5000ms suite-default and dal tests that need MySQL. + ## [2026-06-28] workflow | record egg-bin Windows shell probe hotspot - sources touched: `tools/egg-bin/bin/run.js`, `tools/egg-bin/test/fixtures/my-egg-bin/bin/run.js`, PR #6014 CI logs @@ -132,3 +144,9 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - sources touched: `tegg/plugin/aop/src/lib/AopContextHook.ts`, `tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts`, `tegg/core/aop-runtime/src/LoadUnitAopHook.ts`, `tegg/plugin/dal/src/index.ts`, `tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts` - pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` - note: Replaced stale DAL source paths and updated the AOP note after `AopContextHook` moved to lifecycle-proto/inner-object registration backed by `AopContextAdviceRegistry`. + +## [2026-07-05] package | standalone service worker (方案二 complete) + +- sources touched: `tegg/plugin/controller`, `tegg/standalone/{service-worker-runtime,service-worker}`, `examples/helloworld-service-worker` +- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/packages/service-worker.md` +- note: Completed the service-worker migration on top of the module plugin mechanism: made the controller plugin a dual-host module carrying its host-agnostic runtime under `lib/runtime/`, added the two service worker packages (fetch adapter + protocol-agnostic runtime), MCP stateless streamable HTTP via the SDK's web-standard transport (SDK >= 1.29 forbids stateless transport reuse — fresh server+transport per request), streaming-response lifecycle via BackgroundTaskHelper drain, unified `{ code, message }` errors, `mcpAuthHandler` auth extension point, and a runnable example. Gotcha recorded: frameworkDeps module scans must exclude `test/**` or framework test fixtures load as business modules. diff --git a/wiki/packages/service-worker.md b/wiki/packages/service-worker.md new file mode 100644 index 0000000000..18fcc17984 --- /dev/null +++ b/wiki/packages/service-worker.md @@ -0,0 +1,78 @@ +--- +title: Standalone service worker (@eggjs/service-worker[-runtime]) +type: package +summary: Fetch-semantics standalone runtime — HTTP controllers and MCP tools served from a tegg module without an egg application +source_files: + - tegg/standalone/service-worker-runtime/src + - tegg/standalone/service-worker/src + - tegg/standalone/service-worker-controller/src + - tegg/core/controller-runtime/src + - tegg/plugin/controller/src + - examples/helloworld-service-worker +updated_at: 2026-07-10 +status: active +--- + +Two packages provide the standalone service worker runtime on top of the +module-plugin mechanism (declarative `@InnerObjectProto` / +`@XxxLifecycleProto` hooks, see the module-plugin pages): + +- `@eggjs/service-worker-runtime` — protocol-agnostic: `@Runner()` entry + dispatching events to `@EventHandlerProto('')` handlers, event + injection into ContextProtos, `BackgroundTaskHelper` re-export + (ctx-destroy draining). +- `@eggjs/service-worker-controller` — the fetch controller transport (the + `serviceWorker` eggModule): `FetchEventHandler`, `FetchRouter` + fetch + parameter binding (no `@Cookies`), `HTTP/MCP RegisterProvider`, + `ServiceWorkerMcpRouter`, MCP stateless streamable HTTP under + `/mcp[/name]/stream`. +- `@eggjs/service-worker` — the host app only: the `ServiceWorkerApp` facade + over `StandaloneApp` with `serve()` (node:http bridge) and embedded + `handleEvent()`, loading the runtime + controller packages as frameworkDeps. + Its `index` re-exports the controller package so the public API is stable. + +Key mechanics and constraints: + +- **Four-package controller layering** (mirrors the egg host): host-agnostic + runtime `@eggjs/controller-runtime` (a plain LIBRARY, not an eggModule — base + register classes, collect-only `MCPControllerRegister`, `McpRouter`/`Router` + abstractions, `MCPServerHelper`, and the controller inner-object prototypes) + → egg transport `@eggjs/controller-plugin` (the `teggController` plugin + module) → fetch transport `@eggjs/service-worker-controller` (the + `serviceWorker` module) → host app `@eggjs/service-worker`. Each HOST package + owns the scanned eggModule and re-exports the runtime's inner-object protos + into it (`ControllerModule.ts`); the runtime library itself is never scanned. + The service worker depends only on the egg-free runtime, never on the egg + plugin. +- **Host-agnostic MCP register + `McpRouter` boundary**: the shared + `MCPControllerRegister` (controller-plugin) only COLLECTS tool/resource/prompt + records and delegates transport to an injected `McpRouter` + (`registerServer(reg)`); it no longer touches `app`. The egg host's node-HTTP + transport is `EggMcpRouter` (built imperatively in the controller plugin's + `app.ts`, which holds `app`), the service worker's fetch transport is + `ServiceWorkerMcpRouter`. Both provide the `mcpRouter` DI object; the two host + plugins are never used together, so the shared name does not collide. Because + the register needs no `app`, the egg host no longer provides `eggApp` as a + module inner object — the Egg `Application` is out of the module DI graph. +- **MCP SDK >= 1.29 stateless transports are single-shot** (reuse throws), so + each MCP request builds a fresh `MCPServerHelper` + web-standard transport + from register records collected at boot. The service worker's + `ServiceWorkerMcpRouter` uses `WebStandardStreamableHTTPServerTransport` + (Request in, Response out) — no node req/res bridging. Auth is an + `mcpAuthHandler` extension point (default: allow). +- **Streaming lifecycle**: `FetchEventHandler` routes every response body + through a passthrough and registers the drain as a background task, so ctx + destroy waits (bounded by `config.backgroundTask.timeout`) until the client + consumes the stream. +- **Unified errors**: framework failures reply `{ code, message }` JSON + (`NOT_FOUND` / `INTERNAL_SERVER_ERROR`); `ctx.responseHeaders` merge onto + the final response. +- **frameworkDeps scan excludes `test/**`\*\*: the framework packages are + themselves modules; without the exclusion their test fixtures load as + business modules (duplicate controller names) in workspace layouts. +- Per-app state is all inner objects in the app's TeggScope bag — two + concurrent `ServiceWorkerApp`s are isolated (`test/MultiApp.test.ts`). + +Example: `examples/helloworld-service-worker` (entry `main.ts` lives outside +the scanned `app/` module dir on purpose — the scan imports every module +file, and importing an entry that boots the app recurses).