arcane-os 0.52.1 → 0.53.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.53.0
4
+
5
+ - Add `acceptWebSocket` through `arcane-os/websocket` and the Node root export.
6
+ Accept a caller-selected native HTTP/1.1 Upgrade and exchange complete text
7
+ and binary messages with fragmentation, Ping/Pong, ordered writable
8
+ backpressure, close/abort handling and observable connection settlement.
9
+ Buffered Upgrade input reaches synchronously registered listeners in order.
10
+ - Preserve valid text, leading Unicode markers and binary view boundaries.
11
+ Text that cannot be represented exactly as UTF-8 reports an explicit error
12
+ before transmission or a close-state change. Browser clients use native
13
+ `WebSocket`; existing HTTP/TLS listeners, routes and application messages
14
+ remain caller-owned.
15
+ - No third-party WebSocket runtime, client sharing, reconnect, application
16
+ envelope, storage, HTTP/2 extended CONNECT or extension negotiation is added.
17
+ Existing SDK behavior and dependencies remain unchanged. Ordinary consumers
18
+ update through their public package workflow; optional server integration
19
+ belongs to the consuming application.
20
+
3
21
  ## 0.52.1
4
22
 
5
23
  - Preserve an authored host `index.html` at `/` independently of application
package/README.md CHANGED
@@ -20,7 +20,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
20
20
  share the theme, packaging, event, cancellation,
21
21
  and browser run contracts while retaining their selected application layout.
22
22
 
23
- This checkout defines the `0.52.1` SDK contract. Applications pin one exact npm
23
+ This checkout defines the `0.53.0` SDK contract. Applications pin one exact npm
24
24
  version and lockfile; registry state is deliberately not baked into application
25
25
  artifacts.
26
26
 
@@ -6,12 +6,26 @@
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
7
7
  "moduleSystem": "ESM"
8
8
  },
9
- "memberCount": 223,
9
+ "memberCount": 224,
10
10
  "runtimeSubpathPatterns": {
11
11
  "arcane-os/modules/*": "./runtime/arcane/modules/*",
12
12
  "arcane-os/entities/*": "./runtime/arcane/entities/*"
13
13
  },
14
14
  "members": [
15
+ {
16
+ "id": "root:acceptWebSocket",
17
+ "name": "acceptWebSocket",
18
+ "displayName": "acceptWebSocket()",
19
+ "kind": "function",
20
+ "signature": "acceptWebSocket({request,socket,head,protocol,signal})",
21
+ "entrypoints": ["arcane-os", "arcane-os/websocket"],
22
+ "primaryImport": "arcane-os/websocket",
23
+ "group": "WebSocket protocol",
24
+ "summary": "Accept one caller-selected HTTP/1.1 Upgrade and exchange complete native WebSocket messages.",
25
+ "availability": "Node; interoperates with browser native WebSocket clients",
26
+ "protocol": "RFC6455 version 13, without extensions or HTTP/2 extended CONNECT",
27
+ "normalization": "Native EventTarget message/error/close events and owned send/close promises; no application envelopes or storage"
28
+ },
15
29
  {
16
30
  "id": "root:APP_BUNDLE_DESCRIPTOR_NAME",
17
31
  "name": "APP_BUNDLE_DESCRIPTOR_NAME",
@@ -41,6 +41,7 @@ runtime layouts.
41
41
  | `arcane-os` | Complete high-level SDK surface. |
42
42
  | `arcane-os/toolchain` | Headless application operations. |
43
43
  | `arcane-os/events` | CLI/event reporter. |
44
+ | `arcane-os/websocket` | Node HTTP/1.1 WebSocket Upgrade, complete messages and connection lifecycle. Browser clients use native `WebSocket`. |
44
45
  | `arcane-os/testing` | Isolated test registration and execution. |
45
46
  | `arcane-os/targets` | Target adapters and dispatch. |
46
47
  | `arcane-os/native` | Native plan and builder protocol. |
@@ -130,6 +131,7 @@ The remaining data-only subpaths are eight JSON Schemas and package metadata.
130
131
 
131
132
  | Member | Kind | Import | Group | Availability |
132
133
  | --- | --- | --- | --- | --- |
134
+ | `acceptWebSocket()` | function | `arcane-os`, `arcane-os/websocket` | WebSocket protocol | Node; browser-native client interoperability |
133
135
  | `fetchRequest()` | function | `arcane-os/ai/twin-cloud` | TWiN Cloud requests | Node and Browser; remote HTTPS provider |
134
136
  | `fetchSystemOneRequest()` | function | `arcane-os/ai/twin-cloud` | TWiN Cloud state-and-questions requests | Node and Browser; remote HTTPS provider |
135
137
  | `generateImages()` | function | `arcane-os/ai/twin-cloud` | TWiN Cloud image generation | Node and Browser with Fetch and Blob; remote HTTPS provider |
@@ -3753,6 +3755,154 @@ async function useselectApp(...arguments_) {
3753
3755
  }
3754
3756
  ```
3755
3757
 
3758
+ ## acceptWebSocket()
3759
+
3760
+ ### Overview
3761
+
3762
+ Accepts one caller-selected RFC6455 HTTP/1.1 Upgrade using Node's native
3763
+ duplex stream. The caller owns HTTP routing, TLS, listeners, application
3764
+ messages and shutdown. Use the raw `server` returned by `startDevServer`, or
3765
+ the public `.server` / `.secureServer` of `node-http-server`. There is no
3766
+ listener creation, default port, application envelope, message persistence,
3767
+ client sharing, reconnect loop or third-party WebSocket runtime.
3768
+
3769
+ Browser clients use native `WebSocket` directly. Node's global `WebSocket` is
3770
+ also a client, not a server. This server adapter supports version 13 over an
3771
+ HTTP/1.1 `upgrade` event, including HTTP/1.1 negotiated on an HTTPS listener.
3772
+ It does not implement HTTP/2 extended CONNECT or negotiate extensions such as
3773
+ compression. TLS setup remains with the existing server owner.
3774
+
3775
+ ### Signature, modes, and result
3776
+
3777
+ ```text
3778
+ acceptWebSocket({request,socket,head,protocol,signal})
3779
+ ```
3780
+
3781
+ Import from `arcane-os/websocket` or `arcane-os`. Supply the exact `request`,
3782
+ `socket` and `head` from the selected Node `upgrade` event. Do not substitute
3783
+ `request.socket` for that event's stream. `head` defaults to an empty buffer.
3784
+ Optional `protocol` is one exact client-offered token; the default empty string
3785
+ selects no subprotocol. A browser that offers subprotocols expects one to be
3786
+ selected. Optional `signal` owns this connection's lifetime.
3787
+
3788
+ The synchronous result is a native `EventTarget` with:
3789
+
3790
+ | Member | Contract |
3791
+ | --- | --- |
3792
+ | `readyState` | `1` open, `2` closing, `3` closed. Acceptance queues the handshake before any messages. |
3793
+ | `protocol` | Selected subprotocol, or the empty string. |
3794
+ | `send(data)` | `Promise<void>` for a complete string, `ArrayBuffer`, or view. A view sends only its selected region. Input is snapshotted at submission; writes stay in submission order. The promise waits for the native write callback and any required drain; it is not a peer/application acknowledgement. |
3795
+ | `close(code=1000,reason='')` | Starts the close handshake after already accepted sends; returns `closed`. Repeated calls return the same promise. A peer Close rejects unsent application messages and retains the close reply. |
3796
+ | `terminate(reason?)` | Destroys only this connection's stream immediately, rejects pending sends and returns `closed`. An optional reason is reported as an error. |
3797
+ | `closed` | Resolves once the stream closes to `{code,reason,wasClean,error}`. It never rejects. Code/reason come from the peer Close; without one, code is `1006`. An empty peer Close reports `1005`. `wasClean` requires both close frames and no recorded failure. |
3798
+
3799
+ Register listeners synchronously after acceptance. The handshake write and
3800
+ initial `head` processing begin in a microtask so those listeners receive
3801
+ buffered messages. `head` is processed once, before later stream chunks.
3802
+
3803
+ | Native event | Payload |
3804
+ | --- | --- |
3805
+ | `message` | `MessageEvent.data`: complete text as a string, or complete binary as `Uint8Array`. Fragment boundaries never replace message boundaries. |
3806
+ | `error` | `CustomEvent.detail`: the complete original transport Error or coded protocol/abort Error. |
3807
+ | `close` | `CustomEvent.detail`: the same result as `closed`. |
3808
+
3809
+ These are transport-local native events, not an application bus or retained
3810
+ event history. Native `EventTarget` owns listener exceptions; an async listener
3811
+ must observe its own promises. Listen for errors or inspect `closed.error`.
3812
+ Ping receives a matching Pong internally; Pong is not an application message.
3813
+ Text decoding preserves a leading U+FEFF and validates UTF-8 across the complete
3814
+ fragmented message. Binary data is never interpreted or wrapped as JSON.
3815
+
3816
+ ### Errors and cancellation
3817
+
3818
+ Before changing the socket, an invalid handshake throws
3819
+ `ARCANE_WEBSOCKET_HANDSHAKE_INVALID` with `statusCode:400,headers:{}`;
3820
+ an unsupported WebSocket version throws `ARCANE_WEBSOCKET_VERSION_UNSUPPORTED`
3821
+ with `statusCode:426` and `headers:{'Sec-WebSocket-Version':'13'}`. The host
3822
+ owns sending that HTTP response and ending the rejected stream. Invalid socket,
3823
+ data or close arguments report `TypeError` / `RangeError`; a failed `send`
3824
+ rejects its promise without changing the payload.
3825
+ An unpaired UTF-16 surrogate cannot be represented as UTF-8: sending such text
3826
+ rejects with `ARCANE_WEBSOCKET_TEXT_INVALID`, and using it as a close reason
3827
+ throws that error before changing connection state. Valid strings remain exact.
3828
+
3829
+ After acceptance, invalid framing reports `ARCANE_WEBSOCKET_PROTOCOL_INVALID`
3830
+ and close status `1002`; invalid text reports `ARCANE_WEBSOCKET_TEXT_INVALID`
3831
+ and `1007`. Complete platform errors remain observable. Framing lengths,
3832
+ masking, the handshake's SHA-1 accept calculation and control-frame bounds are
3833
+ RFC transport requirements only. There are no application message limits,
3834
+ byte progress, content identities, or optional security controls.
3835
+
3836
+ A pre-aborted signal throws before taking ownership. Later abort destroys the
3837
+ stream with an `AbortError` coded `ARCANE_WEBSOCKET_ABORTED`, preserving the
3838
+ signal reason as its cause. Closing rejects new sends with
3839
+ `ARCANE_WEBSOCKET_CONNECTION_CLOSED` (or the existing failure).
3840
+ No timer silently terminates a slow close handshake; use the caller's signal or
3841
+ `terminate()` when that owner decides to stop. Server listener closure alone,
3842
+ including Node `closeAllConnections()`, does not own upgraded sockets. Track
3843
+ connections or provide a shared lifetime signal, then await their `closed`
3844
+ promises separately. None of this changes chat storage or `persist:false`.
3845
+
3846
+ ### Example
3847
+
3848
+ This example attaches to an already-owned listener and returns a separate
3849
+ connection cleanup function. Its route and echo policy belong to the caller.
3850
+ Call it with `publicServer.server`, `publicServer.secureServer`, or an SDK
3851
+ development server's returned `server`; it creates no additional HTTP server.
3852
+
3853
+ ```javascript
3854
+ import {acceptWebSocket} from 'arcane-os/websocket';
3855
+
3856
+ function attachEcho(server,signal){
3857
+ const connections=new Set();
3858
+ function upgrade(request,socket,head){
3859
+ if(request.url!=='/socket'){
3860
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
3861
+ return;
3862
+ }
3863
+ let connection;
3864
+ try{connection=acceptWebSocket({request,socket,head,signal});}
3865
+ catch(error){
3866
+ console.error(error);
3867
+ const status=error.statusCode??400;
3868
+ const version=status===426?'Sec-WebSocket-Version: 13\r\n':'';
3869
+ socket.end(`HTTP/1.1 ${status} Upgrade rejected\r\n${version}Connection: close\r\n\r\n`);
3870
+ return;
3871
+ }
3872
+ connections.add(connection);
3873
+ connection.addEventListener('error',function failed(event){console.error(event.detail);});
3874
+ connection.addEventListener('message',function echo(event){
3875
+ connection.send(event.data).catch(function sendFailed(error){console.error(error);});
3876
+ });
3877
+ connection.addEventListener('close',function remove(){connections.delete(connection);});
3878
+ }
3879
+ server.on('upgrade',upgrade);
3880
+ return async function detach(){
3881
+ server.off('upgrade',upgrade);
3882
+ await Promise.all([...connections].map(function stop(connection){return connection.terminate();}));
3883
+ };
3884
+ }
3885
+
3886
+ // In browser code, after the host has selected its ws: or wss: URL:
3887
+ function connectEcho(url){
3888
+ const client=new WebSocket(url);
3889
+ client.addEventListener('open',function greet(){client.send('The moon needs a coffee break.');});
3890
+ client.addEventListener('message',function received(event){console.log(event.data);});
3891
+ return client;
3892
+ }
3893
+ ```
3894
+
3895
+ Protocol references: [RFC6455](https://www.rfc-editor.org/rfc/rfc6455.html),
3896
+ [Node upgrade event](https://nodejs.org/api/http.html#event-upgrade), and
3897
+ [native browser WebSocket](https://websockets.spec.whatwg.org/).
3898
+ Work is connection-local and linear in actual framing/content. Input is
3899
+ assembled once per complete message; advertised lengths do not preallocate
3900
+ payloads. Outbound framing snapshots each submitted message and uses native
3901
+ writable backpressure. Callers await `send` when producing a sustained stream.
3902
+ There is no history scan, app loop, diagnostic payload store or automatic retry.
3903
+ The authored `test/websocket.test.mjs` fixtures cover this boundary; source
3904
+ inspection is distinct from executing them or verifying a browser deployment.
3905
+
3756
3906
  ## startDevServer()
3757
3907
 
3758
3908
  ### Overview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.52.1",
3
+ "version": "0.53.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -12,6 +12,7 @@
12
12
  ".": "./src/index.mjs",
13
13
  "./toolchain": "./src/toolchain.mjs",
14
14
  "./events": "./src/events.mjs",
15
+ "./websocket": "./src/websocket.mjs",
15
16
  "./event-manager": "./src/event-manager.mjs",
16
17
  "./modules/*": "./runtime/arcane/modules/*",
17
18
  "./entities/*": "./runtime/arcane/entities/*",
@@ -86,7 +87,7 @@
86
87
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
87
88
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
88
89
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
89
- "test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
90
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
90
91
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
91
92
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
92
93
  "check": "node tools/check-source.mjs && npm test",
package/src/index.mjs CHANGED
@@ -146,6 +146,7 @@ export {
146
146
  export {materializeInstalledSdkRuntime} from './installed-sdk-runtime.mjs';
147
147
  export {startDevServer} from './dev-server.mjs';
148
148
  export {startSourceExampleServer} from './source-server.mjs';
149
+ export {acceptWebSocket} from './websocket.mjs';
149
150
  export {
150
151
  NATIVE_BUILD_PLAN_PROTOCOL,
151
152
  NATIVE_BUILDER_PROTOCOL,
@@ -0,0 +1,479 @@
1
+ import {createHash} from 'node:crypto';
2
+ import Is from 'strong-type';
3
+
4
+ const is=new Is(false);
5
+ const OPEN=1;
6
+ const CLOSING=2;
7
+ const CLOSED=3;
8
+ const TOKEN=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
9
+
10
+ function protocolError(code,message,details={}){
11
+ const error=new Error(message);
12
+ error.code=`ARCANE_WEBSOCKET_${code}`;
13
+ return Object.assign(error,details);
14
+ }
15
+
16
+ function handshake(request,protocol){
17
+ const headers=request?.headers??{};
18
+ function tokens(value){
19
+ return is.string(value)?value.split(',').map(function trimToken(token){
20
+ return token.trim();
21
+ }):[];
22
+ }
23
+ function hasToken(value,expected){
24
+ return tokens(value).some(function matchesToken(token){
25
+ return token.toLowerCase()===expected;
26
+ });
27
+ }
28
+ function invalid(message){
29
+ return protocolError('HANDSHAKE_INVALID',message,{statusCode:400,headers:{}});
30
+ }
31
+ if(request?.method!=='GET'
32
+ ||request.httpVersionMajor!==1||request.httpVersionMinor<1
33
+ ||!is.string(headers.host)||!headers.host.trim()
34
+ ||!hasToken(headers.upgrade,'websocket')
35
+ ||!hasToken(headers.connection,'upgrade')){
36
+ throw invalid('WebSocket requires an HTTP/1.1 GET Upgrade request with Host.');
37
+ }
38
+ if(headers['sec-websocket-version']!=='13'){
39
+ throw protocolError('VERSION_UNSUPPORTED','WebSocket version 13 is required.',{
40
+ statusCode:426,
41
+ headers:{'Sec-WebSocket-Version':'13'}
42
+ });
43
+ }
44
+ const key=headers['sec-websocket-key'];
45
+ // The key syntax and SHA-1 accept value are required HTTP Upgrade framing,
46
+ // not a content identity, admission receipt, or application security policy.
47
+ if(!is.string(key)||!/^[A-Za-z0-9+/]{22}==$/u.test(key)
48
+ ||Buffer.from(key,'base64').length!==16){
49
+ throw invalid('Sec-WebSocket-Key must encode the RFC6455 handshake nonce.');
50
+ }
51
+ const offered=tokens(headers['sec-websocket-protocol']);
52
+ if(offered.some(function invalidToken(token){return !TOKEN.test(token);})
53
+ ||new Set(offered).size!==offered.length){
54
+ throw invalid('Sec-WebSocket-Protocol must contain distinct protocol tokens.');
55
+ }
56
+ if(!is.string(protocol)||(protocol!==''&&(!TOKEN.test(protocol)||!offered.includes(protocol)))){
57
+ throw invalid('The selected WebSocket protocol must be offered by the client.');
58
+ }
59
+ const accept=createHash('sha1')
60
+ .update(key+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
61
+ .digest('base64');
62
+ return Buffer.from([
63
+ 'HTTP/1.1 101 Switching Protocols',
64
+ 'Upgrade: websocket',
65
+ 'Connection: Upgrade',
66
+ `Sec-WebSocket-Accept: ${accept}`,
67
+ ...(protocol?[`Sec-WebSocket-Protocol: ${protocol}`]:[]),
68
+ '',
69
+ ''
70
+ ].join('\r\n'));
71
+ }
72
+
73
+ function binaryData(data){
74
+ if(data instanceof ArrayBuffer)return Buffer.from(data);
75
+ if(ArrayBuffer.isView(data))return Buffer.from(data.buffer,data.byteOffset,data.byteLength);
76
+ throw new TypeError('WebSocket data must be text, an ArrayBuffer, or an ArrayBuffer view.');
77
+ }
78
+
79
+ function framePacket(opcode,payload){
80
+ // Length fields belong only to the wire format. No product payload limit is imposed.
81
+ const length=payload.length;
82
+ const header=Buffer.alloc(length<126?2:length<=65535?4:10);
83
+ header[0]=0x80|opcode;
84
+ if(length<126)header[1]=length;
85
+ else if(length<=65535){
86
+ header[1]=126;
87
+ header.writeUInt16BE(length,2);
88
+ }else{
89
+ header[1]=127;
90
+ header.writeBigUInt64BE(BigInt(length),2);
91
+ }
92
+ // Snapshot the selected view at submission. Queued callers may reuse their input.
93
+ return Buffer.concat([header,payload]);
94
+ }
95
+
96
+ function validCloseCode(code){
97
+ return is.integer(code)&&(
98
+ (code>=1000&&code<=1014&&![1004,1005,1006].includes(code))
99
+ ||(code>=3000&&code<=4999)
100
+ );
101
+ }
102
+
103
+ function encodeText(text){
104
+ if(!text.isWellFormed()){
105
+ throw protocolError('TEXT_INVALID','WebSocket UTF-8 cannot represent an unpaired UTF-16 surrogate.');
106
+ }
107
+ return Buffer.from(text);
108
+ }
109
+
110
+ function closePayload(code,reason){
111
+ if(!validCloseCode(code))throw new RangeError('Invalid WebSocket close status code.');
112
+ if(!is.string(reason))throw new TypeError('WebSocket close reason must be text.');
113
+ const text=encodeText(reason);
114
+ // RFC6455 control frames permit 125 octets, including the two-octet status.
115
+ if(text.length>123)throw new RangeError('WebSocket close reason exceeds its protocol frame.');
116
+ const payload=Buffer.alloc(2+text.length);
117
+ payload.writeUInt16BE(code);
118
+ text.copy(payload,2);
119
+ return payload;
120
+ }
121
+
122
+ function abortError(reason){
123
+ const error=protocolError('ABORTED','WebSocket connection was aborted.',{cause:reason});
124
+ error.name='AbortError';
125
+ return error;
126
+ }
127
+
128
+ /** Accept one caller-selected HTTP/1.1 Upgrade. Owns no listener, route or storage. */
129
+ export function acceptWebSocket({request,socket,head=Buffer.alloc(0),protocol='',signal}={}){
130
+ if(signal!=null&&(!is.function(signal.addEventListener)||!is.function(signal.removeEventListener))){
131
+ throw new TypeError('WebSocket signal must provide the AbortSignal event interface.');
132
+ }
133
+ if(signal?.aborted)throw abortError(signal.reason);
134
+ const response=handshake(request,protocol);
135
+ for(const method of ['on','off','pause','resume','write','end','destroy']){
136
+ if(!is.function(socket?.[method]))throw new TypeError(`Upgrade socket requires ${method}().`);
137
+ }
138
+ if(socket.destroyed||socket.readableEncoding){
139
+ throw new TypeError('Upgrade socket must be open and retain binary transport data.');
140
+ }
141
+ let initial=Buffer.from(binaryData(head));
142
+ const connection=new EventTarget();
143
+ let state=OPEN;
144
+ let started=false;
145
+ let failure=null;
146
+ let ending=false;
147
+ let failedProtocol=false;
148
+ let peerClose=null;
149
+ let closeQueued=false;
150
+ let closeWritten=false;
151
+ let activeWrite=null;
152
+ let incomingFrame=null;
153
+ let messageOpcode=0;
154
+ let messageParts=[];
155
+ const pendingInput=[];
156
+ const writes=[];
157
+ const header=Buffer.alloc(14);
158
+ let headerUsed=0;
159
+ let headerNeeded=2;
160
+ let settleClosed;
161
+ const closed=new Promise(function retainCloseSettlement(resolve){settleClosed=resolve;});
162
+
163
+ function closedError(){
164
+ return failure??protocolError('CONNECTION_CLOSED','WebSocket connection is closing or closed.');
165
+ }
166
+
167
+ function reportError(error){
168
+ if(failure)return;
169
+ failure=error;
170
+ connection.dispatchEvent(new CustomEvent('error',{detail:error}));
171
+ }
172
+
173
+ function rejectQueued(error,dataOnly=false){
174
+ for(const entry of writes.splice(0)){
175
+ if(dataOnly&&entry.kind!=='data')writes.push(entry);
176
+ else entry.reject(error);
177
+ }
178
+ }
179
+
180
+ function disposeInput(){
181
+ initial=null;
182
+ pendingInput.length=0;
183
+ messageParts=[];
184
+ incomingFrame=null;
185
+ messageOpcode=0;
186
+ headerUsed=0;
187
+ }
188
+
189
+ function finish(){
190
+ if(state===CLOSED)return;
191
+ state=CLOSED;
192
+ activeWrite?.finish(closedError());
193
+ rejectQueued(closedError());
194
+ disposeInput();
195
+ socket.off('data',receive);
196
+ socket.off('end',onEnd);
197
+ socket.off('error',onError);
198
+ socket.off('close',onClose);
199
+ signal?.removeEventListener('abort',onAbort);
200
+ const detail={
201
+ code:peerClose?.code??1006,
202
+ reason:peerClose?.reason??'',
203
+ wasClean:Boolean(peerClose&&closeWritten&&!failure),
204
+ error:failure
205
+ };
206
+ settleClosed(detail);
207
+ connection.dispatchEvent(new CustomEvent('close',{detail}));
208
+ }
209
+
210
+ function terminate(reason){
211
+ if(state===CLOSED)return closed;
212
+ state=CLOSING;
213
+ if(reason!==undefined)reportError(reason instanceof Error?reason:new Error('WebSocket terminated.',{cause:reason}));
214
+ activeWrite?.finish(closedError());
215
+ rejectQueued(closedError());
216
+ disposeInput();
217
+ // Keep the error/close observers installed until the actual stream closes.
218
+ socket.destroy();
219
+ return closed;
220
+ }
221
+
222
+ function onError(error){terminate(error);}
223
+ function onAbort(){terminate(abortError(signal.reason));}
224
+ function onClose(){finish();}
225
+ function onEnd(){
226
+ if(peerClose&&closeQueued){
227
+ if(closeWritten)endTransport();
228
+ }else{
229
+ terminate(protocolError('CONNECTION_CLOSED','WebSocket transport ended without a close exchange.'));
230
+ }
231
+ }
232
+
233
+ function endTransport(){
234
+ if(ending||state===CLOSED)return;
235
+ ending=true;
236
+ try{socket.end(function transportFlushed(){socket.destroy();});}
237
+ catch(error){terminate(error);}
238
+ }
239
+
240
+ function pumpWrites(){
241
+ if(!started||activeWrite||state===CLOSED||socket.destroyed||ending)return;
242
+ const entry=writes.shift();
243
+ if(!entry)return;
244
+ let callbackDone=false;
245
+ let drained=false;
246
+ let returned=false;
247
+ let settled=false;
248
+ function finishWrite(error){
249
+ if(settled)return;
250
+ if(!error&&(!callbackDone||!drained||!returned))return;
251
+ settled=true;
252
+ socket.off('drain',onDrain);
253
+ activeWrite=null;
254
+ if(error)entry.reject(error);
255
+ else entry.resolve();
256
+ queueMicrotask(pumpWrites);
257
+ }
258
+ function onDrain(){drained=true;finishWrite();}
259
+ function onWritten(error){
260
+ if(error){
261
+ finishWrite(error);
262
+ terminate(error);
263
+ return;
264
+ }
265
+ callbackDone=true;
266
+ finishWrite();
267
+ }
268
+ activeWrite={finish:finishWrite};
269
+ socket.on('drain',onDrain);
270
+ try{
271
+ // false means already accepted: await drain, never resubmit this packet.
272
+ if(socket.write(entry.packet,onWritten))drained=true;
273
+ returned=true;
274
+ finishWrite();
275
+ }catch(error){
276
+ finishWrite(error);
277
+ terminate(error);
278
+ }
279
+ }
280
+
281
+ function enqueue(packet,kind='data'){
282
+ const promise=new Promise(function retainWriteSettlement(resolve,reject){
283
+ writes.push({packet,kind,resolve,reject});
284
+ });
285
+ pumpWrites();
286
+ return promise;
287
+ }
288
+
289
+ function send(data){
290
+ if(state!==OPEN)return Promise.reject(closedError());
291
+ try{
292
+ const text=is.string(data);
293
+ return enqueue(framePacket(text?1:2,text?encodeText(data):binaryData(data)));
294
+ }catch(error){return Promise.reject(error);}
295
+ }
296
+
297
+ function queueClose(payload){
298
+ if(closeQueued)return;
299
+ closeQueued=true;
300
+ enqueue(framePacket(8,payload),'close').then(function closeSent(){
301
+ closeWritten=true;
302
+ if(peerClose||failedProtocol)endTransport();
303
+ },function closeSendFailed(error){terminate(error);});
304
+ }
305
+
306
+ function close(code=1000,reason=''){
307
+ if(state!==OPEN)return closed;
308
+ const payload=closePayload(code,reason);
309
+ state=CLOSING;
310
+ queueClose(payload);
311
+ return closed;
312
+ }
313
+
314
+ function failProtocol(error){
315
+ if(failedProtocol||state===CLOSED)return;
316
+ failedProtocol=true;
317
+ state=CLOSING;
318
+ socket.pause();
319
+ disposeInput();
320
+ reportError(error);
321
+ rejectQueued(error,true);
322
+ // A packet already accepted by write must finish before a close frame.
323
+ if(!closeQueued){
324
+ queueClose(closePayload(error.closeCode??1011,''));
325
+ }else if(closeWritten){endTransport();}
326
+ }
327
+
328
+ function invalidFrame(message){
329
+ throw protocolError('PROTOCOL_INVALID',message,{closeCode:1002});
330
+ }
331
+
332
+ function decodeText(payload){
333
+ try{
334
+ return new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(payload);
335
+ }catch(cause){
336
+ throw protocolError('TEXT_INVALID','WebSocket text is not valid UTF-8.',{closeCode:1007,cause});
337
+ }
338
+ }
339
+
340
+ function validateBaseHeader(){
341
+ const opcode=header[0]&15;
342
+ const final=Boolean(header[0]&128);
343
+ const control=opcode>=8;
344
+ const encodedLength=header[1]&127;
345
+ if(header[0]&112)invalidFrame('WebSocket extensions were not negotiated.');
346
+ if(!(header[1]&128))invalidFrame('Client WebSocket frames must be masked.');
347
+ if(![0,1,2,8,9,10].includes(opcode))invalidFrame('Unknown WebSocket opcode.');
348
+ if(control&&(!final||encodedLength>125))invalidFrame('Invalid WebSocket control frame.');
349
+ if(!control){
350
+ if(opcode===0&&!messageOpcode)invalidFrame('Unexpected WebSocket continuation.');
351
+ if(opcode!==0&&messageOpcode)invalidFrame('Interleaved WebSocket messages.');
352
+ }
353
+ }
354
+
355
+ function beginFrame(){
356
+ const opcode=header[0]&15;
357
+ const final=Boolean(header[0]&128);
358
+ const control=opcode>=8;
359
+ const encodedLength=header[1]&127;
360
+ let length=BigInt(encodedLength);
361
+ if(encodedLength===126){
362
+ length=BigInt(header.readUInt16BE(2));
363
+ if(length<126n)invalidFrame('Nonminimal WebSocket length encoding.');
364
+ }else if(encodedLength===127){
365
+ length=header.readBigUInt64BE(2);
366
+ if(length<65536n||length>>63n)invalidFrame('Invalid WebSocket extended length.');
367
+ }
368
+ if(!control){
369
+ if(opcode!==0)messageOpcode=opcode;
370
+ }
371
+ incomingFrame={
372
+ opcode,final,control,remaining:length,
373
+ mask:Buffer.from(header.subarray(headerNeeded-4,headerNeeded)),
374
+ maskIndex:0,parts:[]
375
+ };
376
+ headerUsed=0;
377
+ headerNeeded=2;
378
+ }
379
+
380
+ function completeFrame(){
381
+ const frame=incomingFrame;
382
+ incomingFrame=null;
383
+ if(frame.control){
384
+ const payload=Buffer.concat(frame.parts);
385
+ if(frame.opcode===8){
386
+ if(payload.length===1)invalidFrame('Incomplete WebSocket close status.');
387
+ const code=payload.length?payload.readUInt16BE(0):1005;
388
+ if(payload.length&&!validCloseCode(code))invalidFrame('Invalid WebSocket close status.');
389
+ const reason=payload.length?decodeText(payload.subarray(2)):'';
390
+ peerClose={code,reason};
391
+ state=CLOSING;
392
+ rejectQueued(closedError(),true);
393
+ if(!closeQueued){
394
+ queueClose(payload);
395
+ }
396
+ if(closeWritten)endTransport();
397
+ }else if(frame.opcode===9&&!peerClose){
398
+ enqueue(framePacket(10,payload),'control').catch(function pongFailed(error){terminate(error);});
399
+ }
400
+ }else if(frame.final){
401
+ const opcode=messageOpcode;
402
+ const parts=messageParts;
403
+ messageParts=[];
404
+ messageOpcode=0;
405
+ if(state===OPEN){
406
+ const payload=Buffer.concat(parts);
407
+ const data=opcode===1?decodeText(payload):new Uint8Array(payload.buffer,payload.byteOffset,payload.length);
408
+ connection.dispatchEvent(new MessageEvent('message',{data}));
409
+ }
410
+ }
411
+ }
412
+
413
+ function consume(chunk){
414
+ let offset=0;
415
+ while(offset<chunk.length&&!failedProtocol&&state!==CLOSED&&!socket.destroyed&&!peerClose){
416
+ if(!incomingFrame){
417
+ const count=Math.min(headerNeeded-headerUsed,chunk.length-offset);
418
+ chunk.copy(header,headerUsed,offset,offset+count);
419
+ offset+=count;
420
+ headerUsed+=count;
421
+ if(headerUsed<headerNeeded)continue;
422
+ if(headerNeeded===2){
423
+ validateBaseHeader();
424
+ const encodedLength=header[1]&127;
425
+ headerNeeded=2+(encodedLength===126?2:encodedLength===127?8:0)+4;
426
+ continue;
427
+ }
428
+ beginFrame();
429
+ }
430
+ const frame=incomingFrame;
431
+ const available=chunk.length-offset;
432
+ const count=frame.remaining<BigInt(available)?Number(frame.remaining):available;
433
+ if(count){
434
+ const payload=Buffer.allocUnsafe(count);
435
+ for(let index=0;index<count;index++){
436
+ payload[index]=chunk[offset+index]^frame.mask[frame.maskIndex];
437
+ frame.maskIndex=(frame.maskIndex+1)&3;
438
+ }
439
+ offset+=count;
440
+ frame.remaining-=BigInt(count);
441
+ if(frame.control)frame.parts.push(payload);
442
+ else if(state===OPEN)messageParts.push(payload);
443
+ }
444
+ if(frame.remaining===0n)completeFrame();
445
+ }
446
+ }
447
+
448
+ function receive(chunk){
449
+ if(failedProtocol||state===CLOSED||socket.destroyed||peerClose)return;
450
+ if(!started){pendingInput.push(chunk);return;}
451
+ try{consume(chunk);}
452
+ catch(error){failProtocol(error);}
453
+ }
454
+
455
+ Object.defineProperties(connection,{
456
+ readyState:{get:function readState(){return state;},enumerable:true},
457
+ protocol:{value:protocol,enumerable:true},
458
+ closed:{value:closed,enumerable:true}
459
+ });
460
+ Object.assign(connection,{send,close,terminate});
461
+ socket.pause();
462
+ socket.on('data',receive);
463
+ socket.on('end',onEnd);
464
+ socket.on('error',onError);
465
+ socket.on('close',onClose);
466
+ signal?.addEventListener('abort',onAbort,{once:true});
467
+ enqueue(response,'handshake').catch(function handshakeWriteFailed(error){terminate(error);});
468
+ queueMicrotask(function startConnection(){
469
+ started=true;
470
+ if(signal?.aborted){onAbort();return;}
471
+ if(state===CLOSED||socket.destroyed)return;
472
+ pumpWrites();
473
+ receive(initial);
474
+ initial=null;
475
+ for(const chunk of pendingInput.splice(0))receive(chunk);
476
+ if(!failedProtocol&&!peerClose&&state!==CLOSED&&!socket.destroyed)socket.resume();
477
+ });
478
+ return connection;
479
+ }