node-opcua-address-space 2.181.1 → 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 (121) 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_alarm_condition_impl.ts +10 -0
  109. package/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.ts +17 -0
  110. package/impl/base_node_impl.ts +15 -0
  111. package/impl/base_node_private.ts +97 -146
  112. package/impl/nodeset_tools/construct_namespace_dependency.ts +5 -1
  113. package/impl/nodeset_tools/nodeset_to_records.ts +66 -16
  114. package/impl/nodeset_tools/nodeset_to_xml.ts +127 -160
  115. package/impl/ua_data_type_impl.ts +40 -2
  116. package/impl/ua_object_type_impl.ts +16 -3
  117. package/impl/ua_variable_impl.ts +27 -10
  118. package/impl/ua_variable_impl_ext_obj.ts +20 -2
  119. package/impl/ua_variable_type_impl.ts +48 -2
  120. package/package.json +29 -27
  121. package/source_nodejs/generate_address_space.ts +7 -1
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @module node-opcua-address-space
3
+ *
4
+ * The two serialisations the loader has always read, expressed as {@link NodesetFormat}s.
5
+ *
6
+ * Registering them here rather than branching on a boolean inside the loader is the whole point
7
+ * of the registry: the built-ins get no privilege a third-party format does not also get, so the
8
+ * contract cannot quietly grow a dependency on something only the built-ins can reach. If these
9
+ * two can be written against it, so can a fourth.
10
+ *
11
+ * Importing this module registers them. The loader does that once, at its own import.
12
+ */
13
+
14
+ import { getMinOPCUADate } from "node-opcua-date-time";
15
+ import type { NodeSetLoaderOptions } from "../interfaces/nodeset_loader_options.js";
16
+ import {
17
+ type NodesetDocument,
18
+ type NodesetFormat,
19
+ type NodesetHead,
20
+ type NodesetModel,
21
+ type NodesetModelInfo,
22
+ registerNodesetFormat
23
+ } from "./nodeset_format.js";
24
+ import { imageLinesToRecords, readNodesetImageInfo } from "./nodeset_image.js";
25
+ import type { NodesetRecord } from "./nodeset_record.js";
26
+ import { headerComplete, parseDependencies, sliceHeader } from "./nodeset_xml_header.js";
27
+ import { xmlNodesetRecords } from "./nodeset_xml_producer.js";
28
+
29
+ /** the name the NodeSet2 XML format is registered under */
30
+ export const NODESET2_XML_FORMAT = "nodeset2-xml";
31
+ /** the name the NDJSON image format is registered under */
32
+ export const NDJSON_IMAGE_FORMAT = "ndjson";
33
+
34
+ /**
35
+ * NodeSet2 XML: the fallback.
36
+ *
37
+ * It claims every document no other format recognised, which is exactly what the loader did
38
+ * before there was a registry, and it is the right default for a reason that outlives this
39
+ * refactor: XML has no compact magic number worth sniffing. A document may open with a byte-order
40
+ * mark, an XML declaration, a comment, a doctype or whitespace before `<UANodeSet` ever appears,
41
+ * and a sniffer strict enough to be sure would reject documents the loader reads today. So it
42
+ * does not guess. It takes what is left, and reports a real parse error if it was wrong, which is
43
+ * a better diagnostic than "unrecognised format" ever was.
44
+ */
45
+ const nodeset2XmlFormat: NodesetFormat = {
46
+ name: NODESET2_XML_FORMAT,
47
+ extensions: [".xml"],
48
+ priority: -100,
49
+ sniff(_head: NodesetHead): boolean {
50
+ return true;
51
+ },
52
+ async readModels(document: NodesetDocument): Promise<NodesetModelInfo> {
53
+ const head = await document.readHead(headerComplete);
54
+ return await parseDependencies(sliceHeader(head, document.name));
55
+ },
56
+ records(document: NodesetDocument, _options?: NodeSetLoaderOptions): AsyncIterable<NodesetRecord> {
57
+ return xmlNodesetRecords(textChunks(document));
58
+ }
59
+ };
60
+
61
+ /** the chunks of a document as text: the XML producer takes text, and a source may deliver bytes */
62
+ async function* textChunks(document: NodesetDocument): AsyncGenerator<string> {
63
+ const decoder = new TextDecoder("utf-8");
64
+ for await (const chunk of document.chunks()) {
65
+ yield typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
66
+ }
67
+ }
68
+
69
+ /**
70
+ * The NDJSON image: node-opcua's own cache of a document it has already parsed.
71
+ *
72
+ * Its first line is a header record, so it is told from every other line-delimited nodeset format
73
+ * by the two fields that open it: `{"kind":"header","schema":<n>`. That is a stronger test than
74
+ * "begins with a brace", which is all the loader used to do, and it is what lets a foreign
75
+ * `.jsonl` sit in the same directory without being mistaken for one of ours.
76
+ */
77
+ const ndjsonImageFormat: NodesetFormat = {
78
+ name: NDJSON_IMAGE_FORMAT,
79
+ extensions: [".ndjson", ".ndjson.gz"],
80
+ priority: 100,
81
+ sniff(head: NodesetHead): boolean {
82
+ return isNdjsonHeaderLine(head.firstLine);
83
+ },
84
+ async readModels(document: NodesetDocument): Promise<NodesetModelInfo> {
85
+ // an image is small, a few hundred KB, and its header is line 1: reading it whole costs
86
+ // nothing that the load itself was not about to spend
87
+ const { header } = await readNodesetImageInfo(await document.rawBytes());
88
+ const models: NodesetModel[] = header.models.map((m) => ({
89
+ modelUri: m.modelUri,
90
+ version: m.version,
91
+ publicationDate: m.publicationDate ? new Date(m.publicationDate) : getMinOPCUADate(),
92
+ requiredModel: m.requiredModels.map((r) => ({
93
+ ...r,
94
+ publicationDate: new Date(r.publicationDate ?? Number.NaN)
95
+ }))
96
+ }));
97
+ if (models.length === 0 && header.namespaceUris.length >= 1) {
98
+ models.push({
99
+ modelUri: header.namespaceUris[0],
100
+ version: "1",
101
+ publicationDate: getMinOPCUADate(),
102
+ requiredModel: []
103
+ });
104
+ }
105
+ return { models, namespaceUris: header.namespaceUris };
106
+ },
107
+ async *records(document: NodesetDocument, _options?: NodeSetLoaderOptions): AsyncGenerator<NodesetRecord> {
108
+ yield* imageLinesToRecords(await document.lines());
109
+ }
110
+ };
111
+
112
+ /**
113
+ * whether this line is the header record of an NDJSON image.
114
+ *
115
+ * Deliberately a string test before it is a parse: the discriminating fields are written first
116
+ * and the line can be hundreds of kilobytes, so there is no reason to build an object to answer
117
+ * a question the first thirty characters already settle. The parse is the confirmation, not the
118
+ * test, and it is only reached for a line that already looks like ours.
119
+ */
120
+ export function isNdjsonHeaderLine(line: string): boolean {
121
+ const start = line.slice(0, 200);
122
+ if (!start.startsWith("{") || !start.includes('"kind"') || !start.includes('"header"')) {
123
+ return false;
124
+ }
125
+ try {
126
+ const parsed = JSON.parse(line) as { kind?: unknown; schema?: unknown };
127
+ return parsed?.kind === "header" && typeof parsed.schema === "number";
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ let registered = false;
134
+
135
+ /** register the built-in formats; called by the loader, and safe to call more than once */
136
+ export function registerBuiltinNodesetFormats(): void {
137
+ if (registered) return;
138
+ registered = true;
139
+ registerNodesetFormat(nodeset2XmlFormat);
140
+ registerNodesetFormat(ndjsonImageFormat);
141
+ }
142
+
143
+ registerBuiltinNodesetFormats();
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @module node-opcua-address-space
3
+ *
4
+ * Which serialisation a nodeset document is written in, and who can read it.
5
+ *
6
+ * The loader is built as `producer -> NodesetRecord -> consumer`: whatever a document is written
7
+ * in, it becomes a stream of records, and everything downstream of that -- the address space, the
8
+ * NodeSet2 XML writer, the image writer -- neither knows nor asks. What was missing is the other
9
+ * half of that seam. The *choice* of producer was a hard-coded pair, XML or image, decided on one
10
+ * byte, so a third serialisation had no way in short of editing the loader.
11
+ *
12
+ * A {@link NodesetFormat} is that way in. It answers three questions and nothing else: are these
13
+ * bytes mine, what does this document depend on, and what are its records. Formats are found by
14
+ * sniffing the document, never by its file name: an extension is a claim about a file, and the
15
+ * loader reads sources that are not files at all.
16
+ *
17
+ * Two formats are built in and registered by the loader itself, so the default behaviour is
18
+ * exactly what it always was: NodeSet2 XML, and the NDJSON image. Anything else is registered by
19
+ * the code that implements it, the way the Node.js entry point installs its zlib codecs.
20
+ */
21
+
22
+ import type { RequiredModel } from "node-opcua-address-space-base";
23
+ import type { NodeSetLoaderOptions } from "../interfaces/nodeset_loader_options.js";
24
+ import type { NodesetRecord } from "./nodeset_record.js";
25
+ import type { NodesetChunk } from "./nodeset_source.js";
26
+
27
+ /** a model a document defines, together with the models it says it needs */
28
+ export interface NodesetModel extends RequiredModel {
29
+ requiredModel: RequiredModel[];
30
+ }
31
+
32
+ /**
33
+ * what a document says about itself before its body is read: enough, and only enough, to sort a
34
+ * set of documents into the order they have to be loaded in.
35
+ */
36
+ export interface NodesetModelInfo {
37
+ namespaceUris: string[];
38
+ models: NodesetModel[];
39
+ }
40
+
41
+ /**
42
+ * the beginning of a document, for {@link NodesetFormat.sniff}.
43
+ *
44
+ * `text` is inflated when the document arrived gzipped, so a sniffer never has to care whether it
45
+ * is looking at a compressed file: compression is a property of how a document was stored, never
46
+ * of what it says. It is decoded as UTF-8, which is lossless for the text formats and, for a
47
+ * binary container, faithful across the ASCII and NUL runs that container formats put their magic
48
+ * in (a tar header, for instance, is ASCII and NUL for its whole first block).
49
+ */
50
+ export interface NodesetHead {
51
+ /** the first bytes of the document, decoded as UTF-8, inflated if the document was gzipped */
52
+ readonly text: string;
53
+ /** `text` up to the first line feed: what the line-delimited formats decide on */
54
+ readonly firstLine: string;
55
+ /** whether the document was gzip on the wire */
56
+ readonly gzip: boolean;
57
+ /** the source's name, for diagnostics only; never a basis for the decision */
58
+ readonly name: string;
59
+ }
60
+
61
+ /**
62
+ * a document handed to a format, in whichever shape that format finds convenient. Every accessor
63
+ * inflates first when the document was gzipped, and the results are computed once and shared, so
64
+ * a format that asks for both the lines and the bytes pays for one inflate.
65
+ */
66
+ export interface NodesetDocument {
67
+ readonly name: string;
68
+ /** the whole document, inflated */
69
+ bytes(): Promise<Uint8Array>;
70
+ /** the whole document as text, inflated and decoded */
71
+ text(): Promise<string>;
72
+ /** the document's lines, for the line-delimited formats */
73
+ lines(): Promise<string[]>;
74
+ /** the bytes exactly as they arrived, still compressed if they were */
75
+ rawBytes(): Promise<Uint8Array>;
76
+ /** the document as it arrives, for a format that parses incrementally rather than whole */
77
+ chunks(): AsyncIterable<NodesetChunk>;
78
+ /**
79
+ * read from the start until `complete(text)` holds, or the document ends. What was read stays
80
+ * in the document and is delivered again, first, by {@link NodesetDocument.chunks}, so a
81
+ * format that reads a header this way has not spent the source.
82
+ *
83
+ * This is how a format avoids reading a body it does not need: the XML header pre-pass stops
84
+ * after a few hundred bytes of a four megabyte file.
85
+ */
86
+ readHead(complete: (text: string) => boolean): Promise<string>;
87
+ /** the SHA-256 of the bytes as they arrived, when the loader was asked to compute one */
88
+ digest(): string | undefined;
89
+ }
90
+
91
+ /** a serialisation the loader can read */
92
+ export interface NodesetFormat {
93
+ /** how this format is named in diagnostics and in the tools; unique among registered formats */
94
+ readonly name: string;
95
+ /**
96
+ * the file extensions this format is usually written with, lowercase and dotted.
97
+ * Documentation and tooling only: the loader never decides on a name.
98
+ */
99
+ readonly extensions?: readonly string[];
100
+ /**
101
+ * which format wins when more than one claims a document. Higher is stronger; the default is
102
+ * 0. NodeSet2 XML registers at -100 because it is the fallback: it claims anything no other
103
+ * format recognised, which is the behaviour the loader has always had.
104
+ */
105
+ readonly priority?: number;
106
+ /** whether this format claims the document that begins like this */
107
+ sniff(head: NodesetHead): boolean;
108
+ /** what the document depends on, read without parsing its body */
109
+ readModels(document: NodesetDocument): Promise<NodesetModelInfo>;
110
+ /** the document's records, in document order */
111
+ records(document: NodesetDocument, options?: NodeSetLoaderOptions): AsyncIterable<NodesetRecord>;
112
+ }
113
+
114
+ const formats = new Map<string, NodesetFormat>();
115
+
116
+ /**
117
+ * make a format available to the loader. Returns a function that removes it again, which is what
118
+ * a test uses to put the registry back as it found it; production code registers once, at import.
119
+ *
120
+ * Registering a name twice replaces the earlier format, so a caller can override a built-in.
121
+ */
122
+ export function registerNodesetFormat(format: NodesetFormat): () => void {
123
+ formats.set(format.name, format);
124
+ return () => {
125
+ if (formats.get(format.name) === format) {
126
+ formats.delete(format.name);
127
+ }
128
+ };
129
+ }
130
+
131
+ /** every registered format, strongest claim first */
132
+ export function nodesetFormats(): readonly NodesetFormat[] {
133
+ return [...formats.values()].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
134
+ }
135
+
136
+ /** the registered format of that name, if any */
137
+ export function nodesetFormatByName(name: string): NodesetFormat | undefined {
138
+ return formats.get(name);
139
+ }
140
+
141
+ /**
142
+ * which format claims a document that begins like this, or `undefined` when none does. Formats
143
+ * are asked strongest first, so a specific format is always given the chance to claim a document
144
+ * before the XML fallback sees it.
145
+ */
146
+ export function findNodesetFormat(head: NodesetHead): NodesetFormat | undefined {
147
+ for (const format of nodesetFormats()) {
148
+ if (format.sniff(head)) {
149
+ return format;
150
+ }
151
+ }
152
+ return undefined;
153
+ }
@@ -31,9 +31,24 @@ import type { NodesetChunk, NodesetChunkStream } from "./nodeset_source.js";
31
31
 
32
32
  export { NodesetImageError, type NodesetImageHeader, type NodesetImageTrailer } from "./nodeset_image_codec.js";
33
33
 
34
- /** the two bytes every gzip stream starts with: how an image is told from XML */
34
+ const GZIP_MAGIC_0 = 0x1f;
35
+ const GZIP_MAGIC_1 = 0x8b;
36
+ /** `{`: the first byte of a document whose header record was not compressed */
37
+ const OPEN_BRACE = 0x7b;
38
+
39
+ /** whether these bytes are gzip, and so need inflating before they are text */
40
+ export function isGzip(firstBytes: Uint8Array): boolean {
41
+ return firstBytes.length >= 2 && firstBytes[0] === GZIP_MAGIC_0 && firstBytes[1] === GZIP_MAGIC_1;
42
+ }
43
+
44
+ /**
45
+ * a NodeSet-NDJSON document, compressed or not, told from NodeSet2 XML by its first byte: a gzip
46
+ * stream starts `1f 8b`, an XML document `<`, and an uncompressed document `{`. Compression is a
47
+ * property of how a document was stored, never of what it says, so a reader that took only the
48
+ * compressed form would be refusing the same document written plainly.
49
+ */
35
50
  export function isNodesetImage(firstBytes: Uint8Array): boolean {
36
- return firstBytes.length >= 2 && firstBytes[0] === 0x1f && firstBytes[1] === 0x8b;
51
+ return isGzip(firstBytes) || (firstBytes.length >= 1 && firstBytes[0] === OPEN_BRACE);
37
52
  }
38
53
 
39
54
  async function toReadableStream(source: Uint8Array | NodesetChunkStream): Promise<ReadableStream<Uint8Array>> {
@@ -59,11 +74,28 @@ async function toReadableStream(source: Uint8Array | NodesetChunkStream): Promis
59
74
  });
60
75
  }
61
76
 
62
- async function gzip(text: string): Promise<Uint8Array> {
77
+ /** how the text of an image is compressed; see {@link setImageDeflater} */
78
+ export type ImageDeflater = (text: string) => Promise<Uint8Array>;
79
+
80
+ const deflateWithCompressionStream: ImageDeflater = async (text) => {
63
81
  const compressed = new Blob([text])
64
82
  .stream()
65
83
  .pipeThrough(new CompressionStream("gzip") as unknown as ReadableWritablePair<Uint8Array, Uint8Array>);
66
84
  return new Uint8Array(await new Response(compressed).arrayBuffer());
85
+ };
86
+
87
+ let gzip: ImageDeflater = deflateWithCompressionStream;
88
+
89
+ /**
90
+ * how an image is compressed: CompressionStream everywhere by default, which offers no way to ask
91
+ * for a compression level and so writes at the default one; the Node.js entry point installs zlib
92
+ * at level 9, four percent smaller on the published nodesets for no change to the reader -- every
93
+ * gzip stream inflates the same whatever level wrote it. Returns the deflater installed before.
94
+ */
95
+ export function setImageDeflater(deflater: ImageDeflater): ImageDeflater {
96
+ const previous = gzip;
97
+ gzip = deflater;
98
+ return previous;
67
99
  }
68
100
 
69
101
  /**
@@ -115,7 +147,8 @@ export function inflatedImageLines(image: Uint8Array): Promise<string[]> {
115
147
  lines = (async () => {
116
148
  let text: string;
117
149
  try {
118
- text = await inflateImage(image);
150
+ // an uncompressed document is already its own text; only gzip needs inflating
151
+ text = isGzip(image) ? await inflateImage(image) : new TextDecoder("utf-8").decode(image);
119
152
  } catch (err) {
120
153
  throw new NodesetImageError(`the image cannot be inflated: ${(err as Error).message}`);
121
154
  }