diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 222952cf38..125caa922e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -209,6 +209,7 @@ catalog: type-is: ^2.0.0 typebox: ^1.0.65 typescript: ^5.9.3 + undici: ^7.10.0 unplugin-unused: ^0.5.4 urijs: ^1.19.11 urllib: ^4.8.2 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..5ca1ab24aa --- /dev/null +++ b/tegg/core/standalone-decorator/src/event/EventHandler.ts @@ -0,0 +1,27 @@ +import { type EggProtoImplClass, ImplDecorator, SingletonProtoParams } from '@eggjs/tegg-types'; +import { QualifierImplDecoratorUtil, SingletonProto } from '@eggjs/tegg'; + +export abstract class AbstractEventHandler { + abstract handleEvent(event: E): Promise; +} + +export const EVENT_HANDLER_ATTRIBUTE = Symbol('EVENT_HANDLER_ATTRIBUTE'); + +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); + }; +}; + +export interface FetchEventLike { + type: string; + request: Request; + respondWith(response: Response | Promise): void; + waitUntil?(promise: Promise): void; +} 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/core/test-util/package.json b/tegg/core/test-util/package.json index b74d7f35f5..e7c7ba5e24 100644 --- a/tegg/core/test-util/package.json +++ b/tegg/core/test-util/package.json @@ -29,11 +29,13 @@ "types": "./dist/index.d.ts", "exports": { ".": "./src/index.ts", + "./StandaloneTestUtil": "./src/StandaloneTestUtil.ts", "./package.json": "./package.json" }, "publishConfig": { "exports": { ".": "./dist/index.js", + "./StandaloneTestUtil": "./dist/StandaloneTestUtil.js", "./package.json": "./package.json" } }, @@ -47,7 +49,9 @@ "@eggjs/tegg-common-util": "workspace:*", "@eggjs/tegg-loader": "workspace:*", "@eggjs/tegg-runtime": "workspace:*", - "globby": "catalog:" + "@eggjs/tegg-types": "workspace:*", + "globby": "catalog:", + "undici": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/tegg/core/test-util/src/StandaloneTestUtil.ts b/tegg/core/test-util/src/StandaloneTestUtil.ts new file mode 100644 index 0000000000..83e013ee9f --- /dev/null +++ b/tegg/core/test-util/src/StandaloneTestUtil.ts @@ -0,0 +1,81 @@ +import { createServer, IncomingMessage, OutgoingHttpHeaders, Server, ServerOptions, ServerResponse } from 'node:http'; +import { pipeline } from 'node:stream'; +import { Headers, BodyInit, Request, Response } from 'undici'; +import { FetchEvent } from '@eggjs/tegg-types/standalone'; + +export type FetchEventListener = (event: FetchEvent) => Promise; + +export interface StartHTTPServerOptions extends ServerOptions { + listener: FetchEventListener; +} + +export class StandaloneTestUtil { + static skipOnNode(minVersion = 18) { + const version = parseInt(process.versions.node.split('.')[0], 10); + return version < minVersion; + } + + static #buildRequest(req: IncomingMessage): Request { + const origin = `http://${req.headers.host ?? 'localhost'}`; + const url = new URL(req.url ?? '', origin); + + const body: BodyInit | null = req.method === 'GET' || req.method === 'HEAD' ? null : req; + + req.headers.host = url.host; + + const headers = new Headers(); + for (const [ name, values ] of Object.entries(req.headers)) { + if (Array.isArray(values)) { + for (const value of values) { + headers.append(name, value); + } + } else if (values !== undefined) { + headers.append(name, values); + } + } + + return new Request(url, { + method: req.method, + headers, + body, + duplex: body ? 'half' : undefined, + }); + } + + static #createHTTPServerListener(listener: FetchEventListener) { + return async (req: IncomingMessage, res: ServerResponse) => { + const request = StandaloneTestUtil.#buildRequest(req); + // TODO currently fake FetchEvent + const event: any = new Event('fetch'); + event.request = request; + const response = await listener(event); + + const headers: OutgoingHttpHeaders = {}; + for (const [ key, value ] of response.headers) { + headers[key.toLowerCase()] = value; + } + + res.writeHead(response.status, headers); + + if (!response.body) { + res.end(); + return; + } + pipeline(response.body, res, e => { + if (e) { + console.error(`pipeline writing response error for url ${response.url}`, e); + res.end(); + } + }); + }; + } + + static startHTTPServer(host: string, port: number, { listener, ...options }: StartHTTPServerOptions) { + const serverListener = StandaloneTestUtil.#createHTTPServerListener(listener); + const server = createServer(options ?? {}, serverListener); + + return new Promise(resolve => { + server.listen(port, host, () => resolve(server)); + }); + } +} diff --git a/tegg/core/types/package.json b/tegg/core/types/package.json index edde6dce83..f8f722e1d2 100644 --- a/tegg/core/types/package.json +++ b/tegg/core/types/package.json @@ -124,6 +124,9 @@ "./runtime/model/EggObject": "./src/runtime/model/EggObject.ts", "./runtime/model/LoadUnitInstance": "./src/runtime/model/LoadUnitInstance.ts", "./schedule": "./src/schedule.ts", + "./standalone": "./standalone/index.ts", + "./standalone/fetch": "./standalone/fetch.ts", + "./standalone/ServiceWorkerContext": "./standalone/ServiceWorkerContext.ts", "./transaction": "./src/transaction.ts", "./package.json": "./package.json" }, @@ -227,6 +230,9 @@ "./runtime/model/EggObject": "./dist/runtime/model/EggObject.js", "./runtime/model/LoadUnitInstance": "./dist/runtime/model/LoadUnitInstance.js", "./schedule": "./dist/schedule.js", + "./standalone": "./dist/standalone/index.js", + "./standalone/fetch": "./dist/standalone/fetch.js", + "./standalone/ServiceWorkerContext": "./dist/standalone/ServiceWorkerContext.js", "./transaction": "./dist/transaction.js", "./package.json": "./package.json" } diff --git a/tegg/core/types/standalone/ServiceWorkerContext.ts b/tegg/core/types/standalone/ServiceWorkerContext.ts new file mode 100644 index 0000000000..53ce2ddb61 --- /dev/null +++ b/tegg/core/types/standalone/ServiceWorkerContext.ts @@ -0,0 +1,16 @@ +import { FetchEvent } from './fetch.ts'; + +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); +} + +export type ServiceWorkerFetchContext = ServiceWorkerContext; diff --git a/tegg/core/types/standalone/fetch.ts b/tegg/core/types/standalone/fetch.ts new file mode 100644 index 0000000000..cd0027bf33 --- /dev/null +++ b/tegg/core/types/standalone/fetch.ts @@ -0,0 +1,5 @@ +export interface FetchEvent extends Event { + request: Request; + waitUntil(f: Promise): void; + respondWith(r: Response | PromiseLike): void; +} diff --git a/tegg/core/types/standalone/index.ts b/tegg/core/types/standalone/index.ts new file mode 100644 index 0000000000..cd50d5c1d5 --- /dev/null +++ b/tegg/core/types/standalone/index.ts @@ -0,0 +1,2 @@ +export * from './fetch.ts'; +export * from './ServiceWorkerContext.ts'; diff --git a/tegg/standalone/service-worker/package.json b/tegg/standalone/service-worker/package.json new file mode 100644 index 0000000000..b2af200035 --- /dev/null +++ b/tegg/standalone/service-worker/package.json @@ -0,0 +1,74 @@ +{ + "name": "@eggjs/tegg-service-worker", + "version": "4.0.0-beta.29", + "private": true, + "description": "tegg service worker", + "keywords": [ + "egg", + "typescript", + "tegg", + "standalone", + "service worker" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/tegg/standalone/service-worker", + "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/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": { + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/router": "workspace:*", + "@eggjs/ajv-plugin": "workspace:*", + "@eggjs/tegg-aop-runtime": "workspace:*", + "@eggjs/tegg-dal-plugin": "workspace:*", + "@eggjs/tegg-dynamic-inject-runtime": "workspace:*", + "@eggjs/tegg-lifecycle": "workspace:*", + "@eggjs/tegg-metadata": "workspace:*", + "@eggjs/tegg-standalone": "workspace:*", + "@eggjs/tegg-types": "workspace:*", + "@modelcontextprotocol/sdk": "1.24.3", + "egg-errors": "catalog:", + "path-to-regexp": "catalog:path-to-regexp1", + "type-is": "catalog:", + "urllib": "catalog:" + }, + "peerDependencies": { + "@eggjs/tegg": "workspace:*" + }, + "devDependencies": { + "@eggjs/module-test-util": "workspace:*", + "@eggjs/tegg": "workspace:*", + "@types/node": "catalog:", + "@types/type-is": "catalog:", + "typescript": "catalog:", + "undici": "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..9b9cfcc5a5 --- /dev/null +++ b/tegg/standalone/service-worker/src/ServiceWorkerApp.ts @@ -0,0 +1,116 @@ +import path from 'node:path'; +import { + EggPrototypeLifecycleUtil, + LoadUnitLifecycleUtil, +} from '@eggjs/tegg-metadata'; +import { Runner, RunnerOptions, StandaloneContext } from '@eggjs/tegg-standalone'; +import type { Logger } from '@eggjs/tegg-types'; +import { getDefaultHttpClient } from 'urllib'; +import { ContextProtoProperty } from './constants.ts'; +import { FetchRouter } from './http/FetchRouter.ts'; +import { RootProtoManager } from './controller/RootProtoManager.ts'; +import { ControllerMetadataManager } from './controller/ControllerMetadataManager.ts'; +import { ControllerRegisterFactory } from './controller/ControllerRegisterFactory.ts'; +import { ContextProtoLoadUnitHook } from './hook/ContextProtoLoadUnitHook.ts'; +import { ControllerPrototypeHook } from './hook/ControllerPrototypeHook.ts'; +import { ControllerLoadUnitHook } from './hook/ControllerLoadUnitHook.ts'; +import { HTTPControllerRegister } from './http/HTTPControllerRegister.ts'; +import { MCPControllerRegister } from './mcp/MCPControllerRegister.ts'; +import { LoadUnitInnerClassHook } from './hook/LoadUnitInnerClassHook.ts'; +import { ServiceWorkerRunner } from './ServiceWorkerRunner.ts'; +import { StandaloneEggObjectFactory } from './StandaloneEggObjectFactory.ts'; +import { FetchEventHandler } from './http/FetchEventHandler.ts'; + +export interface ServiceWorkerAppOptions { + innerObjectHandlers?: RunnerOptions['innerObjectHandlers']; + logger?: Logger; +} + +export class ServiceWorkerApp { + private readonly runner: Runner; + private readonly contextProtoLoadUnitHook: ContextProtoLoadUnitHook; + private readonly controllerPrototypeHook: ControllerPrototypeHook; + private readonly controllerLoadUnitHook: ControllerLoadUnitHook; + private readonly fetchRouter: FetchRouter; + private readonly rootProtoManager: RootProtoManager; + private readonly controllerMetadataManager: ControllerMetadataManager; + private readonly controllerRegisterFactory: ControllerRegisterFactory; + private readonly loadUnitInnerClassHook: LoadUnitInnerClassHook; + + constructor(cwd: string, options?: ServiceWorkerAppOptions & RunnerOptions) { + // Create shared objects + this.fetchRouter = new FetchRouter(); + this.rootProtoManager = new RootProtoManager(); + this.controllerMetadataManager = new ControllerMetadataManager(); + this.controllerRegisterFactory = new ControllerRegisterFactory(); + + // Create lifecycle hooks + this.contextProtoLoadUnitHook = new ContextProtoLoadUnitHook('serviceWorker'); + this.controllerPrototypeHook = new ControllerPrototypeHook(); + this.controllerLoadUnitHook = new ControllerLoadUnitHook( + this.controllerRegisterFactory, + this.rootProtoManager, + this.controllerMetadataManager, + this.fetchRouter, + ); + + // Register lifecycle hooks + LoadUnitLifecycleUtil.registerLifecycle(this.contextProtoLoadUnitHook); + LoadUnitLifecycleUtil.registerLifecycle(this.controllerLoadUnitHook); + EggPrototypeLifecycleUtil.registerLifecycle(this.controllerPrototypeHook); + + // Build dependencies list - include this package as a framework dependency + const frameworkDep = { baseDir: path.join(__dirname, '..'), extraFilePattern: [ '!**/test' ] }; + const deps = [ ...(options?.dependencies || []), frameworkDep ]; + + // Register FetchRouter and RootProtoManager as inner objects so they can be @Inject()-ed + const innerObjectHandlers: RunnerOptions['innerObjectHandlers'] = { + ...options?.innerObjectHandlers, + fetchRouter: [{ obj: this.fetchRouter }], + rootProtoManager: [{ obj: this.rootProtoManager }], + }; + + // Provide default logger (fallback to console) and httpclient (urllib singleton) + if (!innerObjectHandlers.logger) { + innerObjectHandlers.logger = [{ obj: options?.logger || console }]; + } + if (!innerObjectHandlers.httpclient) { + innerObjectHandlers.httpclient = [{ obj: getDefaultHttpClient() }]; + } + + this.loadUnitInnerClassHook = new LoadUnitInnerClassHook([ StandaloneEggObjectFactory, ServiceWorkerRunner, FetchEventHandler ]); + + LoadUnitLifecycleUtil.registerLifecycle(this.loadUnitInnerClassHook); + + this.runner = new Runner(cwd, { + ...options, + dependencies: deps, + innerObjectHandlers, + }); + } + + async init() { + await this.runner.init(); + } + + async handleEvent(event: Event) { + const context = new StandaloneContext(); + context.set(ContextProtoProperty.Event.contextKey, event); + + return await this.runner.run(context); + } + + async destroy() { + // Clean up static singletons + HTTPControllerRegister.clean(); + MCPControllerRegister.clean(); + + // Unregister lifecycle hooks + LoadUnitLifecycleUtil.deleteLifecycle(this.contextProtoLoadUnitHook); + LoadUnitLifecycleUtil.deleteLifecycle(this.controllerLoadUnitHook); + LoadUnitLifecycleUtil.deleteLifecycle(this.loadUnitInnerClassHook); + EggPrototypeLifecycleUtil.deleteLifecycle(this.controllerPrototypeHook); + + await this.runner.destroy(); + } +} diff --git a/tegg/standalone/service-worker/src/ServiceWorkerRunner.ts b/tegg/standalone/service-worker/src/ServiceWorkerRunner.ts new file mode 100644 index 0000000000..2574d58316 --- /dev/null +++ b/tegg/standalone/service-worker/src/ServiceWorkerRunner.ts @@ -0,0 +1,24 @@ +import { SingletonProto, Inject, EggObjectFactory } from '@eggjs/tegg'; +import { Runner, AbstractEventHandler, FetchEventLike } from '@eggjs/tegg/standalone'; +import { ContextHandler } from '@eggjs/tegg/helper'; +import { ContextProtoProperty } from './constants.ts'; + +@Runner() +@SingletonProto() +export class ServiceWorkerRunner { + @Inject({ name: 'standaloneEggObjectFactory' }) + private readonly eggObjectFactory: EggObjectFactory; + + async main(): Promise { + const requestContext = ContextHandler.getContext(); + if (!requestContext) { + throw new Error('[tegg-standalone-framework] no request context in FetchRunner'); + } + const event = requestContext.get(ContextProtoProperty.Event.contextKey) as FetchEventLike | undefined; + if (!event) { + throw new Error('[tegg-standalone-framework] no fetch event on context'); + } + const handler = await this.eggObjectFactory.getEggObject(AbstractEventHandler, event.type); + return await handler.handleEvent(event); + } +} diff --git a/tegg/standalone/service-worker/src/StandaloneEggObjectFactory.ts b/tegg/standalone/service-worker/src/StandaloneEggObjectFactory.ts new file mode 100644 index 0000000000..e6de2083fc --- /dev/null +++ b/tegg/standalone/service-worker/src/StandaloneEggObjectFactory.ts @@ -0,0 +1,11 @@ +import { AccessLevel, SingletonProto } from '@eggjs/tegg'; +import { EggObjectFactory } from '@eggjs/tegg-dynamic-inject-runtime'; +import { + EGG_OBJECT_FACTORY_PROTO_IMPLE_TYPE, +} from '@eggjs/tegg-dynamic-inject-runtime/src/EggObjectFactoryPrototype'; + +@SingletonProto({ + protoImplType: EGG_OBJECT_FACTORY_PROTO_IMPLE_TYPE, + accessLevel: AccessLevel.PRIVATE, +}) +export class StandaloneEggObjectFactory extends EggObjectFactory {} diff --git a/tegg/standalone/service-worker/src/constants.ts b/tegg/standalone/service-worker/src/constants.ts new file mode 100644 index 0000000000..fd9d3cd941 --- /dev/null +++ b/tegg/standalone/service-worker/src/constants.ts @@ -0,0 +1,8 @@ +import { ProtoMeta } from './types.ts'; + +export class ContextProtoProperty { + static readonly Event: ProtoMeta = { + protoName: 'event', + contextKey: Symbol('context#event'), + }; +} diff --git a/tegg/standalone/service-worker/src/controller/ControllerMetadataManager.ts b/tegg/standalone/service-worker/src/controller/ControllerMetadataManager.ts new file mode 100644 index 0000000000..e484959d18 --- /dev/null +++ b/tegg/standalone/service-worker/src/controller/ControllerMetadataManager.ts @@ -0,0 +1,25 @@ +import { ControllerMetadata, ControllerTypeLike } from '@eggjs/tegg'; +import { MapUtil } from '@eggjs/tegg/helper'; + +export class ControllerMetadataManager { + private readonly controllers = new Map(); + + addController(metadata: ControllerMetadata) { + const typeControllers = MapUtil.getOrStore(this.controllers, metadata.type, []); + // 1.check controller name + // 2.check proto name + const sameNameControllers = typeControllers.filter(c => c.controllerName === metadata.controllerName); + if (sameNameControllers.length) { + throw new Error(`duplicate controller name ${metadata.controllerName}`); + } + const sameProtoControllers = typeControllers.filter(c => c.protoName === metadata.protoName); + if (sameProtoControllers.length) { + throw new Error(`duplicate proto name ${String(metadata.protoName)}`); + } + typeControllers.push(metadata); + } + + clear() { + this.controllers.clear(); + } +} diff --git a/tegg/standalone/service-worker/src/controller/ControllerRegister.ts b/tegg/standalone/service-worker/src/controller/ControllerRegister.ts new file mode 100644 index 0000000000..930810156b --- /dev/null +++ b/tegg/standalone/service-worker/src/controller/ControllerRegister.ts @@ -0,0 +1,5 @@ +import { RootProtoManager } from './RootProtoManager.ts'; + +export interface ControllerRegister { + register(rootProtoManager: RootProtoManager): Promise; +} diff --git a/tegg/standalone/service-worker/src/controller/ControllerRegisterFactory.ts b/tegg/standalone/service-worker/src/controller/ControllerRegisterFactory.ts new file mode 100644 index 0000000000..9c35aaba52 --- /dev/null +++ b/tegg/standalone/service-worker/src/controller/ControllerRegisterFactory.ts @@ -0,0 +1,25 @@ +import { ControllerMetadata, ControllerTypeLike } from '@eggjs/tegg'; +import { EggPrototype } from '@eggjs/tegg/helper'; +import { ControllerRegister } from './ControllerRegister.ts'; + +export type RegisterCreator = (proto: EggPrototype, controllerMeta: ControllerMetadata) => ControllerRegister; + +export class ControllerRegisterFactory { + #registerCreatorMap: Map; + + constructor() { + this.#registerCreatorMap = new Map(); + } + + registerControllerRegister(type: ControllerTypeLike, creator: RegisterCreator) { + this.#registerCreatorMap.set(type, creator); + } + + getControllerRegister(proto: EggPrototype, metadata: ControllerMetadata): ControllerRegister | undefined { + const creator = this.#registerCreatorMap.get(metadata.type); + if (!creator) { + return; + } + return creator(proto, metadata); + } +} diff --git a/tegg/standalone/service-worker/src/controller/RootProtoManager.ts b/tegg/standalone/service-worker/src/controller/RootProtoManager.ts new file mode 100644 index 0000000000..b8d25fb243 --- /dev/null +++ b/tegg/standalone/service-worker/src/controller/RootProtoManager.ts @@ -0,0 +1,38 @@ +import { EggPrototype, MapUtil } from '@eggjs/tegg/helper'; +import { EggContext } from '@eggjs/tegg'; + +export type GetRootProtoCallback = (ctx: EggContext) => EggPrototype | undefined; + +export class RootProtoManager { + // + protoMap: Map = new Map(); + + registerRootProto(method: string, cb: GetRootProtoCallback, host?: string) { + host = host || ''; + const cbList = MapUtil.getOrStore(this.protoMap, method + host, []); + cbList.push(cb); + } + + getRootProto(ctx: EggContext): EggPrototype | undefined { + const hostCbList = this.protoMap.get(ctx.method + ctx.host); + if (hostCbList) { + for (const cb of hostCbList) { + const proto = cb(ctx); + if (proto) { + return proto; + } + } + } + + const cbList = this.protoMap.get(ctx.method); + if (!cbList) { + return; + } + for (const cb of cbList) { + const proto = cb(ctx); + if (proto) { + return proto; + } + } + } +} diff --git a/tegg/standalone/service-worker/src/controller/ServiceWorkerContext.ts b/tegg/standalone/service-worker/src/controller/ServiceWorkerContext.ts new file mode 100644 index 0000000000..8d1905a091 --- /dev/null +++ b/tegg/standalone/service-worker/src/controller/ServiceWorkerContext.ts @@ -0,0 +1,21 @@ +import { ServiceWorkerContext, ServiceWorkerContextInit } from '@eggjs/tegg-types/standalone'; + +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/src/hook/ContextProtoLoadUnitHook.ts b/tegg/standalone/service-worker/src/hook/ContextProtoLoadUnitHook.ts new file mode 100644 index 0000000000..3ba8e0def9 --- /dev/null +++ b/tegg/standalone/service-worker/src/hook/ContextProtoLoadUnitHook.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert'; +import { IdenticalUtil, LifecycleHook } from '@eggjs/tegg-lifecycle'; +import { ContextHandler, EggPrototypeFactory, LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg/helper'; +import { StandaloneInnerObjectProto } from '@eggjs/tegg-standalone'; +import { ObjectInitType } from '@eggjs/tegg-types'; +import { ProtoMeta } from '../types.ts'; +import { ContextProtoProperty } from '../constants.ts'; + +export class ContextProtoLoadUnitHook implements LifecycleHook { + private readonly moduleName: string; + + constructor(moduleName: string) { + this.moduleName = moduleName; + } + + async preCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + if (loadUnit.name === this.moduleName) { + // can `@Inject() event` + ContextProtoLoadUnitHook.registerPrototype(ContextProtoProperty.Event, loadUnit); + } + } + + static registerPrototype(protoMeta: ProtoMeta, loadUnit: LoadUnit) { + const proto = new StandaloneInnerObjectProto( + 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 any, + ObjectInitType.CONTEXT, + loadUnit.id, + [], + ); + EggPrototypeFactory.instance.registerPrototype(proto, loadUnit); + } +} diff --git a/tegg/standalone/service-worker/src/hook/ControllerLoadUnitHook.ts b/tegg/standalone/service-worker/src/hook/ControllerLoadUnitHook.ts new file mode 100644 index 0000000000..9c5bf74c98 --- /dev/null +++ b/tegg/standalone/service-worker/src/hook/ControllerLoadUnitHook.ts @@ -0,0 +1,54 @@ +import { LifecycleHook } from '@eggjs/tegg-lifecycle'; +import { EggPrototype, LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg/helper'; +import { CONTROLLER_META_DATA, ControllerMetadata, ControllerType } from '@eggjs/tegg'; +import { ControllerRegisterFactory } from '../controller/ControllerRegisterFactory.ts'; +import { RootProtoManager } from '../controller/RootProtoManager.ts'; +import { ControllerMetadataManager } from '../controller/ControllerMetadataManager.ts'; +import { FetchRouter } from '../http/FetchRouter.ts'; +import { HTTPControllerRegister } from '../http/HTTPControllerRegister.ts'; +import { MCPControllerRegister } from '../mcp/MCPControllerRegister.ts'; + +export class ControllerLoadUnitHook implements LifecycleHook { + private readonly controllerRegisterFactory: ControllerRegisterFactory; + private readonly rootProtoManager: RootProtoManager; + private readonly controllerMetadataManager: ControllerMetadataManager; + private readonly fetchRouter: FetchRouter; + + constructor( + controllerRegisterFactory: ControllerRegisterFactory, + rootProtoManager: RootProtoManager, + controllerMetadataManager: ControllerMetadataManager, + fetchRouter: FetchRouter, + ) { + this.controllerRegisterFactory = controllerRegisterFactory; + this.rootProtoManager = rootProtoManager; + this.controllerMetadataManager = controllerMetadataManager; + this.fetchRouter = fetchRouter; + + // Register the HTTP controller register creator + this.controllerRegisterFactory.registerControllerRegister(ControllerType.HTTP, (proto: EggPrototype, controllerMeta: ControllerMetadata) => { + return HTTPControllerRegister.create(proto, controllerMeta, this.fetchRouter); + }); + + // Register the MCP controller register creator + this.controllerRegisterFactory.registerControllerRegister(ControllerType.MCP, (proto: EggPrototype, controllerMeta: ControllerMetadata) => { + return MCPControllerRegister.create(proto, controllerMeta, this.fetchRouter); + }); + } + + async postCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + const iterator = loadUnit.iterateEggPrototype(); + for (const proto of iterator) { + const metadata: ControllerMetadata | undefined = proto.getMetaData(CONTROLLER_META_DATA); + if (!metadata) { + continue; + } + const register = this.controllerRegisterFactory.getControllerRegister(proto, metadata); + if (!register) { + throw new Error(`not find controller implement for ${String(proto.name)} which type is ${metadata.type}`); + } + this.controllerMetadataManager.addController(metadata); + await register.register(this.rootProtoManager); + } + } +} diff --git a/tegg/standalone/service-worker/src/hook/ControllerPrototypeHook.ts b/tegg/standalone/service-worker/src/hook/ControllerPrototypeHook.ts new file mode 100644 index 0000000000..eeee290c59 --- /dev/null +++ b/tegg/standalone/service-worker/src/hook/ControllerPrototypeHook.ts @@ -0,0 +1,15 @@ +import { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-metadata'; +import { + ControllerMetaBuilderFactory, + ControllerMetadataUtil, + LifecycleHook, +} from '@eggjs/tegg'; + +export class ControllerPrototypeHook implements LifecycleHook { + async postCreate(ctx: EggPrototypeLifecycleContext): Promise { + const metadata = ControllerMetaBuilderFactory.build(ctx.clazz); + if (metadata) { + ControllerMetadataUtil.setControllerMetadata(ctx.clazz, metadata); + } + } +} diff --git a/tegg/standalone/service-worker/src/hook/LoadUnitInnerClassHook.ts b/tegg/standalone/service-worker/src/hook/LoadUnitInnerClassHook.ts new file mode 100644 index 0000000000..2284dc165d --- /dev/null +++ b/tegg/standalone/service-worker/src/hook/LoadUnitInnerClassHook.ts @@ -0,0 +1,22 @@ +import { EggProtoImplClass, LifecycleHook } from '@eggjs/tegg'; +import { EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/tegg/helper'; +import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg/helper'; + +export class LoadUnitInnerClassHook implements LifecycleHook { + private readonly innerClasses: EggProtoImplClass[]; + + constructor(innerClasses: EggProtoImplClass[]) { + this.innerClasses = innerClasses; + } + + async postCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + if (loadUnit.type === 'StandaloneLoadUnitType') { + for (const clazz of this.innerClasses) { + const protos = await EggPrototypeCreatorFactory.createProto(clazz, loadUnit); + for (const proto of protos) { + EggPrototypeFactory.instance.registerPrototype(proto, loadUnit); + } + } + } + } +} diff --git a/tegg/standalone/service-worker/src/http/FetchEventHandler.ts b/tegg/standalone/service-worker/src/http/FetchEventHandler.ts new file mode 100644 index 0000000000..65e6024f90 --- /dev/null +++ b/tegg/standalone/service-worker/src/http/FetchEventHandler.ts @@ -0,0 +1,49 @@ +import { MiddlewareFuncWithRouter } from '@eggjs/router'; +import { FetchEvent } from '@eggjs/tegg-types/standalone'; +import { + AccessLevel, + Inject, +} from '@eggjs/tegg'; +import { AbstractEventHandler, EventHandlerProto } from '@eggjs/tegg/standalone'; +import { FetchRouter } from './FetchRouter.ts'; +import { ServiceWorkerFetchContext } from './ServiceWorkerFetchContext.ts'; +import { RootProtoManager } from '../controller/RootProtoManager.ts'; +import { HTTPControllerRegister } from './HTTPControllerRegister.ts'; +import { MCPControllerRegister } from '../mcp/MCPControllerRegister.ts'; + +@EventHandlerProto('fetch', { accessLevel: AccessLevel.PUBLIC }) +export class FetchEventHandler extends AbstractEventHandler { + @Inject() + private readonly fetchRouter: FetchRouter; + + @Inject() + private readonly rootProtoManager: RootProtoManager; + + #routes: MiddlewareFuncWithRouter; + #initialized = false; + + private async initRoutes() { + if (!this.#initialized) { + HTTPControllerRegister.instance?.doRegister(this.rootProtoManager); + await MCPControllerRegister.instance?.doRegister(); + this.#routes = this.fetchRouter.middleware(); + this.#initialized = true; + } + } + + async handleEvent(event: FetchEvent): Promise { + await this.initRoutes(); + const ctx = new ServiceWorkerFetchContext({ event }); + try { + await this.#routes(ctx, async () => { /**/ }); + if (ctx.response) { + return ctx.response; + } + + return new Response(null, { status: 404 }); + } catch (e) { + console.error('handle event failed:', e); + return new Response(e.message, { status: 500 }); + } + } +} diff --git a/tegg/standalone/service-worker/src/http/FetchRouter.ts b/tegg/standalone/service-worker/src/http/FetchRouter.ts new file mode 100644 index 0000000000..4cba141729 --- /dev/null +++ b/tegg/standalone/service-worker/src/http/FetchRouter.ts @@ -0,0 +1,3 @@ +import { KoaRouter } from '@eggjs/router'; + +export class FetchRouter extends KoaRouter {} diff --git a/tegg/standalone/service-worker/src/http/HTTPControllerRegister.ts b/tegg/standalone/service-worker/src/http/HTTPControllerRegister.ts new file mode 100644 index 0000000000..6468a7838f --- /dev/null +++ b/tegg/standalone/service-worker/src/http/HTTPControllerRegister.ts @@ -0,0 +1,77 @@ +import assert from 'assert'; +import { Router as KoaRouter } from '@eggjs/router'; +import { + CONTROLLER_META_DATA, + ControllerMetadata, + ControllerType, + HTTPControllerMeta, + HTTPMethodMeta, +} from '@eggjs/tegg'; +import { EggPrototype } from '@eggjs/tegg/helper'; +import { ControllerRegister } from '../controller/ControllerRegister.ts'; +import { HTTPMethodRegister } from './HTTPMethodRegister.ts'; +import { RootProtoManager } from '../controller/RootProtoManager.ts'; + +export class HTTPControllerRegister implements ControllerRegister { + static instance?: HTTPControllerRegister; + + private readonly router: KoaRouter; + private readonly checkRouters: Map; + private controllerProtos: EggPrototype[] = []; + + static create(proto: EggPrototype, controllerMeta: ControllerMetadata, router: KoaRouter) { + assert(controllerMeta.type === ControllerType.HTTP, 'controller meta type is not HTTP'); + if (!HTTPControllerRegister.instance) { + HTTPControllerRegister.instance = new HTTPControllerRegister(router); + } + HTTPControllerRegister.instance.controllerProtos.push(proto); + return HTTPControllerRegister.instance; + } + + constructor(router: KoaRouter) { + this.router = router; + this.checkRouters = new Map(); + this.checkRouters.set('default', router); + } + + register(): Promise { + // do noting + return Promise.resolve(); + } + + static clean() { + if (this.instance) { + this.instance.controllerProtos = []; + this.instance.checkRouters.clear(); + } + this.instance = undefined; + } + + doRegister(rootProtoManager: RootProtoManager) { + 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); + + 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); + 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); + methodRegister.register(rootProtoManager); + } + } +} diff --git a/tegg/standalone/service-worker/src/http/HTTPMethodRegister.ts b/tegg/standalone/service-worker/src/http/HTTPMethodRegister.ts new file mode 100644 index 0000000000..00b6eebb4c --- /dev/null +++ b/tegg/standalone/service-worker/src/http/HTTPMethodRegister.ts @@ -0,0 +1,189 @@ +import pathToRegexp from 'path-to-regexp'; +import { FrameworkErrorFormater } from 'egg-errors'; +import { Router as KoaRouter } from '@eggjs/router'; +import { + EggContext, + HTTPControllerMeta, + HTTPMethodMeta, + HTTPParamType, + IncomingHttpHeaders, + Next, + PathParamMeta, + QueriesParamMeta, + QueryParamMeta, +} from '@eggjs/tegg'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; +import { CONTROLLER_AOP_MIDDLEWARES, METHOD_AOP_MIDDLEWARES } from '@eggjs/tegg-types/controller-decorator'; +import { EggContainerFactory, EggPrototype } from '@eggjs/tegg/helper'; +import type { AbstractControllerAdvice } from '../mcp/AbstractControllerAdvice.ts'; +import { RootProtoManager } from '../controller/RootProtoManager.ts'; +import { ServiceWorkerFetchContext } from './ServiceWorkerFetchContext.ts'; +import { RequestUtils } from '../utils/RequestUtils.ts'; + +const noop = () => { /* noop */ }; + +export class HTTPMethodRegister { + private readonly router: KoaRouter; + private readonly checkRouters: Map; + private readonly controllerMeta: HTTPControllerMeta; + private readonly methodMeta: HTTPMethodMeta; + private readonly proto: EggPrototype; + + constructor( + proto: EggPrototype, + controllerMeta: HTTPControllerMeta, + methodMeta: HTTPMethodMeta, + router: KoaRouter, + checkRouters: Map, + ) { + this.proto = proto; + this.controllerMeta = controllerMeta; + this.router = router; + this.methodMeta = methodMeta; + this.checkRouters = checkRouters; + } + + private createHandler(methodMeta: HTTPMethodMeta, host: string | undefined) { + const argsLength = methodMeta.paramMap.size; + const hasContext = methodMeta.contextParamIndex !== undefined; + const contextIndex = methodMeta.contextParamIndex; + const methodArgsLength = argsLength + (hasContext ? 1 : 0); + const self = this; + return async function(ctx: ServiceWorkerFetchContext, next: Next) { + // 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 EggContainerFactory.getOrCreateEggObject(self.proto, self.proto.name); + const realObj = eggObj.obj; + const realMethod = realObj[methodMeta.name]; + const args: Array = new Array(methodArgsLength); + if (hasContext) { + args[contextIndex!] = ctx; + } + for (const [ index, param ] of methodMeta.paramMap) { + switch (param.type) { + case HTTPParamType.BODY: { + const request = ctx.event.request; + args[index] = await RequestUtils.getRequestBody(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(`unknown param type ${param.type} in method ${self.controllerMeta.controllerName}.${methodMeta.name}`); + } + } + const res = await Reflect.apply(realMethod, realObj, args); + if (res instanceof Response) { + ctx.response = res; + } else { + ctx.body = res; + } + }; + } + + checkDuplicate() { + // 1. check duplicate with egg controller + this.checkDuplicateInRouter(this.router); + + // 2. check duplicate with host tegg controller + let hostRouter; + const hosts = this.controllerMeta.getMethodHosts(this.methodMeta) || []; + hosts.forEach(h => { + if (h) { + hostRouter = this.checkRouters.get(h); + if (!hostRouter) { + hostRouter = new KoaRouter({ sensitive: true }); + this.checkRouters.set(h, hostRouter); + } + } + if (hostRouter) { + this.checkDuplicateInRouter(hostRouter); + this.registerToRouter(hostRouter); + } + }); + } + + private registerToRouter(router: KoaRouter) { + const routerFunc = router[this.methodMeta.method.toLowerCase()]; + const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); + const methodName = this.controllerMeta.getMethodName(this.methodMeta); + Reflect.apply(routerFunc, router, [ methodName, methodRealPath, noop ]); + } + + private checkDuplicateInRouter(router: KoaRouter) { + 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 Error(`register http controller ${methodName} failed, ${this.methodMeta.method} ${methodRealPath} is conflict with exists rule ${layer.path}`); + throw FrameworkErrorFormater.format(err); + } + } + + private getAopMiddlewares(): Array<(ctx: ServiceWorkerFetchContext, next: Next) => Promise> { + // Controller-level AOP middlewares + const controllerAopClasses = (this.proto.getMetaData(CONTROLLER_AOP_MIDDLEWARES) ?? []) as EggProtoImplClass[]; + // Method-level AOP middlewares + const methodAopMap = this.proto.getMetaData(METHOD_AOP_MIDDLEWARES) as Map[]> | undefined; + const methodAopClasses = methodAopMap?.get(this.methodMeta.name) ?? []; + + const allAopClasses = [ ...controllerAopClasses, ...methodAopClasses ]; + return allAopClasses.map(clazz => { + return async (ctx: ServiceWorkerFetchContext, next: Next) => { + const eggObj = await EggContainerFactory.getOrCreateEggObjectFromClazz(clazz); + await (eggObj.obj as AbstractControllerAdvice).middleware(ctx, next); + }; + }); + } + + register(rootProtoManager: RootProtoManager) { + const methodRealPath = this.controllerMeta.getMethodRealPath(this.methodMeta); + const methodName = this.controllerMeta.getMethodName(this.methodMeta); + const routerFunc = this.router[this.methodMeta.method.toLowerCase()]; + const methodMiddlewares = this.controllerMeta.getMethodMiddlewares(this.methodMeta); + const aopMiddlewares = this.getAopMiddlewares(); + + const hosts = this.controllerMeta.getMethodHosts(this.methodMeta) || [ undefined ]; + hosts.forEach(h => { + const handler = this.createHandler(this.methodMeta, h); + Reflect.apply(routerFunc, this.router, [ methodName, methodRealPath, ...aopMiddlewares, ...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; + } + }, h || ''); + }); + } +} diff --git a/tegg/standalone/service-worker/src/http/ServiceWorkerFetchContext.ts b/tegg/standalone/service-worker/src/http/ServiceWorkerFetchContext.ts new file mode 100644 index 0000000000..ea0c962c4a --- /dev/null +++ b/tegg/standalone/service-worker/src/http/ServiceWorkerFetchContext.ts @@ -0,0 +1,40 @@ +import { FetchEvent, ServiceWorkerContextInit } from '@eggjs/tegg-types/standalone'; +import { BaseServiceWorkerContextImpl } from '../controller/ServiceWorkerContext.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 = {}; + #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/src/index.ts b/tegg/standalone/service-worker/src/index.ts new file mode 100644 index 0000000000..fc0c1abf69 --- /dev/null +++ b/tegg/standalone/service-worker/src/index.ts @@ -0,0 +1,21 @@ +export * from './constants.ts'; +export * from './types.ts'; +export * from './ServiceWorkerApp.ts'; +export * from './hook/ContextProtoLoadUnitHook.ts'; +export * from './hook/ControllerPrototypeHook.ts'; +export * from './hook/ControllerLoadUnitHook.ts'; +export * from './controller/ControllerMetadataManager.ts'; +export * from './controller/ControllerRegister.ts'; +export * from './controller/ControllerRegisterFactory.ts'; +export * from './controller/RootProtoManager.ts'; +export * from './controller/ServiceWorkerContext.ts'; +export * from './http/FetchEventHandler.ts'; +export * from './http/FetchRouter.ts'; +export * from './http/HTTPControllerRegister.ts'; +export * from './http/HTTPMethodRegister.ts'; +export * from './http/ServiceWorkerFetchContext.ts'; +export * from './utils/RequestUtils.ts'; +export * from './utils/ResponseUtils.ts'; +export * from './mcp/AbstractControllerAdvice.ts'; +export * from './mcp/MCPControllerRegister.ts'; +export * from './mcp/MCPServerHelper.ts'; diff --git a/tegg/standalone/service-worker/src/mcp/AbstractControllerAdvice.ts b/tegg/standalone/service-worker/src/mcp/AbstractControllerAdvice.ts new file mode 100644 index 0000000000..87a66f7ae2 --- /dev/null +++ b/tegg/standalone/service-worker/src/mcp/AbstractControllerAdvice.ts @@ -0,0 +1,12 @@ +import type { Next } from '@eggjs/tegg-types/controller-decorator'; +import type { IAdvice, AdviceContext } from '@eggjs/tegg-types/aop'; +import type { ServiceWorkerFetchContext } from '../http/ServiceWorkerFetchContext.ts'; + +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: Next): Promise; +} diff --git a/tegg/standalone/service-worker/src/mcp/MCPControllerRegister.ts b/tegg/standalone/service-worker/src/mcp/MCPControllerRegister.ts new file mode 100644 index 0000000000..a31ef2a88e --- /dev/null +++ b/tegg/standalone/service-worker/src/mcp/MCPControllerRegister.ts @@ -0,0 +1,365 @@ +import assert from 'node:assert'; +import { IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http'; +import { Socket } from 'node:net'; +import { Readable, PassThrough } from 'node:stream'; +import { Router as KoaRouter } from '@eggjs/router'; +import { + CONTROLLER_META_DATA, + ControllerMetadata, + ControllerType, + MCPControllerMeta, + MCPPromptMeta, + MCPResourceMeta, + MCPToolMeta, +} from '@eggjs/tegg'; +import type { EggObject, EggObjectName, EggProtoImplClass, EggPrototype } from '@eggjs/tegg-types'; +import { CONTROLLER_AOP_MIDDLEWARES } from '@eggjs/tegg-types/controller-decorator'; +import { EggContainerFactory } from '@eggjs/tegg/helper'; +import type { AbstractControllerAdvice } from './AbstractControllerAdvice.ts'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ControllerRegister } from '../controller/ControllerRegister.ts'; +import { ServiceWorkerFetchContext } from '../http/ServiceWorkerFetchContext.ts'; +import { MCPServerHelper } from './MCPServerHelper.ts'; + +interface ServerRegisterRecord { + getOrCreateEggObject: (proto: EggPrototype, name?: EggObjectName) => Promise; + proto: EggPrototype; + meta: T; +} + +// Bridge ServerResponse to a PassThrough stream for Fetch API Response construction +class ServiceWorkerMCPServerResponse extends ServerResponse { + callback: (value: object) => void; + #stream: PassThrough; + + constructor(req: IncomingMessage, callback: (value: object) => void) { + super(req); + this.callback = callback; + this.#stream = new PassThrough(); + } + + write(chunk: any, callback?: (error: Error | null | undefined) => void): boolean; + write(chunk: any, encoding: BufferEncoding, callback?: (error: Error | null | undefined) => void): boolean; + write( + chunk: any, + encoding?: BufferEncoding | ((error: Error | null | undefined) => void), + callback?: (error: Error | null | undefined) => void, + ): boolean { + super.write(chunk, encoding as any, callback); + return this.#stream.write(chunk, encoding as any, callback); + } + + get stream() { + return this.#stream; + } + + writeHead( + statusCode: number, + headers?: OutgoingHttpHeaders | OutgoingHttpHeader[] + ): this; + writeHead( + statusCode: number, + statusMessage?: string, + headers?: OutgoingHttpHeaders | OutgoingHttpHeader[], + ): this; + writeHead( + statusCode: number, + reason?: string | (OutgoingHttpHeaders | OutgoingHttpHeader[]), + obj?: OutgoingHttpHeaders | OutgoingHttpHeader[], + ): this { + if (typeof reason === 'string') { + super.writeHead(statusCode, reason, obj); + this.callback({ + status: statusCode, + headers: { + ...(obj ? obj : {}), + 'X-Accel-Buffering': 'no', + }, + }); + } else { + super.writeHead(statusCode, reason); + this.callback({ + status: statusCode, + headers: { + ...(reason ? { + ...reason, + ...(reason['content-length'] ? {} : { 'transfer-encoding': 'chunked' }), + } : {}), + 'X-Accel-Buffering': 'no', + }, + }); + } + return this; + } + + end(cb?: () => void): this; + end(chunk: any, cb?: () => void): this; + end(chunk: any, encoding: BufferEncoding, cb?: () => void): this; + end(...args: any[]): this { + this.#stream.end(...args); + super.end(...args); + return this; + } +} + +export class MCPControllerRegister implements ControllerRegister { + static instance?: MCPControllerRegister; + + private readonly router: KoaRouter; + private controllerProtos: EggPrototype[] = []; + private registeredControllerProtos: EggPrototype[] = []; + private controllerMeta: MCPControllerMeta; + mcpServerHelperMap: Record MCPServerHelper> = {}; + streamTransports: Record = {}; + middlewaresMap: Record Promise) => Promise>> = {}; + registerMap: Record[]; + prompts: ServerRegisterRecord[]; + resources: ServerRegisterRecord[]; + }> = {}; + + static create(proto: EggPrototype, controllerMeta: ControllerMetadata, router: KoaRouter) { + assert(controllerMeta.type === ControllerType.MCP, 'controller meta type is not MCP'); + if (!MCPControllerRegister.instance) { + MCPControllerRegister.instance = new MCPControllerRegister(controllerMeta as MCPControllerMeta, router); + } + MCPControllerRegister.instance.controllerProtos.push(proto); + return MCPControllerRegister.instance; + } + + constructor(controllerMeta: MCPControllerMeta, router: KoaRouter) { + this.router = router; + this.controllerMeta = controllerMeta; + } + + static clean() { + if (this.instance) { + this.instance.controllerProtos = []; + this.instance.registeredControllerProtos = []; + this.instance.registerMap = {}; + this.instance.mcpServerHelperMap = {}; + this.instance.middlewaresMap = {}; + } + this.instance = undefined; + } + + /** + * Connect the long-lived stateless stream transport and prime it with an + * initialize call, matching the chair service-worker pattern. Must be + * called after register() so all tools are already on the helper. + */ + async connectStatelessStreamTransport(name?: string) { + const inst = MCPControllerRegister.instance; + if (!inst) return; + const transport = inst.streamTransports[name ?? 'default']; + const mcpServerHelper = this.mcpServerHelperMap[name ?? 'default'](); + const registerEntry = this.registerMap[name ?? 'default']; + if (registerEntry) { + for (const tool of registerEntry.tools) { + await mcpServerHelper.mcpToolRegister( + tool.getOrCreateEggObject, + tool.proto, + tool.meta, + ); + } + for (const resource of registerEntry.resources) { + await mcpServerHelper.mcpResourceRegister( + resource.getOrCreateEggObject, + resource.proto, + resource.meta, + ); + } + for (const prompt of registerEntry.prompts) { + await mcpServerHelper.mcpPromptRegister( + prompt.getOrCreateEggObject, + prompt.proto, + prompt.meta, + ); + } + } + await mcpServerHelper.server.connect(transport); + const socket = new Socket(); + const req = new IncomingMessage(socket); + const res = new ServerResponse(req); + req.method = 'POST'; + req.url = '/mcp/stream'; + req.headers = { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }; + const initBody = { + jsonrpc: '2.0', id: 0, method: 'initialize', + params: { + protocolVersion: '2024-11-05', capabilities: {}, + clientInfo: { name: 'init-client', version: '1.0.0' }, + }, + }; + await inst.streamTransports[name ?? 'default'].handleRequest(req, res, initBody); + } + + private async mcpStatelessStreamServerInit(name?: string) { + const postRouterFunc = this.router.post; + // Create fresh transport and server per request (stateless mode) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + MCPControllerRegister.instance!.streamTransports[name ?? 'default'] = transport; + const initHandler = async (ctx: ServiceWorkerFetchContext) => { + const transport = MCPControllerRegister.instance!.streamTransports[name ?? 'default']; + + // Bridge Fetch Request → Node.js IncomingMessage + const socket = new Socket(); + const req = new IncomingMessage(socket); + req.url = ctx.url.pathname + ctx.url.search; + const headers: Record = {}; + for (const [ key, value ] of ctx.event.request.headers.entries()) { + headers[key] = value; + req.rawHeaders.push(key); + req.rawHeaders.push(value); + } + req.headers = headers; + req.method = ctx.event.request.method; + + // Create bridge ServerResponse → PassThrough → Fetch Response + let callback: (value: object) => void; + const resPromise = new Promise(resolve => { + callback = resolve; + }); + const response = new ServiceWorkerMCPServerResponse(req, callback!); + + // Parse body from Fetch Request + const body = await ctx.event.request.json(); + + // Handle the request (don't await - handleRequest writes to response stream) + transport.handleRequest(req, response, body); + + const init = await resPromise; + + ctx.response = new Response(Readable.toWeb(response.stream) as any, init) as any; + }; + + const streamPath = `/mcp${name ? `/${name}` : ''}/stream`; + const middlewares = this.middlewaresMap[name ?? 'default'] ?? []; + Reflect.apply(postRouterFunc, this.router, [ + 'mcpStatelessStreamInit', + streamPath, + ...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 = this.router.get; + const delRouterFunc = this.router.del; + Reflect.apply(getRouterFunc, this.router, [ + 'mcpStatelessStreamNotAllowed', + streamPath, + notAllowedHandler, + ]); + Reflect.apply(delRouterFunc, this.router, [ + 'mcpStatelessStreamNotAllowed', + streamPath, + notAllowedHandler, + ]); + } + + async register() { + 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 ?? `mcp-${metadata.name ?? 'default'}-server`, + version: this.controllerMeta.version ?? '1.0.0', + }); + }; + } + if (!this.registerMap[metadata.name ?? 'default']) { + this.registerMap[metadata.name ?? 'default'] = { + prompts: [], + resources: [], + tools: [], + }; + } + for (const tool of metadata.tools) { + this.registerMap[metadata.name ?? 'default'].tools.push({ + getOrCreateEggObject: EggContainerFactory.getOrCreateEggObject.bind( + EggContainerFactory, + ), + proto, + meta: tool, + }); + } + for (const resource of metadata.resources) { + this.registerMap[metadata.name ?? 'default'].resources.push({ + getOrCreateEggObject: EggContainerFactory.getOrCreateEggObject.bind( + EggContainerFactory, + ), + proto, + meta: resource, + }); + } + for (const prompt of metadata.prompts) { + this.registerMap[metadata.name ?? 'default'].prompts.push({ + getOrCreateEggObject: EggContainerFactory.getOrCreateEggObject.bind( + EggContainerFactory, + ), + proto, + meta: prompt, + }); + } + + // Collect middlewares for this server name + const serverName = metadata.name ?? 'default'; + if (!this.middlewaresMap[serverName]) { + this.middlewaresMap[serverName] = []; + } + + // Function-type middlewares from MCPControllerMeta + const classMiddlewares = metadata.middlewares ?? []; + for (const mw of classMiddlewares) { + this.middlewaresMap[serverName].push(mw as unknown as (ctx: ServiceWorkerFetchContext, next: () => Promise) => Promise); + } + + // AOP-type middlewares from class metadata + const aopMiddlewareClasses = (proto.getMetaData(CONTROLLER_AOP_MIDDLEWARES) ?? []) as EggProtoImplClass[]; + for (const clazz of aopMiddlewareClasses) { + this.middlewaresMap[serverName].push(async (ctx: ServiceWorkerFetchContext, next: () => Promise) => { + const eggObj = await EggContainerFactory.getOrCreateEggObjectFromClazz(clazz); + await (eggObj.obj as AbstractControllerAdvice).middleware(ctx, next); + }); + } + + this.registeredControllerProtos.push(proto); + } + } + + async doRegister() { + // Initialize MCP routes for each server name + const names = Object.keys(this.registerMap); + for (const name of names) { + await this.mcpStatelessStreamServerInit(name === 'default' ? undefined : name); + await this.connectStatelessStreamTransport(name); + } + } +} diff --git a/tegg/standalone/service-worker/src/mcp/MCPServerHelper.ts b/tegg/standalone/service-worker/src/mcp/MCPServerHelper.ts new file mode 100644 index 0000000000..b9a378162d --- /dev/null +++ b/tegg/standalone/service-worker/src/mcp/MCPServerHelper.ts @@ -0,0 +1,130 @@ +import { + McpServer, + ReadResourceCallback, + ToolCallback, + PromptCallback, +} from '@modelcontextprotocol/sdk/server/mcp.js'; +import { CONTROLLER_META_DATA } from '@eggjs/tegg'; +import type { EggObject, EggObjectName, EggPrototype } from '@eggjs/tegg-types'; +import { MCPControllerMeta, MCPPromptMeta, MCPResourceMeta, MCPToolMeta } from '@eggjs/tegg'; + +export interface MCPServerHelperOptions { + name: string; + version: string; +} + +export class MCPServerHelper { + server: McpServer; + + constructor(opts: MCPServerHelperOptions) { + this.server = new McpServer( + { + name: opts.name, + version: opts.version, + }, + { capabilities: { logging: {} } }, + ); + } + + async mcpResourceRegister( + getOrCreateEggObject: (proto: EggPrototype, name?: EggObjectName) => Promise, + controllerProto: EggPrototype, + resourceMeta: MCPResourceMeta, + ) { + const handler = async (...args) => { + const eggObj = await getOrCreateEggObject( + controllerProto, + controllerProto.name, + ); + const realObj = eggObj.obj; + const realMethod = realObj[resourceMeta.name]; + return Reflect.apply( + realMethod, + realObj, + args, + ) as ReturnType; + }; + const name = resourceMeta.mcpName ?? resourceMeta.name; + if (resourceMeta.uri) { + this.server.registerResource(name, resourceMeta.uri, resourceMeta.metadata ?? {}, handler); + } else if (resourceMeta.template) { + this.server.registerResource(name, resourceMeta.template as unknown as any, resourceMeta.metadata ?? {}, handler); + } else { + throw new Error(`MCPResource ${name} must have uri or template`); + } + } + + async mcpToolRegister( + getOrCreateEggObject: (proto: EggPrototype, name?: EggObjectName) => Promise, + controllerProto: EggPrototype, + toolMeta: MCPToolMeta, + ) { + const controllerMeta = controllerProto.getMetaData( + CONTROLLER_META_DATA, + ) as MCPControllerMeta; + void controllerMeta; + const name: string = toolMeta.mcpName ?? toolMeta.name; + const description: string | undefined = toolMeta.description; + let schema: NonNullable['argsSchema'] | undefined; + if (toolMeta.detail?.argsSchema) { + schema = toolMeta.detail?.argsSchema; + } + const handler = async (...args) => { + const eggObj = await getOrCreateEggObject( + controllerProto, + controllerProto.name, + ); + const realObj = eggObj.obj; + const realMethod = realObj[toolMeta.name]; + let newArgs: any[] = []; + if (schema && toolMeta.detail) { + newArgs[toolMeta.detail.index] = args[0]; + if (toolMeta.extra) { + newArgs[toolMeta.extra] = args[1]; + } + } else if (toolMeta.extra) { + newArgs[toolMeta.extra] = args[0]; + } + newArgs = [ ...newArgs, ...args ]; + return Reflect.apply(realMethod, realObj, newArgs) as ReturnType; + }; + this.server.registerTool(name, { + description, + inputSchema: schema, + }, handler); + } + + async mcpPromptRegister( + getOrCreateEggObject: (proto: EggPrototype, name?: EggObjectName) => Promise, + controllerProto: EggPrototype, + promptMeta: MCPPromptMeta, + ) { + const name: string = promptMeta.mcpName ?? promptMeta.name; + const description: string | undefined = promptMeta.description; + let schema: NonNullable['argsSchema'] | undefined; + if (promptMeta.detail?.argsSchema) { + schema = promptMeta.detail?.argsSchema; + } + const handler = async (...args) => { + const eggObj = await getOrCreateEggObject(controllerProto, controllerProto.name); + const realObj = eggObj.obj; + const realMethod = realObj[promptMeta.name]; + let newArgs: any[] = []; + if (schema && promptMeta.detail) { + newArgs[promptMeta.detail.index] = args[0]; + if (promptMeta.extra) { + newArgs[promptMeta.extra] = args[1]; + } + } else if (promptMeta.extra) { + newArgs[promptMeta.extra] = args[0]; + } + newArgs = [ ...newArgs, ...args ]; + return Reflect.apply(realMethod, realObj, newArgs) as ReturnType; + }; + this.server.registerPrompt(name, { + title: promptMeta.title, + description, + argsSchema: schema, + }, handler); + } +} diff --git a/tegg/standalone/service-worker/src/types.ts b/tegg/standalone/service-worker/src/types.ts new file mode 100644 index 0000000000..eaf3591690 --- /dev/null +++ b/tegg/standalone/service-worker/src/types.ts @@ -0,0 +1,4 @@ +export interface ProtoMeta { + protoName: PropertyKey; + contextKey: string | symbol; +} diff --git a/tegg/standalone/service-worker/src/utils/RequestUtils.ts b/tegg/standalone/service-worker/src/utils/RequestUtils.ts new file mode 100644 index 0000000000..a4ffafd0dc --- /dev/null +++ b/tegg/standalone/service-worker/src/utils/RequestUtils.ts @@ -0,0 +1,37 @@ +import typeis from 'type-is'; + +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) { + 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[]) { + const contentType = request.headers.get('content-type'); + const contentTypeValue = typeof contentType === 'string' + // trim extra semicolon + ? contentType.replace(/;$/, '') + : contentType; + + return typeis.is(contentTypeValue!, types); + } +} diff --git a/tegg/standalone/service-worker/src/utils/ResponseUtils.ts b/tegg/standalone/service-worker/src/utils/ResponseUtils.ts new file mode 100644 index 0000000000..fbe5e531c3 --- /dev/null +++ b/tegg/standalone/service-worker/src/utils/ResponseUtils.ts @@ -0,0 +1,16 @@ +export class ResponseUtils { + static createResponseByBody(body: any) { + 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/test/Utils.ts b/tegg/standalone/service-worker/test/Utils.ts new file mode 100644 index 0000000000..dd4da41863 --- /dev/null +++ b/tegg/standalone/service-worker/test/Utils.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { ServiceWorkerApp, ServiceWorkerAppOptions } from '../src/ServiceWorkerApp.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; + +export class TestUtils { + static baseDir(name: string) { + return path.join(__dirname, 'fixtures', name); + } + + static async createApp(name: string, init?: ServiceWorkerAppOptions) { + const app = new ServiceWorkerApp(TestUtils.baseDir(name), { + ...init, + env: 'unittest', + name, + }); + await app.init(); + + return app; + } + + static async createFetchApp(name: string, init?: ServiceWorkerAppOptions) { + const app = await TestUtils.createApp(name, init); + // Use port 0 to let the OS assign a free port + const server = await StandaloneTestUtil.startHTTPServer('127.0.0.1', 0, { + listener: e => app.handleEvent(e), + }); + + return { app, server }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-builtin/BuiltinController.ts b/tegg/standalone/service-worker/test/fixtures/http-builtin/BuiltinController.ts new file mode 100644 index 0000000000..c3d3b9cce1 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-builtin/BuiltinController.ts @@ -0,0 +1,23 @@ +import { HTTPMethodEnum, HTTPController, HTTPMethod, Inject, Logger } from '@eggjs/tegg'; +import { HttpClient } from 'urllib'; + +@HTTPController() +export class BuiltinController { + @Inject() + private readonly logger: Logger; + + @Inject() + private readonly httpclient: HttpClient; + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/log' }) + async log() { + this.logger.info('hello from controller'); + return { ok: true }; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/httpclient' }) + async httpclientCheck() { + // Just verify httpclient is injected and has request method + return { hasRequest: typeof this.httpclient.request === 'function' }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-builtin/package.json b/tegg/standalone/service-worker/test/fixtures/http-builtin/package.json new file mode 100644 index 0000000000..bef298f700 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-builtin/package.json @@ -0,0 +1,6 @@ +{ + "name": "http-builtin-app", + "eggModule": { + "name": "httpBuiltinApp" + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-inject/UserController.ts b/tegg/standalone/service-worker/test/fixtures/http-inject/UserController.ts new file mode 100644 index 0000000000..71575f0585 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-inject/UserController.ts @@ -0,0 +1,26 @@ +import { + HTTPController, + HTTPMethod, + HTTPMethodEnum, + HTTPParam, + Inject, +} from '@eggjs/tegg'; +import { UserService } from './UserService.ts'; + +@HTTPController({ + path: '/api/users', +}) +export class UserController { + @Inject() + private readonly userService: UserService; + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/' }) + async list() { + return await this.userService.list(); + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/:id' }) + async findById(@HTTPParam() id: string) { + return await this.userService.findById(id); + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-inject/UserService.ts b/tegg/standalone/service-worker/test/fixtures/http-inject/UserService.ts new file mode 100644 index 0000000000..0b0204a1aa --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-inject/UserService.ts @@ -0,0 +1,18 @@ +import { SingletonProto, AccessLevel } from '@eggjs/tegg'; + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class UserService { + async findById(id: string) { + return { + id, + name: `user-${id}`, + }; + } + + async list() { + return [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-inject/package.json b/tegg/standalone/service-worker/test/fixtures/http-inject/package.json new file mode 100644 index 0000000000..dc7a2cee3c --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-inject/package.json @@ -0,0 +1,6 @@ +{ + "name": "http-inject-app", + "eggModule": { + "name": "httpInjectApp" + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-params/ParamController.ts b/tegg/standalone/service-worker/test/fixtures/http-params/ParamController.ts new file mode 100644 index 0000000000..b12f2b2805 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-params/ParamController.ts @@ -0,0 +1,59 @@ +import { + HTTPBody, + HTTPController, + HTTPHeaders, + HTTPMethod, + HTTPMethodEnum, + HTTPParam, + HTTPQueries, + HTTPQuery, + HTTPRequest, + Request, +} from '@eggjs/tegg'; + +@HTTPController() +export class ParamController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/query' }) + async hello(@HTTPQuery() name: string, @HTTPQueries() type: string[]) { + return { + name, + type, + }; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/find/:id' }) + async find(@HTTPParam() id: string) { + return { + id, + }; + } + + @HTTPMethod({ method: HTTPMethodEnum.POST, path: '/echo/body' }) + async echoBody(@HTTPBody() body: object) { + if (body.constructor.name === 'FormData') { + const res = {}; + for (const [ key, value ] of (body as FormData).entries()) { + res[key] = value; + } + return { type: 'formData', body: res }; + } + return { body }; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/headers' }) + async headers(@HTTPHeaders() headers: Record) { + return { + headers, + }; + } + + @HTTPMethod({ method: HTTPMethodEnum.POST, path: '/request' }) + async request(@Request() req: HTTPRequest) { + return { + url: req.url, + method: req.method, + customHeaders: req.headers.get('x-custom'), + body: await req.json(), + }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-params/package.json b/tegg/standalone/service-worker/test/fixtures/http-params/package.json new file mode 100644 index 0000000000..d53116eec6 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-params/package.json @@ -0,0 +1,6 @@ +{ + "name": "http-params-app", + "eggModule": { + "name": "httpParamsApp" + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-priority/PriorityController.ts b/tegg/standalone/service-worker/test/fixtures/http-priority/PriorityController.ts new file mode 100644 index 0000000000..8de2616154 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-priority/PriorityController.ts @@ -0,0 +1,27 @@ +import { + HTTPController, + HTTPMethod, + HTTPMethodEnum, +} from '@eggjs/tegg'; + +@HTTPController({ + path: '/users', +}) +export class PriorityController { + + @HTTPMethod({ + method: HTTPMethodEnum.GET, + path: '/*', + }) + async lowPriority() { + return 'low priority'; + } + + @HTTPMethod({ + method: HTTPMethodEnum.GET, + path: '/group', + }) + async highPriority() { + return 'high priority'; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-priority/ViewController.ts b/tegg/standalone/service-worker/test/fixtures/http-priority/ViewController.ts new file mode 100644 index 0000000000..0f6a8af57b --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-priority/ViewController.ts @@ -0,0 +1,17 @@ +import { + HTTPController, + HTTPMethod, + HTTPMethodEnum, +} from '@eggjs/tegg'; + +@HTTPController() +export class ViewController { + + @HTTPMethod({ + method: HTTPMethodEnum.GET, + path: '/*', + }) + async get() { + return 'hello, view'; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http-priority/package.json b/tegg/standalone/service-worker/test/fixtures/http-priority/package.json new file mode 100644 index 0000000000..67f59ae256 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http-priority/package.json @@ -0,0 +1,6 @@ +{ + "name": "http-priority-app", + "eggModule": { + "name": "httpPriorityApp" + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http/AopMiddlewareController.ts b/tegg/standalone/service-worker/test/fixtures/http/AopMiddlewareController.ts new file mode 100644 index 0000000000..f41ca418c3 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http/AopMiddlewareController.ts @@ -0,0 +1,11 @@ +import { HTTPMethodEnum, HTTPController, HTTPMethod, Middleware } from '@eggjs/tegg'; +import { HttpTestAdvice } from './HttpTestAdvice.ts'; + +@Middleware(HttpTestAdvice) +@HTTPController() +export class AopMiddlewareController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/middleware/aop' }) + async aopMiddlewareTest() { + return { msg: 'hello' }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http/GetController.ts b/tegg/standalone/service-worker/test/fixtures/http/GetController.ts new file mode 100644 index 0000000000..0fd5a215b6 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http/GetController.ts @@ -0,0 +1,25 @@ +import { HTTPMethodEnum, HTTPController, HTTPMethod, HTTPQuery } from '@eggjs/tegg'; + +@HTTPController() +export class GetController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/hello' }) + async hello() { + return 'hello'; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/api/null-body' }) + async nullBody(@HTTPQuery() nil: string) { + return nil ? null : undefined; + } + + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/api/response' }) + async response() { + return new Response('full response', { + status: 500, + headers: { + 'content-type': 'text/plain', + 'x-custom-header': 'custom-value', + }, + }); + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http/HttpTestAdvice.ts b/tegg/standalone/service-worker/test/fixtures/http/HttpTestAdvice.ts new file mode 100644 index 0000000000..0324debb56 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http/HttpTestAdvice.ts @@ -0,0 +1,16 @@ +import { ObjectInitType } from '@eggjs/tegg'; +import { Advice } from '@eggjs/tegg/aop'; +import { AbstractControllerAdvice } from '../../../src/mcp/AbstractControllerAdvice.ts'; +import type { ServiceWorkerFetchContext } from '../../../src/http/ServiceWorkerFetchContext.ts'; + +// Track middleware execution for testing +export const httpAdviceExecutionLog: string[] = []; + +@Advice({ initType: ObjectInitType.SINGLETON }) +export class HttpTestAdvice extends AbstractControllerAdvice { + async middleware(_ctx: ServiceWorkerFetchContext, next: () => Promise): Promise { + httpAdviceExecutionLog.push('before'); + await next(); + httpAdviceExecutionLog.push('after'); + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http/PostController.ts b/tegg/standalone/service-worker/test/fixtures/http/PostController.ts new file mode 100644 index 0000000000..a3b5576d67 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http/PostController.ts @@ -0,0 +1,12 @@ +import { HTTPMethodEnum, HTTPController, HTTPMethod, HTTPBody } from '@eggjs/tegg'; + +@HTTPController() +export class PostController { + @HTTPMethod({ method: HTTPMethodEnum.POST, path: '/echo' }) + async hello(@HTTPBody() data: object) { + return { + success: true, + data, + }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/http/package.json b/tegg/standalone/service-worker/test/fixtures/http/package.json new file mode 100644 index 0000000000..b1f87de6a6 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/http/package.json @@ -0,0 +1,6 @@ +{ + "name": "http-app", + "eggModule": { + "name": "httpApp" + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/mcp/MCPTestController.ts b/tegg/standalone/service-worker/test/fixtures/mcp/MCPTestController.ts new file mode 100644 index 0000000000..15d4fb35e3 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/mcp/MCPTestController.ts @@ -0,0 +1,30 @@ +import { MCPController, MCPTool, MCPToolResponse, ToolArgsSchema, ToolArgs, Middleware } from '@eggjs/tegg'; +import * as z from 'zod/v4'; +import { McpTestAdvice } from './McpTestAdvice.ts'; + +const EchoArgs = { + message: z.string().describe('The message to echo'), +}; + +const AddArgs = { + a: z.number().describe('First number'), + b: z.number().describe('Second number'), +}; + +@Middleware(McpTestAdvice) +@MCPController({ name: 'test-server', version: '1.0.0' }) +export class MCPTestController { + @MCPTool({ description: 'Echo the input message' }) + async echo(@ToolArgsSchema(EchoArgs) args: ToolArgs): Promise { + return { + content: [{ type: 'text', text: args.message }], + }; + } + + @MCPTool({ description: 'Add two numbers' }) + async add(@ToolArgsSchema(AddArgs) args: ToolArgs): Promise { + return { + content: [{ type: 'text', text: String(args.a + args.b) }], + }; + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/mcp/McpTestAdvice.ts b/tegg/standalone/service-worker/test/fixtures/mcp/McpTestAdvice.ts new file mode 100644 index 0000000000..bb315a8448 --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/mcp/McpTestAdvice.ts @@ -0,0 +1,16 @@ +import { ObjectInitType } from '@eggjs/tegg'; +import { Advice } from '@eggjs/tegg/aop'; +import { AbstractControllerAdvice } from '../../../src/mcp/AbstractControllerAdvice.ts'; +import type { ServiceWorkerFetchContext } from '../../../src/http/ServiceWorkerFetchContext.ts'; + +// Track middleware execution for testing +export const adviceExecutionLog: string[] = []; + +@Advice({ initType: ObjectInitType.SINGLETON }) +export class McpTestAdvice extends AbstractControllerAdvice { + async middleware(_ctx: ServiceWorkerFetchContext, next: () => Promise): Promise { + adviceExecutionLog.push('before'); + await next(); + adviceExecutionLog.push('after'); + } +} diff --git a/tegg/standalone/service-worker/test/fixtures/mcp/package.json b/tegg/standalone/service-worker/test/fixtures/mcp/package.json new file mode 100644 index 0000000000..df63a4337f --- /dev/null +++ b/tegg/standalone/service-worker/test/fixtures/mcp/package.json @@ -0,0 +1,6 @@ +{ + "name": "mcp-app", + "eggModule": { + "name": "mcpApp" + } +} diff --git a/tegg/standalone/service-worker/test/http/builtin.test.ts b/tegg/standalone/service-worker/test/http/builtin.test.ts new file mode 100644 index 0000000000..ef83c4de2e --- /dev/null +++ b/tegg/standalone/service-worker/test/http/builtin.test.ts @@ -0,0 +1,34 @@ +import { Server } from 'node:http'; +import httpRequest from 'supertest'; +import { ServiceWorkerApp } from '../../src/ServiceWorkerApp.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; +import { TestUtils } from '../Utils.ts'; + +describe('standalone/service-worker/test/http/builtin.test.ts', () => { + let app: ServiceWorkerApp; + let server: Server; + + before(async function() { + if (StandaloneTestUtil.skipOnNode()) { + return this.skip(); + } + ({ app, server } = await TestUtils.createFetchApp('http-builtin')); + }); + + after(async () => { + server?.close(); + await app?.destroy(); + }); + + it('should inject logger and log successfully', async () => { + await httpRequest(server) + .get('/log') + .expect(200, { ok: true }); + }); + + it('should inject httpclient', async () => { + await httpRequest(server) + .get('/httpclient') + .expect(200, { hasRequest: true }); + }); +}); diff --git a/tegg/standalone/service-worker/test/http/inject.test.ts b/tegg/standalone/service-worker/test/http/inject.test.ts new file mode 100644 index 0000000000..ec72853310 --- /dev/null +++ b/tegg/standalone/service-worker/test/http/inject.test.ts @@ -0,0 +1,40 @@ +import { Server } from 'node:http'; +import httpRequest from 'supertest'; +import { ServiceWorkerApp } from '../../src/ServiceWorkerApp.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; +import { TestUtils } from '../Utils.ts'; + +describe('standalone/service-worker/test/http/inject.test.ts', () => { + let app: ServiceWorkerApp; + let server: Server; + + before(async function() { + if (StandaloneTestUtil.skipOnNode()) { + return this.skip(); + } + ({ app, server } = await TestUtils.createFetchApp('http-inject')); + }); + + after(async () => { + server?.close(); + await app?.destroy(); + }); + + it('should inject service and list users', async () => { + await httpRequest(server) + .get('/api/users/') + .expect(200, [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]); + }); + + it('should inject service and find user by id', async () => { + await httpRequest(server) + .get('/api/users/42') + .expect(200, { + id: '42', + name: 'user-42', + }); + }); +}); diff --git a/tegg/standalone/service-worker/test/http/params.test.ts b/tegg/standalone/service-worker/test/http/params.test.ts new file mode 100644 index 0000000000..0cfea600e0 --- /dev/null +++ b/tegg/standalone/service-worker/test/http/params.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert'; +import { Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import httpRequest from 'supertest'; +import { ServiceWorkerApp } from '../../src/ServiceWorkerApp.ts'; +import { TestUtils } from '../Utils.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; + +describe('standalone/service-worker/test/http/params.test.ts', () => { + let app: ServiceWorkerApp; + let server: Server; + + before(async function() { + if (StandaloneTestUtil.skipOnNode()) { + return this.skip(); + } + ({ app, server } = await TestUtils.createFetchApp('http-params')); + }); + + after(async () => { + server?.close(); + await app?.destroy(); + }); + + it('should query param work', async () => { + await httpRequest(server) + .get('/query?name=tegg&type=1&type=2') + .expect(200, { + name: 'tegg', + type: [ '1', '2' ], + }); + }); + + it('should path param work', async () => { + await httpRequest(server) + .get('/find/123') + .expect(200, { + id: '123', + }); + }); + + describe('@HTTPBody()', () => { + it('should json body param work', async () => { + await httpRequest(server) + .post('/echo/body') + .type('json') + .send({ foo: 'bar' }) + .expect(200, { body: { foo: 'bar' } }); + }); + + it('should formData body param work', async () => { + await httpRequest(server) + .post('/echo/body') + .type('form') + .send({ foo: 'bar' }) + .expect(200, { type: 'formData', body: { foo: 'bar' } }); + }); + + it('should text body param work', async () => { + await httpRequest(server) + .post('/echo/body') + .type('text') + .send('hello world') + .expect(200, { body: 'hello world' }); + }); + }); + + it('should headers param work', async () => { + const res = await httpRequest(server) + .get('/headers') + .set('x-custom-header', 'custom-value') + .expect(200); + assert.equal(res.body.headers['x-custom-header'], 'custom-value'); + }); + + it('should request param work', async () => { + const port = (server.address() as AddressInfo).port; + const res = await httpRequest(server) + .post('/request') + .set('x-custom', 'custom-value') + .send({ foo: 'bar' }) + .expect(200); + assert.equal(res.body.url, `http://127.0.0.1:${port}/request`); + assert.equal(res.body.method, 'POST'); + assert.equal(res.body.customHeaders, 'custom-value'); + assert.deepStrictEqual(res.body.body, { foo: 'bar' }); + }); +}); diff --git a/tegg/standalone/service-worker/test/http/priority.test.ts b/tegg/standalone/service-worker/test/http/priority.test.ts new file mode 100644 index 0000000000..3168d124a3 --- /dev/null +++ b/tegg/standalone/service-worker/test/http/priority.test.ts @@ -0,0 +1,43 @@ +import { Server } from 'node:http'; +import httpRequest from 'supertest'; +import { ServiceWorkerApp } from '../../src/ServiceWorkerApp.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; +import { TestUtils } from '../Utils.ts'; + +describe('standalone/service-worker/test/http/priority.test.ts', () => { + let app: ServiceWorkerApp; + let server: Server; + + before(async function() { + if (StandaloneTestUtil.skipOnNode()) { + return this.skip(); + } + ({ app, server } = await TestUtils.createFetchApp('http-priority')); + }); + + after(async () => { + server?.close(); + await app?.destroy(); + }); + + it('should /* work', async () => { + await httpRequest(server) + .get('/view/foo') + .expect(200) + .expect('hello, view'); + }); + + it('should /users/group work', async () => { + await httpRequest(server) + .get('/users/group') + .expect(200) + .expect('high priority'); + }); + + it('should /users/* work', async () => { + await httpRequest(server) + .get('/users/foo') + .expect(200) + .expect('low priority'); + }); +}); diff --git a/tegg/standalone/service-worker/test/http/response.test.ts b/tegg/standalone/service-worker/test/http/response.test.ts new file mode 100644 index 0000000000..56789b89df --- /dev/null +++ b/tegg/standalone/service-worker/test/http/response.test.ts @@ -0,0 +1,40 @@ +import { Server } from 'node:http'; +import httpRequest from 'supertest'; +import { ServiceWorkerApp } from '../../src/ServiceWorkerApp.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; +import { TestUtils } from '../Utils.ts'; + +describe('standalone/service-worker/test/http/response.test.ts', () => { + let app: ServiceWorkerApp; + let server: Server; + + before(async function() { + if (StandaloneTestUtil.skipOnNode()) { + return this.skip(); + } + ({ app, server } = await TestUtils.createFetchApp('http')); + }); + + after(async () => { + server?.close(); + await app?.destroy(); + }); + + it('should return Response work', async () => { + await httpRequest(server) + .get('/api/response') + .expect(500, 'full response') + .expect('Content-Type', 'text/plain') + .expect('x-custom-header', 'custom-value'); + }); + + it('should return 204 with no content', async () => { + await httpRequest(server) + .get('/api/null-body') + .expect(204, ''); + + await httpRequest(server) + .get('/api/null-body?nil=1') + .expect(204, ''); + }); +}); diff --git a/tegg/standalone/service-worker/test/http/router.test.ts b/tegg/standalone/service-worker/test/http/router.test.ts new file mode 100644 index 0000000000..7f72f7af4e --- /dev/null +++ b/tegg/standalone/service-worker/test/http/router.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert'; +import { Server } from 'node:http'; +import httpRequest from 'supertest'; +import { ServiceWorkerApp } from '../../src/ServiceWorkerApp.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; +import { TestUtils } from '../Utils.ts'; +import { httpAdviceExecutionLog } from '../fixtures/http/HttpTestAdvice.ts'; + +describe('standalone/service-worker/test/http/router.test.ts', () => { + let app: ServiceWorkerApp; + let server: Server; + + before(async function() { + if (StandaloneTestUtil.skipOnNode()) { + return this.skip(); + } + ({ app, server } = await TestUtils.createFetchApp('http')); + }); + + after(async () => { + server?.close(); + await app?.destroy(); + }); + + it('should get work', async () => { + await httpRequest(server) + .get('/hello') + .expect(200, 'hello'); + }); + + it('should post work', async () => { + await httpRequest(server) + .post('/echo') + .send({ name: 'tegg' }) + .expect(200, { + success: true, + data: { name: 'tegg' }, + }); + }); + + it('should return 404 with invalid path', async () => { + await httpRequest(server) + .get('/invalid-path') + .expect(404); + }); + + it('should execute AOP middleware for HTTP controller', async () => { + httpAdviceExecutionLog.length = 0; + + await httpRequest(server) + .get('/middleware/aop') + .expect(200, { msg: 'hello' }); + + assert(httpAdviceExecutionLog.length > 0, 'middleware should have been executed'); + assert(httpAdviceExecutionLog.includes('before'), 'middleware before should have been called'); + assert(httpAdviceExecutionLog.includes('after'), 'middleware after should have been called'); + }); +}); diff --git a/tegg/standalone/service-worker/test/mcp/mcp.test.ts b/tegg/standalone/service-worker/test/mcp/mcp.test.ts new file mode 100644 index 0000000000..058297dc8a --- /dev/null +++ b/tegg/standalone/service-worker/test/mcp/mcp.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert'; +import { Server } from 'node:http'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { ServiceWorkerApp } from '../../src/ServiceWorkerApp.ts'; +import { StandaloneTestUtil } from '@eggjs/module-test-util/StandaloneTestUtil'; +import { TestUtils } from '../Utils.ts'; +import { adviceExecutionLog } from '../fixtures/mcp/McpTestAdvice.ts'; + +describe('standalone/service-worker/test/mcp/mcp.test.ts', () => { + let app: ServiceWorkerApp; + let server: Server; + let client: Client; + let baseUrl: string; + + before(async function() { + if (StandaloneTestUtil.skipOnNode()) { + return this.skip(); + } + ({ app, server } = await TestUtils.createFetchApp('mcp')); + const address = server.address(); + if (typeof address === 'object' && address) { + baseUrl = `http://127.0.0.1:${address.port}`; + } + }); + + after(async () => { + await client?.close(); + server?.close(); + await app?.destroy(); + }); + + it('should list tools', async () => { + client = new Client({ + name: 'test-mcp-client', + version: '1.0.0', + }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp/test-server/stream`), + ); + await client.connect(transport); + + const result = await client.listTools(); + const toolNames = result.tools.map(t => t.name); + assert(toolNames.includes('echo'), 'should have echo tool'); + assert(toolNames.includes('add'), 'should have add tool'); + + const echoTool = result.tools.find(t => t.name === 'echo'); + assert.strictEqual(echoTool?.description, 'Echo the input message'); + + const addTool = result.tools.find(t => t.name === 'add'); + assert.strictEqual(addTool?.description, 'Add two numbers'); + }); + + it('should call echo tool', async () => { + client = new Client({ + name: 'test-mcp-client', + version: '1.0.0', + }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp/test-server/stream`), + ); + await client.connect(transport); + + const result = await client.callTool({ + name: 'echo', + arguments: { message: 'hello tegg' }, + }); + assert.deepStrictEqual(result, { + content: [{ type: 'text', text: 'hello tegg' }], + }); + }); + + it('should call add tool', async () => { + client = new Client({ + name: 'test-mcp-client', + version: '1.0.0', + }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp/test-server/stream`), + ); + await client.connect(transport); + + const result = await client.callTool({ + name: 'add', + arguments: { a: 3, b: 5 }, + }); + assert.deepStrictEqual(result, { + content: [{ type: 'text', text: '8' }], + }); + }); + + it('should execute AOP middleware', async () => { + adviceExecutionLog.length = 0; + + client = new Client({ + name: 'test-mcp-client', + version: '1.0.0', + }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp/test-server/stream`), + ); + await client.connect(transport); + + await client.callTool({ + name: 'echo', + arguments: { message: 'middleware test' }, + }); + + // The middleware should have been executed for both the connect and callTool requests + assert(adviceExecutionLog.length > 0, 'middleware should have been executed'); + assert(adviceExecutionLog.includes('before'), 'middleware before should have been called'); + assert(adviceExecutionLog.includes('after'), 'middleware after should have been called'); + }); +}); 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/tsconfig.json b/tsconfig.json index 0a167c7226..520eb8445a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -118,6 +118,9 @@ }, { "path": "./tegg/core/agent-runtime" + }, + { + "path": "./tegg/standalone/service-worker" } ] }