almostnode 0.2.7 → 0.2.9

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 (64) hide show
  1. package/README.md +4 -2
  2. package/dist/CNAME +1 -0
  3. package/dist/__sw__.js +80 -84
  4. package/dist/assets/{runtime-worker-B8_LZkBX.js → runtime-worker-ujGAG2t7.js} +1278 -828
  5. package/dist/assets/runtime-worker-ujGAG2t7.js.map +1 -0
  6. package/dist/frameworks/code-transforms.d.ts.map +1 -1
  7. package/dist/frameworks/next-config-parser.d.ts +16 -0
  8. package/dist/frameworks/next-config-parser.d.ts.map +1 -0
  9. package/dist/frameworks/next-dev-server.d.ts +6 -6
  10. package/dist/frameworks/next-dev-server.d.ts.map +1 -1
  11. package/dist/frameworks/next-html-generator.d.ts +35 -0
  12. package/dist/frameworks/next-html-generator.d.ts.map +1 -0
  13. package/dist/frameworks/next-shims.d.ts +79 -0
  14. package/dist/frameworks/next-shims.d.ts.map +1 -0
  15. package/dist/index.cjs +3024 -2465
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.mjs +3336 -2787
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/og-image.png +0 -0
  22. package/dist/runtime.d.ts +26 -0
  23. package/dist/runtime.d.ts.map +1 -1
  24. package/dist/server-bridge.d.ts +2 -0
  25. package/dist/server-bridge.d.ts.map +1 -1
  26. package/dist/shims/crypto.d.ts +2 -0
  27. package/dist/shims/crypto.d.ts.map +1 -1
  28. package/dist/shims/esbuild.d.ts.map +1 -1
  29. package/dist/shims/fs.d.ts.map +1 -1
  30. package/dist/shims/http.d.ts +29 -0
  31. package/dist/shims/http.d.ts.map +1 -1
  32. package/dist/shims/path.d.ts.map +1 -1
  33. package/dist/shims/stream.d.ts.map +1 -1
  34. package/dist/shims/vfs-adapter.d.ts.map +1 -1
  35. package/dist/shims/ws.d.ts +2 -0
  36. package/dist/shims/ws.d.ts.map +1 -1
  37. package/dist/types/package-json.d.ts +1 -0
  38. package/dist/types/package-json.d.ts.map +1 -1
  39. package/dist/utils/binary-encoding.d.ts +13 -0
  40. package/dist/utils/binary-encoding.d.ts.map +1 -0
  41. package/dist/virtual-fs.d.ts.map +1 -1
  42. package/package.json +4 -4
  43. package/src/convex-app-demo-entry.ts +229 -35
  44. package/src/frameworks/code-transforms.ts +5 -1
  45. package/src/frameworks/next-config-parser.ts +140 -0
  46. package/src/frameworks/next-dev-server.ts +76 -1675
  47. package/src/frameworks/next-html-generator.ts +597 -0
  48. package/src/frameworks/next-shims.ts +1050 -0
  49. package/src/frameworks/tailwind-config-loader.ts +1 -1
  50. package/src/index.ts +2 -0
  51. package/src/runtime.ts +271 -25
  52. package/src/server-bridge.ts +61 -28
  53. package/src/shims/crypto.ts +13 -0
  54. package/src/shims/esbuild.ts +4 -1
  55. package/src/shims/fs.ts +9 -11
  56. package/src/shims/http.ts +312 -3
  57. package/src/shims/path.ts +6 -13
  58. package/src/shims/stream.ts +12 -26
  59. package/src/shims/vfs-adapter.ts +5 -2
  60. package/src/shims/ws.ts +95 -2
  61. package/src/types/package-json.ts +1 -0
  62. package/src/utils/binary-encoding.ts +43 -0
  63. package/src/virtual-fs.ts +7 -15
  64. package/dist/assets/runtime-worker-B8_LZkBX.js.map +0 -1
package/src/shims/fs.ts CHANGED
@@ -5,9 +5,13 @@
5
5
 
6
6
  import { VirtualFS, createNodeError } from '../virtual-fs';
7
7
  import type { Stats, FSWatcher, WatchListener, WatchEventType } from '../virtual-fs';
8
+ import { uint8ToBase64, uint8ToHex } from '../utils/binary-encoding';
8
9
 
9
10
  export type { Stats, FSWatcher, WatchListener, WatchEventType };
10
11
 
12
+ const _decoder = new TextDecoder();
13
+ const _encoder = new TextEncoder();
14
+
11
15
  export interface FsShim {
12
16
  readFileSync(path: string): Buffer;
13
17
  readFileSync(path: string, encoding: 'utf8' | 'utf-8'): string;
@@ -131,19 +135,13 @@ function createBuffer(data: Uint8Array): Buffer {
131
135
  Object.defineProperty(buffer, 'toString', {
132
136
  value: function (encoding?: string) {
133
137
  if (encoding === 'utf8' || encoding === 'utf-8' || !encoding) {
134
- return new TextDecoder().decode(this);
138
+ return _decoder.decode(this);
135
139
  }
136
140
  if (encoding === 'base64') {
137
- let binary = '';
138
- for (let i = 0; i < this.length; i++) {
139
- binary += String.fromCharCode(this[i]);
140
- }
141
- return btoa(binary);
141
+ return uint8ToBase64(this);
142
142
  }
143
143
  if (encoding === 'hex') {
144
- return Array.from(this as Uint8Array)
145
- .map((b) => b.toString(16).padStart(2, '0'))
146
- .join('');
144
+ return uint8ToHex(this);
147
145
  }
148
146
  throw new Error(`Unsupported encoding: ${encoding}`);
149
147
  },
@@ -437,7 +435,7 @@ export function createFsShim(vfs: VirtualFS, getCwd?: () => string): FsShim {
437
435
  throw err;
438
436
  }
439
437
  // Convert string to Uint8Array if needed
440
- const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
438
+ const bytes = typeof data === 'string' ? _encoder.encode(data) : data;
441
439
  // Replace entire content
442
440
  entry.content = new Uint8Array(bytes);
443
441
  entry.position = bytes.length;
@@ -620,7 +618,7 @@ export function createFsShim(vfs: VirtualFS, getCwd?: () => string): FsShim {
620
618
  // Handle string input
621
619
  let data: Uint8Array;
622
620
  if (typeof buffer === 'string') {
623
- data = new TextEncoder().encode(buffer);
621
+ data = _encoder.encode(buffer);
624
622
  offset = 0;
625
623
  length = data.length;
626
624
  } else {
package/src/shims/http.ts CHANGED
@@ -6,6 +6,16 @@
6
6
  import { EventEmitter, type EventListener } from './events';
7
7
  import { Readable, Writable, Buffer } from './stream';
8
8
  import { Socket, Server as NetServer, AddressInfo } from './net';
9
+ import { createHash } from './crypto';
10
+
11
+ // Save the browser's native WebSocket at module load time, BEFORE any CLI bundle
12
+ // can overwrite it (e.g. Convex CLI does `globalThis.WebSocket = bundledWs`).
13
+ // This ensures our WebSocket bridge always uses the real browser implementation.
14
+ // Only capture in a real browser — Node.js 21+ has native WebSocket but it connects
15
+ // to real servers, which isn't what the shim needs.
16
+ const _isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
17
+ const _BrowserWebSocket: typeof globalThis.WebSocket | null =
18
+ _isBrowser && typeof globalThis.WebSocket === 'function' ? globalThis.WebSocket : null;
9
19
 
10
20
  export type RequestListener = (req: IncomingMessage, res: ServerResponse) => void;
11
21
 
@@ -14,6 +24,7 @@ export interface RequestOptions {
14
24
  path?: string;
15
25
  headers?: Record<string, string | string[]>;
16
26
  hostname?: string;
27
+ host?: string;
17
28
  port?: number;
18
29
  }
19
30
 
@@ -604,13 +615,29 @@ export class ClientRequest extends Writable {
604
615
  if (this._aborted) return;
605
616
 
606
617
  try {
607
- // Build URL
618
+ // Build URL — Node.js supports both `hostname` and `host` (host may include port)
608
619
  const protocol = this._protocol === 'https' ? 'https:' : 'http:';
609
- const hostname = this._options.hostname || 'localhost';
610
- const port = this._options.port ? `:${this._options.port}` : '';
620
+ let hostname = this._options.hostname || '';
621
+ let port = this._options.port ? `:${this._options.port}` : '';
622
+ if (!hostname && this._options.host) {
623
+ // host can be "domain.com" or "domain.com:8080"
624
+ const hostParts = this._options.host.split(':');
625
+ hostname = hostParts[0];
626
+ if (!port && hostParts[1]) {
627
+ port = `:${hostParts[1]}`;
628
+ }
629
+ }
630
+ if (!hostname) hostname = 'localhost';
611
631
  const path = this._options.path || '/';
612
632
  const url = `${protocol}//${hostname}${port}${path}`;
613
633
 
634
+ // WebSocket upgrade requests can't use fetch() — browsers strip
635
+ // Connection/Upgrade headers. Bridge to the browser's native WebSocket.
636
+ if (this.headers['upgrade']?.toLowerCase() === 'websocket') {
637
+ this._handleWebSocketUpgrade(url);
638
+ return;
639
+ }
640
+
614
641
  // Use CORS proxy if configured
615
642
  const corsProxy = getCorsProxy();
616
643
  const fetchUrl = corsProxy
@@ -691,6 +718,181 @@ export class ClientRequest extends Writable {
691
718
 
692
719
  return msg;
693
720
  }
721
+
722
+ /**
723
+ * Bridge a WebSocket upgrade request to the browser's native WebSocket.
724
+ *
725
+ * The bundled ws library (inside the Convex CLI) creates WebSocket connections
726
+ * via http.request() with Upgrade headers. It expects frame-level I/O on the
727
+ * socket from the 'upgrade' event. This method bridges between:
728
+ * - ws library ↔ frame-level I/O on a mock Socket
729
+ * - browser native WebSocket ↔ message-level I/O
730
+ */
731
+ private _handleWebSocketUpgrade(url: string): void {
732
+ // Convert http(s):// to ws(s)://
733
+ const wsUrl = url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
734
+
735
+ // Get Sec-WebSocket-Key from request headers (sent by ws library)
736
+ const wsKey = this.headers['sec-websocket-key'] || '';
737
+
738
+ // Use the saved browser WebSocket (captured at module load time before CLI overrides)
739
+ const NativeWS = _BrowserWebSocket;
740
+
741
+ if (!NativeWS) {
742
+ // No native WebSocket (test env / Node.js) — emit TypeError like fetch would
743
+ setTimeout(() => {
744
+ this.emit('error', new TypeError('Failed to fetch'));
745
+ }, 0);
746
+ return;
747
+ }
748
+
749
+ // Compute Sec-WebSocket-Accept using the same hash as the ws library.
750
+ // The ws library (bundled in the Convex CLI) uses require("crypto") which
751
+ // resolves to our crypto shim's createHash (syncHash). We must use the same
752
+ // GUID as the bundled ws@8.18.0 (which differs from the standard RFC 6455 GUID).
753
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
754
+ const acceptValue = createHash('sha1')
755
+ .update(wsKey + GUID)
756
+ .digest('base64') as string;
757
+
758
+ let nativeWs: globalThis.WebSocket;
759
+ try {
760
+ nativeWs = new NativeWS(wsUrl);
761
+ nativeWs.binaryType = 'arraybuffer';
762
+ } catch (e) {
763
+ setTimeout(() => {
764
+ this.emit('error', e instanceof Error ? e : new Error(String(e)));
765
+ }, 0);
766
+ return;
767
+ }
768
+
769
+ // Create mock socket for the ws library's frame-level I/O
770
+ const socket = new Socket();
771
+ // ws library calls cork/uncork for batching — add as no-ops if missing
772
+ if (typeof (socket as any).cork !== 'function') (socket as any).cork = () => {};
773
+ if (typeof (socket as any).uncork !== 'function') (socket as any).uncork = () => {};
774
+ // ws library checks _readableState and _writableState on socket close
775
+ (socket as any)._readableState = { endEmitted: false };
776
+ (socket as any)._writableState = { finished: false, errorEmitted: false };
777
+
778
+ // Buffer for partial frame writes from ws library
779
+ let writeBuffer = new Uint8Array(0);
780
+
781
+ // Override socket.write to intercept outgoing frames from ws library
782
+ socket.write = ((
783
+ chunk: Uint8Array | string,
784
+ encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
785
+ callback?: (error?: Error | null) => void
786
+ ): boolean => {
787
+ const data = typeof chunk === 'string' ? Buffer.from(chunk) : new Uint8Array(chunk);
788
+ const cb = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
789
+
790
+ // Append to write buffer
791
+ const newBuf = new Uint8Array(writeBuffer.length + data.length);
792
+ newBuf.set(writeBuffer, 0);
793
+ newBuf.set(data, writeBuffer.length);
794
+ writeBuffer = newBuf;
795
+
796
+ // Parse and forward complete frames
797
+ while (writeBuffer.length >= 2) {
798
+ const parsed = _parseWsFrame(writeBuffer);
799
+ if (!parsed) break; // incomplete frame — wait for more data
800
+
801
+ const { opcode, payload, totalLength } = parsed;
802
+ writeBuffer = writeBuffer.slice(totalLength);
803
+
804
+ if (nativeWs.readyState !== NativeWS.OPEN) continue;
805
+
806
+ if (opcode === 0x08) {
807
+ // Close frame
808
+ nativeWs.close();
809
+ } else if (opcode === 0x09) {
810
+ // Ping — respond with pong (shouldn't happen from client but handle it)
811
+ nativeWs.send(payload);
812
+ } else if (opcode === 0x0A) {
813
+ // Pong — ignore
814
+ } else if (opcode === 0x01) {
815
+ // Text frame
816
+ const text = new TextDecoder().decode(payload);
817
+ nativeWs.send(text);
818
+ } else if (opcode === 0x02) {
819
+ // Binary frame
820
+ nativeWs.send(payload);
821
+ }
822
+ }
823
+
824
+ if (cb) queueMicrotask(() => cb(null));
825
+ return true;
826
+ }) as any;
827
+
828
+ nativeWs.onopen = () => {
829
+ // Create HTTP 101 response
830
+ const response = new IncomingMessage(socket);
831
+ response.statusCode = 101;
832
+ response.statusMessage = 'Switching Protocols';
833
+ response.headers = {
834
+ 'upgrade': 'websocket',
835
+ 'connection': 'Upgrade',
836
+ 'sec-websocket-accept': acceptValue,
837
+ };
838
+ // Mark as complete so ws library doesn't wait for body
839
+ response.complete = true;
840
+ response.push(null);
841
+
842
+ // Emit upgrade event — ws library listens for this
843
+ this.emit('upgrade', response, socket, Buffer.alloc(0));
844
+ };
845
+
846
+ nativeWs.onmessage = (event: MessageEvent) => {
847
+ // Create unmasked WebSocket frame and push to mock socket
848
+ let payload: Uint8Array;
849
+ let opcode: number;
850
+
851
+ if (typeof event.data === 'string') {
852
+ payload = new TextEncoder().encode(event.data);
853
+ opcode = 0x01; // text
854
+ } else if (event.data instanceof ArrayBuffer) {
855
+ payload = new Uint8Array(event.data);
856
+ opcode = 0x02; // binary
857
+ } else {
858
+ return;
859
+ }
860
+
861
+ const frame = _createWsFrame(opcode, payload, false); // unmasked (server → client)
862
+ socket._receiveData(Buffer.from(frame));
863
+ };
864
+
865
+ nativeWs.onclose = (event: CloseEvent) => {
866
+ // Send close frame to ws library
867
+ const code = event.code || 1000;
868
+ const closePayload = new Uint8Array(2);
869
+ closePayload[0] = (code >> 8) & 0xFF;
870
+ closePayload[1] = code & 0xFF;
871
+ const frame = _createWsFrame(0x08, closePayload, false);
872
+ socket._receiveData(Buffer.from(frame));
873
+
874
+ setTimeout(() => {
875
+ (socket as any)._readableState.endEmitted = true;
876
+ socket._receiveEnd();
877
+ socket.emit('close', false);
878
+ }, 10);
879
+ };
880
+
881
+ nativeWs.onerror = () => {
882
+ socket.emit('error', new Error('WebSocket connection error'));
883
+ socket.destroy();
884
+ };
885
+
886
+ // Clean up native WS when socket is destroyed
887
+ const origDestroy = socket.destroy.bind(socket);
888
+ socket.destroy = ((error?: Error): Socket => {
889
+ if (nativeWs.readyState === NativeWS.OPEN || nativeWs.readyState === NativeWS.CONNECTING) {
890
+ nativeWs.close();
891
+ }
892
+ return origDestroy(error);
893
+ }) as any;
894
+ }
895
+
694
896
  }
695
897
 
696
898
  /**
@@ -884,6 +1086,111 @@ export class Agent extends EventEmitter {
884
1086
  // Global agent instance
885
1087
  export const globalAgent = new Agent();
886
1088
 
1089
+ /**
1090
+ * Parse a WebSocket frame from raw bytes.
1091
+ * Returns null if the buffer doesn't contain a complete frame.
1092
+ */
1093
+ export function _parseWsFrame(data: Uint8Array): {
1094
+ opcode: number;
1095
+ payload: Uint8Array;
1096
+ totalLength: number;
1097
+ } | null {
1098
+ if (data.length < 2) return null;
1099
+
1100
+ const opcode = data[0] & 0x0F;
1101
+ const masked = (data[1] & 0x80) !== 0;
1102
+ let payloadLength = data[1] & 0x7F;
1103
+ let offset = 2;
1104
+
1105
+ if (payloadLength === 126) {
1106
+ if (data.length < 4) return null;
1107
+ payloadLength = (data[2] << 8) | data[3];
1108
+ offset = 4;
1109
+ } else if (payloadLength === 127) {
1110
+ if (data.length < 10) return null;
1111
+ // Use lower 32 bits (sufficient for WebSocket messages)
1112
+ payloadLength = (data[6] << 24) | (data[7] << 16) | (data[8] << 8) | data[9];
1113
+ offset = 10;
1114
+ }
1115
+
1116
+ if (masked) {
1117
+ if (data.length < offset + 4 + payloadLength) return null;
1118
+ const maskKey = data.slice(offset, offset + 4);
1119
+ offset += 4;
1120
+
1121
+ const payload = new Uint8Array(payloadLength);
1122
+ for (let i = 0; i < payloadLength; i++) {
1123
+ payload[i] = data[offset + i] ^ maskKey[i % 4];
1124
+ }
1125
+
1126
+ return { opcode, payload, totalLength: offset + payloadLength };
1127
+ } else {
1128
+ if (data.length < offset + payloadLength) return null;
1129
+ const payload = data.slice(offset, offset + payloadLength);
1130
+ return { opcode, payload, totalLength: offset + payloadLength };
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Create a WebSocket frame.
1136
+ * @param opcode - Frame opcode (0x01=text, 0x02=binary, 0x08=close, 0x09=ping, 0x0A=pong)
1137
+ * @param payload - Frame payload
1138
+ * @param masked - Whether to mask the payload (client→server frames are masked)
1139
+ */
1140
+ export function _createWsFrame(opcode: number, payload: Uint8Array, masked: boolean): Uint8Array {
1141
+ const length = payload.length;
1142
+ let headerSize = 2;
1143
+
1144
+ if (length > 125 && length <= 65535) {
1145
+ headerSize += 2;
1146
+ } else if (length > 65535) {
1147
+ headerSize += 8;
1148
+ }
1149
+
1150
+ if (masked) {
1151
+ headerSize += 4;
1152
+ }
1153
+
1154
+ const frame = new Uint8Array(headerSize + length);
1155
+ frame[0] = 0x80 | opcode; // FIN + opcode
1156
+
1157
+ let offset = 2;
1158
+ if (length <= 125) {
1159
+ frame[1] = (masked ? 0x80 : 0) | length;
1160
+ } else if (length <= 65535) {
1161
+ frame[1] = (masked ? 0x80 : 0) | 126;
1162
+ frame[2] = (length >> 8) & 0xFF;
1163
+ frame[3] = length & 0xFF;
1164
+ offset = 4;
1165
+ } else {
1166
+ frame[1] = (masked ? 0x80 : 0) | 127;
1167
+ frame[2] = 0; frame[3] = 0; frame[4] = 0; frame[5] = 0;
1168
+ frame[6] = (length >> 24) & 0xFF;
1169
+ frame[7] = (length >> 16) & 0xFF;
1170
+ frame[8] = (length >> 8) & 0xFF;
1171
+ frame[9] = length & 0xFF;
1172
+ offset = 10;
1173
+ }
1174
+
1175
+ if (masked) {
1176
+ const maskKey = new Uint8Array(4);
1177
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1178
+ crypto.getRandomValues(maskKey);
1179
+ } else {
1180
+ for (let i = 0; i < 4; i++) maskKey[i] = Math.floor(Math.random() * 256);
1181
+ }
1182
+ frame.set(maskKey, offset);
1183
+ offset += 4;
1184
+ for (let i = 0; i < length; i++) {
1185
+ frame[offset + i] = payload[i] ^ maskKey[i % 4];
1186
+ }
1187
+ } else {
1188
+ frame.set(payload, offset);
1189
+ }
1190
+
1191
+ return frame;
1192
+ }
1193
+
887
1194
  export default {
888
1195
  Server,
889
1196
  IncomingMessage,
@@ -901,4 +1208,6 @@ export default {
901
1208
  _createClientRequest,
902
1209
  Agent,
903
1210
  globalAgent,
1211
+ _parseWsFrame,
1212
+ _createWsFrame,
904
1213
  };
package/src/shims/path.ts CHANGED
@@ -33,20 +33,9 @@ export function normalize(path: string): string {
33
33
  return result || '.';
34
34
  }
35
35
 
36
- // Track join calls to debug infinite recursion
37
- let joinCallCount = 0;
38
-
39
36
  export function join(...paths: string[]): string {
40
37
  if (paths.length === 0) return '.';
41
- const result = normalize(paths.filter(Boolean).join('/'));
42
- // Debug: Log _generated path joins
43
- if (paths.some(p => p && p.includes('_generated'))) {
44
- joinCallCount++;
45
- if (joinCallCount <= 20) {
46
- console.log(`[path.join] (${paths.join(', ')}) -> ${result}`);
47
- }
48
- }
49
- return result;
38
+ return normalize(paths.filter(Boolean).join('/'));
50
39
  }
51
40
 
52
41
  export function resolve(...paths: string[]): string {
@@ -59,7 +48,11 @@ export function resolve(...paths: string[]): string {
59
48
  }
60
49
 
61
50
  if (!resolvedPath.startsWith('/')) {
62
- resolvedPath = '/' + resolvedPath;
51
+ // Use process.cwd() if available, matching Node.js behavior
52
+ const cwd = typeof globalThis !== 'undefined' && globalThis.process && typeof globalThis.process.cwd === 'function'
53
+ ? globalThis.process.cwd()
54
+ : '/';
55
+ resolvedPath = cwd + (resolvedPath ? '/' + resolvedPath : '');
63
56
  }
64
57
 
65
58
  return normalize(resolvedPath);
@@ -4,6 +4,10 @@
4
4
  */
5
5
 
6
6
  import { EventEmitter } from './events';
7
+ import { uint8ToBase64, uint8ToHex, uint8ToBinaryString } from '../utils/binary-encoding';
8
+
9
+ const _encoder = new TextEncoder();
10
+ const _decoder = new TextDecoder('utf-8');
7
11
 
8
12
  export class Readable extends EventEmitter {
9
13
  private _buffer: Uint8Array[] = [];
@@ -547,8 +551,7 @@ class BufferPolyfill extends Uint8Array {
547
551
  }
548
552
 
549
553
  // Default: utf8
550
- const encoder = new TextEncoder();
551
- const bytes = encoder.encode(data);
554
+ const bytes = _encoder.encode(data);
552
555
  return new BufferPolyfill(bytes);
553
556
  }
554
557
  if (data instanceof ArrayBuffer) {
@@ -602,47 +605,30 @@ class BufferPolyfill extends Uint8Array {
602
605
  if (enc === 'hex') {
603
606
  return string.length / 2;
604
607
  }
605
- return new TextEncoder().encode(string).length;
608
+ return _encoder.encode(string).length;
606
609
  }
607
610
 
608
611
  toString(encoding: BufferEncoding = 'utf8'): string {
609
612
  const enc = (encoding || 'utf8').toLowerCase();
610
613
 
611
614
  if (enc === 'base64') {
612
- let binary = '';
613
- for (let i = 0; i < this.length; i++) {
614
- binary += String.fromCharCode(this[i]);
615
- }
616
- return btoa(binary);
615
+ return uint8ToBase64(this);
617
616
  }
618
617
 
619
618
  if (enc === 'base64url') {
620
- let binary = '';
621
- for (let i = 0; i < this.length; i++) {
622
- binary += String.fromCharCode(this[i]);
623
- }
624
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
619
+ return uint8ToBase64(this).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
625
620
  }
626
621
 
627
622
  if (enc === 'hex') {
628
- let hex = '';
629
- for (let i = 0; i < this.length; i++) {
630
- hex += this[i].toString(16).padStart(2, '0');
631
- }
632
- return hex;
623
+ return uint8ToHex(this);
633
624
  }
634
625
 
635
626
  if (enc === 'latin1' || enc === 'binary') {
636
- let str = '';
637
- for (let i = 0; i < this.length; i++) {
638
- str += String.fromCharCode(this[i]);
639
- }
640
- return str;
627
+ return uint8ToBinaryString(this);
641
628
  }
642
629
 
643
630
  // Default: utf8
644
- const decoder = new TextDecoder('utf-8');
645
- return decoder.decode(this);
631
+ return _decoder.decode(this);
646
632
  }
647
633
 
648
634
  slice(start?: number, end?: number): BufferPolyfill {
@@ -654,7 +640,7 @@ class BufferPolyfill extends Uint8Array {
654
640
  }
655
641
 
656
642
  write(string: string, offset?: number): number {
657
- const bytes = new TextEncoder().encode(string);
643
+ const bytes = _encoder.encode(string);
658
644
  this.set(bytes, offset || 0);
659
645
  return bytes.length;
660
646
  }
@@ -14,6 +14,9 @@ import type {
14
14
  } from 'just-bash';
15
15
  import type { VirtualFS } from '../virtual-fs';
16
16
  import { createNodeError } from '../virtual-fs';
17
+ import { uint8ToBinaryString } from '../utils/binary-encoding';
18
+
19
+ const _decoder = new TextDecoder();
17
20
 
18
21
  // Local types for just-bash interface compatibility
19
22
  // These are not exported from just-bash main entry point
@@ -53,7 +56,7 @@ export class VirtualFSAdapter implements IFileSystem {
53
56
  // For binary/latin1 encoding, convert each byte to a character
54
57
  if (encoding === 'binary' || encoding === 'latin1') {
55
58
  const buffer = this.vfs.readFileSync(path);
56
- return String.fromCharCode(...buffer);
59
+ return uint8ToBinaryString(buffer);
57
60
  }
58
61
 
59
62
  // For other encodings, fall back to utf8
@@ -93,7 +96,7 @@ export class VirtualFSAdapter implements IFileSystem {
93
96
  // File doesn't exist, start with empty content
94
97
  }
95
98
  const newContent =
96
- typeof content === 'string' ? content : new TextDecoder().decode(content);
99
+ typeof content === 'string' ? content : _decoder.decode(content);
97
100
  this.vfs.writeFileSync(path, existing + newContent);
98
101
  }
99
102
 
package/src/shims/ws.ts CHANGED
@@ -59,6 +59,7 @@ export class WebSocket extends EventEmitter {
59
59
 
60
60
  private _id: string;
61
61
  private _server: WebSocketServer | null = null;
62
+ private _nativeWs: globalThis.WebSocket | null = null;
62
63
 
63
64
  // Event handler properties
64
65
  onopen: ((event: Event) => void) | null = null;
@@ -88,7 +89,15 @@ export class WebSocket extends EventEmitter {
88
89
  return;
89
90
  }
90
91
 
91
- // If no BroadcastChannel, act as if connected
92
+ // For external WebSocket connections, use the browser's native WebSocket.
93
+ // This allows libraries like the Convex CLI (which require('ws')) to
94
+ // communicate with real remote servers.
95
+ if (this.url.startsWith('ws://') || this.url.startsWith('wss://')) {
96
+ this._connectNative();
97
+ return;
98
+ }
99
+
100
+ // For all other URLs, use BroadcastChannel (internal Vite HMR)
92
101
  if (!messageChannel) {
93
102
  setTimeout(() => {
94
103
  this.readyState = WebSocket.OPEN;
@@ -158,11 +167,78 @@ export class WebSocket extends EventEmitter {
158
167
  }, 100);
159
168
  }
160
169
 
170
+ private _connectNative(): void {
171
+ // Check that the browser's native WebSocket is available and is not our own shim.
172
+ // Only use native WebSocket in a real browser — Node.js 21+ has native WebSocket
173
+ // but it connects to real servers, which breaks tests and isn't what the shim needs.
174
+ const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
175
+ const NativeWS = isBrowser && typeof globalThis.WebSocket === 'function' && globalThis.WebSocket !== (WebSocket as any)
176
+ ? globalThis.WebSocket
177
+ : null;
178
+
179
+ if (!NativeWS) {
180
+ // No native WebSocket (test env, Node.js, etc.) — act as if connected
181
+ setTimeout(() => {
182
+ this.readyState = WebSocket.OPEN;
183
+ this.emit('open');
184
+ if (this.onopen) this.onopen(new Event('open'));
185
+ }, 0);
186
+ return;
187
+ }
188
+
189
+ try {
190
+ this._nativeWs = new NativeWS(this.url);
191
+ this._nativeWs.binaryType = this.binaryType === 'arraybuffer' ? 'arraybuffer' : 'blob';
192
+ } catch {
193
+ this.readyState = WebSocket.CLOSED;
194
+ const errorEvent = new Event('error');
195
+ this.emit('error', errorEvent);
196
+ if (this.onerror) this.onerror(errorEvent);
197
+ return;
198
+ }
199
+
200
+ this._nativeWs.onopen = () => {
201
+ this.readyState = WebSocket.OPEN;
202
+ this.emit('open');
203
+ if (this.onopen) this.onopen(new Event('open'));
204
+ };
205
+
206
+ this._nativeWs.onmessage = (event: globalThis.MessageEvent) => {
207
+ const msgEvent = new MessageEventPolyfill('message', { data: event.data });
208
+ this.emit('message', msgEvent);
209
+ if (this.onmessage) this.onmessage(msgEvent as unknown as MessageEvent);
210
+ };
211
+
212
+ this._nativeWs.onclose = (event: globalThis.CloseEvent) => {
213
+ this.readyState = WebSocket.CLOSED;
214
+ this._nativeWs = null;
215
+ const closeEvent = new CloseEventPolyfill('close', {
216
+ code: event.code,
217
+ reason: event.reason,
218
+ wasClean: event.wasClean,
219
+ });
220
+ this.emit('close', closeEvent);
221
+ if (this.onclose) this.onclose(closeEvent as unknown as CloseEvent);
222
+ };
223
+
224
+ this._nativeWs.onerror = () => {
225
+ const errorEvent = new Event('error');
226
+ this.emit('error', errorEvent);
227
+ if (this.onerror) this.onerror(errorEvent);
228
+ };
229
+ }
230
+
161
231
  send(data: string | ArrayBuffer | Uint8Array): void {
162
232
  if (this.readyState !== WebSocket.OPEN) {
163
233
  throw new Error('WebSocket is not open');
164
234
  }
165
235
 
236
+ // If connected to native WebSocket (external server)
237
+ if (this._nativeWs) {
238
+ this._nativeWs.send(data);
239
+ return;
240
+ }
241
+
166
242
  // If connected to internal server
167
243
  if (this._server) {
168
244
  this._server._handleClientMessage(this, data);
@@ -187,6 +263,12 @@ export class WebSocket extends EventEmitter {
187
263
 
188
264
  this.readyState = WebSocket.CLOSING;
189
265
 
266
+ // If connected to native WebSocket, close it (onclose handler emits events)
267
+ if (this._nativeWs) {
268
+ this._nativeWs.close(code, reason);
269
+ return;
270
+ }
271
+
190
272
  if (messageChannel) {
191
273
  messageChannel.postMessage({
192
274
  type: 'disconnect',
@@ -218,7 +300,18 @@ export class WebSocket extends EventEmitter {
218
300
  }
219
301
 
220
302
  terminate(): void {
221
- this.close(1006, 'Connection terminated');
303
+ if (this._nativeWs) {
304
+ this._nativeWs.close();
305
+ this._nativeWs = null;
306
+ }
307
+ this.readyState = WebSocket.CLOSED;
308
+ const closeEvent = new CloseEventPolyfill('close', {
309
+ code: 1006,
310
+ reason: 'Connection terminated',
311
+ wasClean: false,
312
+ });
313
+ this.emit('close', closeEvent);
314
+ if (this.onclose) this.onclose(closeEvent as unknown as CloseEvent);
222
315
  }
223
316
 
224
317
  // For internal server use