@techspokes/typescript-wsdl-client 0.16.1 → 0.17.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.
Files changed (71) hide show
  1. package/README.md +5 -0
  2. package/dist/app/generateApp.d.ts.map +1 -1
  3. package/dist/app/generateApp.js +1 -0
  4. package/dist/cli.js +46 -2
  5. package/dist/client/generateClient.d.ts.map +1 -1
  6. package/dist/client/generateClient.js +62 -6
  7. package/dist/client/generateOperations.d.ts.map +1 -1
  8. package/dist/client/generateOperations.js +27 -4
  9. package/dist/compiler/schemaCompiler.d.ts +40 -11
  10. package/dist/compiler/schemaCompiler.d.ts.map +1 -1
  11. package/dist/compiler/schemaCompiler.js +81 -6
  12. package/dist/compiler/shapeResolver.d.ts +18 -0
  13. package/dist/compiler/shapeResolver.d.ts.map +1 -0
  14. package/dist/compiler/shapeResolver.js +280 -0
  15. package/dist/gateway/generateGateway.d.ts.map +1 -1
  16. package/dist/gateway/generateGateway.js +2 -1
  17. package/dist/gateway/generators.d.ts +13 -1
  18. package/dist/gateway/generators.d.ts.map +1 -1
  19. package/dist/gateway/generators.js +98 -13
  20. package/dist/gateway/helpers.d.ts +16 -0
  21. package/dist/gateway/helpers.d.ts.map +1 -1
  22. package/dist/gateway/helpers.js +1 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -1
  26. package/dist/openapi/generateOpenAPI.d.ts.map +1 -1
  27. package/dist/openapi/generateOpenAPI.js +28 -0
  28. package/dist/pipeline.d.ts +13 -0
  29. package/dist/pipeline.d.ts.map +1 -1
  30. package/dist/pipeline.js +17 -1
  31. package/dist/runtime/ndjson.d.ts +24 -0
  32. package/dist/runtime/ndjson.d.ts.map +1 -0
  33. package/dist/runtime/ndjson.js +30 -0
  34. package/dist/runtime/streamXml.d.ts +45 -0
  35. package/dist/runtime/streamXml.d.ts.map +1 -0
  36. package/dist/runtime/streamXml.js +212 -0
  37. package/dist/test/generators.d.ts.map +1 -1
  38. package/dist/test/generators.js +50 -0
  39. package/dist/test/mockData.d.ts +6 -0
  40. package/dist/test/mockData.d.ts.map +1 -1
  41. package/dist/test/mockData.js +6 -2
  42. package/dist/util/cli.d.ts.map +1 -1
  43. package/dist/util/cli.js +3 -1
  44. package/dist/util/runtimeSource.d.ts +2 -0
  45. package/dist/util/runtimeSource.d.ts.map +1 -0
  46. package/dist/util/runtimeSource.js +38 -0
  47. package/dist/util/streamConfig.d.ts +59 -0
  48. package/dist/util/streamConfig.d.ts.map +1 -0
  49. package/dist/util/streamConfig.js +230 -0
  50. package/docs/README.md +1 -0
  51. package/docs/api-reference.md +146 -0
  52. package/docs/architecture.md +27 -5
  53. package/docs/cli-reference.md +30 -0
  54. package/docs/concepts.md +51 -0
  55. package/docs/configuration.md +40 -0
  56. package/docs/decisions/002-streamable-responses.md +308 -0
  57. package/docs/gateway-guide.md +37 -0
  58. package/docs/generated-code.md +21 -0
  59. package/docs/migration-playbook.md +33 -0
  60. package/docs/migration.md +31 -6
  61. package/docs/output-anatomy.md +49 -0
  62. package/docs/production.md +32 -0
  63. package/docs/start-here.md +33 -0
  64. package/docs/supported-patterns.md +2 -0
  65. package/docs/testing.md +14 -0
  66. package/docs/troubleshooting.md +18 -0
  67. package/package.json +5 -2
  68. package/src/runtime/clientStreamMethods.tpl.txt +183 -0
  69. package/src/runtime/ndjson.ts +32 -0
  70. package/src/runtime/operationsStreamHelper.tpl.txt +13 -0
  71. package/src/runtime/streamXml.ts +293 -0
package/docs/testing.md CHANGED
@@ -246,6 +246,20 @@ const client = createMockClient({
246
246
 
247
247
  Mock responses use the pre-unwrap SOAP wrapper shape. The generated `unwrapArrayWrappers()` function handles conversion at runtime.
248
248
 
249
+ For operations opted in via `--stream-config`, the mock returns `records: AsyncIterable<RecordType>` (via a small `asyncIterableOf` helper) and the generated happy-path test asserts on the NDJSON content-type and parseable record lines. Override a stream op with a multi-record iterable to exercise downstream backpressure:
250
+
251
+ ```typescript
252
+ const client = createMockClient({
253
+ UnitDescriptiveInfoStream: async () => ({
254
+ records: (async function* () {
255
+ yield { Id: "1", Name: "Villa A" };
256
+ yield { Id: "2", Name: "Villa B" };
257
+ })(),
258
+ headers: {},
259
+ }),
260
+ });
261
+ ```
262
+
249
263
  ## For Consumer Projects
250
264
 
251
265
  If you're using wsdl-tsc as a dependency and want to test your integration:
@@ -17,6 +17,12 @@ See [README](../README.md) for quick start and [CLI Reference](cli-reference.md)
17
17
  | TypeScript compilation errors | Check --import-extensions matches your tsconfig moduleResolution |
18
18
  | Gateway validation failures | Ensure OpenAPI has valid $ref paths and all schemas in components.schemas |
19
19
  | Catalog file not found | Catalog defaults to output directory; use --catalog-file to specify |
20
+ | Stream config references unknown operation | Operation name must match the WSDL exactly; check spelling and casing |
21
+ | Stream record type not found | `recordType` must exist in the main catalog or a companion `shapeCatalog` must supply it; confirm the companion WSDL compiles cleanly in isolation |
22
+ | Structural collision between main and companion catalog | Two types share a name but differ structurally; rename in the companion source or point `recordType` at a distinct subtree |
23
+ | NDJSON response ends abruptly | Mid-stream upstream error per the terminal-error policy; check gateway logs for the classified error |
24
+ | Stream recordPath does not match | SAX matching is positional and case-sensitive; verify duplicate local-name segments are spelled exactly |
25
+ | Stream client throws "stream request failed" | The upstream SOAP endpoint rejected the hand-built envelope; check `requestRaw` on the response and verify SOAP action and namespaces match the WSDL binding |
20
26
 
21
27
  ## SOAP Wire Logging
22
28
 
@@ -68,3 +74,15 @@ Or inspect catalog from client generation:
68
74
  ```bash
69
75
  cat ./src/services/hotel/catalog.json | jq '.types'
70
76
  ```
77
+
78
+ ## Streaming Debug
79
+
80
+ Inspect stream metadata on the compiled catalog to confirm the config was parsed and applied:
81
+
82
+ ```bash
83
+ cat ./src/services/hotel/catalog.json | jq '.operations[] | select(.stream) | {name, stream}'
84
+ ```
85
+
86
+ Each entry shows the normalized `OperationStreamMetadata` (mode, format, mediaType, recordPath, recordTypeName, and any `shapeCatalogName`). If an expected operation is missing, the config either did not match the WSDL operation name or was not passed to the generation command.
87
+
88
+ For record-path and chunk-boundary issues, the reference integration test pattern lives in `test/integration/stream-end-to-end.test.ts` and the SAX record matcher is exercised by `test/unit/stream-xml.test.ts`. Running them against a local fixture isolates whether the issue is in the config, the parser, or the upstream SOAP server.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techspokes/typescript-wsdl-client",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Turn legacy WSDL/SOAP services into typed TypeScript clients, OpenAPI 3.1 specs, and production-ready Fastify REST gateways. Built for enterprise SOAP modernization.",
5
5
  "keywords": [
6
6
  "wsdl",
@@ -53,7 +53,9 @@
53
53
  "docs",
54
54
  "README.md",
55
55
  "LICENSE",
56
- "llms.txt"
56
+ "llms.txt",
57
+ "src/runtime/*.ts",
58
+ "src/runtime/*.tpl.txt"
57
59
  ],
58
60
  "scripts": {
59
61
  "dev": "tsx src/cli.ts",
@@ -93,6 +95,7 @@
93
95
  "@apidevtools/swagger-parser": "^12.1.0",
94
96
  "fast-xml-parser": "^5.7.1",
95
97
  "js-yaml": "^4.1.1",
98
+ "saxes": "^6.0.0",
96
99
  "soap": "^1.9.1",
97
100
  "yargs": "^18.0.0"
98
101
  },
@@ -0,0 +1,183 @@
1
+
2
+ /**
3
+ * Streaming transport for operations flagged with a stream configuration.
4
+ *
5
+ * node-soap buffers the full response before invoking the operation callback
6
+ * (verified empirically in ADR-002 / phase-0 research), so this method
7
+ * bypasses node-soap and POSTs a hand-built SOAP envelope directly, then
8
+ * pipes the response body through the SAX-driven record parser.
9
+ */
10
+ protected async callStream<RequestType, RecordType, HeadersType>(
11
+ args: RequestType,
12
+ operation: string,
13
+ requestType: string | undefined,
14
+ recordTypeName: string,
15
+ inputElementLocal: string,
16
+ inputElementNs: string,
17
+ soapAction: string,
18
+ recordPath: string[]
19
+ ): Promise<StreamOperationResponse<RecordType, HeadersType>> {
20
+ const client = await this.soapClient();
21
+ const endpoint = this.resolveStreamEndpoint(client);
22
+ const envelope = this.buildSoapEnvelope(args, operation, requestType, inputElementLocal, inputElementNs);
23
+ const res = await fetch(endpoint, {
24
+ method: "POST",
25
+ headers: {
26
+ "content-type": "text/xml; charset=utf-8",
27
+ "soapaction": '"' + soapAction + '"',
28
+ },
29
+ body: envelope,
30
+ });
31
+ if (!res.ok) {
32
+ throw new Error(
33
+ operation + " stream request failed: " + res.status + " " + res.statusText
34
+ );
35
+ }
36
+ if (!res.body) {
37
+ throw new Error(operation + " stream request returned an empty response body");
38
+ }
39
+ const headers: Record<string, string> = {};
40
+ res.headers.forEach((value, key) => { headers[key] = value; });
41
+ const spec: RecordParseSpec = {
42
+ recordPath,
43
+ recordTypeName,
44
+ attributesKey: this.attributesKeyOut,
45
+ childType: this.dataTypes?.ChildrenTypes,
46
+ };
47
+ const records = parseRecords<RecordType>(
48
+ this.webStreamToAsyncIterable(res.body),
49
+ spec
50
+ );
51
+ return {
52
+ records,
53
+ headers: headers as unknown as HeadersType,
54
+ requestRaw: envelope,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Walk the node-soap WSDL descriptor to locate the first service port's
60
+ * SOAP address. Falls back to the \`source\` URL when the descriptor is
61
+ * unavailable (e.g., \`source\` was given as a direct endpoint URL).
62
+ */
63
+ protected resolveStreamEndpoint(client: soap.Client): string {
64
+ const services = (client as any)?.wsdl?.definitions?.services ?? {};
65
+ for (const svc of Object.values(services)) {
66
+ const ports = (svc as any)?.ports ?? {};
67
+ for (const port of Object.values(ports)) {
68
+ const location = (port as any)?.location;
69
+ if (typeof location === "string" && location) return location;
70
+ }
71
+ }
72
+ return this.source;
73
+ }
74
+
75
+ /**
76
+ * Build a SOAP 1.1 envelope around the operation's input element. Uses the
77
+ * existing attributes / children metadata so attribute bags render as XML
78
+ * attributes and child properties render as nested elements.
79
+ */
80
+ protected buildSoapEnvelope(
81
+ args: unknown,
82
+ operation: string,
83
+ requestType: string | undefined,
84
+ inputElementLocal: string,
85
+ inputElementNs: string
86
+ ): string {
87
+ const body = this.toXmlElement(args, requestType, inputElementLocal, inputElementNs);
88
+ return '<?xml version="1.0" encoding="utf-8"?>' +
89
+ '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' +
90
+ '<soap:Body>' + body + '</soap:Body>' +
91
+ '</soap:Envelope>';
92
+ }
93
+
94
+ /**
95
+ * Serialize a value as a single XML element. The element namespace is only
96
+ * emitted when \`namespace\` is provided (i.e., the envelope's top-level body
97
+ * element). Nested elements inherit the namespace via XML scoping.
98
+ */
99
+ protected toXmlElement(
100
+ value: unknown,
101
+ typeName: string | undefined,
102
+ elementName: string,
103
+ namespace?: string
104
+ ): string {
105
+ const nsAttr = namespace ? ' xmlns="' + this.escapeXml(namespace) + '"' : "";
106
+ if (value === null || value === undefined) {
107
+ return '<' + elementName + nsAttr +
108
+ ' xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>';
109
+ }
110
+ if (typeof value !== "object") {
111
+ return '<' + elementName + nsAttr + '>' + this.escapeXml(String(value)) + '</' + elementName + '>';
112
+ }
113
+ if (Array.isArray(value)) {
114
+ return value.map((v) => this.toXmlElement(v, typeName, elementName, namespace)).join("");
115
+ }
116
+ const obj = value as Record<string, unknown>;
117
+ const attributesList = (typeName && this.dataTypes?.Attributes?.[typeName]) || [];
118
+ const childrenTypes = (typeName && this.dataTypes?.ChildrenTypes?.[typeName]) || {};
119
+ const attrPairs: Array<[string, string]> = [];
120
+ const bagIn = (obj as any)[this.attributesKeyIn] ?? (obj as any)["attributes"];
121
+ if (bagIn && typeof bagIn === "object") {
122
+ for (const [k, v] of Object.entries(bagIn)) attrPairs.push([k, this.normalizeAttr(v)]);
123
+ }
124
+ const childParts: string[] = [];
125
+ let textContent: string | undefined;
126
+ if ("$value" in obj) textContent = String(obj.$value ?? "");
127
+ for (const [k, v] of Object.entries(obj)) {
128
+ if (k === "$value" || k === this.attributesKeyIn || k === "attributes") continue;
129
+ if ((attributesList as readonly string[]).includes(k)) {
130
+ attrPairs.push([k, this.normalizeAttr(v)]);
131
+ continue;
132
+ }
133
+ const childTypeRaw = (childrenTypes as Record<string, string>)[k];
134
+ const childType = childTypeRaw?.endsWith("[]") ? childTypeRaw.slice(0, -2) : childTypeRaw;
135
+ if (Array.isArray(v)) {
136
+ for (const item of v) childParts.push(this.toXmlElement(item, childType, k));
137
+ } else {
138
+ childParts.push(this.toXmlElement(v, childType, k));
139
+ }
140
+ }
141
+ const attrStr = attrPairs.map(([k, val]) => ' ' + k + '="' + this.escapeXml(val) + '"').join("");
142
+ if (childParts.length === 0 && textContent === undefined) {
143
+ return '<' + elementName + nsAttr + attrStr + '/>';
144
+ }
145
+ const inner = childParts.join("") + (textContent !== undefined ? this.escapeXml(textContent) : "");
146
+ return '<' + elementName + nsAttr + attrStr + '>' + inner + '</' + elementName + '>';
147
+ }
148
+
149
+ protected normalizeAttr(v: unknown): string {
150
+ if (v === null || v === undefined) return "";
151
+ if (typeof v === "boolean") return v ? "true" : "false";
152
+ if (typeof v === "number") return Number.isFinite(v) ? String(v) : "";
153
+ return String(v);
154
+ }
155
+
156
+ protected escapeXml(s: string): string {
157
+ return s
158
+ .replace(/&/g, "&amp;")
159
+ .replace(/</g, "&lt;")
160
+ .replace(/>/g, "&gt;")
161
+ .replace(/"/g, "&quot;")
162
+ .replace(/'/g, "&apos;");
163
+ }
164
+
165
+ /**
166
+ * Iterate an uploaded web ReadableStream as Uint8Array chunks. Node 20+
167
+ * ReadableStream implements Symbol.asyncIterator natively; this helper
168
+ * normalizes the types so TypeScript can drive it through \`for await\`.
169
+ */
170
+ protected async *webStreamToAsyncIterable(
171
+ body: ReadableStream<Uint8Array>
172
+ ): AsyncIterable<Uint8Array> {
173
+ const reader = body.getReader();
174
+ try {
175
+ while (true) {
176
+ const {value, done} = await reader.read();
177
+ if (done) return;
178
+ if (value) yield value;
179
+ }
180
+ } finally {
181
+ try { reader.releaseLock(); } catch { /* noop */ }
182
+ }
183
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * NDJSON adapter for record iterables.
3
+ *
4
+ * Phase 3 of ADR-002. Given an `AsyncIterable<T>` (typically the output of
5
+ * `parseRecords`), produce a Node `Readable` that emits one JSON-encoded line
6
+ * per record and respects downstream backpressure.
7
+ *
8
+ * Terminal-error policy (Q3 resolved): the stream aborts on source errors.
9
+ * Before-first-byte errors surface as the stream's `error` event before any
10
+ * bytes are pushed, so Fastify can translate them into a normal JSON error
11
+ * envelope. Errors after the first byte propagate as `error` events too, but
12
+ * callers should treat them as a truncated response.
13
+ */
14
+ import {Readable} from "node:stream";
15
+
16
+ /**
17
+ * Wrap an async iterable of records in a Node `Readable` stream that emits
18
+ * NDJSON (one JSON document per line, LF-terminated). Downstream backpressure
19
+ * is honored via `Readable.from`'s default behavior: the iterator's `next()`
20
+ * is not called until the internal buffer has room.
21
+ *
22
+ * Source errors are forwarded to the returned stream's `error` event.
23
+ */
24
+ export function toNdjson<T>(records: AsyncIterable<T>): Readable {
25
+ return Readable.from(encode(records), {objectMode: false, encoding: "utf-8"});
26
+ }
27
+
28
+ async function* encode<T>(records: AsyncIterable<T>): AsyncIterable<string> {
29
+ for await (const record of records) {
30
+ yield JSON.stringify(record) + "\n";
31
+ }
32
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Response shape for streaming __CLIENT_NAME__ operations. `records` is a
3
+ * single-pass async iterable of parsed record objects; iteration pulls bytes
4
+ * from the upstream SOAP response on demand. `headers` is the HTTP response
5
+ * header map captured before the first record is parsed. `requestRaw`, when
6
+ * populated, contains the serialized SOAP envelope that was sent upstream.
7
+ */
8
+ export type StreamOperationResponse<RecordType, HeadersType = Record<string, unknown>> = {
9
+ records: AsyncIterable<RecordType>;
10
+ headers: HeadersType;
11
+ requestRaw?: string;
12
+ };
13
+
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Streaming SOAP-payload to record iterator.
3
+ *
4
+ * Phase 3 of ADR-002. Driven by saxes (chunk-boundary safe — proven in
5
+ * test/research/sax-record-path.test.ts) and the compiled-catalog metadata
6
+ * that the buffered client already relies on. The parser accepts an async
7
+ * iterable of bytes/strings (typically an upstream SOAP HTTP response) and
8
+ * yields fully-materialized record objects as the corresponding end tags
9
+ * close. Consumers never see partial records.
10
+ *
11
+ * Open questions resolved here:
12
+ * Q3 (terminal error policy): stream aborts. Errors that happen before the
13
+ * first record bubble out as a rejected promise from the first
14
+ * iterator.next(). Errors after a record was emitted throw from a
15
+ * later iterator.next() — callers are expected to treat that as a
16
+ * truncated stream.
17
+ * Q4 (saxes placement): runtime dependency of the wsdl-tsc package; the
18
+ * generated client imports the emitted copy of this module and
19
+ * inherits saxes via its own dependency tree.
20
+ */
21
+ import {SaxesParser, type SaxesTagPlain} from "saxes";
22
+
23
+ /**
24
+ * Catalog-driven parse specification. `recordPath` is an ordered XML element
25
+ * path from the SOAP body payload down to the repeated record element. The
26
+ * path is matched as a suffix of the open-tag stack, so callers may either
27
+ * pre-strip the SOAP envelope or feed the entire response body.
28
+ *
29
+ * Duplicate local names in `recordPath` are supported and expected (Escapia's
30
+ * EVRN content service nests two elements named `EVRN_UnitDescriptiveInfoRS`).
31
+ */
32
+ export interface RecordParseSpec {
33
+ recordPath: string[];
34
+ /** TypeScript type name of the record, used to look up propMeta. */
35
+ recordTypeName: string;
36
+ /** Attribute bag key to stash XML attributes under. Defaults to "$attributes". */
37
+ attributesKey?: string;
38
+ /**
39
+ * Compiled-catalog child-type map: `childType[typeName][propName] = tsType`.
40
+ * Used to descend into nested complex types and to detect array-valued
41
+ * props (trailing `[]`). Optional — absent means occurrence-based array
42
+ * detection only.
43
+ */
44
+ childType?: Record<string, Record<string, string>>;
45
+ /**
46
+ * Compiled-catalog prop-meta map: carries min/max/nillable/declaredType.
47
+ * When present, `max > 1` or `max === "unbounded"` drives array emission
48
+ * even for props that happen to occur just once in a given record.
49
+ */
50
+ propMeta?: Record<string, Record<string, PropMeta>>;
51
+ }
52
+
53
+ export interface PropMeta {
54
+ min?: number;
55
+ max?: number | "unbounded";
56
+ nillable?: boolean;
57
+ declaredType?: string;
58
+ }
59
+
60
+ /**
61
+ * Consume an async iterable of XML bytes/strings and yield parsed records.
62
+ *
63
+ * The returned iterator is single-pass; iteration must complete (or be
64
+ * interrupted by the consumer via `break`/`return`) before the upstream
65
+ * source is released. Errors from the source or from the SAX parser abort
66
+ * the iteration via a rejected `next()`.
67
+ */
68
+ export async function* parseRecords<T = unknown>(
69
+ source: AsyncIterable<string | Uint8Array>,
70
+ spec: RecordParseSpec,
71
+ ): AsyncIterable<T> {
72
+ const parser = new SaxesParser({xmlns: false, position: false});
73
+ const recordPath = spec.recordPath;
74
+ if (recordPath.length === 0) {
75
+ throw new Error("parseRecords: recordPath must not be empty");
76
+ }
77
+ const attrsKey = spec.attributesKey ?? "$attributes";
78
+
79
+ // Global tag stack, maintained across the entire document.
80
+ const stack: string[] = [];
81
+ // Records materialized during the current chunk write, awaiting yield.
82
+ const pending: T[] = [];
83
+ // When a parser.on('error') fires we buffer the error for re-throw on the
84
+ // next yield cycle; saxes emits 'error' and continues unless we stop it.
85
+ let parseError: Error | null = null;
86
+
87
+ // While we're inside the record element (stack tail matches recordPath),
88
+ // we maintain a stack of "open" element nodes capturing their XML shape.
89
+ interface OpenNode {
90
+ obj: Record<string, unknown>;
91
+ /** Type name for `childType`/`propMeta` lookup on direct children. */
92
+ typeName: string | null;
93
+ /** Name this node was opened as, in the parent object. null for the record root. */
94
+ propName: string | null;
95
+ /** Accumulated text content (including CDATA). */
96
+ textBuf: string[];
97
+ /** True once we've seen at least one child element — distinguishes leaves from containers. */
98
+ hadChildren: boolean;
99
+ /** xsi:nil="true" marker → materialize as null regardless of content. */
100
+ isNil: boolean;
101
+ }
102
+ const openNodes: OpenNode[] = [];
103
+
104
+ parser.on("opentag", (tag: SaxesTagPlain) => {
105
+ stack.push(tag.name);
106
+ if (openNodes.length === 0) {
107
+ // Not yet inside a record. Check whether entering the path tail.
108
+ if (tailMatches(stack, recordPath)) {
109
+ openNodes.push(newNode(tag, spec.recordTypeName, null, attrsKey));
110
+ }
111
+ return;
112
+ }
113
+ // Inside a record: descend.
114
+ const parent = openNodes[openNodes.length - 1];
115
+ parent.hadChildren = true;
116
+ const parentType = parent.typeName;
117
+ const childTypeRaw = parentType ? spec.childType?.[parentType]?.[tag.name] : undefined;
118
+ const childTypeName = childTypeRaw ? stripArraySuffix(childTypeRaw) : null;
119
+ openNodes.push(newNode(tag, childTypeName, tag.name, attrsKey));
120
+ });
121
+
122
+ const appendText = (t: string) => {
123
+ if (openNodes.length > 0) openNodes[openNodes.length - 1].textBuf.push(t);
124
+ };
125
+ parser.on("text", appendText);
126
+ parser.on("cdata", appendText);
127
+
128
+ parser.on("closetag", (_tag: SaxesTagPlain) => {
129
+ if (openNodes.length > 0) {
130
+ const closing = openNodes.pop()!;
131
+ const value = materialize(closing, attrsKey);
132
+ if (openNodes.length === 0) {
133
+ // We just closed the record root. Stack tail must match once more.
134
+ if (tailMatches(stack, recordPath)) {
135
+ pending.push(value as T);
136
+ }
137
+ } else {
138
+ assignChild(openNodes[openNodes.length - 1], closing.propName!, value, spec);
139
+ }
140
+ }
141
+ stack.pop();
142
+ });
143
+
144
+ parser.on("error", (err: Error) => {
145
+ parseError = err;
146
+ });
147
+
148
+ try {
149
+ for await (const chunk of source) {
150
+ if (parseError) throw parseError;
151
+ const text = typeof chunk === "string" ? chunk : decodeUtf8(chunk);
152
+ parser.write(text);
153
+ if (parseError) throw parseError;
154
+ while (pending.length > 0) {
155
+ yield pending.shift()!;
156
+ }
157
+ }
158
+ parser.close();
159
+ if (parseError) throw parseError;
160
+ while (pending.length > 0) {
161
+ yield pending.shift()!;
162
+ }
163
+ } finally {
164
+ // Best-effort cleanup: detach handlers so the parser can be GC'd even
165
+ // when the consumer aborts iteration early.
166
+ parser.off("opentag");
167
+ parser.off("closetag");
168
+ parser.off("text");
169
+ parser.off("cdata");
170
+ parser.off("error");
171
+ }
172
+ }
173
+
174
+ function newNode(
175
+ tag: SaxesTagPlain,
176
+ typeName: string | null,
177
+ propName: string | null,
178
+ attrsKey: string,
179
+ ): {
180
+ obj: Record<string, unknown>;
181
+ typeName: string | null;
182
+ propName: string | null;
183
+ textBuf: string[];
184
+ hadChildren: boolean;
185
+ isNil: boolean;
186
+ } {
187
+ const attrs = tag.attributes;
188
+ const obj: Record<string, unknown> = {};
189
+ let isNil = false;
190
+ const attrKeys = Object.keys(attrs);
191
+ if (attrKeys.length > 0) {
192
+ // Detect xsi:nil and drop it from the attribute bag — it's a wire-level
193
+ // concern that should not pollute the user-visible record.
194
+ const cleaned: Record<string, string> = {};
195
+ for (const k of attrKeys) {
196
+ if ((k === "xsi:nil" || k === "nil") && attrs[k] === "true") {
197
+ isNil = true;
198
+ continue;
199
+ }
200
+ cleaned[k] = attrs[k];
201
+ }
202
+ if (Object.keys(cleaned).length > 0) {
203
+ obj[attrsKey] = cleaned;
204
+ }
205
+ }
206
+ return {
207
+ obj,
208
+ typeName,
209
+ propName,
210
+ textBuf: [],
211
+ hadChildren: false,
212
+ isNil,
213
+ };
214
+ }
215
+
216
+ function materialize(
217
+ node: {obj: Record<string, unknown>; textBuf: string[]; hadChildren: boolean; isNil: boolean},
218
+ attrsKey: string,
219
+ ): unknown {
220
+ if (node.isNil) return null;
221
+ if (!node.hadChildren) {
222
+ // Leaf with no child elements: it's simple text. Preserve attributes via
223
+ // a `$value` pairing when present, mirroring how the buffered mapper
224
+ // surfaces simpleContent-with-attributes types.
225
+ const text = node.textBuf.join("");
226
+ if (attrsKey in node.obj) {
227
+ return {...node.obj, $value: text};
228
+ }
229
+ return text;
230
+ }
231
+ return node.obj;
232
+ }
233
+
234
+ function assignChild(
235
+ parent: {obj: Record<string, unknown>; typeName: string | null},
236
+ propName: string,
237
+ value: unknown,
238
+ spec: RecordParseSpec,
239
+ ): void {
240
+ const parentType = parent.typeName;
241
+ const propMetaEntry = parentType ? spec.propMeta?.[parentType]?.[propName] : undefined;
242
+ const childTypeHint = parentType ? spec.childType?.[parentType]?.[propName] : undefined;
243
+
244
+ // Array if: (a) propMeta says max > 1 or "unbounded", or (b) childType hint
245
+ // ends in `[]`, or (c) the slot is already occupied (implicit repetition).
246
+ const metaSaysArray =
247
+ propMetaEntry?.max === "unbounded" ||
248
+ (typeof propMetaEntry?.max === "number" && propMetaEntry.max > 1);
249
+ const hintSaysArray = !!childTypeHint && childTypeHint.endsWith("[]");
250
+ const existing = parent.obj[propName];
251
+ const slotTaken = existing !== undefined;
252
+
253
+ if (metaSaysArray || hintSaysArray) {
254
+ if (Array.isArray(existing)) {
255
+ existing.push(value);
256
+ } else if (slotTaken) {
257
+ parent.obj[propName] = [existing, value];
258
+ } else {
259
+ parent.obj[propName] = [value];
260
+ }
261
+ return;
262
+ }
263
+ if (slotTaken) {
264
+ // Schema said scalar but the wire repeated it. Promote to array rather
265
+ // than drop data.
266
+ if (Array.isArray(existing)) {
267
+ existing.push(value);
268
+ } else {
269
+ parent.obj[propName] = [existing, value];
270
+ }
271
+ return;
272
+ }
273
+ parent.obj[propName] = value;
274
+ }
275
+
276
+ function stripArraySuffix(tsType: string): string {
277
+ return tsType.endsWith("[]") ? tsType.slice(0, -2) : tsType;
278
+ }
279
+
280
+ function tailMatches(stack: string[], path: string[]): boolean {
281
+ if (stack.length < path.length) return false;
282
+ const offset = stack.length - path.length;
283
+ for (let i = 0; i < path.length; i++) {
284
+ if (stack[offset + i] !== path[i]) return false;
285
+ }
286
+ return true;
287
+ }
288
+
289
+ const UTF8_DECODER = new TextDecoder("utf-8");
290
+
291
+ function decodeUtf8(buf: Uint8Array): string {
292
+ return UTF8_DECODER.decode(buf);
293
+ }