@telorun/http-server 0.17.1 → 0.18.1

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 CHANGED
@@ -1,5 +1,46 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.18.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8a9b494: Inbound dispatch now goes through `ctx.rootContext()` — the route handler, the
8
+ `notFoundHandler`, and a `contentTypeParsers` parser all receive a context
9
+ minted for the request rather than whatever was ambient when the route was
10
+ registered.
11
+
12
+ No behaviour changes for an app today: a route handler already ran on a
13
+ per-request cancellation context, and the other two are dispatched from socket
14
+ callbacks where the ambient is empty anyway. What changes is that the guarantee
15
+ is now _stated_ rather than incidental. Execution zones (`kernel/specs/execution-zones.md`
16
+ §7) make it a conformance obligation on every inbound registrant, and the
17
+ analyzer's hard error on `trigger.inbound` edges — a zone requirement reaching
18
+ an HTTP route is `ZONE_REQUIREMENT_UNSATISFIED` — rests on it holding. Before,
19
+ it held because every shipped inbound kind happens to be a `Telo.Service`,
20
+ which is a property of those kinds rather than of the edge.
21
+
22
+ - @telorun/http-dispatch@0.4.2
23
+
24
+ ## 0.18.0
25
+
26
+ ### Minor Changes
27
+
28
+ - d23de89: Layered module artifacts: a published module is now one artifact of several layers instead of one tarball, and each layer is materialized only when something needs it.
29
+
30
+ `telo.yaml` gets its own layer, so reading a manifest no longer downloads (and discards) the whole payload. The rest of `files:` is partitioned into one layer per bundled-controller selector — `format` plus optional `os`/`arch`/`libc` PURL qualifiers — plus an `assets` layer for what the new optional `assets:` list claims and a `common` layer for everything else. A Node kernel never fetches a `napi` layer, a `linux/amd64` host never fetches the `darwin/arm64` binary, and an app that imports a module for its API alone never fetches its frontend.
31
+
32
+ This fixes a cold-start failure: bundled controllers used to resolve against an `oci://` base URI that was read as a filesystem path, because the payload was written to disk by a CLI hook running _after_ `kernel.load()`. The first run of any OCI-imported module with bundled controllers failed and the second succeeded. Controller layers now materialize at resolve time through a module-scoped `ModuleArtifact`, built during load where the pinned import ref and the verified manifest are both available — so verification stays anchored to the importer's `#sha256-` pin rather than to whatever is in the cache.
33
+
34
+ `ctx.resolveModuleFile(relative)` is the new, URI-returning way to reach a file that ships with a module; it materializes the asset layer on first use. `Http.Static`, `mcp-client`, `assert`'s manifest loader and `Test.Suite` all use it, which also fixes a silent bug where a non-`file://` module resolved a relative root against the process working directory and served the wrong directory instead of failing.
35
+
36
+ Also: `telo install --platform os/arch[/libc]` pre-fetches layers for a platform other than the build machine's, the layer index and selector grammar are specified normatively in `kernel/specs/module-artifact.md`, and the cross-process cache lock is shared between the npm loader and layer materialization instead of duplicated.
37
+
38
+ Modules published before layers keep resolving: the manifest read path still accepts a single-blob artifact, which contains `telo.yaml` — so nothing that ships no payload needs anything done to it, and npm-backed modules are entirely unaffected. What such an artifact cannot supply is a layer index, so a module that _does_ ship a payload resolves its manifest and then fails at the controller with an actionable "republish" error. That is the six modules shipping `files:` — `oauth-client`, `scheduler`, `kv-store-memory`, `kv-store-redis`, `kv-store-sql`, `idempotency` — which must be republished, with consumers bumping to the new versions.
39
+
40
+ ### Patch Changes
41
+
42
+ - @telorun/http-dispatch@0.4.2
43
+
3
44
  ## 0.17.1
4
45
 
5
46
  ### Patch Changes
package/README.md CHANGED
@@ -27,8 +27,8 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
27
27
  kind: Telo.Application
28
28
  metadata: { name: hello-http, version: 1.0.0 }
29
29
  imports:
30
- Http: std/http-server@0.19.1
31
- JS: std/javascript@0.7.0
30
+ Http: oci://ghcr.io/telorun/http-server@0.19.1
31
+ JS: oci://ghcr.io/telorun/javascript@0.7.0
32
32
  targets: [ !ref Server ]
33
33
  ---
34
34
  kind: Http.Server
@@ -139,7 +139,10 @@ export class HttpServerApi {
139
139
  // Open a request span rooting this request's own trace: the handler (and
140
140
  // its nested invokes) nest under it, and it's labelled with the route so
141
141
  // the trace shows the actual method+path, attributed to this Http.Api.
142
- const span = await this.ctx.openSpan(cancellation.context, {
142
+ // rootContext: an inbound registrant dispatches with a context that
143
+ // inherits nothing ambient — no zones, no trace parent, no caller token
144
+ // (the conformance obligation in kernel/specs/execution-zones.md §7).
145
+ const span = await this.ctx.openSpan(this.ctx.rootContext({ cancellation }), {
143
146
  ref: { kind: "Http.Api", name: this.apiName },
144
147
  label: `${route.request.method} ${route.request.path}`,
145
148
  attributes: { method: route.request.method, path: route.request.path },
@@ -77,7 +77,12 @@ class HttpServer {
77
77
  else if (parser) {
78
78
  this.app.addContentTypeParser(contentType, { parseAs: "string" }, async (_req, body, done) => {
79
79
  try {
80
- done(null, await parser.invoke({ body }));
80
+ // The bound entry point forwards every argument, so the root
81
+ // context rides in as the InvokeContext — §7's obligation for an
82
+ // inbound registrant. (This path still calls the instance
83
+ // directly rather than going through `invokeResolved`, so it is
84
+ // untraced; that predates zones and is tracked separately.)
85
+ done(null, await parser.invoke({ body }, this.ctx.rootContext()));
81
86
  }
82
87
  catch (err) {
83
88
  done(err, undefined);
@@ -230,7 +235,11 @@ class HttpServer {
230
235
  };
231
236
  let result;
232
237
  try {
233
- result = await this.ctx.invoke(handler.kind, handler.name, invokeInput);
238
+ // rootContext: an inbound registrant dispatches with a context that
239
+ // inherits nothing ambient (kernel/specs/execution-zones.md §7).
240
+ result = await this.ctx.invoke(handler.kind, handler.name, invokeInput, {
241
+ ctx: this.ctx.rootContext(),
242
+ });
234
243
  }
235
244
  catch (err) {
236
245
  if (!isInvokeError(err))
@@ -1,6 +1,6 @@
1
1
  import fastifyStatic from "@fastify/static";
2
2
  import { readFile } from "node:fs/promises";
3
- import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  /** Collapse a mount prefix to a single leading slash with no trailing slash;
6
6
  * an empty/`"/"` prefix becomes `"/"`. Unlike Http.Api (which returns `""` and
@@ -23,8 +23,8 @@ class HttpStatic {
23
23
  spaFallback;
24
24
  maxAge;
25
25
  immutable;
26
- constructor(resource, ctx) {
27
- this.root = resolveRoot(resource.root, ctx);
26
+ constructor(resource, root) {
27
+ this.root = root;
28
28
  this.index = resource.index ?? "index.html";
29
29
  this.spaFallback = resource.spaFallback === true;
30
30
  this.maxAge = resource.maxAge;
@@ -76,17 +76,33 @@ class HttpStatic {
76
76
  }, { prefix: mountPrefix });
77
77
  }
78
78
  }
79
- /** Resolve the asset root relative to the manifest that declared the resource, so
80
- * the frontend ships co-located with the app (same pattern as mcp-client). */
81
- function resolveRoot(root, ctx) {
79
+ /**
80
+ * Resolve the asset root against the declaring module's own directory, so the
81
+ * frontend ships co-located with the app.
82
+ *
83
+ * `ctx.resolveModuleFile` is the only correct way to do this: for a published
84
+ * module the manifest URL is not where its payload lives, and it also materializes
85
+ * the module's asset layer on first access. Deriving the directory from
86
+ * `moduleContext.source` by hand silently fell back to the process working
87
+ * directory for any non-`file://` module — serving the wrong files instead of
88
+ * failing.
89
+ */
90
+ async function resolveRoot(root, ctx) {
82
91
  if (isAbsolute(root))
83
92
  return root;
84
- const source = ctx.moduleContext.source;
85
- if (!source.startsWith("file://"))
86
- return resolve(root);
87
- const baseDir = dirname(fileURLToPath(source));
88
- return resolve(baseDir, root);
93
+ // A directory reference: the trailing slash keeps URL resolution from treating
94
+ // the last segment as a sibling file.
95
+ // A module whose files cannot be located raises its own actionable error from
96
+ // `resolveModuleFile` (naming republication), so this only guards a scheme that
97
+ // resolved fine but is not servable from disk.
98
+ const uri = await ctx.resolveModuleFile(root.endsWith("/") ? root : `${root}/`);
99
+ if (!uri.startsWith("file://")) {
100
+ throw new Error(`Http.Static root '${root}' resolved to '${uri}'. A static root must be a local ` +
101
+ `directory, and this runtime can only serve files from one.`);
102
+ }
103
+ // Strip the trailing separator the directory form introduced.
104
+ return resolve(fileURLToPath(uri));
89
105
  }
90
106
  export async function create(resource, ctx) {
91
- return new HttpStatic(resource, ctx);
107
+ return new HttpStatic(resource, await resolveRoot(resource.root, ctx));
92
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -55,7 +55,7 @@
55
55
  "@types/node": "^20.0.0",
56
56
  "typescript": "^5.0.0",
57
57
  "vitest": "^2.1.8",
58
- "@telorun/sdk": "0.56.0"
58
+ "@telorun/sdk": "0.67.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@telorun/sdk": "*"
@@ -174,7 +174,10 @@ export class HttpServerApi implements ResourceInstance {
174
174
  // Open a request span rooting this request's own trace: the handler (and
175
175
  // its nested invokes) nest under it, and it's labelled with the route so
176
176
  // the trace shows the actual method+path, attributed to this Http.Api.
177
- const span = await this.ctx.openSpan(cancellation.context, {
177
+ // rootContext: an inbound registrant dispatches with a context that
178
+ // inherits nothing ambient — no zones, no trace parent, no caller token
179
+ // (the conformance obligation in kernel/specs/execution-zones.md §7).
180
+ const span = await this.ctx.openSpan(this.ctx.rootContext({ cancellation }), {
178
181
  ref: { kind: "Http.Api", name: this.apiName },
179
182
  label: `${route.request.method} ${route.request.path}`,
180
183
  attributes: { method: route.request.method, path: route.request.path },
@@ -159,7 +159,12 @@ class HttpServer implements ResourceInstance {
159
159
  { parseAs: "string" },
160
160
  async (_req, body, done) => {
161
161
  try {
162
- done(null, await parser.invoke({ body }));
162
+ // The bound entry point forwards every argument, so the root
163
+ // context rides in as the InvokeContext — §7's obligation for an
164
+ // inbound registrant. (This path still calls the instance
165
+ // directly rather than going through `invokeResolved`, so it is
166
+ // untraced; that predates zones and is tracked separately.)
167
+ done(null, await parser.invoke({ body }, this.ctx.rootContext()));
163
168
  } catch (err) {
164
169
  done(err as Error, undefined);
165
170
  }
@@ -322,7 +327,11 @@ class HttpServer implements ResourceInstance {
322
327
 
323
328
  let result: any;
324
329
  try {
325
- result = await this.ctx.invoke(handler.kind, handler.name, invokeInput);
330
+ // rootContext: an inbound registrant dispatches with a context that
331
+ // inherits nothing ambient (kernel/specs/execution-zones.md §7).
332
+ result = await this.ctx.invoke(handler.kind, handler.name, invokeInput, {
333
+ ctx: this.ctx.rootContext(),
334
+ });
326
335
  } catch (err) {
327
336
  if (!isInvokeError(err)) throw err;
328
337
  return dispatchCatches(
@@ -2,7 +2,7 @@ import fastifyStatic from "@fastify/static";
2
2
  import { type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
3
3
  import { FastifyInstance } from "fastify";
4
4
  import { readFile } from "node:fs/promises";
5
- import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  type HttpStaticResource = RuntimeResource & {
@@ -35,8 +35,8 @@ class HttpStatic implements ResourceInstance {
35
35
  private readonly maxAge?: number;
36
36
  private readonly immutable: boolean;
37
37
 
38
- constructor(resource: HttpStaticResource, ctx: ResourceContext) {
39
- this.root = resolveRoot(resource.root, ctx);
38
+ constructor(resource: HttpStaticResource, root: string) {
39
+ this.root = root;
40
40
  this.index = resource.index ?? "index.html";
41
41
  this.spaFallback = resource.spaFallback === true;
42
42
  this.maxAge = resource.maxAge;
@@ -95,19 +95,38 @@ class HttpStatic implements ResourceInstance {
95
95
  }
96
96
  }
97
97
 
98
- /** Resolve the asset root relative to the manifest that declared the resource, so
99
- * the frontend ships co-located with the app (same pattern as mcp-client). */
100
- function resolveRoot(root: string, ctx: ResourceContext): string {
98
+ /**
99
+ * Resolve the asset root against the declaring module's own directory, so the
100
+ * frontend ships co-located with the app.
101
+ *
102
+ * `ctx.resolveModuleFile` is the only correct way to do this: for a published
103
+ * module the manifest URL is not where its payload lives, and it also materializes
104
+ * the module's asset layer on first access. Deriving the directory from
105
+ * `moduleContext.source` by hand silently fell back to the process working
106
+ * directory for any non-`file://` module — serving the wrong files instead of
107
+ * failing.
108
+ */
109
+ async function resolveRoot(root: string, ctx: ResourceContext): Promise<string> {
101
110
  if (isAbsolute(root)) return root;
102
- const source = ctx.moduleContext.source;
103
- if (!source.startsWith("file://")) return resolve(root);
104
- const baseDir = dirname(fileURLToPath(source));
105
- return resolve(baseDir, root);
111
+ // A directory reference: the trailing slash keeps URL resolution from treating
112
+ // the last segment as a sibling file.
113
+ // A module whose files cannot be located raises its own actionable error from
114
+ // `resolveModuleFile` (naming republication), so this only guards a scheme that
115
+ // resolved fine but is not servable from disk.
116
+ const uri = await ctx.resolveModuleFile(root.endsWith("/") ? root : `${root}/`);
117
+ if (!uri.startsWith("file://")) {
118
+ throw new Error(
119
+ `Http.Static root '${root}' resolved to '${uri}'. A static root must be a local ` +
120
+ `directory, and this runtime can only serve files from one.`,
121
+ );
122
+ }
123
+ // Strip the trailing separator the directory form introduced.
124
+ return resolve(fileURLToPath(uri));
106
125
  }
107
126
 
108
127
  export async function create(
109
128
  resource: HttpStaticResource,
110
129
  ctx: ResourceContext,
111
130
  ): Promise<ResourceInstance> {
112
- return new HttpStatic(resource, ctx);
131
+ return new HttpStatic(resource, await resolveRoot(resource.root, ctx));
113
132
  }
@@ -1,4 +1,9 @@
1
- import { ERR_INVOKE_CANCELLED, InvokeError, createCancellationSource } from "@telorun/sdk";
1
+ import {
2
+ ERR_INVOKE_CANCELLED,
3
+ InvokeError,
4
+ UNCANCELLABLE_CONTEXT,
5
+ createCancellationSource,
6
+ } from "@telorun/sdk";
2
7
  import Fastify from "fastify";
3
8
  import net from "node:net";
4
9
  import type { AddressInfo } from "node:net";
@@ -45,6 +50,12 @@ describe("http-server request cancellation", () => {
45
50
  ensureKindRef: () => ({ kind: "Test.Handler", name: "SlowWork" }),
46
51
  moduleContext: { expandWith: (value: unknown) => value },
47
52
  createCancellationSource: () => createCancellationSource(),
53
+ // As the kernel implements it: an inbound registrant dispatches through a
54
+ // context it minted rather than the ambient (execution-zones spec §7), and
55
+ // the request's cancellation token rides in on it — which is what makes
56
+ // the disconnect below reach the handler.
57
+ rootContext: (opts?: { cancellation?: { context: unknown } }) =>
58
+ opts?.cancellation?.context ?? UNCANCELLABLE_CONTEXT,
48
59
  invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown, c: unknown) =>
49
60
  h.invoke(input, c as { cancellation?: any }),
50
61
  emitEvent: () => {},
@@ -1,4 +1,4 @@
1
- import { createCancellationSource } from "@telorun/sdk";
1
+ import { UNCANCELLABLE_CONTEXT, createCancellationSource } from "@telorun/sdk";
2
2
  import Fastify from "fastify";
3
3
  import type { AddressInfo } from "node:net";
4
4
  import { describe, expect, it } from "vitest";
@@ -31,6 +31,10 @@ describe("http-server request span", () => {
31
31
  ensureKindRef: () => ({ kind: "JS.Script", name: "Echo" }),
32
32
  moduleContext: { expandWith: (value: unknown) => value },
33
33
  createCancellationSource: () => createCancellationSource(),
34
+ // The controller dispatches through a context it minted rather than the
35
+ // ambient — execution-zones spec §7 — so the mock must offer it.
36
+ rootContext: (opts?: { cancellation?: { context: unknown } }) =>
37
+ opts?.cancellation?.context ?? UNCANCELLABLE_CONTEXT,
34
38
  invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown) =>
35
39
  h.invoke(input),
36
40
  emitEvent: () => {},