diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts
index 414878e35..93300d655 100644
--- a/src/backend/clients/event/types.ts
+++ b/src/backend/clients/event/types.ts
@@ -474,6 +474,14 @@ export type EventMap = {
worker: string;
};
+ // An app's events worker coming into being (its handler count going 0→1)
+ // and going away (1→0, whether by removing the last handler or by the
+ // destroy route). `actor.user` is the app's owner, not the caller — a
+ // developer session publishing for an app it owns is the common case, but
+ // billing follows ownership.
+ 'events.worker.create': { actor: Actor; appUid: string };
+ 'events.worker.destroy': { actor: Actor; appUid: string };
+
// ---- Outer / GUI broadcast ----
'outer.cacheUpdate': {
cacheKey: string[];
diff --git a/src/backend/clients/events/EventsWorkerInvokerClient.test.ts b/src/backend/clients/events/EventsWorkerInvokerClient.test.ts
new file mode 100644
index 000000000..60a6d169b
--- /dev/null
+++ b/src/backend/clients/events/EventsWorkerInvokerClient.test.ts
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * `DispatcherInvokeTransport` against an injected fetch, so the handled/error
+ * header contract is pinned without a real dispatcher on the other end.
+ */
+
+import type { fetch as undiciFetch } from 'undici';
+import { describe, expect, it } from 'vitest';
+import {
+ DispatcherInvokeTransport,
+ EVENTS_DISPATCH_ERROR_HEADER,
+ EVENTS_DISPATCH_PATH,
+ EVENTS_ERROR_HEADER,
+ EVENTS_HANDLED_HEADER,
+} from './EventsWorkerInvokerClient.js';
+
+type FetchImpl = typeof undiciFetch;
+
+const CALL = {
+ script: 'evw-test',
+ appUid: 'app-test',
+ key: 'k1:test',
+ body: '{}',
+ timeoutMs: 5_000,
+};
+
+/** A stub fetch that answers the same way on every call. */
+const stubFetch = (
+ status: number,
+ headers: Record = {},
+): FetchImpl =>
+ (async () =>
+ new Response(null, { status, headers })) as unknown as FetchImpl;
+
+/** A stub fetch that never answers until the abort signal fires. */
+const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () =>
+ reject(new Error('This operation was aborted')),
+ );
+ })) as unknown as FetchImpl;
+
+/** A stub fetch that records the URL it was called with. */
+const capturingFetch = (
+ calls: string[],
+ status = 200,
+): FetchImpl =>
+ (async (url: string) => {
+ calls.push(url);
+ return new Response(null, { status });
+ }) as unknown as FetchImpl;
+
+describe('DispatcherInvokeTransport', () => {
+ it('reports a handled 400 with no error — the handler`s own refusal', async () => {
+ const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
+ fetchImpl: stubFetch(400, { [EVENTS_HANDLED_HEADER]: '1' }),
+ });
+ expect(await transport.send(CALL)).toEqual({
+ status: 400,
+ handled: true,
+ });
+ });
+
+ it('reports an unmarked 404 with no dispatch-error header as unhandled', async () => {
+ const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
+ fetchImpl: stubFetch(404),
+ });
+ expect(await transport.send(CALL)).toEqual({
+ status: 404,
+ handled: false,
+ });
+ });
+
+ it('turns a dispatch-error header into a null status with the reason', async () => {
+ const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
+ fetchImpl: stubFetch(502, { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-failed' }),
+ });
+ expect(await transport.send(CALL)).toEqual({
+ status: null,
+ error: 'dispatcher: deploy-failed (502)',
+ });
+ });
+
+ it('reports a handled 200 as settled with no error', async () => {
+ const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
+ fetchImpl: stubFetch(200, { [EVENTS_HANDLED_HEADER]: '1' }),
+ });
+ expect(await transport.send(CALL)).toEqual({
+ status: 200,
+ handled: true,
+ });
+ });
+
+ it('passes a 429 through as-is', async () => {
+ const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
+ fetchImpl: stubFetch(429),
+ });
+ expect(await transport.send(CALL)).toEqual({
+ status: 429,
+ handled: false,
+ });
+ });
+
+ it('answers null with an error when the request times out', async () => {
+ const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
+ fetchImpl: hangingFetch,
+ });
+ const result = await transport.send({ ...CALL, timeoutMs: 10 });
+ expect(result.status).toBeNull();
+ expect(result.error).toMatch(/abort/i);
+ });
+
+ it('surfaces the runtime`s own error header alongside its status', async () => {
+ const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
+ fetchImpl: stubFetch(500, {
+ [EVENTS_HANDLED_HEADER]: '1',
+ [EVENTS_ERROR_HEADER]: 'handler-threw',
+ }),
+ });
+ expect(await transport.send(CALL)).toEqual({
+ status: 500,
+ handled: true,
+ error: 'handler-threw',
+ });
+ });
+
+ it('preserves a path prefix on the dispatcher URL', async () => {
+ const calls: string[] = [];
+ const transport = new DispatcherInvokeTransport(
+ 'http://dispatcher/prefix/',
+ 's',
+ { fetchImpl: capturingFetch(calls) },
+ );
+ await transport.send(CALL);
+ expect(calls).toEqual([`http://dispatcher/prefix${EVENTS_DISPATCH_PATH}`]);
+ });
+});
diff --git a/src/backend/clients/events/EventsWorkerInvokerClient.ts b/src/backend/clients/events/EventsWorkerInvokerClient.ts
index 4b4aaba0d..d03c3bf76 100644
--- a/src/backend/clients/events/EventsWorkerInvokerClient.ts
+++ b/src/backend/clients/events/EventsWorkerInvokerClient.ts
@@ -27,69 +27,109 @@ import { PuterClient } from '../types.js';
/**
* The call that leaves the platform and runs an app's own code.
*
- * The protocol is fixed and lives here rather than in the events service
- * because it is a wire format, not a delivery decision: one POST, one header,
- * and a status code that says whether the handler took the delivery, refused
- * it, or could not answer.
+ * Events workers are unreachable from the internet: deployed into their own
+ * dispatch namespace and reached only through the events dispatcher, which
+ * answers on its own hostname behind the internal secret.
*
- * POST /__events/invoke
- * puter-auth:
- * { handler, event, ctx }
+ * POST /invoke
+ * x-puter-internal-auth:
+ * x-puter-events-script:
+
+