@techspokes/typescript-wsdl-client 1.1.8 → 1.3.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 (85) hide show
  1. package/README.md +22 -3
  2. package/dist/checkedPlanning.js +27 -4
  3. package/dist/cli.js +17 -1
  4. package/dist/client/checkedDirectRuntime.d.ts +11 -1
  5. package/dist/client/checkedDirectRuntime.js +19 -2
  6. package/dist/client/directClientRuntime.d.ts +18 -1
  7. package/dist/client/directClientRuntime.js +15 -3
  8. package/dist/client/directClientVersion.d.ts +2 -0
  9. package/dist/client/directClientVersion.js +2 -0
  10. package/dist/client/generateClient.js +72 -16
  11. package/dist/client/generateDirectClient.js +14 -9
  12. package/dist/client/generateOperations.js +30 -11
  13. package/dist/client/generateUtils.js +5 -1
  14. package/dist/compiler/canonicalGraph.d.ts +4 -15
  15. package/dist/compiler/canonicalGraph.js +1 -8
  16. package/dist/compiler/componentRecordProjection.d.ts +3 -0
  17. package/dist/compiler/componentRecordProjection.js +10 -0
  18. package/dist/compiler/declarationIdentity.d.ts +22 -0
  19. package/dist/compiler/declarationIdentity.js +9 -0
  20. package/dist/compiler/directCodec.d.ts +22 -2
  21. package/dist/compiler/directCodec.js +40 -7
  22. package/dist/compiler/directOperation.d.ts +28 -5
  23. package/dist/compiler/directOperation.js +84 -14
  24. package/dist/compiler/directSoap.d.ts +40 -10
  25. package/dist/compiler/directSoap.js +151 -18
  26. package/dist/compiler/directSoapSource.js +3 -2
  27. package/dist/compiler/directSourceCapsule.js +69 -4
  28. package/dist/compiler/directStructure.js +6 -2
  29. package/dist/compiler/directStructurePlan.d.ts +10 -1
  30. package/dist/compiler/directStructurePlan.js +27 -7
  31. package/dist/compiler/directValues.d.ts +15 -3
  32. package/dist/compiler/directValues.js +20 -4
  33. package/dist/compiler/legacyDeclarations.d.ts +26 -0
  34. package/dist/compiler/legacyDeclarations.js +144 -0
  35. package/dist/compiler/projectionScalar.d.ts +18 -1
  36. package/dist/compiler/projectionScalar.js +47 -13
  37. package/dist/compiler/rpcStringValues.d.ts +30 -0
  38. package/dist/compiler/rpcStringValues.js +196 -0
  39. package/dist/compiler/rpcTypedValues.d.ts +48 -0
  40. package/dist/compiler/rpcTypedValues.js +190 -0
  41. package/dist/compiler/schemaCompiler.js +44 -109
  42. package/dist/compiler/semanticCatalog.d.ts +2 -2
  43. package/dist/compiler/soapHeaders.d.ts +10 -4
  44. package/dist/compiler/soapHeaders.js +28 -9
  45. package/dist/gateway/generateCheckedGateway.js +26 -6
  46. package/dist/gateway/generateComposedGateway.d.ts +16 -0
  47. package/dist/gateway/generateComposedGateway.js +65 -0
  48. package/dist/gateway/generateGateway.js +1 -0
  49. package/dist/gateway/generators.js +19 -4
  50. package/dist/gateway/helpers.d.ts +1 -0
  51. package/dist/gateway/helpers.js +15 -0
  52. package/dist/generation/componentShape.js +25 -1
  53. package/dist/index.d.ts +5 -1
  54. package/dist/index.js +3 -1
  55. package/dist/loader/orderedSyntax.d.ts +4 -0
  56. package/dist/loader/orderedSyntax.js +18 -4
  57. package/dist/multiServicePipeline.d.ts +41 -0
  58. package/dist/multiServicePipeline.js +287 -0
  59. package/dist/openapi/composeOpenAPI.d.ts +29 -0
  60. package/dist/openapi/composeOpenAPI.js +322 -0
  61. package/dist/pipeline.d.ts +1 -1
  62. package/dist/pipeline.js +2 -0
  63. package/docs/agent-skill.md +10 -1
  64. package/docs/api-reference.md +127 -9
  65. package/docs/checked-gateway.md +10 -0
  66. package/docs/checked-generation.md +11 -5
  67. package/docs/cli-reference.md +25 -5
  68. package/docs/concepts.md +4 -5
  69. package/docs/configuration.md +10 -3
  70. package/docs/corpus.md +1 -1
  71. package/docs/gateway-guide.md +104 -27
  72. package/docs/generated-code.md +12 -6
  73. package/docs/migration-playbook.md +4 -0
  74. package/docs/migration.md +134 -6
  75. package/docs/output-anatomy.md +20 -17
  76. package/docs/production.md +7 -5
  77. package/docs/start-here.md +12 -2
  78. package/docs/supported-patterns.md +23 -0
  79. package/docs/testing.md +2 -0
  80. package/docs/troubleshooting.md +3 -1
  81. package/llms.txt +9 -1
  82. package/package.json +2 -2
  83. package/src/runtime/clientStreamMethods.tpl.txt +156 -148
  84. package/src/runtime/operationsStreamHelper.tpl.txt +6 -6
  85. package/src/runtime/streamXml.ts +48 -20
@@ -1,11 +1,18 @@
1
1
 
2
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.
3
+ * Stream complete SOAP records through the legacy DTO mapper.
4
+ * @param args - Flat request DTO, serialized using the generated metadata.
5
+ * @param operation - SOAP operation name for diagnostics.
6
+ * @param requestType - Request metadata key.
7
+ * @param recordTypeName - Record metadata key, including companion catalog types.
8
+ * @param inputElementLocal - SOAP body element local name.
9
+ * @param inputElementNs - SOAP body element namespace.
10
+ * @param soapAction - SOAP 1.1 action.
11
+ * @param recordPath - Local-name path to repeated records.
12
+ * @param callOptions - Cancellation and total response deadline.
13
+ * @returns Single-pass records; callers must exhaust or return their iterator to release the connection.
14
+ * @throws Error for unsupported transport settings, HTTP errors, XML faults, or invalid mapped values.
15
+ * @constraints Streaming supports SOAP 1.1 document/literal requests, static SOAP headers, and ISecurity hooks.
9
16
  */
10
17
  protected async callStream<RequestType, RecordType, HeadersType>(
11
18
  args: RequestType,
@@ -15,169 +22,170 @@
15
22
  inputElementLocal: string,
16
23
  inputElementNs: string,
17
24
  soapAction: string,
18
- recordPath: string[]
25
+ recordPath: string[],
26
+ callOptions?: StreamCallOptions
19
27
  ): Promise<StreamOperationResponse<RecordType, HeadersType>> {
28
+ callOptions?.signal?.throwIfAborted();
20
29
  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
- );
30
+ const soapOptions = client.wsdl.options;
31
+ if (soapOptions.forceSoap12Headers || soapOptions.envelopeSoapUrl && soapOptions.envelopeSoapUrl !== "http://schemas.xmlsoap.org/soap/envelope/") {
32
+ throw new Error("Streaming supports SOAP 1.1 only");
35
33
  }
36
- if (!res.body) {
37
- throw new Error(operation + " stream request returned an empty response body");
34
+ const envelopeKey = soapOptions.envelopeKey ?? "soap";
35
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(envelopeKey) || envelopeKey === "xml" || envelopeKey === "xmlns") {
36
+ throw new Error("Streaming requires a supported XML envelope prefix");
38
37
  }
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,
38
+ const soapHeaders = client.getSoapHeaders() ?? [];
39
+ if (soapHeaders.some(header => typeof header !== "string")) {
40
+ throw new Error("Streaming requires static XML SOAP headers");
41
+ }
42
+ if (client.getBodyAttributes()?.length) {
43
+ throw new Error("Streaming does not support SOAP body attributes");
44
+ }
45
+ const transport = this.options?.httpClient ?? new __SOAP_HTTP_CLIENT__(this.options);
46
+ if (!transport.requestStream) throw new Error("Configured SOAP HTTP client does not support requestStream");
47
+ const security = (client as unknown as {security?: __SOAP_SECURITY__}).security ?? this.security;
48
+ const {endpoint, input} = this.resolveStreamBinding(client, operation, soapAction, inputElementLocal, inputElementNs);
49
+ const headers: Record<string, string> = {
50
+ "Content-Type": "text/xml; charset=utf-8",
51
+ SOAPAction: '"' + soapAction + '"',
46
52
  };
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,
53
+ const requestOptions: __SOAP_EX_OPTIONS__ = {...this.streamRequestOptions};
54
+ security?.addHeaders?.(headers);
55
+ security?.addOptions?.(requestOptions);
56
+ Object.assign(headers, client.getHttpHeaders());
57
+ if (requestOptions.forceMTOM || requestOptions.attachments?.length || requestOptions.ntlm) {
58
+ throw new Error("Streaming does not support MTOM attachments or NTLM transport");
59
+ }
60
+ // @why node-soap's schema serializer retains element qualification and foreign references that DTO type metadata omits.
61
+ const soapArgs = this.toSoapArgs(args, requestType);
62
+ const message = client.wsdl.objectToDocumentXML(input.$name, soapArgs, input.targetNSAlias, input.targetNamespace, input.$type || input.$lookupType, requestOptions);
63
+ let envelope = this.buildSoapEnvelope(message, envelopeKey, client);
64
+ const headerXml = soapHeaders.join(" ") + (security?.postProcess ? "" : security?.toXML?.() ?? "");
65
+ if (headerXml || security?.postProcess) {
66
+ const body = "<" + envelopeKey + ":Body>";
67
+ const headerNamespaces = client.wsdl.xmlnsInHeader ? " " + client.wsdl.xmlnsInHeader : "";
68
+ envelope = envelope.replace(body, "<" + envelopeKey + ":Header" + headerNamespaces + ">" + headerXml + "</" + envelopeKey + ":Header>" + body);
69
+ }
70
+ if (security?.postProcess) envelope = security.postProcess(envelope, envelopeKey);
71
+ if (typeof requestOptions.postProcess === "function") envelope = requestOptions.postProcess(envelope);
72
+ const controller = new AbortController();
73
+ const signals = [controller.signal, callOptions?.signal, requestOptions.signal].filter((signal): signal is AbortSignal => !!signal);
74
+ requestOptions.signal = AbortSignal.any(signals);
75
+ const timeoutMs = callOptions?.timeoutMs;
76
+ if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)) {
77
+ throw new RangeError("Stream timeoutMs must be a positive safe timer duration");
78
+ }
79
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => controller.abort(new Error("Stream request timed out")), timeoutMs);
80
+ timer?.unref();
81
+ let body: (AsyncIterable<string | Uint8Array> & {destroy(): void}) | undefined;
82
+ const cleanup = () => {
83
+ if (timer) clearTimeout(timer);
84
+ if (typeof body?.destroy === "function") body.destroy();
85
+ controller.abort();
55
86
  };
87
+ try {
88
+ const res = await transport.requestStream(endpoint, envelope, headers, requestOptions, client);
89
+ body = res.data as unknown as typeof body;
90
+ if (!body || typeof body[Symbol.asyncIterator] !== "function" || typeof body.destroy !== "function") {
91
+ throw new Error(operation + " stream request returned an invalid response body");
92
+ }
93
+ if (res.status < 200 || res.status >= 300) {
94
+ throw new Error(operation + " stream request failed: " + res.status + " " + res.statusText);
95
+ }
96
+ const spec: RecordParseSpec = {
97
+ recordPath,
98
+ recordTypeName,
99
+ attributesKey: this.attributesKeyOut,
100
+ childType: this.dataTypes?.ChildrenTypes,
101
+ repeatedElements: this.dataTypes?.RepeatedElements,
102
+ };
103
+ const source = parseRecords(body, spec);
104
+ const self = this;
105
+ async function* mapped(): AsyncGenerator<RecordType> {
106
+ try {
107
+ for await (const record of source) yield self.fromSoapResult(record, recordTypeName) as RecordType;
108
+ } finally { cleanup(); }
109
+ }
110
+ const iterator = mapped();
111
+ // @constraints return() must close transport even before the generator's first next().
112
+ const records: AsyncIterable<RecordType> = {
113
+ [Symbol.asyncIterator]() {
114
+ return {
115
+ next: () => iterator.next(),
116
+ return: async () => { cleanup(); return iterator.return(undefined); },
117
+ throw: async error => { cleanup(); return iterator.throw(error); },
118
+ };
119
+ },
120
+ };
121
+ return {records, headers: res.headers as unknown as HeadersType, requestRaw: envelope};
122
+ } catch (error) {
123
+ cleanup();
124
+ throw error;
125
+ }
56
126
  }
57
127
 
58
128
  /**
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).
129
+ * Select one compatible live document/literal operation binding and endpoint.
130
+ * @param client - Initialized node-soap client.
131
+ * @param operation - Compiled operation name.
132
+ * @param soapAction - Compiled SOAP action, used to disambiguate live methods.
133
+ * @param inputElementLocal - Expected input element local name.
134
+ * @param inputElementNs - Expected input namespace.
135
+ * @returns Matched input descriptor and current override or owning port address.
136
+ * @throws Error for missing, ambiguous, or incompatible live operation bindings.
137
+ * @constraints Distinct bindings cannot be assumed equivalent after node-soap has processed shared port-type metadata.
62
138
  */
63
- protected resolveStreamEndpoint(client: __SOAP_CLIENT_TYPE__): string {
139
+ protected resolveStreamBinding(
140
+ client: __SOAP_CLIENT_TYPE__,
141
+ operation: string,
142
+ soapAction: string,
143
+ inputElementLocal: string,
144
+ inputElementNs: string
145
+ ): {endpoint: string; input: {$name: string; targetNSAlias: string; targetNamespace: string; $type?: string; $lookupType?: string}} {
146
+ const override = (client as unknown as {endpoint?: string}).endpoint ?? this.options?.endpoint;
147
+ const candidates: Array<{endpoint: string; binding: unknown; method: any}> = [];
64
148
  const services = (client as any)?.wsdl?.definitions?.services ?? {};
65
149
  for (const svc of Object.values(services)) {
66
150
  const ports = (svc as any)?.ports ?? {};
67
151
  for (const port of Object.values(ports)) {
68
- const location = (port as any)?.location;
69
- if (typeof location === "string" && location) return location;
152
+ const binding = (port as any)?.binding;
153
+ const method = binding?.methods?.[operation];
154
+ if (!method || method.$name !== operation || (method.soapAction ?? "") !== soapAction) continue;
155
+ const endpoint = override || (port as any)?.location;
156
+ if (typeof endpoint !== "string" || !endpoint) throw new Error("No SOAP endpoint configured for stream request");
157
+ candidates.push({endpoint, binding, method});
70
158
  }
71
159
  }
72
- return this.source;
160
+ if (candidates.length === 0) throw new Error("No matching SOAP operation binding for stream request: " + operation);
161
+ const selected = candidates[0];
162
+ if (candidates.some(candidate => candidate.binding !== selected.binding || candidate.method !== selected.method || candidate.endpoint !== selected.endpoint)) {
163
+ throw new Error("Ambiguous or incompatible SOAP operation bindings for stream request: " + operation);
164
+ }
165
+ const method = selected.method;
166
+ const input = method.input;
167
+ if (method.style && method.style !== "document" || method.inputSoap?.use && method.inputSoap.use !== "literal" ||
168
+ !input || input.parts || input.$name !== inputElementLocal || input.targetNamespace !== inputElementNs) {
169
+ throw new Error("Incompatible SOAP document/literal input binding for stream request: " + operation);
170
+ }
171
+ return {endpoint: selected.endpoint, input};
73
172
  }
74
173
 
75
174
  /**
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.
175
+ * Wrap the schema-serialized request using the initialized client's envelope namespace context.
176
+ * @param body - Document/literal XML from node-soap's public WSDL serializer.
177
+ * @param envelopeKey - Validated SOAP envelope prefix, also passed to security processing.
178
+ * @param client - Initialized client supplying the WSDL namespace declarations.
179
+ * @returns A SOAP 1.1 envelope ready for static headers and security hooks.
79
180
  */
80
181
  protected buildSoapEnvelope(
81
- args: unknown,
82
- operation: string,
83
- requestType: string | undefined,
84
- inputElementLocal: string,
85
- inputElementNs: string
182
+ body: string,
183
+ envelopeKey: string,
184
+ client: __SOAP_CLIENT_TYPE__
86
185
  ): string {
87
- const body = this.toXmlElement(args, requestType, inputElementLocal, inputElementNs);
88
186
  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 24+
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
- }
187
+ '<' + envelopeKey + ':Envelope xmlns:' + envelopeKey + '="http://schemas.xmlsoap.org/soap/envelope/" ' +
188
+ 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' + client.wsdl.xmlnsInEnvelope + '>' +
189
+ '<' + envelopeKey + ':Body>' + body + '</' + envelopeKey + ':Body>' +
190
+ '</' + envelopeKey + ':Envelope>';
183
191
  }
@@ -1,11 +1,11 @@
1
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.
2
+ * Represents a streamed __CLIENT_NAME__ response.
3
+ * @typeParam RecordType Parsed record DTO yielded by the single-pass upstream iterator.
4
+ * @typeParam HeadersType HTTP headers captured before the first record is parsed.
5
+ * @constraints The structural default permits WSDL DTOs named Record without shadowing a utility type.
6
+ * @remarks requestRaw contains the serialized SOAP request when populated.
7
7
  */
8
- export type StreamOperationResponse<RecordType, HeadersType = Record<string, unknown>> = {
8
+ export type StreamOperationResponse<RecordType, HeadersType = {[key: string]: unknown}> = {
9
9
  records: AsyncIterable<RecordType>;
10
10
  headers: HeadersType;
11
11
  requestRaw?: string;
@@ -17,6 +17,7 @@
17
17
  * Q4 (saxes placement): runtime dependency of the wsdl-tsc package; the
18
18
  * generated client imports the emitted copy of this module and
19
19
  * inherits saxes via its own dependency tree.
20
+ * @since Unreleased
20
21
  */
21
22
  import {SaxesParser, type SaxesTagPlain} from "saxes";
22
23
 
@@ -42,6 +43,8 @@ export interface RecordParseSpec {
42
43
  * detection only.
43
44
  */
44
45
  childType?: Record<string, Record<string, string>>;
46
+ /** Explicit repeated XML properties; when supplied, child-type [] hints describe list values instead. */
47
+ repeatedElements?: Record<string, readonly string[]>;
45
48
  /**
46
49
  * Compiled-catalog prop-meta map: carries min/max/nillable/declaredType.
47
50
  * When present, `max > 1` or `max === "unbounded"` drives array emission
@@ -58,12 +61,12 @@ export interface PropMeta {
58
61
  }
59
62
 
60
63
  /**
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()`.
64
+ * Consume XML bytes or strings and yield complete legacy record nodes.
65
+ * @param source - Single-pass XML bytes or strings; UTF-8 byte boundaries may split characters.
66
+ * @param spec - Local-name record path and optional catalog metadata; attribute bags remain lexical strings.
67
+ * @returns Complete records without buffering the complete XML document.
68
+ * @throws Error for malformed XML, SOAP Body faults, or an empty record path.
69
+ * @constraints Exhaust or return the single-pass iterator to release its source; undeclared xsi:nil remains accepted for legacy compatibility.
67
70
  */
68
71
  export async function* parseRecords<T = unknown>(
69
72
  source: AsyncIterable<string | Uint8Array>,
@@ -75,9 +78,12 @@ export async function* parseRecords<T = unknown>(
75
78
  throw new Error("parseRecords: recordPath must not be empty");
76
79
  }
77
80
  const attrsKey = spec.attributesKey ?? "$attributes";
81
+ const decoder = new TextDecoder("utf-8");
78
82
 
79
83
  // Global tag stack, maintained across the entire document.
80
84
  const stack: string[] = [];
85
+ const namespaces: Array<Record<string, string>> = [];
86
+ const elementNamespaces: string[] = [];
81
87
  // Records materialized during the current chunk write, awaiting yield.
82
88
  const pending: T[] = [];
83
89
  // When a parser.on('error') fires we buffer the error for re-throw on the
@@ -102,11 +108,27 @@ export async function* parseRecords<T = unknown>(
102
108
  const openNodes: OpenNode[] = [];
103
109
 
104
110
  parser.on("opentag", (tag: SaxesTagPlain) => {
105
- stack.push(tag.name);
111
+ const inherited = namespaces.at(-1);
112
+ const scope: Record<string, string> = Object.assign(Object.create(null) as Record<string, string>, inherited);
113
+ for (const [name, value] of Object.entries(tag.attributes)) {
114
+ if (name === "xmlns") scope[""] = value;
115
+ else if (name.startsWith("xmlns:")) scope[name.slice(6)] = value;
116
+ }
117
+ const localName = local(tag.name);
118
+ const prefix = tag.name.includes(":") ? tag.name.slice(0, tag.name.indexOf(":")) : "";
119
+ const namespace = scope[prefix] ?? "";
120
+ if (localName === "Fault" && namespace === elementNamespaces.at(-1) &&
121
+ (namespace === "http://schemas.xmlsoap.org/soap/envelope/" || namespace === "http://www.w3.org/2003/05/soap-envelope") &&
122
+ stack.at(-1) === "Body") {
123
+ parseError = new Error("SOAP Fault received in stream response");
124
+ }
125
+ namespaces.push(scope);
126
+ elementNamespaces.push(namespace);
127
+ stack.push(localName);
106
128
  if (openNodes.length === 0) {
107
129
  // Not yet inside a record. Check whether entering the path tail.
108
130
  if (tailMatches(stack, recordPath)) {
109
- openNodes.push(newNode(tag, spec.recordTypeName, null, attrsKey));
131
+ openNodes.push(newNode(tag, spec.recordTypeName, null, attrsKey, scope));
110
132
  }
111
133
  return;
112
134
  }
@@ -114,9 +136,9 @@ export async function* parseRecords<T = unknown>(
114
136
  const parent = openNodes[openNodes.length - 1];
115
137
  parent.hadChildren = true;
116
138
  const parentType = parent.typeName;
117
- const childTypeRaw = parentType ? spec.childType?.[parentType]?.[tag.name] : undefined;
139
+ const childTypeRaw = parentType ? spec.childType?.[parentType]?.[localName] : undefined;
118
140
  const childTypeName = childTypeRaw ? stripArraySuffix(childTypeRaw) : null;
119
- openNodes.push(newNode(tag, childTypeName, tag.name, attrsKey));
141
+ openNodes.push(newNode(tag, childTypeName, localName, attrsKey, scope));
120
142
  });
121
143
 
122
144
  const appendText = (t: string) => {
@@ -139,6 +161,8 @@ export async function* parseRecords<T = unknown>(
139
161
  }
140
162
  }
141
163
  stack.pop();
164
+ namespaces.pop();
165
+ elementNamespaces.pop();
142
166
  });
143
167
 
144
168
  parser.on("error", (err: Error) => {
@@ -148,13 +172,14 @@ export async function* parseRecords<T = unknown>(
148
172
  try {
149
173
  for await (const chunk of source) {
150
174
  if (parseError) throw parseError;
151
- const text = typeof chunk === "string" ? chunk : decodeUtf8(chunk);
175
+ const text = typeof chunk === "string" ? decoder.decode() + chunk : decoder.decode(chunk, {stream: true});
152
176
  parser.write(text);
153
177
  if (parseError) throw parseError;
154
178
  while (pending.length > 0) {
155
179
  yield pending.shift()!;
156
180
  }
157
181
  }
182
+ parser.write(decoder.decode());
158
183
  parser.close();
159
184
  if (parseError) throw parseError;
160
185
  while (pending.length > 0) {
@@ -176,6 +201,7 @@ function newNode(
176
201
  typeName: string | null,
177
202
  propName: string | null,
178
203
  attrsKey: string,
204
+ namespaces: Record<string, string>,
179
205
  ): {
180
206
  obj: Record<string, unknown>;
181
207
  typeName: string | null;
@@ -193,7 +219,11 @@ function newNode(
193
219
  // concern that should not pollute the user-visible record.
194
220
  const cleaned: Record<string, string> = {};
195
221
  for (const k of attrKeys) {
196
- if ((k === "xsi:nil" || k === "nil") && attrs[k] === "true") {
222
+ if (k === "xmlns" || k.startsWith("xmlns:")) continue;
223
+ const prefix = k.includes(":") ? k.slice(0, k.indexOf(":")) : "";
224
+ const isNilAttribute = k === "nil" || k === "xsi:nil" && !namespaces.xsi ||
225
+ local(k) === "nil" && namespaces[prefix] === "http://www.w3.org/2001/XMLSchema-instance";
226
+ if (isNilAttribute && (attrs[k] === "true" || attrs[k] === "1")) {
197
227
  isNil = true;
198
228
  continue;
199
229
  }
@@ -241,16 +271,16 @@ function assignChild(
241
271
  const propMetaEntry = parentType ? spec.propMeta?.[parentType]?.[propName] : undefined;
242
272
  const childTypeHint = parentType ? spec.childType?.[parentType]?.[propName] : undefined;
243
273
 
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).
274
+ // @constraints Explicit repetition metadata distinguishes inline xs:list values from repeated XML elements.
246
275
  const metaSaysArray =
247
276
  propMetaEntry?.max === "unbounded" ||
248
277
  (typeof propMetaEntry?.max === "number" && propMetaEntry.max > 1);
249
- const hintSaysArray = !!childTypeHint && childTypeHint.endsWith("[]");
278
+ const hintSaysArray = spec.repeatedElements === undefined && !!childTypeHint && childTypeHint.endsWith("[]");
279
+ const declaredRepeated = parentType ? spec.repeatedElements?.[parentType]?.includes(propName) : false;
250
280
  const existing = parent.obj[propName];
251
281
  const slotTaken = existing !== undefined;
252
282
 
253
- if (metaSaysArray || hintSaysArray) {
283
+ if (metaSaysArray || hintSaysArray || declaredRepeated) {
254
284
  if (Array.isArray(existing)) {
255
285
  existing.push(value);
256
286
  } else if (slotTaken) {
@@ -286,8 +316,6 @@ function tailMatches(stack: string[], path: string[]): boolean {
286
316
  return true;
287
317
  }
288
318
 
289
- const UTF8_DECODER = new TextDecoder("utf-8");
290
-
291
- function decodeUtf8(buf: Uint8Array): string {
292
- return UTF8_DECODER.decode(buf);
319
+ function local(name: string): string {
320
+ return name.slice(name.lastIndexOf(":") + 1);
293
321
  }