@telorun/http-server 0.19.0 → 0.26.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 CHANGED
@@ -1,5 +1,92 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c8d457b: One value-type annotation: `x-telo-type` says what a value IS, and the
8
+ vocabulary is data both runtimes read.
9
+
10
+ Three annotations answered one question — _what is the value at this slot, beyond
11
+ what JSON Schema's `type` vocabulary can say?_ — and each answered it
12
+ differently: `x-telo-type: TcpPort` (a nominal brand from a closed kernel table),
13
+ `x-telo-binary: true` (raw bytes, the one annotation that emitted validation
14
+ code), `x-telo-stream: true` (a live handle, exempt from schema walks). They
15
+ differ in _posture_ toward the JSON Schema layer — refine, replace, exempt — not
16
+ in kind, so a fourth cost eleven files across four packages, and three defects
17
+ followed from the spread: an unrecognized brand degraded silently, a byte slot's
18
+ expression typed as `dyn` because nothing consulted `x-telo-binary`, and a module
19
+ string-matched the keyword because a module may import `@telorun/sdk` and there
20
+ was nothing there to read.
21
+
22
+ - **The vocabulary is DATA; the binding to a language is not.** A type is one
23
+ JSON file under `sdk/value-types/`, copied into the SDK by its `prepare` and
24
+ embedded by Rust with `include_str!` — the `analyzer/migrations/` arrangement,
25
+ for the same reason. An entry declares `name`, `representation` (`json` + a
26
+ `base`, or `instance` + a symbolic `binding`), `live`, `parameters` and a
27
+ `description`, and nothing about any runtime. Each runtime carries its own
28
+ table mapping `binding` to its own identity; a binding with **no row is a hard
29
+ startup error**, never a skipped assertion, because a type that cannot be
30
+ asserted would silently exempt every slot declaring it.
31
+ - **`registerTeloKeywords`** replaces five drifted AJV registration sites — the
32
+ analyzer's `createAjv` and the kernel's `schema-validator`, `resource-context`,
33
+ `observed-state` and `manifest-schemas`, which registered overlapping lists of
34
+ twelve, four, one and one. Drift there is not cosmetic: a keyword that emits
35
+ code was simply missing from any instance that forgot it, so one schema
36
+ validated two ways depending on which AJV saw it.
37
+ - **Exemption is a property of the TYPE, not of a position.** The old walk
38
+ neutralized only a key it found in a `properties` map, so an array-of-streams
39
+ element was reached and left constrained even though it descended into `items`.
40
+ Reading liveness off the declared type makes an item, a union branch and a
41
+ property one case. It is exemption from **validation**, never from **typing**:
42
+ a live type's arguments still travel through every schema-typing walk.
43
+ - **Value types are generic.** An entry declares named type parameters and the
44
+ annotation's object form supplies arguments — `{ name: Telo.Stream, of:
45
+ Telo.Bytes }`. An argument is a schema node, so it nests with no new grammar,
46
+ and a bare name is sugar for a node carrying only the annotation, normalized in
47
+ the single reader. Comparison is **covariant and gradual**: an omitted argument
48
+ is _any_ in both directions, so every producer and consumer that has not
49
+ declared an element keeps checking exactly as it did. A definite conflict is
50
+ `CEL_TYPE_ARGUMENT_MISMATCH`, reported where a produced value's schema meets a
51
+ consuming slot's — a step's `inputs:` against the invoked target's contract,
52
+ which is the one place both halves are in hand.
53
+ - **A shape is named with `!ref`**, Telo's one reference grammar, and `use:
54
+ schema` has been in the `x-telo-ref` vocabulary for exactly this relation all
55
+ along. The loader normalizes it to the canonical `telo:<module>/<Type>` `$ref`
56
+ — authoring surface and internal form, the split `resolveRefSentinels` and
57
+ `resolveSchemaRefKinds` already have. Normalizing rather than inlining is what
58
+ preserves schema identity (the compiled-validator cache is keyed on it) and
59
+ leaves a recursive shape expressible; carrying the owning module is what makes
60
+ resolution alias-aware, where matching a bare `metadata.name` across a
61
+ flattened list silently dropped the alias.
62
+ - **A tag's produced type is declared by its ENGINE.** `TemplatingEngine` gains
63
+ `producedType()`; `!include-bytes` declares `Telo.Bytes` and `!include-text`
64
+ declares `type: string`, and `substituteCelFields` loses its tag-name branch —
65
+ the only place a tag's produced type was written down, written in the consumer.
66
+ - **`X_TELO_TYPE_UNKNOWN`** (Levenshtein-suggested) and
67
+ **`X_TELO_TYPE_ARGUMENT_UNKNOWN`** replace the silent degrade, on every
68
+ schema-bearing field of every manifest — not only definition docs, since an
69
+ inline `inputType:` on an ordinary resource carries a schema too.
70
+ - **The migration selector gains a schema region.** `inSchema: true` bounds a
71
+ rule to the kernel's own schema-valued keys, and only with it may `inKind` /
72
+ `under` be `["*"]`, and only for a rule keyed on an `x-telo-*` annotation. That
73
+ pairing is the containment: an annotation keyword occurs in author-written
74
+ schema fragments inside ordinary resource documents, and that set of kinds is
75
+ open, so enumerating it would be both incomplete and a violation of the
76
+ topology-driven constraint.
77
+
78
+ **Breaking:** `x-telo-binary` and `x-telo-stream` are rewritten to `x-telo-type`
79
+ at load by the `normalize-value-types` migration, so every published manifest
80
+ keeps working and `telo migrate` repairs a file in place. `binaryKeyword` /
81
+ `isBinarySlot` / `X_TELO_BINARY` are removed from `@telorun/analyzer` in favour
82
+ of `registerTeloKeywords` and the SDK accessors; `withStreamPropertiesSkipped` is
83
+ now `withLiveValuesSkipped`. Value brands are `Telo.`-qualified (`TcpPort` →
84
+ `Telo.TcpPort`), which the same migration rewrites.
85
+
86
+ ### Patch Changes
87
+
88
+ - @telorun/http-dispatch@0.4.2
89
+
3
90
  ## 0.19.0
4
91
 
5
92
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { CatchEntry, dispatchCatches, dispatchReturns, ReturnEntry, validateNoContentTypeHeader, validateStreamWhenDoesNotReferenceResult, } from "@telorun/http-dispatch";
3
- import { InvokeError, isCancellationError, isInvokeError, Ref, Stream, } from "@telorun/sdk";
3
+ import { InvokeError, isCancellationError, isInvokeError, isLiveSlot, Ref, Stream, } from "@telorun/sdk";
4
4
  import { fastifyReplySink } from "./fastify-reply-sink.js";
5
5
  const HttpApiRouteManifest = Type.Object({
6
6
  request: Type.Object({
@@ -61,10 +61,16 @@ export class HttpServerApi {
61
61
  const handlerName = handlerRef?.name ?? "";
62
62
  const translatedPath = joinMountPath(prefix, translateOpenApiPath(route.request.path));
63
63
  const schema = { response: {} };
64
- // A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
64
+ // A live-typed body is delivered as a raw `Stream<Uint8Array>` (see the
65
65
  // server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
66
66
  // body-schema registration and wrap the raw request stream in the handler.
67
- const streamBody = route.request.schema?.body?.["x-telo-stream"] === true;
67
+ //
68
+ // Read through the SDK accessor rather than by string-matching the keyword.
69
+ // A module may import `@telorun/sdk` and nothing else, and before the value
70
+ // types were unified there was nothing on that surface to read — so this was
71
+ // a literal key comparison, the same shape as the four surfaces that
72
+ // string-matched `x-telo-ref` before `ref-slot.ts`.
73
+ const streamBody = isLiveSlot(route.request.schema?.body);
68
74
  if (route.request.schema?.query)
69
75
  schema.querystring = route.request.schema.query;
70
76
  if (route.request.schema?.params)
@@ -164,8 +164,35 @@ class HttpServer {
164
164
  }, { eventName: "http.server.request" });
165
165
  });
166
166
  }
167
+ /**
168
+ * Accept a multipart body out of the box.
169
+ *
170
+ * Fastify ships parsers for JSON and urlencoded and nothing else, so a route
171
+ * receiving a file upload answered 415 before any handler ran — a failure that
172
+ * names a media type the author DID send and points at no fix. Every server
173
+ * taking an upload had to discover `contentTypeParsers` first.
174
+ *
175
+ * Registered as RAW BYTES rather than a string, because that is what a
176
+ * multipart body is: decoding it as text corrupts every binary part, and the
177
+ * parts are the point. The handler receives the undrained request stream, which
178
+ * `Multipart.Decoder` consumes.
179
+ *
180
+ * Registered UNCONDITIONALLY, as a regex. Fastify keys a duplicate on the exact
181
+ * string (or the regex's `toString()`) and consults its string parsers before
182
+ * its regex ones, so a declared `multipart/form-data` neither collides with this
183
+ * nor is shadowed by it — it simply wins for its own type. Skipping the default
184
+ * whenever any multipart parser was declared would instead disable it for the
185
+ * SIBLING subtypes the author did not customize, so declaring a parser for
186
+ * `form-data` would silently restore the 415 for `related` and `mixed`.
187
+ */
188
+ installDefaultMultipartParser() {
189
+ this.app.addContentTypeParser(/^multipart\//, (_req, payload, done) => {
190
+ done(null, payload);
191
+ });
192
+ }
167
193
  async setupPlugins() {
168
194
  this.installRequestLogging();
195
+ this.installDefaultMultipartParser();
169
196
  for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
170
197
  if (stream) {
171
198
  // Raw passthrough: omit `parseAs` so Fastify hands the handler the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.19.0",
3
+ "version": "0.26.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -49,13 +49,13 @@
49
49
  "ajv": "^8.17.1",
50
50
  "ajv-formats": "^3.0.1",
51
51
  "fastify": "^5.7.2",
52
- "@telorun/http-dispatch": "0.4.2"
52
+ "@telorun/http-dispatch": "0.11.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/node": "^20.0.0",
56
56
  "typescript": "^5.0.0",
57
57
  "vitest": "^2.1.8",
58
- "@telorun/sdk": "0.70.0"
58
+ "@telorun/sdk": "0.75.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@telorun/sdk": "*"
@@ -14,6 +14,7 @@ import {
14
14
  InvokeError,
15
15
  isCancellationError,
16
16
  isInvokeError,
17
+ isLiveSlot,
17
18
  KindRef,
18
19
  Ref,
19
20
  ResourceContext,
@@ -95,10 +96,16 @@ export class HttpServerApi implements ResourceInstance {
95
96
 
96
97
  const schema: any = { response: {} };
97
98
 
98
- // A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
99
+ // A live-typed body is delivered as a raw `Stream<Uint8Array>` (see the
99
100
  // server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
100
101
  // body-schema registration and wrap the raw request stream in the handler.
101
- const streamBody = route.request.schema?.body?.["x-telo-stream"] === true;
102
+ //
103
+ // Read through the SDK accessor rather than by string-matching the keyword.
104
+ // A module may import `@telorun/sdk` and nothing else, and before the value
105
+ // types were unified there was nothing on that surface to read — so this was
106
+ // a literal key comparison, the same shape as the four surfaces that
107
+ // string-matched `x-telo-ref` before `ref-slot.ts`.
108
+ const streamBody = isLiveSlot(route.request.schema?.body);
102
109
 
103
110
  if (route.request.schema?.query) schema.querystring = route.request.schema.query;
104
111
  if (route.request.schema?.params) schema.params = route.request.schema.params;
@@ -261,8 +261,36 @@ class HttpServer implements ResourceInstance {
261
261
  });
262
262
  }
263
263
 
264
+ /**
265
+ * Accept a multipart body out of the box.
266
+ *
267
+ * Fastify ships parsers for JSON and urlencoded and nothing else, so a route
268
+ * receiving a file upload answered 415 before any handler ran — a failure that
269
+ * names a media type the author DID send and points at no fix. Every server
270
+ * taking an upload had to discover `contentTypeParsers` first.
271
+ *
272
+ * Registered as RAW BYTES rather than a string, because that is what a
273
+ * multipart body is: decoding it as text corrupts every binary part, and the
274
+ * parts are the point. The handler receives the undrained request stream, which
275
+ * `Multipart.Decoder` consumes.
276
+ *
277
+ * Registered UNCONDITIONALLY, as a regex. Fastify keys a duplicate on the exact
278
+ * string (or the regex's `toString()`) and consults its string parsers before
279
+ * its regex ones, so a declared `multipart/form-data` neither collides with this
280
+ * nor is shadowed by it — it simply wins for its own type. Skipping the default
281
+ * whenever any multipart parser was declared would instead disable it for the
282
+ * SIBLING subtypes the author did not customize, so declaring a parser for
283
+ * `form-data` would silently restore the 415 for `related` and `mixed`.
284
+ */
285
+ private installDefaultMultipartParser(): void {
286
+ this.app.addContentTypeParser(/^multipart\//, (_req, payload, done) => {
287
+ done(null, payload);
288
+ });
289
+ }
290
+
264
291
  private async setupPlugins() {
265
292
  this.installRequestLogging();
293
+ this.installDefaultMultipartParser();
266
294
  for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
267
295
  if (stream) {
268
296
  // Raw passthrough: omit `parseAs` so Fastify hands the handler the