node-opcua-address-space 2.181.0 → 2.182.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 (136) hide show
  1. package/api/helpers/ensure_secure_access.ts +53 -2
  2. package/api/index.ts +30 -1
  3. package/api/loader/generateAddressSpaceRaw.ts +44 -139
  4. package/api/loader/load_nodeset2.ts +5 -0
  5. package/api/loader/narrow_instance_datatypes.ts +264 -0
  6. package/api/loader/nodeset_builtin_formats.ts +143 -0
  7. package/api/loader/nodeset_format.ts +153 -0
  8. package/api/loader/nodeset_image.ts +37 -4
  9. package/api/loader/nodeset_image_codec.ts +153 -34
  10. package/api/loader/nodeset_record.ts +26 -2
  11. package/api/loader/nodeset_record_applier.ts +54 -3
  12. package/api/loader/nodeset_records_to_xml.ts +620 -0
  13. package/api/loader/nodeset_source.ts +185 -26
  14. package/api/loader/nodeset_to_image.ts +16 -5
  15. package/api/loader/nodeset_xml_header.ts +119 -0
  16. package/api/loader/nodeset_xml_primitives.ts +209 -0
  17. package/api/loader/nodeset_xml_producer.ts +61 -2
  18. package/api/loader/parsers/variant_parser.ts +9 -0
  19. package/bin/nodeset_equivalence.js +750 -0
  20. package/bin/nodeset_tool_common.js +72 -0
  21. package/bin/opcua-nodeset-image.js +93 -103
  22. package/bin/opcua-nodeset-live.js +139 -0
  23. package/dist/api/helpers/ensure_secure_access.d.ts +20 -0
  24. package/dist/api/helpers/ensure_secure_access.js +39 -0
  25. package/dist/api/helpers/ensure_secure_access.js.map +1 -1
  26. package/dist/api/index.d.ts +7 -2
  27. package/dist/api/index.js +21 -1
  28. package/dist/api/index.js.map +1 -1
  29. package/dist/api/loader/generateAddressSpaceRaw.d.ts +1 -7
  30. package/dist/api/loader/generateAddressSpaceRaw.js +32 -125
  31. package/dist/api/loader/generateAddressSpaceRaw.js.map +1 -1
  32. package/dist/api/loader/load_nodeset2.js +4 -0
  33. package/dist/api/loader/load_nodeset2.js.map +1 -1
  34. package/dist/api/loader/narrow_instance_datatypes.d.ts +30 -0
  35. package/dist/api/loader/narrow_instance_datatypes.js +217 -0
  36. package/dist/api/loader/narrow_instance_datatypes.js.map +1 -0
  37. package/dist/api/loader/nodeset_builtin_formats.d.ts +27 -0
  38. package/dist/api/loader/nodeset_builtin_formats.js +133 -0
  39. package/dist/api/loader/nodeset_builtin_formats.js.map +1 -0
  40. package/dist/api/loader/nodeset_format.d.ts +123 -0
  41. package/dist/api/loader/nodeset_format.js +63 -0
  42. package/dist/api/loader/nodeset_format.js.map +1 -0
  43. package/dist/api/loader/nodeset_image.d.ts +17 -1
  44. package/dist/api/loader/nodeset_image.js +32 -4
  45. package/dist/api/loader/nodeset_image.js.map +1 -1
  46. package/dist/api/loader/nodeset_image_codec.d.ts +4 -0
  47. package/dist/api/loader/nodeset_image_codec.js +142 -53
  48. package/dist/api/loader/nodeset_image_codec.js.map +1 -1
  49. package/dist/api/loader/nodeset_record.d.ts +26 -2
  50. package/dist/api/loader/nodeset_record.js +11 -2
  51. package/dist/api/loader/nodeset_record.js.map +1 -1
  52. package/dist/api/loader/nodeset_record_applier.js +52 -3
  53. package/dist/api/loader/nodeset_record_applier.js.map +1 -1
  54. package/dist/api/loader/nodeset_records_to_xml.d.ts +16 -0
  55. package/dist/api/loader/nodeset_records_to_xml.js +536 -0
  56. package/dist/api/loader/nodeset_records_to_xml.js.map +1 -0
  57. package/dist/api/loader/nodeset_source.d.ts +68 -7
  58. package/dist/api/loader/nodeset_source.js +157 -20
  59. package/dist/api/loader/nodeset_source.js.map +1 -1
  60. package/dist/api/loader/nodeset_to_image.d.ts +4 -4
  61. package/dist/api/loader/nodeset_to_image.js +14 -4
  62. package/dist/api/loader/nodeset_to_image.js.map +1 -1
  63. package/dist/api/loader/nodeset_xml_header.d.ts +22 -0
  64. package/dist/api/loader/nodeset_xml_header.js +113 -0
  65. package/dist/api/loader/nodeset_xml_header.js.map +1 -0
  66. package/dist/api/loader/nodeset_xml_primitives.d.ts +37 -0
  67. package/dist/api/loader/nodeset_xml_primitives.js +196 -0
  68. package/dist/api/loader/nodeset_xml_primitives.js.map +1 -0
  69. package/dist/api/loader/nodeset_xml_producer.js +56 -1
  70. package/dist/api/loader/nodeset_xml_producer.js.map +1 -1
  71. package/dist/api/loader/parsers/variant_parser.js +7 -0
  72. package/dist/api/loader/parsers/variant_parser.js.map +1 -1
  73. package/dist/impl/_instantiate_helpers.js +1 -0
  74. package/dist/impl/_instantiate_helpers.js.map +1 -1
  75. package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js +4 -0
  76. package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js.map +1 -1
  77. package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js +14 -0
  78. package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js.map +1 -1
  79. package/dist/impl/base_node_impl.d.ts +9 -0
  80. package/dist/impl/base_node_impl.js +11 -0
  81. package/dist/impl/base_node_impl.js.map +1 -1
  82. package/dist/impl/base_node_private.js +67 -97
  83. package/dist/impl/base_node_private.js.map +1 -1
  84. package/dist/impl/nodeset_tools/construct_namespace_dependency.js +5 -1
  85. package/dist/impl/nodeset_tools/construct_namespace_dependency.js.map +1 -1
  86. package/dist/impl/nodeset_tools/nodeset_to_records.js +61 -15
  87. package/dist/impl/nodeset_tools/nodeset_to_records.js.map +1 -1
  88. package/dist/impl/nodeset_tools/nodeset_to_xml.d.ts +1 -8
  89. package/dist/impl/nodeset_tools/nodeset_to_xml.js +174 -190
  90. package/dist/impl/nodeset_tools/nodeset_to_xml.js.map +1 -1
  91. package/dist/impl/ua_data_type_impl.d.ts +21 -0
  92. package/dist/impl/ua_data_type_impl.js +27 -2
  93. package/dist/impl/ua_data_type_impl.js.map +1 -1
  94. package/dist/impl/ua_object_type_impl.d.ts +9 -0
  95. package/dist/impl/ua_object_type_impl.js +12 -1
  96. package/dist/impl/ua_object_type_impl.js.map +1 -1
  97. package/dist/impl/ua_variable_impl.js +27 -10
  98. package/dist/impl/ua_variable_impl.js.map +1 -1
  99. package/dist/impl/ua_variable_impl_ext_obj.js +19 -2
  100. package/dist/impl/ua_variable_impl_ext_obj.js.map +1 -1
  101. package/dist/impl/ua_variable_type_impl.d.ts +8 -1
  102. package/dist/impl/ua_variable_type_impl.js +39 -2
  103. package/dist/impl/ua_variable_type_impl.js.map +1 -1
  104. package/dist/tsconfig_base.tsbuildinfo +1 -1
  105. package/distNodeJS/generate_address_space.js +4 -0
  106. package/distNodeJS/generate_address_space.js.map +1 -1
  107. package/impl/_instantiate_helpers.ts +3 -0
  108. package/impl/alarms_and_conditions/ua_acknowledgeable_condition_impl.ts +4 -4
  109. package/impl/alarms_and_conditions/ua_alarm_condition_impl.ts +19 -9
  110. package/impl/alarms_and_conditions/ua_base_event_impl.ts +9 -9
  111. package/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.ts +17 -0
  112. package/impl/alarms_and_conditions/ua_condition_impl.ts +18 -18
  113. package/impl/alarms_and_conditions/ua_exclusive_deviation_alarm_impl.ts +2 -2
  114. package/impl/alarms_and_conditions/ua_exclusive_limit_alarm_impl.ts +1 -1
  115. package/impl/alarms_and_conditions/ua_limit_alarm_impl.ts +16 -16
  116. package/impl/alarms_and_conditions/ua_non_exclusive_deviation_alarm_impl.ts +2 -2
  117. package/impl/alarms_and_conditions/ua_non_exclusive_limit_alarm_impl.ts +4 -4
  118. package/impl/alarms_and_conditions/ua_off_normal_alarm_impl.ts +1 -1
  119. package/impl/base_node_impl.ts +15 -0
  120. package/impl/base_node_private.ts +97 -146
  121. package/impl/data_access/ua_multistate_discrete_impl.ts +1 -1
  122. package/impl/data_access/ua_multistate_value_discrete_impl.ts +2 -2
  123. package/impl/data_access/ua_two_state_discrete_impl.ts +2 -2
  124. package/impl/nodeset_tools/construct_namespace_dependency.ts +5 -1
  125. package/impl/nodeset_tools/nodeset_to_records.ts +66 -16
  126. package/impl/nodeset_tools/nodeset_to_xml.ts +127 -160
  127. package/impl/state_machine/finite_state_machine.ts +3 -3
  128. package/impl/state_machine/ua_shelving_state_machine_ex.ts +20 -20
  129. package/impl/ua_data_type_impl.ts +42 -4
  130. package/impl/ua_method_impl.ts +1 -1
  131. package/impl/ua_object_type_impl.ts +16 -3
  132. package/impl/ua_variable_impl.ts +27 -10
  133. package/impl/ua_variable_impl_ext_obj.ts +20 -2
  134. package/impl/ua_variable_type_impl.ts +49 -3
  135. package/package.json +29 -27
  136. package/source_nodejs/generate_address_space.ts +7 -1
@@ -7,7 +7,8 @@
7
7
  * first bytes.
8
8
  */
9
9
 
10
- import { isNodesetImage } from "./nodeset_image.js";
10
+ import { findNodesetFormat, type NodesetDocument, type NodesetHead } from "./nodeset_format.js";
11
+ import { inflatedImageLines, isGzip, isNodesetImage } from "./nodeset_image.js";
11
12
 
12
13
  /** one piece of a NodeSet2 document: text, or UTF-8 bytes */
13
14
  export type NodesetChunk = string | Uint8Array;
@@ -57,8 +58,26 @@ export interface NamedNodesetSource {
57
58
 
58
59
  const BOM = 0xfeff;
59
60
 
60
- /** what a source turned out to hold */
61
- export type NodesetSourceKind = "xml" | "image";
61
+ /** how many characters of a document a format sniffer is shown */
62
+ const HEAD_CHARS = 4096;
63
+
64
+ /**
65
+ * how a document arrived, before anything has been decided about what it says: as text, or as
66
+ * bytes that are not text until something inflates or decodes them.
67
+ *
68
+ * This is not the format. It is the one thing that can be settled synchronously on a first
69
+ * chunk, and it decides only whether the reader decodes as it goes or keeps the bytes intact.
70
+ */
71
+ export type NodesetContainer = "text" | "bytes";
72
+
73
+ /**
74
+ * the name of the format a source turned out to be written in.
75
+ *
76
+ * This used to be a closed pair, XML or image. It is now whatever is registered, so that a
77
+ * format the loader has never heard of can identify itself; the two built-ins go on reading the
78
+ * documents they always did.
79
+ */
80
+ export type NodesetSourceKind = string;
62
81
 
63
82
  export interface NodesetReaderOptions {
64
83
  /** keep the bytes read so that {@link NodesetReader.digest} can be computed */
@@ -82,7 +101,9 @@ export class NodesetReader {
82
101
  private bodyStarted = false;
83
102
  private decoder: TextDecoder | null = null;
84
103
  private atStart = true;
85
- private kind: NodesetSourceKind | undefined;
104
+ private container: NodesetContainer | undefined;
105
+ private formatName: NodesetSourceKind | undefined;
106
+ private allBytesValue: Uint8Array | undefined;
86
107
  private readonly hashed: Uint8Array[] | null;
87
108
  private bytesRead = 0;
88
109
  private readonly encoder = new TextEncoder();
@@ -103,12 +124,66 @@ export class NodesetReader {
103
124
  return this.options.imageKey;
104
125
  }
105
126
 
106
- /** XML or image: decided on the first bytes */
107
- public async probe(): Promise<NodesetSourceKind> {
108
- if (this.kind === undefined) {
127
+ /**
128
+ * how this document arrived: as text, or as bytes. Settled on the first chunk, and never a
129
+ * statement about which format the document is written in.
130
+ */
131
+ public async probeContainer(): Promise<NodesetContainer> {
132
+ if (this.container === undefined) {
109
133
  await this.pull();
110
134
  }
111
- return this.kind ?? "xml";
135
+ return this.container ?? "text";
136
+ }
137
+
138
+ /**
139
+ * which registered format claims this document.
140
+ *
141
+ * Two stages, because one is not enough. The container is decided synchronously on the first
142
+ * chunk; the format is decided from the head of the document *after* it has been inflated,
143
+ * which is the only way to tell one gzipped serialisation from another. The loader used to
144
+ * skip that second stage and take any gzip stream for one of its own images, so a foreign
145
+ * compressed nodeset was not rejected but misread.
146
+ */
147
+ public async probe(): Promise<NodesetSourceKind> {
148
+ if (this.formatName !== undefined) {
149
+ return this.formatName;
150
+ }
151
+ const head = await this.sniffHead();
152
+ const format = findNodesetFormat(head);
153
+ if (!format) {
154
+ throw new Error(`nodeset source ${this.name}: no registered format recognises this document`);
155
+ }
156
+ this.formatName = format.name;
157
+ return this.formatName;
158
+ }
159
+
160
+ /**
161
+ * the beginning of the document as a sniffer sees it: inflated when it arrived gzipped, so
162
+ * that no format has to know how a document happened to be stored.
163
+ *
164
+ * A byte document is read whole. That is not a concession: every format that arrives as bytes
165
+ * is line-delimited or an archive, and reads whole anyway, and the bytes are kept so the read
166
+ * is not repeated. Text streams on, and is sniffed on as much of the head as has arrived.
167
+ */
168
+ private async sniffHead(): Promise<NodesetHead> {
169
+ if ((await this.probeContainer()) === "bytes") {
170
+ const all = await this.allBytes();
171
+ const gzip = isGzip(all);
172
+ const text = gzip
173
+ ? ((await inflatedImageLines(all))[0] ?? "")
174
+ : new TextDecoder("utf-8").decode(all.subarray(0, HEAD_CHARS));
175
+ return makeHead(this.name, text, gzip);
176
+ }
177
+ while (this.headTextLength() < HEAD_CHARS) {
178
+ if ((await this.pull()) === undefined) break;
179
+ }
180
+ return makeHead(this.name, (this.head as string[]).join("").slice(0, HEAD_CHARS), false);
181
+ }
182
+
183
+ private headTextLength(): number {
184
+ let total = 0;
185
+ for (const chunk of this.head as string[]) total += chunk.length;
186
+ return total;
112
187
  }
113
188
 
114
189
  /**
@@ -119,8 +194,8 @@ export class NodesetReader {
119
194
  if (this.bodyStarted) {
120
195
  throw new Error(`nodeset source ${this.name}: the head cannot be read once the body has been`);
121
196
  }
122
- if ((await this.probe()) !== "xml") {
123
- throw new Error(`nodeset source ${this.name}: not XML`);
197
+ if ((await this.probeContainer()) !== "text") {
198
+ throw new Error(`nodeset source ${this.name}: the head of a byte document is not text`);
124
199
  }
125
200
  let text = (this.head as string[]).join("");
126
201
  while (!complete(text)) {
@@ -133,13 +208,14 @@ export class NodesetReader {
133
208
  return text;
134
209
  }
135
210
 
136
- /** the whole document as text chunks, from the start; usable once */
137
- public async *chunks(): AsyncGenerator<string> {
211
+ /**
212
+ * the whole document from the start, usable once: text chunks for a document that arrived as
213
+ * text, byte chunks for one that did not. A caller that wants text either way decodes.
214
+ */
215
+ public async *chunks(): AsyncGenerator<NodesetChunk> {
138
216
  this.startBody();
139
- if ((await this.probe()) !== "xml") {
140
- throw new Error(`nodeset source ${this.name}: not XML`);
141
- }
142
- const head = this.head as string[];
217
+ await this.probeContainer();
218
+ const head = this.head as NodesetChunk[];
143
219
  this.head = [];
144
220
  for (const chunk of head) {
145
221
  yield chunk;
@@ -149,15 +225,15 @@ export class NodesetReader {
149
225
  if (chunk === undefined) {
150
226
  return;
151
227
  }
152
- yield chunk as string;
228
+ yield chunk;
153
229
  }
154
230
  }
155
231
 
156
- /** the whole document as raw bytes, from the start, for a source holding an image; usable once */
232
+ /** the whole document as raw bytes, from the start, for a source holding bytes; usable once */
157
233
  public async *bytes(): AsyncGenerator<Uint8Array> {
158
234
  this.startBody();
159
- if ((await this.probe()) !== "image") {
160
- throw new Error(`nodeset source ${this.name}: not an image`);
235
+ if ((await this.probeContainer()) !== "bytes") {
236
+ throw new Error(`nodeset source ${this.name}: not a byte document`);
161
237
  }
162
238
  const head = this.head as Uint8Array[];
163
239
  this.head = [];
@@ -175,6 +251,9 @@ export class NodesetReader {
175
251
 
176
252
  /** the whole document as one byte array; reads the source to its end and keeps it */
177
253
  public async allBytes(): Promise<Uint8Array> {
254
+ if (this.allBytesValue !== undefined) {
255
+ return this.allBytesValue;
256
+ }
178
257
  if (this.bodyStarted) {
179
258
  throw new Error(`nodeset source ${this.name}: a source is read once`);
180
259
  }
@@ -182,7 +261,10 @@ export class NodesetReader {
182
261
  /* read to the end */
183
262
  }
184
263
  const parts = this.head.map((chunk) => (typeof chunk === "string" ? this.encoder.encode(chunk) : chunk));
185
- return concat(parts);
264
+ // kept, so that the sniff and the format that follows it share one array: the inflated
265
+ // lines are cached against the identity of these bytes, and a second concat would miss
266
+ this.allBytesValue = concat(parts);
267
+ return this.allBytesValue;
186
268
  }
187
269
 
188
270
  /** the bytes read so far; the whole document once it has been read */
@@ -214,6 +296,16 @@ export class NodesetReader {
214
296
  return this.digestValue;
215
297
  }
216
298
 
299
+ /**
300
+ * the digest if it has already been computed, without computing one.
301
+ *
302
+ * A format may want to record what it read from, but must never be the thing that forces a
303
+ * stream to be drained to find out.
304
+ */
305
+ public digestIfKnown(): string | undefined {
306
+ return this.digestValue;
307
+ }
308
+
217
309
  private startBody(): void {
218
310
  if (this.bodyStarted) {
219
311
  throw new Error(`nodeset source ${this.name}: a source is read once`);
@@ -236,8 +328,8 @@ export class NodesetReader {
236
328
  const result = await this.iterator.next();
237
329
  if (result.done) {
238
330
  this.exhausted = true;
239
- if (this.kind === undefined) {
240
- this.kind = "xml";
331
+ if (this.container === undefined) {
332
+ this.container = "text";
241
333
  }
242
334
  const tail = this.decoder ? this.decoder.decode() : "";
243
335
  if (tail.length > 0) {
@@ -266,15 +358,16 @@ export class NodesetReader {
266
358
  if (chunk.length === 0) {
267
359
  return undefined;
268
360
  }
269
- if (this.kind === undefined) {
270
- this.kind = typeof chunk !== "string" && isNodesetImage(chunk) ? "image" : "xml";
361
+ if (this.container === undefined) {
362
+ // text unless the bytes say otherwise; a string chunk is text by construction
363
+ this.container = typeof chunk !== "string" && isNodesetImage(chunk) ? "bytes" : "text";
271
364
  }
272
365
  const raw = typeof chunk === "string" ? this.encoder.encode(chunk) : chunk;
273
366
  this.bytesRead += raw.length;
274
367
  if (this.hashed) {
275
368
  this.hashed.push(raw);
276
369
  }
277
- if (this.kind === "image") {
370
+ if (this.container === "bytes") {
278
371
  const bytes = typeof chunk === "string" ? raw : chunk;
279
372
  if (!this.bodyStarted) this.head.push(bytes);
280
373
  return bytes;
@@ -365,3 +458,69 @@ export function openNodesetSource(
365
458
  };
366
459
  return new NodesetReader(name, open, whole, { hash: options.hash, imageKey });
367
460
  }
461
+
462
+ /** the head a sniffer is shown, with its first line split out for the line-delimited formats */
463
+ function makeHead(name: string, text: string, gzip: boolean): NodesetHead {
464
+ const newline = text.indexOf("\n");
465
+ const firstLine = newline < 0 ? text : text.slice(0, newline);
466
+ return { name, text, gzip, firstLine: firstLine.endsWith("\r") ? firstLine.slice(0, -1) : firstLine };
467
+ }
468
+
469
+ /**
470
+ * a {@link NodesetDocument} over a reader: what a format is handed.
471
+ *
472
+ * The point of the facade is that a format never touches the reader. It asks for the document in
473
+ * the shape it wants -- lines, text, bytes, a stream -- and the reader answers each of those once
474
+ * and shares the answer, so a format that wants both the lines and the raw bytes pays for one
475
+ * read and one inflate rather than two.
476
+ *
477
+ * @internal
478
+ */
479
+ export function nodesetDocumentOf(reader: NodesetReader): NodesetDocument {
480
+ let inflatedBytes: Promise<Uint8Array> | undefined;
481
+ let textValue: Promise<string> | undefined;
482
+ let linesValue: Promise<string[]> | undefined;
483
+
484
+ const rawBytes = () => reader.allBytes();
485
+
486
+ const bytes = () => {
487
+ if (!inflatedBytes) {
488
+ inflatedBytes = (async () => {
489
+ const raw = await rawBytes();
490
+ if (!isGzip(raw)) return raw;
491
+ // one inflate, shared with lines(): both go through the cache keyed on these bytes
492
+ return new TextEncoder().encode((await inflatedImageLines(raw)).join("\n"));
493
+ })();
494
+ }
495
+ return inflatedBytes;
496
+ };
497
+
498
+ const lines = () => {
499
+ if (!linesValue) {
500
+ linesValue = (async () => {
501
+ const raw = await rawBytes();
502
+ if (isGzip(raw)) return await inflatedImageLines(raw);
503
+ return new TextDecoder("utf-8").decode(raw).split("\n");
504
+ })();
505
+ }
506
+ return linesValue;
507
+ };
508
+
509
+ const text = () => {
510
+ if (!textValue) {
511
+ textValue = (async () => (await lines()).join("\n"))();
512
+ }
513
+ return textValue;
514
+ };
515
+
516
+ return {
517
+ name: reader.name,
518
+ bytes,
519
+ text,
520
+ lines,
521
+ rawBytes,
522
+ chunks: () => reader.chunks(),
523
+ readHead: (complete: (t: string) => boolean) => reader.readHead(complete),
524
+ digest: () => reader.digestIfKnown()
525
+ };
526
+ }
@@ -1,28 +1,39 @@
1
1
  /**
2
2
  * @module node-opcua-address-space
3
3
  */
4
+ import { NDJSON_IMAGE_FORMAT, registerBuiltinNodesetFormats } from "./nodeset_builtin_formats.js";
5
+ import { nodesetFormatByName } from "./nodeset_format.js";
4
6
  import { imageNodesetRecords, NodesetImageWriter, type NodesetImageWriterOptions } from "./nodeset_image.js";
5
- import { type NodesetSource, openNodesetSource } from "./nodeset_source.js";
6
- import { xmlNodesetRecords } from "./nodeset_xml_producer.js";
7
+ import { type NodesetSource, nodesetDocumentOf, openNodesetSource } from "./nodeset_source.js";
8
+
9
+ registerBuiltinNodesetFormats();
7
10
 
8
11
  export interface NodesetToImageOptions extends Pick<NodesetImageWriterOptions, "addressSpaceVersion" | "createdAt"> {}
9
12
 
10
13
  /**
11
- * the precompiled image of a NodeSet2 document, built from the source alone: converting a file
14
+ * the precompiled image of a nodeset document, built from the source alone: converting a file
12
15
  * needs neither its dependencies nor an address space. A source that already holds an image is
13
16
  * returned as it is, once read through to check it.
17
+ *
18
+ * Any registered format can be the input, not only NodeSet2 XML: an image is a cache of records,
19
+ * and records are what every format produces.
14
20
  */
15
21
  export async function nodesetToImage(source: NodesetSource, options: NodesetToImageOptions = {}): Promise<Uint8Array> {
16
22
  const reader = openNodesetSource(source, 0, { hash: true });
17
- if ((await reader.probe()) === "image") {
23
+ const formatName = await reader.probe();
24
+ if (formatName === NDJSON_IMAGE_FORMAT) {
18
25
  const bytes = await reader.allBytes();
19
26
  for await (const _record of imageNodesetRecords(bytes)) {
20
27
  /* a corrupt image is refused here rather than at load time */
21
28
  }
22
29
  return bytes;
23
30
  }
31
+ const format = nodesetFormatByName(formatName);
32
+ if (!format) {
33
+ throw new Error(`nodeset source ${reader.name}: its format is no longer registered`);
34
+ }
24
35
  const writer = new NodesetImageWriter({ ...options });
25
- for await (const record of xmlNodesetRecords(reader.chunks())) {
36
+ for await (const record of format.records(nodesetDocumentOf(reader))) {
26
37
  writer.apply(record);
27
38
  }
28
39
  return writer.finish(await reader.digest(), reader.length);
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @module node-opcua-address-space
3
+ *
4
+ * Reading the header of a NodeSet2 XML document: its `NamespaceUris` and its `Models`, and
5
+ * nothing else.
6
+ *
7
+ * This is the dependency pre-pass. Before any document is loaded the loader has to know what each
8
+ * one defines and what each one needs, so that it can sort them; it does that by reading every
9
+ * source no further than the end of its header, which on the standard nodeset is a few hundred
10
+ * bytes of a four megabyte file.
11
+ *
12
+ * It lives apart from the loader because the built-in XML format needs it too, and a format
13
+ * cannot import the loader that drives it.
14
+ */
15
+
16
+ import { getMinOPCUADate } from "node-opcua-date-time";
17
+ import { checkDebugFlag } from "node-opcua-debug";
18
+ import { type ReaderStateParser, type ReaderStateParserLike, Xml2Json, type XmlAttributes } from "node-opcua-xml2json";
19
+ import type { NodesetModel, NodesetModelInfo } from "./nodeset_format.js";
20
+
21
+ const doDebug = checkDebugFlag("generateAddressSpaceRaw");
22
+
23
+ type Model = NodesetModel;
24
+ type NodesetInfo = NodesetModelInfo;
25
+
26
+ /** the `NamespaceUris` and `Models` a NodeSet2 header declares */
27
+ export async function parseDependencies(xmlData: string): Promise<NodesetInfo> {
28
+ const namespaceUris: string[] = [];
29
+
30
+ const models: Model[] = [];
31
+ let currentModel: Model | undefined;
32
+ const state0: ReaderStateParser = {
33
+ parser: {
34
+ UANodeSet: {
35
+ parser: {
36
+ NamespaceUris: {
37
+ parser: {
38
+ Uri: <ReaderStateParserLike & { text: string }>{
39
+ finish(this: ReaderStateParserLike & { text: string }) {
40
+ namespaceUris.push(this.text);
41
+ }
42
+ }
43
+ }
44
+ },
45
+ Models: {
46
+ parser: {
47
+ Model: {
48
+ init(_elementName: string, attrs: XmlAttributes) {
49
+ const modelUri = attrs.ModelUri;
50
+ const version = attrs.Version;
51
+ const publicationDate = new Date(Date.parse(attrs.PublicationDate));
52
+ currentModel = {
53
+ modelUri,
54
+ version,
55
+ publicationDate,
56
+ requiredModel: []
57
+ };
58
+ doDebug && console.log(`currentModel = ${JSON.stringify(currentModel)}`);
59
+ models.push(currentModel);
60
+ },
61
+ parser: {
62
+ RequiredModel: {
63
+ init(_elementName: string, attrs: XmlAttributes) {
64
+ const modelUri = attrs.ModelUri;
65
+ const version = attrs.Version;
66
+ const publicationDate = new Date(Date.parse(attrs.PublicationDate));
67
+
68
+ if (!currentModel) {
69
+ throw new Error("Internal Error");
70
+ }
71
+ currentModel.requiredModel.push({
72
+ modelUri,
73
+ version,
74
+ publicationDate
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ };
86
+ const parser = new Xml2Json(state0);
87
+ parser.parseString(xmlData);
88
+ if (models.length === 0 && namespaceUris.length >= 1) {
89
+ models.push({
90
+ modelUri: namespaceUris[0],
91
+ version: "1",
92
+ publicationDate: getMinOPCUADate(),
93
+ requiredModel: []
94
+ });
95
+ }
96
+ return { models, namespaceUris: namespaceUris };
97
+ }
98
+
99
+ /**
100
+ * the header of a NodeSet2 file (`NamespaceUris`, `Models`) precedes the aliases and the nodes: the
101
+ * dependency pre-pass has read enough once both closers were seen, or once the body has begun
102
+ */
103
+ const BODY_START =
104
+ /<(Aliases|Extensions|UAObject|UAVariable|UADataType|UAReferenceType|UAObjectType|UAVariableType|UAMethod|UAView)[\s/>]/;
105
+ export function headerComplete(text: string): boolean {
106
+ return (text.includes("</Models>") && text.includes("</NamespaceUris>")) || BODY_START.test(text);
107
+ }
108
+
109
+ /** the `<UANodeSet ...>` opener up to the end of `</Models>` or `</NamespaceUris>`, whichever comes last */
110
+ export function sliceHeader(xmlData: string, name: string): string {
111
+ const indexStart = xmlData.match(/<UANodeSet/m)?.index;
112
+ const i1 = (xmlData.match(/<\/Models>/m)?.index || 0) + "</Models>".length;
113
+ const i2 = (xmlData.match(/<\/NamespaceUris>/m)?.index || 0) + "</NamespaceUris>".length;
114
+ const indexEnd = Math.max(i1, i2);
115
+ if (indexStart === undefined) {
116
+ throw new Error(`nodeset source ${name}: no <UANodeSet> element found`);
117
+ }
118
+ return xmlData.substring(indexStart, indexEnd);
119
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @module node-opcua-address-space
3
+ *
4
+ * The parts of writing a NodeSet2 document that are about XML and nothing else: the namespace
5
+ * prefix bookkeeping an `XmlWriter` carries around, the id and browse-name spellings, and the few
6
+ * `uax:` elements a value is built from.
7
+ *
8
+ * They live here, below the address space, because there are two writers and neither should own
9
+ * them: `impl/nodeset_tools/nodeset_to_xml.ts` writes a live namespace, and
10
+ * `nodeset_records_to_xml.ts` writes a record stream with no address space anywhere in sight. A
11
+ * helper that reached for a node would be usable by only one of them.
12
+ */
13
+ import { assert } from "node-opcua-assert";
14
+ import type { Int64, UInt64 } from "node-opcua-basic-types";
15
+ import { type LocalizedText, QualifiedName } from "node-opcua-data-model";
16
+ import { make_warningLog } from "node-opcua-debug";
17
+ import { NodeId } from "node-opcua-nodeid";
18
+ import type { XmlWriter } from "../xml_writer.js";
19
+
20
+ const warningLog = make_warningLog("nodeset_xml_primitives");
21
+
22
+ export type XmlNamespaceUri = string;
23
+ export type NamespaceUri = string;
24
+ export type XmlNs = string;
25
+
26
+ export const UAX_TYPES_XSD = "http://opcfoundation.org/UA/2008/02/Types.xsd";
27
+
28
+ /** the `<xmlns:nsN>` a namespace's own types are declared under, derived from its uri */
29
+ export function makeTypeXsd(namespaceUri: string): string {
30
+ return `${namespaceUri.replace(/\/$/, "")}/Type.xsd`;
31
+ }
32
+
33
+ interface XmlWriterEx extends XmlWriter {
34
+ map: Record<XmlNamespaceUri, XmlNs>;
35
+ stackMap: Record<XmlNamespaceUri, XmlNs>[];
36
+ namespaceArray: NamespaceUri[];
37
+ }
38
+
39
+ export function initXmlWriterEx(xw: XmlWriter, map: Record<XmlNamespaceUri, XmlNs>, namespaceArray: NamespaceUri[]): void {
40
+ const xwe = xw as XmlWriterEx;
41
+ xwe.map = map;
42
+ xwe.stackMap = [];
43
+ xwe.namespaceArray = namespaceArray;
44
+ }
45
+
46
+ export function findXsdNamespaceUri(xw: XmlWriter, nodeId: NodeId): string {
47
+ const xwe = xw as XmlWriterEx;
48
+ if (!xwe.namespaceArray) {
49
+ return "";
50
+ }
51
+ const namespace = xwe.namespaceArray[nodeId.namespace];
52
+ if (namespace === "http://opcfoundation.org/UA/") {
53
+ return UAX_TYPES_XSD;
54
+ }
55
+ // c8 ignore next
56
+ if (!namespace) {
57
+ return "";
58
+ }
59
+ return `${namespace.replace(/\/$/, "")}/Types.xsd`;
60
+ }
61
+
62
+ export function getPrefix(xw: XmlWriter, namespace: XmlNamespaceUri): XmlNs {
63
+ const xwe = xw as XmlWriterEx;
64
+ if (!xwe.map) return "";
65
+ const p = xwe.map[namespace] || "";
66
+ return p ? `${p}:` : "";
67
+ }
68
+
69
+ export function restoreDefaultNamespace(xw: XmlWriter): void {
70
+ const xwe = xw as XmlWriterEx;
71
+ if (!xwe.map) return;
72
+ const previousMap = xwe.stackMap.pop();
73
+ if (previousMap) {
74
+ xwe.map = previousMap;
75
+ }
76
+ }
77
+
78
+ export function setDefaultNamespace(xw: XmlWriter, namespace: XmlNamespaceUri): void {
79
+ const xwe = xw as XmlWriterEx;
80
+ if (!xwe.map) return;
81
+ if (xwe.map[namespace] !== "") {
82
+ xw.writeAttribute("xmlns", namespace);
83
+ }
84
+
85
+ xwe.stackMap.push({
86
+ ...xwe.map
87
+ });
88
+ xwe.map[namespace] = "";
89
+ }
90
+
91
+ export function startElementEx(xw: XmlWriter, _ns: XmlNs, name: string, defaultNamespace: XmlNamespaceUri): void {
92
+ xw.startElement(name);
93
+ setDefaultNamespace(xw, defaultNamespace);
94
+ }
95
+
96
+ // #region ids and names
97
+ //
98
+ // every id written goes through the writer's translationTable, which maps the index a node carries
99
+ // to the index the document being written declares. A writer whose document keeps the indices it
100
+ // was given -- the record writer -- installs the identity and these are then spelling functions.
101
+
102
+ export function translateNodeId(xw: XmlWriter, nodeId: NodeId): NodeId {
103
+ assert(nodeId instanceof NodeId);
104
+ const nn = xw.translationTable.get(nodeId.namespace);
105
+ return new NodeId(nodeId.identifierType, nodeId.value, nn);
106
+ }
107
+
108
+ /** an id as a document spells it: translated, and with the `ns=0;` that every document leaves out */
109
+ export function n(xw: XmlWriter, nodeId: NodeId): string {
110
+ return translateNodeId(xw, nodeId).toString().replace("ns=0;", "");
111
+ }
112
+
113
+ export function translateBrowseName(xw: XmlWriter, browseName: QualifiedName): QualifiedName {
114
+ assert(browseName instanceof QualifiedName);
115
+ const nn = xw.translationTable.get(browseName.namespaceIndex);
116
+ return new QualifiedName({ namespaceIndex: nn, name: browseName.name });
117
+ }
118
+
119
+ export function b(xw: XmlWriter, browseName: QualifiedName): string {
120
+ return translateBrowseName(xw, browseName).toString().replace("ns=0;", "");
121
+ }
122
+
123
+ /** the identity table, for a writer that keeps the namespace indices its input already carries */
124
+ export function identityTranslationTable(size: number): Map<number, number> {
125
+ const table = new Map<number, number>();
126
+ for (let i = 0; i < size; i++) {
127
+ table.set(i, i);
128
+ }
129
+ return table;
130
+ }
131
+
132
+ // #endregion
133
+
134
+ const TWO_64 = 1n << 64n;
135
+ const TWO_63 = 1n << 63n;
136
+
137
+ /**
138
+ * a 64-bit integer as one decimal number, the only spelling that says what it means.
139
+ *
140
+ * In memory node-opcua holds one as `[high, low]` with unsigned halves, so Int64 -1 and UInt64
141
+ * 18446744073709551615 are the very same pair and only `signed` tells them apart. Writing the low
142
+ * half alone -- which both XML writers used to do -- silently truncates anything past 2^32 and
143
+ * turns -1 into 4294967295.
144
+ */
145
+ export function int64ToDecimalString(value: Int64 | UInt64 | number | string, signed: boolean): string {
146
+ if (typeof value === "number" || typeof value === "string") {
147
+ return value.toString();
148
+ }
149
+ const [high, low] = value as [number, number];
150
+ const unsigned = (BigInt(high >>> 0) << 32n) | BigInt(low >>> 0);
151
+ return (signed && unsigned >= TWO_63 ? unsigned - TWO_64 : unsigned).toString();
152
+ }
153
+
154
+ export function coerceInt64ToInt32(int64: Int64): number {
155
+ if (typeof int64 === "number") {
156
+ return int64;
157
+ }
158
+ if (int64[0] === 0xffffffff && int64[1] === 0xffffffff) {
159
+ return 0xffffffff;
160
+ }
161
+ if (int64[0] !== 0) {
162
+ warningLog("coerceInt64ToInt32 , loosing high word in conversion");
163
+ }
164
+ return int64[1];
165
+ }
166
+
167
+ // #region the `uax:` elements a value is built from
168
+
169
+ export function _dumpLocalizedText(xw: XmlWriter, v: LocalizedText): void {
170
+ const uax = getPrefix(xw, UAX_TYPES_XSD);
171
+ if (v.locale?.length) {
172
+ xw.startElement(`${uax}Locale`);
173
+ xw.text(v.locale);
174
+ xw.endElement();
175
+ }
176
+ xw.startElement(`${uax}Text`);
177
+ if (v.text) {
178
+ xw.text(v.text);
179
+ }
180
+ xw.endElement();
181
+ }
182
+
183
+ export function _dumpQualifiedName(xw: XmlWriter, v: QualifiedName): void {
184
+ const uax = getPrefix(xw, UAX_TYPES_XSD);
185
+ const t = translateBrowseName(xw, v);
186
+ if (t.name) {
187
+ xw.startElement(`${uax}Name`);
188
+ xw.text(t.name);
189
+ xw.endElement();
190
+ }
191
+ if (t.namespaceIndex) {
192
+ xw.startElement(`${uax}NamespaceIndex`);
193
+ xw.text(t.namespaceIndex.toString());
194
+ xw.endElement();
195
+ }
196
+ }
197
+
198
+ export function _dumpXmlElement(xw: XmlWriter, v: string): void {
199
+ xw.text(v);
200
+ }
201
+
202
+ export function _dumpNodeId(xw: XmlWriter, v: NodeId): void {
203
+ const xmlns = getPrefix(xw, UAX_TYPES_XSD);
204
+ xw.startElement(`${xmlns}Identifier`);
205
+ xw.text(n(xw, v));
206
+ xw.endElement();
207
+ }
208
+
209
+ // #endregion