Skip to content
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
5d152cf
refactor(controller): dual-host controller plugin with built-in runtime
gxkl Jul 6, 2026
c365b2d
feat(service-worker): standalone service worker runtime (fetch/HTTP/MCP)
gxkl Jul 6, 2026
1b4e6ab
docs(wiki): service worker package page
gxkl Jul 6, 2026
6c99753
refactor(controller): host-agnostic MCP register via McpRouter boundary
gxkl Jul 10, 2026
edf80ed
docs(wiki): record host-agnostic MCP McpRouter boundary
gxkl Jul 10, 2026
f987101
refactor(controller): extract host-agnostic controller-runtime package
gxkl Jul 10, 2026
156ec4b
refactor(service-worker): split fetch controller plugin from the host…
gxkl Jul 10, 2026
a62aecf
docs(wiki): record four-package controller layering
gxkl Jul 10, 2026
9acb841
refactor(controller): build method middlewares without mutating
gxkl Jul 12, 2026
1bee060
refactor(controller): drop the Base suffix from the shared register c…
gxkl Jul 12, 2026
9d8256f
refactor(controller): fold the Egg* inner-object shells into the runt…
gxkl Jul 12, 2026
b289df6
refactor(controller): make the egg HTTP register a container citizen
gxkl Jul 12, 2026
67041cf
refactor(tegg): inject app properties into inner objects via egg comp…
gxkl Jul 12, 2026
5fdb14b
docs(wiki): record inner-object app-property compat via egg compat pr…
gxkl Jul 13, 2026
312d260
refactor(controller): make the egg MCP register a container citizen too
gxkl Jul 13, 2026
4257c12
docs(wiki): record egg MCP container-citizen + mount-then-compat idiom
gxkl Jul 13, 2026
1e067a4
refactor(controller): drop redundant proto names, tighten inner-objec…
gxkl Jul 13, 2026
15af676
refactor(controller): rename ControllerModule barrel to runtimeProtos
gxkl Jul 13, 2026
ee6c736
refactor(controller): simplify controller boot, drop dead app surface
gxkl Jul 13, 2026
52b7141
refactor(controller): drop code-narrating comments
gxkl Jul 13, 2026
221a3f3
refactor(controller): drive HTTP registration via LoadUnitInstance hook
gxkl Jul 13, 2026
0413a1d
docs(wiki): record LoadUnitInstance-hook registration and rootProtoMa…
gxkl Jul 13, 2026
8ce442e
refactor(controller): drop redundant runtime re-export from plugin index
gxkl Jul 13, 2026
4753a61
refactor(tegg): keep a single PUBLIC copy of app-scoped compat protos
gxkl Jul 13, 2026
8d658b2
docs(wiki): record app-scoped compat proto dedup to a single PUBLIC copy
gxkl Jul 13, 2026
cde06ec
fix(service-worker): pin StandaloneEggObjectFactory name to drop scan…
gxkl Jul 13, 2026
fdeb2da
docs(wiki): record service-worker framework-module auto-discovery + e…
gxkl Jul 13, 2026
e221699
docs(service-worker): add fetch-event interface example + respondWith…
gxkl Jul 13, 2026
f7d59d4
fix(ci): satisfy isolatedDeclarations build and refresh stale artifacts
gxkl Jul 13, 2026
ca62c38
fix(ci): refresh @eggjs/tegg standalone export snapshot
gxkl Jul 13, 2026
b435105
fix(ci): add teggController to DuplicateOptionalModule expected modul…
gxkl Jul 13, 2026
fc9b405
feat(service-worker): run @Middleware advice classes on the fetch path
gxkl Jul 14, 2026
a95d374
test(service-worker): cover @Pointcut and its coexistence with @Middl…
gxkl Jul 14, 2026
ac9ddba
test(service-worker): cover CONTEXT-init @Pointcut advice scoping
gxkl Jul 14, 2026
60ed905
fix(service-worker): correctness fixes from PR review (non-MCP-pipeline)
gxkl Jul 14, 2026
8f9d2b3
fix(service-worker): rework MCP request pipeline + add DNS-rebinding …
gxkl Jul 14, 2026
6276726
feat(service-worker): host seams for context, error mapping, config c…
gxkl Jul 14, 2026
756adc3
fix(service-worker): address PR review findings
gxkl Jul 14, 2026
0b0834b
refactor(service-worker): app config from module.yml + MCP transport …
gxkl Jul 14, 2026
a2ed432
refactor(service-worker): lazy-load node:http/stream in the serve() b…
gxkl Jul 16, 2026
d0b85b5
fix(service-worker): keep streaming context alive via tee + preDestroy
gxkl Jul 16, 2026
28b532c
feat(service-worker): @HTTPCookies fetch-native cookies
gxkl Jul 16, 2026
6849bdd
refactor(service-worker): unify on handleEvent, move event types to t…
gxkl Jul 17, 2026
fdb1dfe
feat(standalone): pass manifest module name as unitName in bundle mode
gxkl Jul 18, 2026
9f5dee6
feat(egg-bundler): standalone service worker bundle for Cloudflare Wo…
gxkl Jul 18, 2026
83bcc57
refactor(egg-bundler): injection-based standalone worker bundle
gxkl Jul 19, 2026
2642937
docs(wiki): injection-based standalone worker bundle + @utoo/pack=Tur…
gxkl Jul 19, 2026
d487751
feat(egg-bundler): service-worker format example + workerd limitation
gxkl Jul 19, 2026
9fa9fdf
refactor(standalone): dedupe manifest lookup in EggModuleLoader.load
gxkl Jul 20, 2026
49de1bd
perf(standalone): index bundle manifest by unit path in EggModuleLoad…
gxkl Jul 20, 2026
4ea5000
docs(standalone,egg-bundler): trim redundant comments
gxkl Jul 20, 2026
f12e5b6
fix(standalone): make bundle-mode dynamic module loading manifest-aware
gxkl Jul 20, 2026
7913ede
feat(egg-bin): bundle a standalone tegg app via egg-bin bundle
gxkl Jul 20, 2026
08136c1
docs(wiki): standalone bundle dynamic-load fix + egg-bin bundle CLI
gxkl Jul 20, 2026
c4942e2
fix(security): address CodeQL alerts in the service worker bundle path
gxkl Jul 20, 2026
d03b5f4
fix(security): fully remove the import.meta regex ReDoS
gxkl Jul 20, 2026
2cf1124
docs(example): simplify service worker example
gxkl Jul 20, 2026
4cc3b46
refactor(controller): align MCP registration with HTTP
gxkl Jul 20, 2026
180acf0
fix(egg-bin): load standalone metadata with TS hooks
gxkl Jul 20, 2026
c63af8d
refactor(controller): reuse HTTP method registers
gxkl Jul 20, 2026
2751964
docs(service-worker): clarify usage and code comments
gxkl Jul 20, 2026
6fd52da
refactor(loader): unify manifest-backed module loading
gxkl Jul 20, 2026
4e712ee
feat(controller): support dependency-injected advice
gxkl Jul 21, 2026
719bbc2
Merge branch 'next' into feat/service-worker
gxkl Jul 21, 2026
3df5e21
test(controller): update loader fixture count
gxkl Jul 21, 2026
78d4f29
test(tegg): update helper export snapshot
gxkl Jul 21, 2026
3dc0448
refactor(controller): register extension hooks declaratively
gxkl Jul 21, 2026
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
46 changes: 46 additions & 0 deletions examples/helloworld-service-worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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.
- `fetch-event.ts` — the same app driven through the Web Service Worker fetch
interface (`self.addEventListener('fetch', e => e.respondWith(app.handleEvent(e)))`)
instead of `serve()`. Run with `npm run start:fetch-event`.

## 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"
}
}
34 changes: 34 additions & 0 deletions examples/helloworld-service-worker/fetch-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { ServiceWorkerApp } from '@eggjs/service-worker';
import { FetchEventImpl } from '@eggjs/service-worker-controller';

// A ServiceWorkerApp plugs straight into the Web Service Worker fetch model:
// self.addEventListener('fetch', event => event.respondWith(app.handleEvent(event)));
// Node has no ServiceWorker global, so this shims `self` to show the exact wiring,
// then dispatches one FetchEvent through it. `app.handleEvent(event)` returns the
// Response you hand to `event.respondWith(...)`.

const app = new ServiceWorkerApp(path.join(path.dirname(fileURLToPath(import.meta.url)), 'app'));
await app.init();

let fetchListener: ((event: FetchEventImpl) => void) | undefined;
const self = {
addEventListener(type: 'fetch', listener: (event: FetchEventImpl) => void) {
if (type === 'fetch') fetchListener = listener;
},
};

// Identical to the wiring inside a browser/edge Service Worker:
self.addEventListener('fetch', (event) => {
event.respondWith(app.handleEvent<Response>(event));
});

// Dispatch one request the way the runtime does, then read what respondWith captured:
const event = new FetchEventImpl(new Request('http://localhost/hello/?name=you'));
fetchListener!(event);
const response = await event.responsePromise!;
console.log(response.status, await response.json()); // 200 { message: 'hello, you' }

await app.destroy();
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;
29 changes: 29 additions & 0 deletions examples/helloworld-service-worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"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",
"start:fetch-event": "node --import=@oxc-node/core/register fetch-event.ts",
"test": "vitest run",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@eggjs/service-worker": "workspace:*",
"@eggjs/service-worker-controller": "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'],
},
});
5 changes: 4 additions & 1 deletion tegg/core/background-task/src/BackgroundTaskHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export class BackgroundTaskHelper implements EggObjectLifecycle {
await this.doPreDestroy();
},
});
if (this.config.backgroundTask?.timeout) {
// `!== undefined` (not truthy) so an explicit `timeout: 0` — documented as
// "wait indefinitely" and honored by the `<= 0` branch in doPreDestroy —
// is not silently swallowed back to the 5s default.
if (this.config.backgroundTask?.timeout !== undefined) {
this.timeout = this.config.backgroundTask.timeout;
}
}
Expand Down
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"
}
}
17 changes: 17 additions & 0 deletions tegg/core/controller-runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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/ControllerLoadUnitInstance.ts';
export * from './lib/ControllerMetadataManager.ts';
export * from './lib/ControllerPrototypeHook.ts';
export * from './lib/ControllerRegister.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/HTTPControllerRegister.ts';
export * from './lib/impl/http/HTTPMethodRegister.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
@@ -1,18 +1,17 @@
import { CONTROLLER_META_DATA, type ControllerMetadata } from '@eggjs/controller-decorator';
import { Inject, LoadUnitLifecycleProto } from '@eggjs/core-decorator';
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';

export class AppLoadUnitControllerHook implements LifecycleHook<LoadUnitLifecycleContext, LoadUnit> {
private readonly controllerRegisterFactory: ControllerRegisterFactory;
private readonly rootProtoManager: RootProtoManager;
@LoadUnitLifecycleProto()
export class ControllerLoadUnitHook implements LifecycleHook<LoadUnitLifecycleContext, LoadUnit> {
private readonly controllerRegisterFactory: ControllerRegisterFactory<any>;

constructor(controllerRegisterFactory: ControllerRegisterFactory, rootProtoManager: RootProtoManager) {
constructor(@Inject() controllerRegisterFactory: ControllerRegisterFactory<any>) {
this.controllerRegisterFactory = controllerRegisterFactory;
this.rootProtoManager = rootProtoManager;
}

async postCreate(_: LoadUnitLifecycleContext, obj: LoadUnit): Promise<void> {
Expand All @@ -27,7 +26,7 @@ export class AppLoadUnitControllerHook implements LifecycleHook<LoadUnitLifecycl
throw new Error(`not find controller implement for ${String(proto.name)} which type is ${metadata.type}`);
}
ControllerMetadataManager.instance.addController(metadata);
await register.register(this.rootProtoManager, obj);
await register.register(obj);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ControllerMetaBuilderFactory, ControllerMetadataUtil } from '@eggjs/controller-decorator';
import { EggPrototypeLifecycleProto } from '@eggjs/core-decorator';
import type { LifecycleHook } from '@eggjs/lifecycle';
import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/metadata';

export class EggControllerPrototypeHook implements LifecycleHook<EggPrototypeLifecycleContext, EggPrototype> {
@EggPrototypeLifecycleProto()
export class ControllerPrototypeHook implements LifecycleHook<EggPrototypeLifecycleContext, EggPrototype> {
async postCreate(ctx: EggPrototypeLifecycleContext): Promise<void> {
const metadata = ControllerMetaBuilderFactory.build(ctx.clazz);
if (metadata) {
Expand Down
Loading
Loading