Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions examples/helloworld-service-worker/README.md
Original file line number Diff line number Diff line change
@@ -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
```
27 changes: 27 additions & 0 deletions examples/helloworld-service-worker/app/CalcMCPController.ts
Original file line number Diff line number Diff line change
@@ -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}`,
},
],
};
}
}
14 changes: 14 additions & 0 deletions examples/helloworld-service-worker/app/HelloController.ts
Original file line number Diff line number Diff line change
@@ -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') };
}
}
8 changes: 8 additions & 0 deletions examples/helloworld-service-worker/app/HelloService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ContextProto } from '@eggjs/tegg';

@ContextProto()
export class HelloService {
hello(name: string): string {
return `hello, ${name}`;
}
}
7 changes: 7 additions & 0 deletions examples/helloworld-service-worker/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "helloworld-service-worker-app",
"type": "module",
"eggModule": {
"name": "helloWorldServiceWorker"
}
}
25 changes: 25 additions & 0 deletions examples/helloworld-service-worker/main.ts
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 27 additions & 0 deletions examples/helloworld-service-worker/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
46 changes: 46 additions & 0 deletions examples/helloworld-service-worker/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
3 changes: 3 additions & 0 deletions examples/helloworld-service-worker/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "@eggjs/tsconfig"
}
7 changes: 7 additions & 0 deletions examples/helloworld-service-worker/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineProject } from 'vitest/config';

export default defineProject({
test: {
include: ['test/**/*.test.ts'],
},
});
64 changes: 64 additions & 0 deletions tegg/core/controller-runtime/package.json
Original file line number Diff line number Diff line change
@@ -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 <killa123@126.com>",
"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"
}
}
19 changes: 19 additions & 0 deletions tegg/core/controller-runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoadUnitLifecycleContext, LoadUnit> {
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<LoadUnitLifecycleContext, LoadUnit> {
private readonly controllerRegisterFactory: ControllerRegisterFactory<any>;
private readonly rootProtoManager: RootProtoManager;

constructor(controllerRegisterFactory: ControllerRegisterFactory, rootProtoManager: RootProtoManager) {
constructor(controllerRegisterFactory: ControllerRegisterFactory<any>, rootProtoManager: RootProtoManager) {
this.controllerRegisterFactory = controllerRegisterFactory;
this.rootProtoManager = rootProtoManager;
}
Expand Down
51 changes: 51 additions & 0 deletions tegg/core/controller-runtime/src/lib/ControllerModule.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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<EggPrototypeLifecycleContext, EggPrototype> {
/**
* Host-agnostic prototype hook: build controller metadata from the decorated
* class when its prototype is created.
*/
export class ControllerPrototypeHook implements LifecycleHook<EggPrototypeLifecycleContext, EggPrototype> {
async postCreate(ctx: EggPrototypeLifecycleContext): Promise<void> {
const metadata = ControllerMetaBuilderFactory.build(ctx.clazz);
if (metadata) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
Expand Down
Loading