node-opcua-client-browser 2.177.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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @module node-opcua-client-browser
3
+ *
4
+ * Thin helper that constructs an `OPCUAClient` (from `node-opcua-client`)
5
+ * pre-wired with the browser WebSocket transport factory and, when no
6
+ * credentials are supplied, an in-memory certificate/key pair provider so
7
+ * no disk paths are touched.
8
+ *
9
+ * All other `OPCUAClient` configuration passes through unchanged — callers
10
+ * configure session timeouts, reconnection strategies, security policies,
11
+ * etc. exactly as they would for the Node client.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { createBrowserClient, AttributeIds, MessageSecurityMode, SecurityPolicy } from "node-opcua-client-browser";
16
+ *
17
+ * const client = createBrowserClient({
18
+ * endpointMustExist: false,
19
+ * securityMode: MessageSecurityMode.None,
20
+ * securityPolicy: SecurityPolicy.None,
21
+ * });
22
+ * await client.connect("opc.ws://localhost:4840");
23
+ * const session = await client.createSession();
24
+ * const dv = await session.read({ nodeId: "ns=1;s=Counter", attributeId: AttributeIds.Value });
25
+ * await session.close();
26
+ * await client.disconnect();
27
+ * ```
28
+ */
29
+
30
+ import { InMemoryCertificateKeyPairProvider, InMemoryCertificateStore } from "node-opcua-common";
31
+ import { OPCUAClient, type OPCUAClientOptions } from "node-opcua-client";
32
+ import type { Certificate, PrivateKey } from "node-opcua-crypto/web";
33
+
34
+ import { browserWsTransportFactory } from "./client_ws_transport";
35
+
36
+ /**
37
+ * Options accepted by {@link createBrowserClient}. Superset of
38
+ * {@link OPCUAClientOptions}; the browser-specific defaults described above
39
+ * apply unless the caller overrides them.
40
+ */
41
+ export interface CreateBrowserClientOptions extends OPCUAClientOptions {
42
+ /**
43
+ * Pre-built client certificate chain (DER). When combined with
44
+ * `clientPrivateKey`, it is loaded into an
45
+ * `InMemoryCertificateKeyPairProvider` so the owning `OPCUAClient`
46
+ * needs no disk cert files.
47
+ *
48
+ * If you already pass `certificateKeyPairProvider`, this option is
49
+ * ignored.
50
+ */
51
+ clientCertificate?: Certificate | Certificate[];
52
+
53
+ /**
54
+ * Pre-built client private key. Paired with `clientCertificate` to
55
+ * populate the in-memory provider.
56
+ *
57
+ * If you already pass `certificateKeyPairProvider`, this option is
58
+ * ignored.
59
+ */
60
+ clientPrivateKey?: PrivateKey;
61
+ }
62
+
63
+ /**
64
+ * Construct an `OPCUAClient` with browser-appropriate defaults.
65
+ *
66
+ * - `transportFactory` defaults to `browserWsTransportFactory` so the
67
+ * underlying secure channel opens a WebSocket. Callers can override by
68
+ * passing a different `transportFactory`.
69
+ * - `certificateKeyPairProvider` defaults to an
70
+ * `InMemoryCertificateKeyPairProvider`, populated with
71
+ * `clientCertificate` / `clientPrivateKey` when those are supplied.
72
+ * When they are NOT supplied, the provider is constructed empty —
73
+ * callers are expected to await `ensureCertificateExists(...)` on the
74
+ * returned client's credentials before connecting with a non-None
75
+ * security policy.
76
+ * - `clientCertificateManager` defaults to an `InMemoryCertificateStore`
77
+ * (auto-accept unknown peer certs). Callers can override by passing
78
+ * their own `clientCertificateManager` (e.g. one backed by IndexedDB
79
+ * or OPFS).
80
+ */
81
+ export function createBrowserClient(options: CreateBrowserClientOptions = {} as CreateBrowserClientOptions): OPCUAClient {
82
+ const { clientCertificate, clientPrivateKey, ...clientOptions } = options;
83
+
84
+ // Default to the browser WS transport factory unless the caller supplies one.
85
+ if (!clientOptions.transportFactory) {
86
+ clientOptions.transportFactory = browserWsTransportFactory;
87
+ }
88
+
89
+ // Default to an in-memory cert/key provider unless the caller supplies one.
90
+ // If the caller provided a client cert + private key, pre-populate the
91
+ // provider so the owning client can reach them without further async setup.
92
+ if (!clientOptions.certificateKeyPairProvider) {
93
+ const chain = clientCertificate
94
+ ? (Array.isArray(clientCertificate) ? clientCertificate : [clientCertificate])
95
+ : undefined;
96
+ clientOptions.certificateKeyPairProvider = new InMemoryCertificateKeyPairProvider(chain, clientPrivateKey);
97
+ }
98
+
99
+ // Default to an in-memory trust store unless the caller supplies one.
100
+ if (!clientOptions.clientCertificateManager) {
101
+ clientOptions.clientCertificateManager = new InMemoryCertificateStore({ autoAcceptUnknown: true });
102
+ }
103
+
104
+ return OPCUAClient.create(clientOptions);
105
+ }
@@ -0,0 +1,49 @@
1
+ /*!
2
+ * The MIT License (MIT)
3
+ * Copyright (c) 2022-2025 Sterfive SAS - 833264583 RCS ORLEANS - France (https://www.sterfive.com)
4
+ *
5
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ * this software and associated documentation files (the "Software"), to deal in
7
+ * the Software without restriction, including without limitation the rights to
8
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ * the Software, and to permit persons to whom the Software is furnished to do so,
10
+ * subject to the following conditions:
11
+ *
12
+ * The above copyright notice and this permission notice shall be included in all
13
+ * copies or substantial portions of the Software.
14
+ *
15
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+ */
22
+ /**
23
+ * @module node-opcua-client-browser
24
+ *
25
+ * Browser entry point for `node-opcua-client`. Exposes the OPC UA WebSocket
26
+ * transport (`ClientWS_transport`, `browserWsTransportFactory`,
27
+ * `parseWsEndpointUrl`).
28
+ *
29
+ * Target environments: modern evergreen browsers (Chromium, Firefox, WebKit).
30
+ * The transport uses the OPC UA WebSocket mapping per Part 6 §7.5
31
+ * (`opcua+uacp` subprotocol, one UACP chunk per binary frame).
32
+ *
33
+ * NOTE: `createBrowserClient` is temporarily NOT re-exported from this
34
+ * barrel. Its `OPCUAClient` import drags in `node-opcua-client`, which
35
+ * still transitively pulls Node-only modules (`node:crypto`, `node:fs`,
36
+ * `node:dns`, `@ster5/global-mutex`, `multicast-dns`, …) at bundle
37
+ * time. The full plan to make those packages browser-safe lives at
38
+ * `C:\Users\etien\.claude\plans\run-pnpm-filter-node-opcua-client-browse-vivid-sparkle.md`.
39
+ * The Node-side helper itself is still built and unit-tested — its 8
40
+ * mocha cases in `test/unit/test_create_browser_client.ts` import it
41
+ * directly from `../../dist`. Re-export will be restored in Step 5 of
42
+ * the plan once Steps 1–4 land.
43
+ */
44
+
45
+ export * from "./client_ws_transport";
46
+ export * as uacp from "./uacp";
47
+ export { type WebSocketLike, WsSocketAdapter } from "./ws_socket_adapter";
48
+
49
+ export const VERSION = "2.172.0";
package/source/uacp.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @module node-opcua-client-browser
3
+ * @internal
4
+ *
5
+ * UACP primitives usable in the browser.
6
+ *
7
+ * These are thin re-exports of the byte-level encoders/decoders from
8
+ * `node-opcua-transport`. Those modules are pure-JS (only `node:events` is
9
+ * pulled in, which Vite polyfills automatically) and are therefore safe to
10
+ * bundle for the browser. Consolidating them here gives us a single point of
11
+ * audit if a future release of `node-opcua-transport` introduces a Node-only
12
+ * import.
13
+ */
14
+
15
+ export {
16
+ AcknowledgeMessage,
17
+ HelloMessage,
18
+ TCPErrorMessage,
19
+ packTcpMessage,
20
+ readRawMessageHeader,
21
+ type TransportSettingsOptions
22
+ } from "node-opcua-transport";
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @module node-opcua-client-browser
3
+ * @internal
4
+ *
5
+ * WebSocket → {@link ISocketLike} adapter.
6
+ *
7
+ * {@link TCP_transport} in `node-opcua-transport` expects an `ISocketLike` that
8
+ * emits `data`, `close`, `end`, `error`, `timeout` events and provides
9
+ * `write`, `end`, `destroy`, `setKeepAlive`, `setNoDelay`, `setTimeout`.
10
+ *
11
+ * This adapter turns a browser `WebSocket` (or the `ws` package used during
12
+ * unit tests) into that shape so the full HEL/ACK + chunk-assembly machinery
13
+ * in `TCP_transport` can be reused without change.
14
+ *
15
+ * Framing per OPC UA Part 6 §7.5: each outgoing UACP chunk is sent as exactly
16
+ * one binary WebSocket message. Incoming binary messages are concatenated via
17
+ * the `TCP_transport` packet-assembler, so even if a peer (e.g. `websockify`)
18
+ * splits a chunk across multiple frames, the assembler re-combines them.
19
+ * Text frames are ignored with a warning.
20
+ */
21
+
22
+ import { EventEmitter } from "events";
23
+ import type { ISocketLike } from "node-opcua-transport";
24
+
25
+ /**
26
+ * Minimal structural type that matches both the browser `WebSocket` global and
27
+ * the `ws` package's Node implementation.
28
+ */
29
+ export interface WebSocketLike {
30
+ readonly readyState: number;
31
+ binaryType: string;
32
+ send(data: ArrayBuffer | ArrayBufferView): void;
33
+ close(code?: number, reason?: string): void;
34
+
35
+ // Browser event model (setter-based)
36
+ onopen: ((this: WebSocketLike, ev: unknown) => void) | null;
37
+ onclose: ((this: WebSocketLike, ev: { code?: number; reason?: string }) => void) | null;
38
+ onerror: ((this: WebSocketLike, ev: unknown) => void) | null;
39
+ onmessage: ((this: WebSocketLike, ev: { data: unknown }) => void) | null;
40
+ }
41
+
42
+ // WebSocket readyState constants (avoid importing the DOM lib to stay Node-side compatible)
43
+ const WS_OPEN = 1;
44
+ const WS_CLOSING = 2;
45
+ const WS_CLOSED = 3;
46
+
47
+ function toBuffer(data: unknown): Buffer | null {
48
+ if (!data) return null;
49
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return data as Buffer;
50
+ if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data));
51
+ if (ArrayBuffer.isView(data)) {
52
+ const view = data as ArrayBufferView;
53
+ return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
54
+ }
55
+ // Blob path: the transport's HEL/ACK uses `binaryType = "arraybuffer"`,
56
+ // so this should never hit. Fail loud if it does.
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Wrap a {@link WebSocketLike} as an {@link ISocketLike}. The returned socket
62
+ * forwards `data` events once the WS is open, and cleans up the WS listeners
63
+ * on `destroy` or `end`.
64
+ *
65
+ * The WebSocket must already be instantiated (i.e. `new WebSocket(url, protocols)`).
66
+ * The caller drives this by the time `_install_socket` is called; we just bind.
67
+ */
68
+ export class WsSocketAdapter extends EventEmitter implements ISocketLike {
69
+ public remoteAddress?: string;
70
+ public remotePort?: number;
71
+ public destroyed = false;
72
+
73
+ private _timeoutHandle: ReturnType<typeof setTimeout> | null = null;
74
+ private _timeoutMs = 0;
75
+ private _timeoutCb: (() => void) | null = null;
76
+
77
+ constructor(private readonly ws: WebSocketLike, url?: string) {
78
+ super();
79
+
80
+ if (url) {
81
+ try {
82
+ const u = new URL(url);
83
+ this.remoteAddress = u.hostname;
84
+ this.remotePort = u.port ? Number(u.port) : undefined;
85
+ } catch {
86
+ /* ignore – URL parse already happened in connect() */
87
+ }
88
+ }
89
+
90
+ ws.binaryType = "arraybuffer";
91
+
92
+ const emitError = (err: Error) => {
93
+ this.emit("error", err);
94
+ };
95
+
96
+ ws.onopen = () => {
97
+ this.emit("connect");
98
+ };
99
+
100
+ ws.onmessage = (ev: { data: unknown }) => {
101
+ // Reset inactivity timer if any
102
+ this._resetTimeout();
103
+
104
+ const buf = toBuffer(ev.data);
105
+ if (!buf) {
106
+ // Text frame or unknown type: ignore with a warning, per spec
107
+ // eslint-disable-next-line no-console
108
+ console.warn("[ClientWS_transport] ignoring non-binary WebSocket frame");
109
+ return;
110
+ }
111
+ this.emit("data", buf);
112
+ };
113
+
114
+ ws.onerror = () => {
115
+ // Browser WebSocket errors carry no useful detail. Translate into a
116
+ // generic Error so downstream log messages still make sense.
117
+ emitError(new Error("WebSocket error"));
118
+ };
119
+
120
+ ws.onclose = (ev: { code?: number; reason?: string }) => {
121
+ this._clearTimeout();
122
+ // `hadError` is best-effort: anything other than 1000 (normal) is
123
+ // treated as an error path.
124
+ const hadError = !!ev && typeof ev.code === "number" && ev.code !== 1000;
125
+ this.emit("end");
126
+ this.emit("close", hadError);
127
+ };
128
+ }
129
+
130
+ public write(data: string | Buffer, callback?: (err?: Error | null) => undefined | undefined): void {
131
+ if (this.destroyed || this.ws.readyState >= WS_CLOSING) {
132
+ callback?.(new Error("WebSocket is not open"));
133
+ return;
134
+ }
135
+ try {
136
+ if (typeof data === "string") {
137
+ // UACP chunks are always binary; if we somehow got a string,
138
+ // encode to UTF-8.
139
+ this.ws.send(new TextEncoder().encode(data));
140
+ } else {
141
+ // Send as ArrayBufferView so the WS layer keeps it as one binary frame.
142
+ const u8 = data instanceof Uint8Array ? data : new Uint8Array(data);
143
+ // `send()` accepts ArrayBuffer or ArrayBufferView; use the view so the
144
+ // underlying buffer isn't copied unnecessarily.
145
+ this.ws.send(u8);
146
+ }
147
+ callback?.();
148
+ } catch (err) {
149
+ callback?.(err as Error);
150
+ }
151
+ }
152
+
153
+ public end(): void {
154
+ this._clearTimeout();
155
+ if (this.ws.readyState === WS_OPEN) {
156
+ try {
157
+ this.ws.close(1000, "normal closure");
158
+ } catch {
159
+ /* swallow */
160
+ }
161
+ }
162
+ }
163
+
164
+ public destroy(_err?: Error): void {
165
+ this.destroyed = true;
166
+ this._clearTimeout();
167
+ if (this.ws.readyState < WS_CLOSED) {
168
+ try {
169
+ this.ws.close(1001, "going away");
170
+ } catch {
171
+ /* swallow */
172
+ }
173
+ }
174
+ // Detach handlers so no late events fire into a destroyed transport.
175
+ try {
176
+ this.ws.onopen = null;
177
+ this.ws.onclose = null;
178
+ this.ws.onerror = null;
179
+ this.ws.onmessage = null;
180
+ } catch {
181
+ /* swallow */
182
+ }
183
+ }
184
+
185
+ // These three are no-ops for WebSocket – keep-alive and nodelay are handled
186
+ // by the browser / underlying TCP stack; we expose them so TCP_transport's
187
+ // setup sequence runs unchanged.
188
+ public setKeepAlive(_enable?: boolean, _initialDelay?: number): this {
189
+ return this;
190
+ }
191
+ public setNoDelay(_noDelay?: boolean): this {
192
+ return this;
193
+ }
194
+
195
+ public setTimeout(timeout: number, callback?: () => void): this {
196
+ this._timeoutMs = timeout;
197
+ this._timeoutCb = callback ?? null;
198
+ this._resetTimeout();
199
+ return this;
200
+ }
201
+
202
+ private _resetTimeout(): void {
203
+ this._clearTimeout();
204
+ if (this._timeoutMs > 0 && this._timeoutCb) {
205
+ const cb = this._timeoutCb;
206
+ this._timeoutHandle = setTimeout(() => {
207
+ this.emit("timeout");
208
+ cb();
209
+ }, this._timeoutMs);
210
+ }
211
+ }
212
+
213
+ private _clearTimeout(): void {
214
+ if (this._timeoutHandle !== null) {
215
+ clearTimeout(this._timeoutHandle);
216
+ this._timeoutHandle = null;
217
+ }
218
+ }
219
+ }