Skip to content

Commit a482daf

Browse files
gxklclaude
andcommitted
feat(service-worker): host seams for context, error mapping, config chain
Adds the extension points an embedding host needs to reuse the fetch runtime without forking: - FetchContextFactory / ErrorResponseMapper inner objects (defaults preserve current behavior): the host can build a richer per-request context (logger/tracer/user) and map its own error types to a Response instead of the fixed 500. Wired through ServiceWorkerAppOptions.fetchContextFactory / errorResponseMapper. - StandaloneApp no longer clobbers a pre-set ModuleConfigUtil.configNames — a host sets its own module-config selection chain (deployType/serverScope) in the app's TeggScope bag before init(); the default only fills when unset. No new option, and it fixes the prior "pre-set configNames is overwritten" bug. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8f9d2b3 commit a482daf

7 files changed

Lines changed: 140 additions & 8 deletions

File tree

tegg/standalone/service-worker-controller/src/http/FetchEventHandler.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { MCPRegisterProvider } from '../mcp/MCPRegisterProvider.ts';
99
import type { FetchEvent } from '../types.ts';
1010
import { ResponseUtils } from '../utils/ResponseUtils.ts';
1111
import { FetchRouter } from './FetchRouter.ts';
12+
import type { ErrorResponseMapper, FetchContextFactory } from './hostSeams.ts';
1213
import { HTTPRegisterProvider } from './HTTPRegisterProvider.ts';
13-
import { ServiceWorkerFetchContext } from './ServiceWorkerFetchContext.ts';
14+
import type { ServiceWorkerFetchContext } from './ServiceWorkerFetchContext.ts';
1415

1516
type RouterMiddleware = (ctx: ServiceWorkerFetchContext, next: () => Promise<void>) => Promise<void>;
1617

@@ -28,6 +29,12 @@ export class FetchEventHandler extends AbstractEventHandler<FetchEvent, Response
2829
@Inject()
2930
private readonly mcpRegisterProvider: MCPRegisterProvider;
3031

32+
@Inject()
33+
private readonly fetchContextFactory: FetchContextFactory;
34+
35+
@Inject()
36+
private readonly errorResponseMapper: ErrorResponseMapper;
37+
3138
#routes?: RouterMiddleware;
3239
#initPromise?: Promise<void>;
3340

@@ -52,7 +59,7 @@ export class FetchEventHandler extends AbstractEventHandler<FetchEvent, Response
5259

5360
async handleEvent(event: FetchEvent): Promise<Response> {
5461
await this.initRoutes();
55-
const ctx = new ServiceWorkerFetchContext({ event });
62+
const ctx = this.fetchContextFactory.create({ event });
5663
try {
5764
await this.#routes!(ctx, async () => {
5865
/* noop */
@@ -63,6 +70,10 @@ export class FetchEventHandler extends AbstractEventHandler<FetchEvent, Response
6370
}
6471
return await this.#guardResponseStream(this.#mergeResponseHeaders(response, ctx.responseHeaders));
6572
} catch (e: any) {
73+
const mapped = this.errorResponseMapper.toResponse(e, ctx);
74+
if (mapped) {
75+
return mapped;
76+
}
6677
console.error('[service-worker] handle fetch event failed:', e);
6778
return ResponseUtils.createErrorResponse(500, 'INTERNAL_SERVER_ERROR', e?.message ?? 'internal error');
6879
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { FetchEvent, ServiceWorkerContextInit } from '../types.ts';
2+
import { ServiceWorkerFetchContext } from './ServiceWorkerFetchContext.ts';
3+
4+
/**
5+
* Host seam for creating the per-request fetch context. A host that needs a
6+
* richer context (logger/tracer/user, extra container lookups) provides its own
7+
* factory as the `fetchContextFactory` inner object; the default builds the
8+
* plain {@link ServiceWorkerFetchContext}.
9+
*/
10+
export interface FetchContextFactory {
11+
create(init: ServiceWorkerContextInit<FetchEvent>): ServiceWorkerFetchContext;
12+
}
13+
14+
export const DEFAULT_FETCH_CONTEXT_FACTORY: FetchContextFactory = {
15+
create(init) {
16+
return new ServiceWorkerFetchContext(init);
17+
},
18+
};
19+
20+
/**
21+
* Host seam for turning an unhandled controller error into a Response. Return
22+
* `undefined` to fall back to the framework's unified `{ code, message }` 500.
23+
* A host maps its own error types (business status/body) here as the
24+
* `errorResponseMapper` inner object.
25+
*/
26+
export interface ErrorResponseMapper {
27+
toResponse(error: unknown, ctx: ServiceWorkerFetchContext): Response | undefined;
28+
}
29+
30+
export const DEFAULT_ERROR_RESPONSE_MAPPER: ErrorResponseMapper = {
31+
toResponse() {
32+
return undefined;
33+
},
34+
};

tegg/standalone/service-worker-controller/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './controller/ServiceWorkerContext.ts';
22
export * from './event/FetchEventImpl.ts';
33
export * from './http/FetchEventHandler.ts';
44
export * from './http/FetchHTTPMethodRegister.ts';
5+
export * from './http/hostSeams.ts';
56
export * from './http/FetchRouter.ts';
67
export * from './http/HTTPRegisterProvider.ts';
78
export * from './http/ServiceWorkerFetchContext.ts';

tegg/standalone/service-worker/src/ServiceWorkerApp.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@ import { Readable } from 'node:stream';
44
import { pipeline } from 'node:stream/promises';
55
import { fileURLToPath } from 'node:url';
66

7-
import { FetchEventImpl, type MCPAuthHandler, type MCPTransportOptions } from '@eggjs/service-worker-controller';
7+
import {
8+
DEFAULT_ERROR_RESPONSE_MAPPER,
9+
DEFAULT_FETCH_CONTEXT_FACTORY,
10+
FetchEventImpl,
11+
type ErrorResponseMapper,
12+
type FetchContextFactory,
13+
type MCPAuthHandler,
14+
type MCPTransportOptions,
15+
} from '@eggjs/service-worker-controller';
816
import { ContextProtoProperty } from '@eggjs/service-worker-runtime';
917
import {
1018
StandaloneApp,
@@ -20,6 +28,10 @@ export interface ServiceWorkerAppOptions extends StandaloneAppOptions {
2028
mcpAuthHandler?: MCPAuthHandler;
2129
/** DNS-rebinding protection for the MCP transport (Host/Origin allow-lists). */
2230
mcp?: MCPTransportOptions;
31+
/** Override the per-request fetch context factory (e.g. to add logger/tracer/user). */
32+
fetchContextFactory?: FetchContextFactory;
33+
/** Map unhandled controller errors to a Response; return undefined for the default 500. */
34+
errorResponseMapper?: ErrorResponseMapper;
2335
}
2436

2537
const PASS_THROUGH_MCP_AUTH_HANDLER: MCPAuthHandler = {
@@ -45,7 +57,8 @@ export class ServiceWorkerApp {
4557
#initialized = false;
4658

4759
constructor(cwd: string, options?: ServiceWorkerAppOptions) {
48-
const { config, mcpAuthHandler, mcp, ...standaloneOptions } = options ?? {};
60+
const { config, mcpAuthHandler, mcp, fetchContextFactory, errorResponseMapper, ...standaloneOptions } =
61+
options ?? {};
4962
// Scan this package's own root so its framework-module deps
5063
// (service-worker-runtime + -controller) are auto-discovered via the
5164
// node_modules eggModule convention. `!test/**` keeps their test fixtures out.
@@ -67,6 +80,8 @@ export class ServiceWorkerApp {
6780
config: [{ obj: config ?? {} }],
6881
mcpAuthHandler: [{ obj: mcpAuthHandler ?? PASS_THROUGH_MCP_AUTH_HANDLER }],
6982
mcpTransportOptions: [{ obj: mcp ?? {} }],
83+
fetchContextFactory: [{ obj: fetchContextFactory ?? DEFAULT_FETCH_CONTEXT_FACTORY }],
84+
errorResponseMapper: [{ obj: errorResponseMapper ?? DEFAULT_ERROR_RESPONSE_MAPPER }],
7085
...standaloneOptions.innerObjectHandlers,
7186
},
7287
});

tegg/standalone/service-worker/test/ServiceWorkerApp.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ import type http from 'node:http';
33
import type { AddressInfo } from 'node:net';
44
import path from 'node:path';
55

6-
import { FetchEventImpl } from '@eggjs/service-worker-controller';
6+
import { FetchEventImpl, ServiceWorkerFetchContext } from '@eggjs/service-worker-controller';
7+
import { ModuleConfigUtil } from '@eggjs/tegg-common-util';
8+
import { TeggScope } from '@eggjs/tegg-types';
79
import { afterAll, beforeAll, describe, it } from 'vitest';
810

911
import { ServiceWorkerApp } from '../src/index.ts';
1012
import { backgroundFlags } from './fixtures/hello-app/HelloController.ts';
1113

14+
const HELLO_APP = path.join(__dirname, 'fixtures/hello-app');
15+
1216
describe('standalone/service-worker/test/ServiceWorkerApp.test.ts', () => {
1317
let app: ServiceWorkerApp;
1418
let server: http.Server;
@@ -176,3 +180,63 @@ describe('standalone/service-worker/test/ServiceWorkerApp.test.ts', () => {
176180
assert.deepEqual(await response.json(), { message: 'hello, tegg' });
177181
});
178182
});
183+
184+
describe('standalone/service-worker/test/ServiceWorkerApp.test.ts host seams', () => {
185+
it('should map controller errors through a custom errorResponseMapper', async () => {
186+
const app = new ServiceWorkerApp(HELLO_APP, {
187+
errorResponseMapper: {
188+
toResponse(error) {
189+
return new Response(JSON.stringify({ mapped: (error as Error).message }), {
190+
status: 418,
191+
headers: { 'content-type': 'application/json' },
192+
});
193+
},
194+
},
195+
});
196+
const server = await app.serve();
197+
const { address, port } = server.address() as AddressInfo;
198+
try {
199+
const res = await fetch(`http://${address}:${port}/stream/boom`);
200+
assert.equal(res.status, 418);
201+
assert.deepEqual(await res.json(), { mapped: 'stream controller boom' });
202+
} finally {
203+
await app.destroy();
204+
}
205+
});
206+
207+
it('should build the request context through a custom fetchContextFactory', async () => {
208+
const seen: string[] = [];
209+
const app = new ServiceWorkerApp(HELLO_APP, {
210+
fetchContextFactory: {
211+
create(init) {
212+
seen.push(new URL(init.event.request.url).pathname);
213+
return new ServiceWorkerFetchContext(init);
214+
},
215+
},
216+
});
217+
const server = await app.serve();
218+
const { address, port } = server.address() as AddressInfo;
219+
try {
220+
await fetch(`http://${address}:${port}/hello/`);
221+
assert.deepEqual(seen, ['/hello/']);
222+
} finally {
223+
await app.destroy();
224+
}
225+
});
226+
227+
it('should not clobber a pre-set ModuleConfigUtil.configNames', async () => {
228+
const app = new ServiceWorkerApp(HELLO_APP);
229+
// Host selection chain set in the app's scope bag before init must survive.
230+
TeggScope.run(app.app.scopeBag, () => {
231+
ModuleConfigUtil.configNames = ['module.default', 'module.beta'];
232+
});
233+
const server = await app.serve();
234+
const { address, port } = server.address() as AddressInfo;
235+
try {
236+
const res = await fetch(`http://${address}:${port}/hello/module-config`);
237+
assert.deepEqual(await res.json(), { features: { greeting: 'beta-hi' } });
238+
} finally {
239+
await app.destroy();
240+
}
241+
});
242+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
features:
2+
greeting: 'beta-hi'

tegg/standalone/standalone/src/StandaloneApp.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,14 @@ export class StandaloneApp {
151151
this.#runtimeConfig.env = opts.env ?? '';
152152
this.#runtimeConfig.baseDir = opts.baseDir;
153153

154-
// Load module.yml and module.env.yml by default. The override is scoped to
155-
// this app's TeggScope bag.
156-
ModuleConfigUtil.configNames = opts.env ? ['module.default', `module.${opts.env}`] : ['module.default'];
154+
// Load module.default.yml and module.${env}.yml by default. A host that
155+
// needs a custom selection chain (deployType/serverScope/...) sets
156+
// `ModuleConfigUtil.configNames` inside this app's TeggScope bag before
157+
// init(); only fill the default when nothing was pre-set, so that override
158+
// is not clobbered.
159+
if (ModuleConfigUtil.configNames === undefined) {
160+
ModuleConfigUtil.configNames = opts.env ? ['module.default', `module.${opts.env}`] : ['module.default'];
161+
}
157162
}
158163

159164
/** Load every module's config and expose it as a qualified `moduleConfig` inner object. */

0 commit comments

Comments
 (0)