@telorun/http-server 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +2 -2
- package/dist/http-api-controller.d.ts +2 -1
- package/dist/http-api-controller.js +25 -9
- package/package.json +2 -2
- package/src/http-api-controller.ts +25 -8
- package/tests/request-cancel.test.ts +1 -0
- package/tests/request-span.test.ts +82 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @telorun/http-server
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a8c99ab: Generic dispatch tracing: trace every capability dispatch (invoke and run) through one instrumented chokepoint and carry trace data in a structured event payload instead of the event name.
|
|
8
|
+
|
|
9
|
+
- Dispatch events drop the kind from the name (`<name>.Invoked` / `.Run`, plus error/cancel variants). The payload now carries `{ spanId, parentSpanId, capability, phase, outcome, ref: { kind, name }, … }`; consumers read the payload and never parse the dotted name. Lifecycle events (`Kind.name.Created` / `.Initialized` / `.Teardown`) are unchanged.
|
|
10
|
+
- `run()` is now span-instrumented like `invoke()`: it mints and propagates a trace id, so Runnables (e.g. a `Run.Sequence` boot target) appear in the trace and their nested invokes re-parent correctly instead of detaching as false roots. Long-lived Services emit a `<name>.Running` start span. Run failures emit `<name>.RunFailed` (rethrown, never swallowed).
|
|
11
|
+
- Invoke/run emit a `<name>.Invoking` / `.Running` start span when tracing is on.
|
|
12
|
+
- SDK: new `REF_IDENTITY` / `stampRefIdentity` / `getRefIdentity`. The kernel stamps a resolved `!ref`'s kind+name onto the injected instance so `executeInvokeStep` routes pre-injected live instances through the traced chokepoint instead of calling `.invoke()` directly and escaping instrumentation.
|
|
13
|
+
- The boot `targets` run is wrapped in an application span (`<appName>.Run`, `ref.kind: "Telo.Application"`), so the application is the trace root with its targets nested beneath. Pre-resolved `!ref` boot targets now dispatch through a new `EvaluationContext.runResolved` (the `run()` analog of `invokeResolved`) instead of calling `instance.run()` directly, so they emit their own run spans nested under the app.
|
|
14
|
+
- A `Telo.Service`'s long-lived `run()` no longer establishes the cancellation/trace ALS scope (its token is delivered via the explicit `run(invokeCtx)` argument instead). This stops the boot scope leaking onto async resources the service creates — e.g. an HTTP server's socket — so inbound work (each request) starts as its own root trace with no inherited boot cancellation token, instead of nesting under the bootstrap trace. Runnables keep the ALS scope so their steps still nest and inherit cancellation.
|
|
15
|
+
- `EventBus.emit` short-circuits in O(1) when there are no subscribers, keeping the always-through-the-chokepoint dispatch effectively free when nobody is listening.
|
|
16
|
+
- OpenTelemetry-ready trace model: every span carries a `traceId` (OTel-compatible 16-byte hex), minted at the root and inherited by descendants, so an exporter groups a trace without walking the parent chain. New generic `ctx.openSpan(base, { ref, label, attributes, inbound? })` primitive opens an inbound-boundary span (capability `"request"`) that roots its own trace; `inbound` allows continuing an upstream distributed trace later. The `TracePayload` gains `traceId`, `label`, and `attributes`.
|
|
17
|
+
- `http-server`: each inbound request opens a request span attributed to the `Http.Api` and labelled with the route (`"POST /feedback"`, attributes `{ method, path }`); the handler invoke and its subtree nest under it, as a trace separate from the bootstrap.
|
|
18
|
+
- Trace context capture: a trace's root span carries `payload.context` — a redacted snapshot of the CEL root scope available to the trace (`variables`, `resources` snapshots, `ports`, and `secrets` with values masked to `"[secret]"`; host `env` omitted). Lets a debug consumer see what data an execution could reference beyond its own inputs/outputs. The UI renders it as an "Available context" section on the root node.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- @telorun/http-dispatch@0.4.1
|
|
23
|
+
|
|
24
|
+
## 0.11.1
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- b41012f: http-server: a route handler now receives its resolved `inputs` once. Previously the dispatch passed `{ ...resolvedInputs, inputs: resolvedInputs }` — the resolved fields plus a second nested copy under `inputs` that nothing read (a templated handler's `${{ inputs.X }}` already resolves against the top-level fields). The redundant copy is gone, so the handler argument — and the debug trace's invocation inputs — show each value a single time. No reader relied on the nested key, so this is behaviour-preserving for handlers.
|
|
29
|
+
- @telorun/http-dispatch@0.4.1
|
|
30
|
+
|
|
3
31
|
## 0.11.0
|
|
4
32
|
|
|
5
33
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
|
|
|
24
24
|
kind: Telo.Application
|
|
25
25
|
metadata: { name: hello-http, version: 1.0.0 }
|
|
26
26
|
imports:
|
|
27
|
-
Http: std/http-server
|
|
28
|
-
JS: std/javascript
|
|
27
|
+
Http: std/http-server@<version>
|
|
28
|
+
JS: std/javascript@<version>
|
|
29
29
|
targets: [ !ref Server ]
|
|
30
30
|
---
|
|
31
31
|
kind: Http.Server
|
|
@@ -51,7 +51,8 @@ export declare class HttpServerApi implements ResourceInstance {
|
|
|
51
51
|
private readonly ctx;
|
|
52
52
|
readonly manifest: HttpApiManifest;
|
|
53
53
|
private readonly handlerRefs;
|
|
54
|
-
|
|
54
|
+
private readonly apiName;
|
|
55
|
+
constructor(ctx: ResourceContext, manifest: HttpApiManifest, handlerRefs: WeakMap<object, HandlerRef>, apiName: string);
|
|
55
56
|
init(): Promise<void>;
|
|
56
57
|
register(app: FastifyInstance, prefix?: string): void;
|
|
57
58
|
private registerRoutes;
|
|
@@ -26,10 +26,12 @@ export class HttpServerApi {
|
|
|
26
26
|
ctx;
|
|
27
27
|
manifest;
|
|
28
28
|
handlerRefs;
|
|
29
|
-
|
|
29
|
+
apiName;
|
|
30
|
+
constructor(ctx, manifest, handlerRefs, apiName) {
|
|
30
31
|
this.ctx = ctx;
|
|
31
32
|
this.manifest = manifest;
|
|
32
33
|
this.handlerRefs = handlerRefs;
|
|
34
|
+
this.apiName = apiName;
|
|
33
35
|
}
|
|
34
36
|
async init() { }
|
|
35
37
|
register(app, prefix = "") {
|
|
@@ -98,13 +100,14 @@ export class HttpServerApi {
|
|
|
98
100
|
},
|
|
99
101
|
};
|
|
100
102
|
const acceptHeader = request.headers["accept"]?.toString();
|
|
101
|
-
|
|
103
|
+
// The handler receives the resolved inputs directly: a templated handler's
|
|
104
|
+
// `${{ inputs.X }}` reads these as its `inputs` bag, and a plain invocable
|
|
105
|
+
// reads the fields off its argument. (This previously also nested a second
|
|
106
|
+
// `inputs: resolvedInputs` copy — which nothing read, and which surfaced as
|
|
107
|
+
// duplicated data in the debug trace.)
|
|
108
|
+
const invokeInput = route.inputs
|
|
102
109
|
? (this.ctx.moduleContext.expandWith(route.inputs, requestContext) ?? {})
|
|
103
110
|
: requestContext;
|
|
104
|
-
const invokeInput = {
|
|
105
|
-
...resolvedInputs,
|
|
106
|
-
inputs: resolvedInputs,
|
|
107
|
-
};
|
|
108
111
|
const sink = fastifyReplySink(reply);
|
|
109
112
|
// Per-request cancellation: abandon downstream work when the client
|
|
110
113
|
// disconnects before the response is sent. Listen on the response
|
|
@@ -117,22 +120,35 @@ export class HttpServerApi {
|
|
|
117
120
|
if (!reply.sent)
|
|
118
121
|
cancellation.cancel("client-disconnect");
|
|
119
122
|
});
|
|
123
|
+
// Open a request span rooting this request's own trace: the handler (and
|
|
124
|
+
// its nested invokes) nest under it, and it's labelled with the route so
|
|
125
|
+
// the trace shows the actual method+path, attributed to this Http.Api.
|
|
126
|
+
const span = await this.ctx.openSpan(cancellation.context, {
|
|
127
|
+
ref: { kind: "Http.Api", name: this.apiName },
|
|
128
|
+
label: `${route.request.method} ${route.request.path}`,
|
|
129
|
+
attributes: { method: route.request.method, path: route.request.path },
|
|
130
|
+
});
|
|
120
131
|
let result;
|
|
121
132
|
try {
|
|
122
133
|
result = handler
|
|
123
|
-
? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput,
|
|
134
|
+
? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput, span.context)
|
|
124
135
|
: undefined;
|
|
125
136
|
}
|
|
126
137
|
catch (err) {
|
|
127
138
|
if (isCancellationError(err)) {
|
|
139
|
+
await span.settle("cancelled");
|
|
128
140
|
if (!reply.sent)
|
|
129
141
|
reply.code(499).send();
|
|
130
142
|
return;
|
|
131
143
|
}
|
|
132
|
-
if (!isInvokeError(err))
|
|
144
|
+
if (!isInvokeError(err)) {
|
|
145
|
+
await span.settle("failed");
|
|
133
146
|
throw err;
|
|
147
|
+
}
|
|
148
|
+
await span.settle("rejected");
|
|
134
149
|
return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink);
|
|
135
150
|
}
|
|
151
|
+
await span.settle("ok");
|
|
136
152
|
return dispatchReturns(route.returns, result, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink, (err, errCtx) => this.ctx.emitEvent("Http.Api.streamFailed", {
|
|
137
153
|
path: route.request.path,
|
|
138
154
|
method: route.request.method,
|
|
@@ -168,7 +184,7 @@ export async function create(resource, ctx) {
|
|
|
168
184
|
handlerRefs.set(route, { kind: "", name: h });
|
|
169
185
|
}
|
|
170
186
|
}
|
|
171
|
-
return new HttpServerApi(ctx, resource, handlerRefs);
|
|
187
|
+
return new HttpServerApi(ctx, resource, handlerRefs, resource?.metadata?.name ?? "");
|
|
172
188
|
}
|
|
173
189
|
/**
|
|
174
190
|
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@types/node": "^20.0.0",
|
|
50
50
|
"typescript": "^5.0.0",
|
|
51
51
|
"vitest": "^2.1.8",
|
|
52
|
-
"@telorun/sdk": "0.
|
|
52
|
+
"@telorun/sdk": "0.32.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@telorun/sdk": "*"
|
|
@@ -59,6 +59,7 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
59
59
|
private readonly ctx: ResourceContext,
|
|
60
60
|
readonly manifest: HttpApiManifest,
|
|
61
61
|
private readonly handlerRefs: WeakMap<object, HandlerRef>,
|
|
62
|
+
private readonly apiName: string,
|
|
62
63
|
) {}
|
|
63
64
|
|
|
64
65
|
async init() {}
|
|
@@ -137,13 +138,14 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
137
138
|
| string
|
|
138
139
|
| undefined
|
|
139
140
|
)?.toString();
|
|
140
|
-
|
|
141
|
+
// The handler receives the resolved inputs directly: a templated handler's
|
|
142
|
+
// `${{ inputs.X }}` reads these as its `inputs` bag, and a plain invocable
|
|
143
|
+
// reads the fields off its argument. (This previously also nested a second
|
|
144
|
+
// `inputs: resolvedInputs` copy — which nothing read, and which surfaced as
|
|
145
|
+
// duplicated data in the debug trace.)
|
|
146
|
+
const invokeInput: Record<string, any> = route.inputs
|
|
141
147
|
? ((this.ctx.moduleContext.expandWith(route.inputs, requestContext) as any) ?? {})
|
|
142
148
|
: requestContext;
|
|
143
|
-
const invokeInput: Record<string, any> = {
|
|
144
|
-
...resolvedInputs,
|
|
145
|
-
inputs: resolvedInputs,
|
|
146
|
-
};
|
|
147
149
|
|
|
148
150
|
const sink = fastifyReplySink(reply);
|
|
149
151
|
|
|
@@ -158,6 +160,15 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
158
160
|
if (!reply.sent) cancellation.cancel("client-disconnect");
|
|
159
161
|
});
|
|
160
162
|
|
|
163
|
+
// Open a request span rooting this request's own trace: the handler (and
|
|
164
|
+
// its nested invokes) nest under it, and it's labelled with the route so
|
|
165
|
+
// the trace shows the actual method+path, attributed to this Http.Api.
|
|
166
|
+
const span = await this.ctx.openSpan(cancellation.context, {
|
|
167
|
+
ref: { kind: "Http.Api", name: this.apiName },
|
|
168
|
+
label: `${route.request.method} ${route.request.path}`,
|
|
169
|
+
attributes: { method: route.request.method, path: route.request.path },
|
|
170
|
+
});
|
|
171
|
+
|
|
161
172
|
let result: unknown;
|
|
162
173
|
try {
|
|
163
174
|
result = handler
|
|
@@ -166,15 +177,20 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
166
177
|
handlerName,
|
|
167
178
|
handler,
|
|
168
179
|
invokeInput,
|
|
169
|
-
|
|
180
|
+
span.context,
|
|
170
181
|
)
|
|
171
182
|
: undefined;
|
|
172
183
|
} catch (err) {
|
|
173
184
|
if (isCancellationError(err)) {
|
|
185
|
+
await span.settle("cancelled");
|
|
174
186
|
if (!reply.sent) reply.code(499).send();
|
|
175
187
|
return;
|
|
176
188
|
}
|
|
177
|
-
if (!isInvokeError(err))
|
|
189
|
+
if (!isInvokeError(err)) {
|
|
190
|
+
await span.settle("failed");
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
await span.settle("rejected");
|
|
178
194
|
return dispatchCatches(
|
|
179
195
|
route.catches,
|
|
180
196
|
{ code: err.code, message: err.message, data: err.data },
|
|
@@ -186,6 +202,7 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
186
202
|
);
|
|
187
203
|
}
|
|
188
204
|
|
|
205
|
+
await span.settle("ok");
|
|
189
206
|
return dispatchReturns(
|
|
190
207
|
route.returns,
|
|
191
208
|
result,
|
|
@@ -231,7 +248,7 @@ export async function create(resource: any, ctx: ResourceContext): Promise<HttpS
|
|
|
231
248
|
handlerRefs.set(route, { kind: "", name: h });
|
|
232
249
|
}
|
|
233
250
|
}
|
|
234
|
-
return new HttpServerApi(ctx, resource, handlerRefs);
|
|
251
|
+
return new HttpServerApi(ctx, resource, handlerRefs, resource?.metadata?.name ?? "");
|
|
235
252
|
}
|
|
236
253
|
|
|
237
254
|
/**
|
|
@@ -48,6 +48,7 @@ describe("http-server request cancellation", () => {
|
|
|
48
48
|
invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown, c: unknown) =>
|
|
49
49
|
h.invoke(input, c as { cancellation?: any }),
|
|
50
50
|
emitEvent: () => {},
|
|
51
|
+
openSpan: async (base: unknown) => ({ context: base, settle: async () => {} }),
|
|
51
52
|
} as unknown as Parameters<typeof create>[1];
|
|
52
53
|
|
|
53
54
|
const resource = {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createCancellationSource } from "@telorun/sdk";
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { create } from "../src/http-api-controller.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Each inbound request opens a trace span attributed to the Http.Api and labelled
|
|
9
|
+
* with the route, under which the handler invoke nests. This drives the real
|
|
10
|
+
* controller (real Fastify) with a mock ResourceContext that captures the
|
|
11
|
+
* `openSpan` call, asserting the controller passes the route's `{kind,name}`,
|
|
12
|
+
* label and `{method,path}` attributes — and settles the span on success.
|
|
13
|
+
*
|
|
14
|
+
* The generic span mechanics (rooting a detached trace, nesting, trace id) are a
|
|
15
|
+
* kernel concern, tested there; this test owns only the HTTP-specific wiring.
|
|
16
|
+
*/
|
|
17
|
+
describe("http-server request span", () => {
|
|
18
|
+
it("opens a route-labelled span and dispatches the handler under it", async () => {
|
|
19
|
+
const spanCalls: any[] = [];
|
|
20
|
+
const settled: string[] = [];
|
|
21
|
+
|
|
22
|
+
const handler = {
|
|
23
|
+
async invoke(input: unknown) {
|
|
24
|
+
return { echoed: (input as { value: number }).value };
|
|
25
|
+
},
|
|
26
|
+
snapshot: () => ({}),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ctx = {
|
|
30
|
+
validateSchema: () => {},
|
|
31
|
+
resolveChildren: () => ({ kind: "JS.Script", name: "Echo" }),
|
|
32
|
+
moduleContext: { expandWith: (value: unknown) => value },
|
|
33
|
+
createCancellationSource: () => createCancellationSource(),
|
|
34
|
+
invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown) =>
|
|
35
|
+
h.invoke(input),
|
|
36
|
+
emitEvent: () => {},
|
|
37
|
+
openSpan: async (base: unknown, opts: unknown) => {
|
|
38
|
+
spanCalls.push(opts);
|
|
39
|
+
return {
|
|
40
|
+
context: base,
|
|
41
|
+
settle: async (outcome: string) => {
|
|
42
|
+
settled.push(outcome);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
} as unknown as Parameters<typeof create>[1];
|
|
47
|
+
|
|
48
|
+
const resource = {
|
|
49
|
+
metadata: { name: "EchoApi", module: "test" },
|
|
50
|
+
routes: [
|
|
51
|
+
{
|
|
52
|
+
request: { path: "/echo", method: "GET" },
|
|
53
|
+
handler,
|
|
54
|
+
inputs: { value: 1 },
|
|
55
|
+
returns: [{ status: 200, content: { "application/json": { body: { ok: true } } } }],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const api = await create(resource, ctx);
|
|
61
|
+
const app = Fastify({ logger: false });
|
|
62
|
+
api.register(app);
|
|
63
|
+
await app.listen({ host: "127.0.0.1", port: 0 });
|
|
64
|
+
const { port } = app.server.address() as AddressInfo;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`http://127.0.0.1:${port}/echo`);
|
|
68
|
+
expect(res.status).toBe(200);
|
|
69
|
+
await res.json();
|
|
70
|
+
|
|
71
|
+
expect(spanCalls).toHaveLength(1);
|
|
72
|
+
expect(spanCalls[0]).toEqual({
|
|
73
|
+
ref: { kind: "Http.Api", name: "EchoApi" },
|
|
74
|
+
label: "GET /echo",
|
|
75
|
+
attributes: { method: "GET", path: "/echo" },
|
|
76
|
+
});
|
|
77
|
+
expect(settled).toEqual(["ok"]);
|
|
78
|
+
} finally {
|
|
79
|
+
await app.close().catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|