node-opcua 2.184.0: Modules, All The Way Down
Compare: v2.183.0...v2.184.0 · 93 commits · 48 pull requests · includes the 2.183.1 patch
⚠️ Read this first if your project is CommonJS
node-opcuais an ES module from this release. 2.183.1 carried notypefield; 2.184.0 declares"type": "module".Your application still runs.
require("node-opcua")works unchanged on the supported Node versions, and there is noexportsmap, somainandtypesresolve as they always did.Your TypeScript build may stop compiling, with
TS1479,TS1541orTS1542, one per import site:error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("node-opcua")' call instead.The fix is one line in your
tsconfig.json. You do not convert your project to ESM, and it stays CommonJS:That cures all three error codes at once. Confirmed on a real 145-file consumer: 258 errors to 0, nothing else changed. Do not hand-fix
TS1541with per-importresolution-modeattributes; it looks like progress and cannot finish the job.Full detail, including what converting to ESM would cost you instead, is under If your build stops compiling below.
TL;DR
Every one of the 122 packages is now a real ES module, the umbrella included. node-opcua was written when CommonJS was the only option, and the whole workspace has just moved across in seven batches, from the eight leaf packages up through the encoding core, the address space, the client and server chain, and finally node-opcua itself. Your application keeps running: require("node-opcua") works exactly as it did, because Node has read ES modules from require() since 22.12 and the project has required 22.13 since last month. Two consumer fixtures, one CommonJS and one ESM, prove it on every single run. A CommonJS TypeScript project does need one line changed before it compiles again, and the banner above says which line.
The toolchain moved with it. The workspace compiles with TypeScript 7, which took a cold build from 192 seconds to 38, a real saving on every push since CI builds seven times before a test runs. Declaration maps ship now, so "go to definition" on a node-opcua symbol lands in the actual source instead of stopping at a .d.ts. Loading the SDK resolves a third fewer files after the generated nodeset packages stopped requiring 532 modules that contain nothing at run time. And five new gates check what npm actually publishes, which immediately found one companion nodeset package that had been shipping with no compiled code at all, and a sample command that had pointed at a deleted file for nearly three years.
Alongside it, the alarm story continues and the test suite got honest. Namespaces can now be deleted and repopulated, which takes a model recompile from 442 ms to single digits, the work behind an editor that stays warm. Alarms and Conditions clients finally get their condition filters answered, so the compliance tool's alarm collector runs at last, after reporting "no alarms received" for months. The suite itself now runs on Node's own type stripping rather than a transpiler, which stopped coverage being measured against compiled output nobody edits: the reported figure went from 79% to 91.7% without a single new test, because 32 500 covered lines had been counted against the wrong files.
Highlights
- 📦 122 of 122 packages are ESM.
require("node-opcua")is unaffected, proven by a CommonJS fixture on every run. - ⚡ TypeScript 7: a cold workspace build drops from 192 s to 38 s.
- 🗺️ Declaration maps ship, so "go to definition" reaches the source.
- 🚀 A third fewer files loaded:
require("node-opcua")goes from 1 606 files to 1 074. - 🧹
deleteNamespace, so a model can be emptied and repopulated in milliseconds instead of rebuilding the address space. - 🔔 Condition filters work: an empty-browsePath ConditionId operand resolves in both select and where clauses.
- 📊 Coverage measured on the source, 79% to 91.7%, no new tests.
- 🛡️ Five packaging gates, which found a package published with no code and a three-year-old broken command.
If your build stops compiling
node-opcua declares "type": "module" from this release. 2.183.1 declared no type at all, so the umbrella went from format-neutral to ESM-only in one step. That is the change most likely to reach you, and it deserves more than a line.
What still works, unchanged. require("node-opcua") from a CommonJS file returns the module and every named export, because Node has read ES modules from require() without a flag since 22.12 and this project has declared a floor of 22.13 since 2.183.0. There is no exports map on the umbrella, so main and types resolve exactly as before. Verified directly: a CommonJS file requiring the published package resolves OPCUAClient, and resolveNodeId("ns=0;i=85") returns a real NodeId. The precondition for that is no module-scope await anywhere in the graph, which the check:tla gate has enforced since 2.181.0.
What stops. A CommonJS TypeScript project compiling against it reports one error per import site:
error TS1479: The current file is a CommonJS module whose imports will produce 'require'
calls; however, the referenced file is an ECMAScript module and cannot be imported with
'require'. Consider writing a dynamic 'import("node-opcua")' call instead.
The compiler is describing an older Node than you are running. Your project says CommonJS, the dependency says ESM, and under module: node16 TypeScript still treats that pair as forbidden. It is a compile-time verdict, not a runtime one.
Exactly one consumer shape breaks. Both versions were installed from npm and driven through the same four projects, compiling and then running the emitted output:
| your project | with 2.183.1 | with 2.184.0 |
|---|---|---|
plain JavaScript, require("node-opcua")
| works | works |
TypeScript, module: commonjs + moduleResolution: node
| works | works |
TypeScript, module: node16
| works | TS1479 |
TypeScript, module: nodenext
| works | works |
The fix is nodenext, and your project stays CommonJS:
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } }nodenext is the setting that knows a modern Node can require() an ES module; node16 is the one that does not. You do not add "type": "module", you do not rewrite imports, and you do not switch to await import(). The compiler keeps emitting CommonJS: with nodenext, tsc emits const node_opcua_1 = require("node-opcua") and the result runs.
This was confirmed on a real consumer, not only on a probe. A 145-file package with 19 node-opcua dependencies went from 258 errors to 0 with those two settings and nothing else changed, and its full pipeline then ran end to end.
Three error codes, one cause. The same upgrade produces TS1479 on value imports, TS1541 on type-only imports and TS1542. In that 145-file package the split was 180, 76 and 2. All three are cured by the one line above. TS1541 is a trap worth naming: it suggests a per-import resolution-mode attribute, which appears to work, so you can spend an afternoon hand-editing a third of the errors before discovering that TS1479 next door cannot be fixed that way at all.
Converting to ESM also works, and costs much more. The moment you set "type": "module" your own code inherits ESM specifier strictness: every relative import needs an explicit .js, measured at 513 rewrites across 107 files in that same package. Extensionless subpaths into node-opcua packages, such as node-opcua-address-space/nodeJS, also stop resolving. Under nodenext-as-CommonJS both keep working, because these packages publish no exports map and CommonJS still guesses extensions. The cost of converting is not one setting, it is every import you own.
One caveat if you are on TypeScript 5. moduleResolution: "node" ignores both type and exports, so a project on that setting compiles today with no change at all. That is not a long-term answer, since TypeScript 7 removed it outright (TS5108), but it explains why some packages in one workspace break and others do not.
If the errors do not go away, delete the build cache. This is the most likely reason you will think the fix failed. With incremental: true, which composite: true implies, TypeScript caches diagnostics per file and a change in an extended tsconfig.json does not invalidate them, so old files replay old errors. Run tsc -b --force, or delete every .tsbuildinfo. Note that when outDir is set the cache lives inside the output directory, not beside your tsconfig.json, so deleting the file you expect to find achieves nothing. Reproduced: node16 gives one TS1479; switching to nodenext and deleting ./tsconfig.tsbuildinfo still gives one; deleting out/tsconfig.tsbuildinfo gives zero, with no source change.
Bundlers: most are fine, and the exception is not about ES module support. What matters is whether your bundler emits the require() and lets Node resolve it, or pre-validates the externalisation against a rule that predates Node 22.12. esbuild is verified working, externalising the whole family from a CommonJS output with a 658-test suite passing. Next.js 16.2.6 with Turbopack is confirmed broken: it refuses to externalise an ES module, bundles it instead, and the build then dies because those packages were externalised precisely for using __dirname and reading nodeset files from disk. Its message says "The package seems invalid", which describes its own check rather than the package, and no node-opcua change can fix it.
For scale, in the 22-package workspace where all of this was measured, twenty-one packages upgraded untouched: a CLI, a language server, an editor extension and several back-end services. One Next.js application broke.
The full guide, with the FAQ and the measurements, is at documentation/migrating_to_esm.md.
⚠️ Behaviour changes to review before upgrading
Every package is an ES module, the umbrella included. "type": "module" on all 122, where 2.183.1 declared none. Nothing changes at run time, and a CommonJS TypeScript project needs one line changed to compile. See If your build stops compiling. #1690 through #1704
A project compiling against node-opcua needs TypeScript 5.0 or above. The generated nodeset indexes now use export type *, which is TypeScript 5.0 syntax and appears in published .d.ts files that node-opcua-address-space re-exports. TypeScript 4.x cannot parse them. TypeScript 5.0 is from March 2023 and the Node floor is already 22.13. #1688
Hand-run scripts are renamed to .cjs. Roughly 40 CommonJS scripts across bin/ and playground directories keep their CommonJS form under an explicit extension. Three published commands moved with their manifest entries, so nothing a consumer types changes. The end-to-end tests that spawn a server by file name were updated, a reference no compiler or gate can see. #1701 #1703 #1704
node-opcua-samples generates its certificate folder as CommonJS. node-opcua-pki writes a CommonJS certificates/config.js and then requires it; once the package became a module that file was read as ESM and pki failed while still exiting 0. A certificates/package.json carrying {"type":"commonjs"} scopes the generated folder back. #1704
Fourteen type re-exports became export type. export { SomeInterface } from "..." is elided by a whole-program compiler and kept by a single-file transpiler, where it becomes a link error under ESM. Five were in the node-opcua and node-opcua-client entry points, so they would have failed to link for any ESM consumer. isolatedModules is now on across the workspace, so the class cannot return. #1710
A test file with # in its name is renamed. ESM resolves a specifier as a URL, so #804.js was read as a fragment and discarded. It was the only such file in the repository, and the gate now reports # and ? in a relative specifier. #1711
Every Variable created by one nodeset load carries the same sourceTimestamp. One clock reading is taken per load and reused, keyed on the address space being loaded and reference counted, so a server writing elsewhere or outside a load still gets a real clock. #1728
A state machine type declares the transition event it raises. node-opcua's finite state machines raise a TransitionEventType on every state change, but no GeneratesEvent reference said so, which OPC 10000-16 requires and the compliance tool checks on every state machine instance. One reference is added to FiniteStateMachineType, covering every subtype. One catalog digest moves with it. #1705
A semantics change raises SemanticChangeEventType on the Server object, alongside the SemanticsChanged bit, as OPC 10000-5 6.4.31 requires. The bit itself no longer becomes part of the change-detection baseline, which used to produce a second, bit-less notification for a value that never changed. #1706
Coverage numbers move for measurement reasons, not code reasons. The suite runs on Node's own type stripping, so coverage lands on the TypeScript sources instead of compiled output. The reported figure goes from 79.06% to 91.70% with the same tests: 32 500 lines were always covered and were being counted against files nobody edits. #1714
🚀 The ESM migration (FEAT-2)
Seven batches, ordered by dependency layer rather than by size, because a CommonJS package sitting on top of flipped ones is the dangerous position: under a transpiling test loader it duplicates every module beneath it, and anything holding module-level state splits in two silently.
| batch | packages | running total |
|---|---|---|
| #1690 | 8 leaves, then 22 more (the service-* family and friends)
| 30 |
| #1692 | the 31 generated companion nodeset packages | 61 |
| #1694 | the encoding and data core, 18 packages | 81 |
| #1696 | the bottom layer, 11 packages | 93 |
| #1699 | leak-detector, the last hand-written CommonJS package
| 94 |
| #1701 | address-space and its neighbours, 7 packages
| 101 |
| #1703 | the client and server chain, and the umbrella, 12 packages | 113 |
| #1704 | the last nine | 122 of 122 |
Three findings worth carrying away:
- Batch 3 failed CI with 10 000 type errors, all cascading from a generator that had quietly written nothing. The cause was two copies of
node-opcua-factory: the built-in type registry is filled by import side effects, and the last CommonJS package in that chain made the test loader create a second module object with its own state, soNumericRangeregistered into the copy nobody read. The generator now exits non-zero when it cannot resolve a schema, instead of logging and returning. - The umbrella could not lag behind the packages it re-exports. Flipping
clientandserveralone broke ESM consumers only: Node discovers a CommonJS module's named exports with a static lexer that can see through other CommonJS files, and once the re-export targets became ES modules the names simply vanished.require("node-opcua")never broke, because it reads an object at run time rather than resolving names before evaluation. tsc -bdoes not re-emit on atypechange alone, so an ordinary build leaves CommonJS output and looks like it worked. Every batch verified the emitted files rather than trusting the build.
🏗️ Modernisation
TypeScript 7 (#1693). A cold tsc -b packages goes from 192 s to 38 s, and build:all from CI's 152 to 224 s down to 51 s. The output was compared rather than assumed: of 2 584 emitted JavaScript files 2 differ, by redundant parentheses around an identifier, and 6 declaration files differ by union member order and character escaping. The checkers under tools/ read the classic compiler API, which TypeScript 7 does not expose, so they depend on a typescript-5 alias that also names, at the import site, which compiler is meant.
A third fewer files at load (#1688). The generated nodeset packages are mostly type declarations: 536 of 576 compiled modules in node-opcua-nodeset-ua have no run-time content, and export * loaded them anyway.
| require | before | after |
|---|---|---|
node-opcua-address-space
| 1 297 files | 765 |
node-opcua
| 1 606 files | 1 074 |
Declaration maps (#1702). 2 628 maps, every one verified to resolve to a file that exists. dist grows about 2%, build time is unchanged.
Five new gates. Each answers a different question about what is published, and each was verified against the defect it exists to catch rather than merely observed green:
| gate | question |
|---|---|
check-build-graph (#1698)
| is the package compiled at all? |
check-package-shape (#1700)
| is the manifest coherent, do a consumer's imports resolve? (publint and attw) |
check-cjs-globals (#1711)
| does an ES module reference require, module or exports?
|
check-erasable (#1718)
| will Node's type stripping refuse this file? |
check-c8-ignore ([77c3feb])
| are the coverage hints in the form c8 reads? |
What they found immediately: node-opcua-nodeset-i-4-aas was published with no compiled code at all, missing from the aggregate build list, and check-pack had been blind to it because main: "dist/index.js" does not start with ./ and was read as a bare specifier. 37 of 115 packages write their entry points that way. node-opcua-leak-detector had been shipping without its types. The convert-nodeset-to-javascript CLI had no shebang, so it was not executable on Linux. And the simple_client command has pointed at a file deleted in November 2023. #1695 #1707
The test suite runs on Node's own type stripping (#1714, #1710, #1715). Two resolver redirects replace the transpiler, with TSX=1 as a fallback. No import is rewritten and no experimental flag is used. Coverage lands on the sources, and c8 finally reads its own configuration: the previous file was an nyc YAML file that c8 ignored silently, so none of its settings had ever applied and --all was reporting 3 103 never-loaded source files at 0%.
🚀 Features
deleteNamespace(indexOrUri)empties a namespace and frees its index for reuse, so a tool that recompiles a model does not have to rebuild the whole address space. Deleting node by node used to leave survivors: a DataType or a ReferenceType could not be deleted at all, so on the AutoID nodeset 15 nodes threw and 15 survived. The delete batches its model-change transaction instead of opening one per node, which is where most of the gain is. Measured on that nodeset: a full rebuild is 442 ms, the old node-by-node loop 89 ms, anddeleteNamespace4 to 8 ms, or 3.8 ms with the newsuspendModelChangeEventsoption.modelChangeTransactionis published too, so a caller can batch its own work the same way. #1726registerNodePromoteraccepts a NodeId, so an application can register a promoter for a type it defines itself or for a companion-specification type, whose namespace index is a property of the load rather than a constant. The numeric form still works. Shipped in 2.183.1. #1684onSetpointDataValueChange, the deviation-alarm counterpart ofonInputDataValueChange, completing the alarm extension points published in 2.183.0. Shipped in 2.183.1. #1682trustSiblingImages(opt-in, off by default) skips the read-and-hash that proves a precompiled nodeset image matches its XML, checking the recorded source length against the file size instead. The bargain is explicit: an edit that changes the length is still caught, one that keeps it byte-for-byte is not. #1728
⚡ Performance
Beyond the build and load figures above, two passes over the address space, both measured with pinned process priority because a laptop migrating a benchmark between performance and efficiency cores reports regressions that are not there:
- The loader stops reading scanning getters per node.
findReferencescalls during a standard-plus-DI load drop from 10 136 to 6 132, andfindReferencesAsObjectfrom 4 898 to 894. Image node decoding loses two allocations per node. #1728 - One chain walk answers three DataType questions.
_getDefinitionasked separately whether a type was an Enumeration, a Structure and a Union, each walking the supertype chain; andisSubtypeOf's memo is invalidated by everyHasSubtypereference added anywhere, so during a load it is thrown away before it is read.allReferencescalls drop from 2 966 to 236, and the median warm load by about 10%. #1729
That PR also carries a correction and a negative result, both worth more than the speedup. The work was opened on a profile claiming one function was 59% of a load; a warm profile, taken around the loads rather than the process, puts it at 3.9%, and the PR says so in its first paragraph. And four routes through image parsing were measured to close the question of whether string slicing was costing anything: the current route is already the fastest available, and the ceiling is 9% for a format change.
🐛 Fixes
Alarms and Conditions, and the compliance tool
- service-filter: an empty
browsePathin aSimpleAttributeOperanddenotes the instance itself, per Part 4 §7.4.4.5, not "nothing to resolve". Every Alarms and Conditions client asks for the ConditionId with exactly that operand, so the select clause came backBadNothingToDo, the compliance tool's alarm collector gave up, and every A&C unit was skipped. #1716 - service-filter: the same rule now applies to where clauses, so
InList(ConditionId, ...)andEqualsmatch the event's own ConditionId. Measured before the fix: a wide monitored item received 3 condition events, a filtered one on the same subscription received 0. Delivery and evaluation now share one resolver so they cannot drift apart again. #1717 - server: the SemanticsChanged bit reaches the notification the client actually reads. The stamped notification used to be appended behind whatever was already queued, and the bit was stored in the change-detection baseline, which produced a spurious second notification. Nine compliance scripts go from error or warning to passing. #1706
- address-space: a state machine type declares
GeneratesEventtoTransitionEventType. #1705
Address space
- address-space: the declared parent names the node. The NodeIdManager named a new node from its inverse aggregating references only, so a folder that a type merely organizes got a half-chained symbolic name, and a modeler seeding ids from a working group's table could not match it.
instantiate()now forwardsparentNodeIdtoo. Shipped in 2.183.1. #1683 - address-space: an
enumValuesentry keeps its description. The array form ofaddMultiStateValueDiscretebuilt eachEnumValueTypefrom the display name and value only, silently dropping a description the type declares. #1723 - conformance: the hand-built ArrayItemType properties are named in namespace 0, so a client translating the standard browse path no longer gets
BadNoMatch; the analog array items accept a write toEngineeringUnits. #1706
Tests and infrastructure
- leak-detector: the registry check was called with one argument too many, so the quiet path was unreachable and every block was judged on whether anything anywhere in the process had ever leaked. One abandoned object took down 42 unrelated tests, and in a single-process run of the whole suite it produced 293 failures. After the fix: 3, all environmental. #1712
- enum: a benchmark comparison asserted our implementation is fastest, from an unawaited async handler, so a timing inversion on a loaded runner escaped as an unhandled rejection and took the runner down with four suites in flight. A real failure in that file now names itself instead of hiding behind a timeout. #1727
- address-space: the shelving test raced its own unshelve timer with a 300 ms margin, and its assertions ran inside a listener where a failure could not reach mocha. #1709
- discovery: a test asserted an exact count of mDNS announcements while listening to the whole subnet, which is an assertion that nothing else on the network is announcing. It counts its own uniquely named announcement now. #1720
- discovery: an LDS restart on a fixed port raced the operating system releasing the socket, which read as an unrelated flake on Node 24. #1697
- generator: the scratch entry point is deleted (15 lines, no references, and the function it called always threw), and the dynamic imports on the deprecated schema path take a file URL, since an absolute Windows path begins with something the ESM loader reads as a URL scheme. #1724
- ci: the API documentation is curated by major and minor line, keeping the latest patch, so the Pages artifact stays under its size limit. #1685
🗑️ Housekeeping
169 dead CommonJS files are deleted: 162 schema literals from 2015 in thirteen service-* packages, three nyc configs, three unreferenced fixtures and one explorer script. None was published, and one search across the whole repository found every reference inside the deleted set itself. #1686
A new gate refuses an import that reaches into another package's impl/ directory. The current count is zero, which is the cheap moment to add one: a gate introduced after the count grows is a negotiation. #1725
📦 Dependencies
TypeScript moves from 5.9.3 to 7.0.2, with a typescript-5 alias kept for the tools that read the classic compiler API and for node-opcua-generator, which compiles generated code at run time. c8 is pinned at 12.0.0 as a devDependency instead of being fetched unpinned on every invocation, and publint and @arethetypeswrong/cli join the packaging gates. typedoc leaves the root devDependencies, since both callers invoke it through pnpx, which resolves its own compiler.
Pull requests
- #1682 feat(address-space): publish the deviation alarm setpoint hook
- #1683 fix(address-space): the declared parent names the node, and instantiate forwards it
- #1684 feat(address-space): registerNodePromoter accepts a NodeId
- #1685 fix(ci): prune old API docs before deploying to Pages
- #1686 chore: delete CommonJS files that nothing ships or loads
- #1687 build(esm-convert): scan .js files, rewrite entry shims, report CommonJS left behind
- #1688 perf(nodesets): re-export types-only generated files with export type *
- #1689 docs: state the real Node.js floor (22.13) in the tutorials
- #1690 build: flip eight leaf packages to ESM (FEAT-2 batch 1)
- #1691 refactor(types): store the callback form, and cast self through unknown
- #1692 build(nodesets): generate the companion packages as ESM
- #1693 build: compile with TypeScript 7
- #1694 build: flip the encoding core to ESM (FEAT-2 batch 3)
- #1695 fix(pack): a path without ./ is still a path, and ship what it names
- #1696 build: flip the bottom layer to ESM (FEAT-2 batch 4)
- #1697 test(discovery): wait for the port before starting another LDS
- #1698 feat(tools): check that every publishable package is actually built
- #1699 build(leak-detector): port the last hand-written CommonJS to ESM
- #1700 feat(tools): gate the published shape with publint and attw
- #1701 build: flip address-space and its neighbours to ESM (FEAT-2 batch 5)
- #1702 build: emit declaration maps
- #1703 build: flip the client and server chain, and the umbrella (FEAT-2 batch 6)
- #1704 build: flip the last nine packages to ESM (FEAT-2 batch 7)
- #1705 fix(address-space): a state machine type declares the transition event it raises
- #1706 fix(server): the SemanticsChanged bit reaches the notification the client reads
- #1707 fix(samples): point simple_client at the script it was renamed to
- #1708 build(coverage): give c8 a config it can read, and stop counting unloaded files
- #1709 test(address-space): stop the shelving test racing its own unshelve timer
- #1710 test: load the suites with the tsx ESM hook, and fix what that exposed (FEAT-4)
- #1711 fix(end2end): name a test file without a URL-reserved character, and gate it
- #1712 fix(leak-detector): report what a block leaked, not what the process accumulated
- #1713 test(end2end): wait for the data, not for a keepalive, in the 5000-node test
- #1714 test(runner): run the suite on Node's own type stripping
- #1715 test: load root-level mocha runs with the tsx ESM hook
- #1716 fix(service-filter): an empty browsePath select clause is the instance itself
- #1717 fix(service-filter): a where clause on the ConditionId resolves the condition instance
- #1718 build(tools): add check-erasable, a gate for Node type stripping
- #1719 build: run check:erasable in the lint job
- #1720 test(discovery): count this test's own announcement, not the subnet's
- #1721 build(tools): let the ESM gates see bin/
- #1722 build(tools): stop asking attw about packages with nothing to import
- #1723 fix(address-space): an enumValues entry keeps its description
- #1724 fix(generator): delete the scratch entry point, import schemas as URLs
- #1725 build(tools): fail on an import into another package's impl/
- #1726 feat(address-space): deleteNamespace, so a namespace can be emptied and reused
- #1727 test(enum): a benchmark result cannot abort the test runner
- #1728 perf(address-space): cheaper nodeset load and namespace teardown
- #1729 perf(address-space): one chain walk for the three DataType questions
Full Changelog: v2.183.0...v2.184.0
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } }