-
Notifications
You must be signed in to change notification settings - Fork 2
/
jasmine-socket-plugin.js
2770 lines (2562 loc) · 116 KB
/
jasmine-socket-plugin.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Croquet Jasmine Socket Plugin
*
* This plugin is a modified version of the SocketPlugin from the SqueakJS project.
* It is used to provide a networking for the Jasmine Smalltalk image.
* It emulates an IP network (TCP and UDP) by joining a (modern) Croquet.io session.
* That network has no connection to any "real" network, it is purely virtual.
*
* Note that the use of croquet.io is purely to emulate that network, the actual
* synchronization logic is all inside the (old) Smalltalk image. That also makes it
* useful beyond just Jasmine. It should be able to support e.g. Croquet Hedgehog and
* other networked Croquet apps as well (e.g. a Seaside server image, and a client
* image accessing it).
*
* TODO:
* [x] UDP
* [~] TCP
* [x] initial handshake
* [x] send/receive data
* [x] accept w/ backlog
* [x] close connection
* [ ] time outs
* [ ] retransmission
* [ ] chunked reads
* [ ] chunked writes?
* [ ] fragmentation for oversized packets
* [ ] leave Croquet session when no longer needed (all sockets destroyed, or maybe
* unconnected for a while)
*
* ORIGINAL COMMENT FROM SOCKET PLUGIN by Fabio Niephaus:
*
* This Socket plugin only fulfills http:/https:/ws:/wss: requests by intercepting them
* and sending as either XMLHttpRequest or Fetch or WebSocket.
* To make connections to servers without CORS, it uses a CORS proxy.
*
* When a WebSocket connection is created in the Smalltalk image a low level socket is
* assumed to be provided by this plugin. Since low level sockets are not supported
* in the browser a WebSocket is used here. This does however require the WebSocket
* protocol (applied by the Smalltalk image) to be 'reversed' or 'faked' here in the
* plugin.
* The WebSocket handshake protocol is faked within the plugin and a regular WebSocket
* connection is set up with the other party resulting in a real handshake.
* When a (WebSocket) message is sent from the Smalltalk runtime it will be packed
* inside a frame (fragment). This Socket plugin will extract the message from the
* frame and send it using the WebSocket object (which will put it into a frame
* again). A bit of unnecessary byte and bit fiddling unfortunately.
* See the following site for an explanation of the WebSocket protocol:
* https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
*
* DNS requests are done through DNS over HTTPS (DoH). Quad9 (IP 9.9.9.9) is used
* as server because it seems to take privacy of users serious. Other servers can
* be found at https://en.wikipedia.org/wiki/Public_recursive_name_server
*/
function SocketPlugin() {
"use strict";
let DEBUG = 2; // 0 = off, 1 = some, 2 = more, 3 = lots
const CROQUET_URL = "https://cdn.jsdelivr.net/npm/@croquet/[email protected]";
// The embedded API key is restricted to codefrau.github.io
// get your own key from https://croquet.io/keys
// to run this locally I use the URL
// http://localhost:8000/jasmine/?apiKey=<my-dev-key>
// where <my-dev-key> is an unrestricted key
const CROQUET_APIKEY = "1bfHo0sk3HLmzqxiaasuEFBccxNDE660vMzghymFm";
const CROQUET_APPID = "net.codefrau.squeakjs"; // can be anything
const CROQUET_SESSION = "10.42.0.0"; // network as session name because why not
const CROQUET_PASSWORD = "none"; // make everyone join the same session
// we use the 10.42.0.0/16 subnet, giving us 65535 IP addresses
const CROQUET_NETWORK = CROQUET_SESSION.split(".").map(d => +d);
const BROADCAST_ADDR = "255.255.255.255"; // technically it's 10.42.255.255
const CROQUET_RELEASE_HOSTNAME = 1000 * 60 * 60 * 24; // 24 hours
const CROQUET_RATE_LIMIT = 1000 / 20; // if we send too often, Croquet will kick us out
const CROQUET_MTU = 1500; // Croquet messages are soft-limited to 4k, but we need to account for BASE64 encoding and headers
const CROQUET_MMS = CROQUET_MTU - 100; // maximum message size (MMS) is MTU minus headers
const IP_HEADER = 2 * 4 + 1; // 2 addresses (4 bytes each) + protocol (1 byte)
const UDP_HEADER = IP_HEADER + 2 * 2; // 2 ports (2 bytes each)
const TCP_HEADER = IP_HEADER + 2 * 2 + 2 * 4 + 1 + 2; // 2 ports (2 bytes each) + seq/ack (4 bytes each) + flags (1 byte) + window (2 bytes)
const TCP_RST = 0x01; // no need to use real TCP flag values
const TCP_SYN = 0x02;
const TCP_FIN = 0x04;
const TCP_ACK = 0x08;
const TCP_MSL = 60 * 1000; // 60 seconds (maximum segment lifetime)
// wrap-around 32 bit math for sequence numbers
function seqNum(a) { return a >>> 0; } // convert to unsigned
function seqLE(a, b) { return ((b - a) | 0) >= 0; }
function seqLT(a, b) { return ((b - a) | 0) > 0; }
function seqGE(a, b) { return ((b - a) | 0) <= 0; }
function seqGT(a, b) { return ((b - a) | 0) < 0; }
return {
getModuleName: function () { return 'SocketPlugin (croquet.io)'; },
interpreterProxy: null,
primHandler: null,
handleCounter: 0,
needProxy: new Set(),
// DNS Lookup
// Cache elements: key is name, value is { address: 1.2.3.4, validUntil: Date.now() + 30000 }
status: 0, // Resolver_Uninitialized,
lookupCache: {
localhost: { address: [127, 0, 0, 1], validUntil: Number.MAX_SAFE_INTEGER }
},
lastLookup: null,
lookupSemaIdx: 0,
// allocated network ports for croquet network sockets (portNumber => socket)
croquetTCPPorts: {},
croquetTCPNextPort: 49152, // 49152 - 65535 (IANA suggested range for dynamic or private ports)
croquetUDPPorts: {},
croquetUDPNextPort: 49152, // 49152 - 65535
// Constants
Domain_Unspecified: 0,
Domain_local: 1,
Domain_IPv4: 2,
Domain_IPv6: 3,
TCP_Socket_Type: 0,
UDP_Socket_Type: 1,
Resolver_Uninitialized: 0,
Resolver_Ready: 1,
Resolver_Busy: 2,
Resolver_Error: 3,
Socket_InvalidSocket: -1,
Socket_Unconnected: 0,
Socket_WaitingForConnection: 1,
Socket_Connected: 2,
Socket_OtherEndClosed: 3,
Socket_ThisEndClosed: 4,
setInterpreter: function (anInterpreter) {
this.interpreterProxy = anInterpreter;
this.vm = anInterpreter.vm;
this.primHandler = this.vm.primHandler;
return true;
},
_signalSemaphore: function (semaIndex) {
if (semaIndex <= 0) return;
this.primHandler.signalSemaphoreWithIndex(semaIndex);
this.vm.forceInterruptCheck();
},
_signalLookupSemaphore: function () { this._signalSemaphore(this.lookupSemaIdx); },
_getAddressFromLookupCache: function (name, skipExpirationCheck) {
if (name) {
// Check for valid dotted decimal name first
var dottedDecimalsMatch = name.match(/^\d+\.\d+\.\d+\.\d+$/);
if (dottedDecimalsMatch) {
var result = name.split(".").map(function (d) { return +d; });
if (result.every(function (d) { return d <= 255; })) {
return new Uint8Array(result);
}
}
// Lookup in cache
var cacheEntry = this.lookupCache[name];
if (cacheEntry && (skipExpirationCheck || cacheEntry.validUntil >= Date.now())) {
return new Uint8Array(cacheEntry.address);
}
}
return null;
},
_addAddressFromResponseToLookupCache: function (response) {
// Check for valid response
if (!response || response.Status !== 0 || !response.Question || !response.Answer) {
return;
}
// Clean up all response elements by removing trailing dots in names
var removeTrailingDot = function (element, field) {
if (element[field] && element[field].replace) {
element[field] = element[field].replace(/\.$/, "");
}
};
var originalQuestion = response.Question[0];
removeTrailingDot(originalQuestion, "name");
response.Answer.forEach(function (answer) {
removeTrailingDot(answer, "name");
removeTrailingDot(answer, "data");
});
// Get address by traversing alias chain
var lookup = originalQuestion.name;
var address = null;
var ttl = 24 * 60 * 60; // One day as safe default
var hasAddress = response.Answer.some(function (answer) {
if (answer.name === lookup) {
// Time To Live can be set on alias and address, keep shortest period
if (answer.TTL) {
ttl = Math.min(ttl, answer.TTL);
}
if (answer.type === 1) {
// Retrieve IP address as array with 4 numeric values
address = answer.data.split(".").map(function (numberString) { return +numberString; });
return true;
} else if (answer.type === 5) {
// Lookup name points to alias, follow alias from here on
lookup = answer.data;
}
}
return false;
});
// Store address found
if (hasAddress) {
DEBUG > 2 && console.log("DNS lookup for " + originalQuestion.name + " found " + address.join(".") + " (TTL " + ttl + ")");
this.lookupCache[originalQuestion.name] = { address: address, validUntil: Date.now() + (ttl * 1000) };
}
},
_compareAddresses: function (address1, address2) {
return address1.every(function (addressPart, index) {
return address2[index] === addressPart;
});
},
_reverseLookupNameForAddress: function (address) {
// Currently public API's for IP to hostname are not standardized yet (like DoH).
// Assume most lookup's will be for reversing earlier name to address lookups.
// Therefor use the lookup cache and otherwise create a dotted decimals name.
var result = null;
Object.keys(this.lookupCache).some(function (name) {
if (this._compareAddresses(address, this.lookupCache[name].address)) {
result = name;
return true;
}
return false;
}.bind(this));
return result || address.join(".");
},
// A socket emulates socket behavior
_newSocket: function (domain, socketType, recvBufSize, sendBufSize, connSemaIdx, readSemaIdx, writeSemaIdx) {
var plugin = this;
return {
domain: domain,
type: socketType,
hostAddress: null,
host: null,
port: null,
connSemaIndex: connSemaIdx,
readSemaIndex: readSemaIdx,
writeSemaIndex: writeSemaIdx,
webSocket: null,
isCroquet: false,
localPort: null, // only for croquet sockets (maybe should generalize this to all sockets?)
listening: false, // ready to receive a connection (TCP) or data (UDP)
recvBufSize: recvBufSize, // buffers are unused for http and ws requests
sendBufSize: sendBufSize, // but are used for TCP flow control in Croquet sockets
sendBuffer: null,
sendTimeout: null,
response: null,
responseReadUntil: 0,
responseReceived: false,
status: plugin.Socket_Unconnected,
_signalConnSemaphore: function () { plugin._signalSemaphore(this.connSemaIndex); DEBUG > 2 && console.log("Signaled conn semaphore: " + this); },
_signalReadSemaphore: function () { plugin._signalSemaphore(this.readSemaIndex); DEBUG > 2 && console.log("Signaled read semaphore: " + this); },
_signalWriteSemaphore: function () { plugin._signalSemaphore(this.writeSemaIndex); DEBUG > 2 && console.log("Signaled write semaphore: " + this); },
_otherEndClosed: function () {
this.status = plugin.Socket_OtherEndClosed;
this.webSocket = null;
this._signalConnSemaphore();
},
_hostAndPort: function () { return this.host + ':' + this.port; },
_requestNeedsProxy: function () {
return plugin.needProxy.has(this._hostAndPort());
},
_getURL: function (targetURL, isRetry) {
var url = '';
if (isRetry || this._requestNeedsProxy()) {
var proxy = typeof SqueakJS === "object" && SqueakJS.options.proxy;
url = proxy || 'https://corsproxy.io/?';
}
if (this.port !== 443) {
url += 'http://' + this._hostAndPort() + targetURL;
} else {
url += 'https://' + this.host + targetURL;
}
return url;
},
_performRequest: function () {
// Assume a send is requested through WebSocket if connection is present
if (this.webSocket) {
this._performWebSocketSend();
return;
}
var request = new TextDecoder("utf-8").decode(this.sendBuffer);
// Remove request from send buffer
var endOfRequestIndex = this.sendBuffer.findIndex(function (element, index, array) {
// Check for presence of "\r\n\r\n" denoting the end of the request (do simplistic but fast check)
return array[index] === "\r" && array[index + 2] === "\r" && array[index + 1] === "\n" && array[index + 3] === "\n";
});
if (endOfRequestIndex >= 0) {
this.sendBuffer = this.sendBuffer.subarray(endOfRequestIndex + 4);
} else {
this.sendBuffer = null;
}
// Extract header fields
var headerLines = request.split('\r\n\r\n')[0].split('\n');
// Split header lines and parse first line
var firstHeaderLineItems = headerLines[0].split(' ');
var httpMethod = firstHeaderLineItems[0];
if (httpMethod !== 'GET' && httpMethod !== 'PUT' &&
httpMethod !== 'POST') {
this._otherEndClosed();
return -1;
}
var targetURL = firstHeaderLineItems[1];
// Extract possible data to send
var seenUpgrade = false;
var seenWebSocket = false;
var data = null;
for (var i = 1; i < headerLines.length; i++) {
var line = headerLines[i];
if (line.match(/Content-Length:/i)) {
var contentLength = parseInt(line.substr(16));
var end = this.sendBuffer.byteLength;
data = this.sendBuffer.subarray(end - contentLength, end);
} else if (line.match(/Host:/i)) {
var hostAndPort = line.substr(6).trim();
var host = hostAndPort.split(':')[0];
var port = parseInt(hostAndPort.split(':')[1]) || this.port;
if (this.host !== host) {
console.warn('Host for ' + this.hostAddress + ' was ' + this.host + ' but from HTTP request now ' + host);
this.host = host;
}
if (this.port !== port) {
console.warn('Port for ' + this.hostAddress + ' was ' + this.port + ' but from HTTP request now ' + port);
this.port = port;
}
} if (line.match(/Connection: Upgrade/i)) {
seenUpgrade = true;
} else if (line.match(/Upgrade: WebSocket/i)) {
seenWebSocket = true;
}
}
if (httpMethod === "GET" && seenUpgrade && seenWebSocket) {
this._performWebSocketRequest(targetURL, httpMethod, data, headerLines);
} else if (self.fetch) {
this._performFetchAPIRequest(targetURL, httpMethod, data, headerLines);
} else {
this._performXMLHTTPRequest(targetURL, httpMethod, data, headerLines);
}
},
_performFetchAPIRequest: function (targetURL, httpMethod, data, requestLines) {
var thisSocket = this;
var headers = {};
for (var i = 1; i < requestLines.length; i++) {
var lineItems = requestLines[i].split(':');
if (lineItems.length === 2) {
headers[lineItems[0]] = lineItems[1].trim();
}
}
if (typeof SqueakJS === "object" && SqueakJS.options.ajax) {
headers["X-Requested-With"] = "XMLHttpRequest";
}
var init = {
method: httpMethod,
headers: headers,
body: data,
mode: 'cors'
};
fetch(this._getURL(targetURL), init)
.then(thisSocket._handleFetchAPIResponse.bind(thisSocket))
.catch(function (e) {
var url = thisSocket._getURL(targetURL, true);
console.warn('Retrying with CORS proxy: ' + url);
fetch(url, init)
.then(function (res) {
console.log('Success: ' + url);
thisSocket._handleFetchAPIResponse(res);
plugin.needProxy.add(thisSocket._hostAndPort());
})
.catch(function (e) {
// KLUDGE! This is just a workaround for a broken
// proxy server - we should remove it when
// crossorigin.me is fixed
console.warn('Fetch API failed, retrying with XMLHttpRequest');
thisSocket._performXMLHTTPRequest(targetURL, httpMethod, data, requestLines);
});
});
},
_handleFetchAPIResponse: function (res) {
if (this.response === null) {
var header = ['HTTP/1.0 ', res.status, ' ', res.statusText, '\r\n'];
res.headers.forEach(function (value, key, array) {
header = header.concat([key, ': ', value, '\r\n']);
});
header.push('\r\n');
this.response = [new TextEncoder('utf-8').encode(header.join(''))];
}
this._readIncremental(res.body.getReader());
},
_readIncremental: function (reader) {
var thisSocket = this;
return reader.read().then(function (result) {
if (result.done) {
thisSocket.responseReceived = true;
return;
}
thisSocket.response.push(result.value);
thisSocket._signalReadSemaphore();
return thisSocket._readIncremental(reader);
});
},
_performXMLHTTPRequest: function (targetURL, httpMethod, data, requestLines) {
var thisSocket = this;
var contentType;
for (var i = 1; i < requestLines.length; i++) {
var line = requestLines[i];
if (line.match(/Content-Type:/i)) {
contentType = encodeURIComponent(line.substr(14));
break;
}
}
var httpRequest = new XMLHttpRequest();
httpRequest.open(httpMethod, this._getURL(targetURL));
if (contentType !== undefined) {
httpRequest.setRequestHeader('Content-type', contentType);
}
if (typeof SqueakJS === "object" && SqueakJS.options.ajax) {
httpRequest.setRequestHeader("X-Requested-With", "XMLHttpRequest");
}
httpRequest.responseType = "arraybuffer";
httpRequest.onload = function (oEvent) {
thisSocket._handleXMLHTTPResponse(this);
};
httpRequest.onerror = function (e) {
var url = thisSocket._getURL(targetURL, true);
console.warn('Retrying with CORS proxy: ' + url);
var retry = new XMLHttpRequest();
retry.open(httpMethod, url);
retry.responseType = httpRequest.responseType;
if (typeof SqueakJS === "object" && SqueakJS.options.ajaxx) {
retry.setRequestHeader("X-Requested-With", "XMLHttpRequest");
}
retry.onload = function (oEvent) {
console.log('Success: ' + url);
thisSocket._handleXMLHTTPResponse(this);
plugin.needProxy.add(thisSocket._hostAndPort());
};
retry.onerror = function () {
thisSocket._otherEndClosed();
console.error("Failed to download:\n" + url);
};
retry.send(data);
};
httpRequest.send(data);
},
_handleXMLHTTPResponse: function (response) {
this.responseReceived = true;
var content = response.response;
if (!content) {
this._otherEndClosed();
return;
}
// Recreate header
var header = new TextEncoder('utf-8').encode(
'HTTP/1.0 ' + response.status + ' ' + response.statusText +
'\r\n' + response.getAllResponseHeaders() + '\r\n');
// Concat header and response
var res = new Uint8Array(header.byteLength + content.byteLength);
res.set(header, 0);
res.set(new Uint8Array(content), header.byteLength);
this.response = [res];
this._signalReadSemaphore();
},
_performWebSocketRequest: function (targetURL, httpMethod, data, requestLines) {
var url = this._getURL(targetURL);
// Extract WebSocket key and subprotocol
var webSocketSubProtocol;
var webSocketKey;
for (var i = 1; i < requestLines.length; i++) {
var requestLine = requestLines[i].split(":");
if (requestLine[0] === "Sec-WebSocket-Protocol") {
webSocketSubProtocol = requestLine[1].trim();
if (webSocketKey) {
break; // Only break if both webSocketSubProtocol and webSocketKey are found
}
} else if (requestLine[0] === "Sec-WebSocket-Key") {
webSocketKey = requestLine[1].trim();
if (webSocketSubProtocol) {
break; // Only break if both webSocketSubProtocol and webSocketKey are found
}
}
}
// Keep track of WebSocket for future send and receive operations
this.webSocket = new WebSocket(url.replace(/^http/, "ws"), webSocketSubProtocol);
var thisSocket = this;
this.webSocket.onopen = function () {
if (thisSocket.status !== plugin.Socket_Connected) {
thisSocket.status = plugin.Socket_Connected;
thisSocket._signalConnSemaphore();
thisSocket._signalWriteSemaphore(); // Immediately ready to write
}
// Send the (fake) handshake back to the caller
var acceptKey = new Uint8Array(sha1.array(webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"));
var acceptKeyString = Squeak.bytesAsString(acceptKey);
thisSocket._performWebSocketReceive(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: " + btoa(acceptKeyString) + "\r\n\r\n",
true,
);
};
this.webSocket.onmessage = function (event) {
thisSocket._performWebSocketReceive(event.data);
};
this.webSocket.onerror = function (e) {
thisSocket._otherEndClosed();
console.error("Error in WebSocket:", e);
};
this.webSocket.onclose = function () {
thisSocket._otherEndClosed();
};
},
_performWebSocketReceive: function (message, skipFramePacking) {
// Process received message
var dataIsBinary = !message.substr;
if (!dataIsBinary) {
message = new TextEncoder("utf-8").encode(message);
}
if (!skipFramePacking) {
// Create WebSocket frame from message for Smalltalk runtime
var frameLength = 1 + 1 + message.length + 4; // 1 byte for initial header bits & opcode, 1 byte for length and 4 bytes for mask
var payloadLengthByte;
if (message.byteLength < 126) {
payloadLengthByte = message.length;
} else if (message.byteLength < 0xffff) {
frameLength += 2; // 2 additional bytes for payload length
payloadLengthByte = 126;
} else {
frameLength += 8; // 8 additional bytes for payload length
payloadLengthByte = 127;
}
var frame = new Uint8Array(frameLength);
frame[0] = dataIsBinary ? 0x82 : 0x81; // Final bit 0x80 set and opcode 0x01 for text and 0x02 for binary
frame[1] = 0x80 | payloadLengthByte; // Mask bit 0x80 and payload length byte
var nextByteIndex;
if (payloadLengthByte === 126) {
frame[2] = message.length >>> 8;
frame[3] = message.length & 0xff;
nextByteIndex = 4;
} else if (payloadLengthByte === 127) {
frame[2] = message.length >>> 56;
frame[3] = (message.length >>> 48) & 0xff;
frame[4] = (message.length >>> 40) & 0xff;
frame[5] = (message.length >>> 32) & 0xff;
frame[6] = (message.length >>> 24) & 0xff;
frame[7] = (message.length >>> 16) & 0xff;
frame[8] = (message.length >>> 8) & 0xff;
frame[9] = message.length & 0xff;
nextByteIndex = 10;
} else {
nextByteIndex = 2;
}
// Add 'empty' mask (requiring no transformation)
// Otherwise a (random) mask and the following line should be added:
// var payload = message.map(function(b, index) { return b ^ maskKey[index & 0x03]; });
var maskKey = new Uint8Array(4);
frame.set(maskKey, nextByteIndex);
nextByteIndex += 4;
var payload = message;
frame.set(payload, nextByteIndex);
// Make sure the frame is set as the response
message = frame;
}
// Store received message in response buffer
if (!this.response || !this.response.length) {
this.response = [message];
} else {
this.response.push(message);
}
this.responseReceived = true;
this._signalReadSemaphore();
},
_performWebSocketSend: function () {
// Decode sendBuffer which is a WebSocket frame (from Smalltalk runtime)
// Read frame header fields
var firstByte = this.sendBuffer[0];
var finalBit = firstByte >>> 7;
var opcode = firstByte & 0x0f;
var dataIsBinary;
if (opcode === 0x00) {
// Continuation frame
console.error("No support for WebSocket frame continuation yet!");
return true;
} else if (opcode === 0x01) {
// Text frame
dataIsBinary = false;
} else if (opcode === 0x02) {
// Binary frame
dataIsBinary = true;
} else if (opcode === 0x08) {
// Close connection
this.webSocket.close();
this.webSocket = null;
return;
} else if (opcode === 0x09 || opcode === 0x0a) {
// Ping/pong frame (ignoring it, is handled by WebSocket implementation itself)
return;
} else {
console.error("Unsupported WebSocket frame opcode " + opcode);
return;
}
var secondByte = this.sendBuffer[1];
var maskBit = secondByte >>> 7;
var payloadLength = secondByte & 0x7f;
var nextByteIndex;
if (payloadLength === 126) {
payloadLength = (this.sendBuffer[2] << 8) | this.sendBuffer[3];
nextByteIndex = 4;
} else if (payloadLength === 127) {
payloadLength =
(this.sendBuffer[2] << 56) |
(this.sendBuffer[3] << 48) |
(this.sendBuffer[4] << 40) |
(this.sendBuffer[5] << 32) |
(this.sendBuffer[6] << 24) |
(this.sendBuffer[7] << 16) |
(this.sendBuffer[8] << 8) |
this.sendBuffer[9]
;
nextByteIndex = 10;
} else {
nextByteIndex = 2;
}
var maskKey;
if (maskBit) {
maskKey = this.sendBuffer.subarray(nextByteIndex, nextByteIndex + 4);
nextByteIndex += 4;
}
// Read (remaining) payload
var payloadData = this.sendBuffer.subarray(nextByteIndex, nextByteIndex + payloadLength);
nextByteIndex += payloadLength;
// Unmask the payload
if (maskBit) {
payloadData = payloadData.map(function (b, index) { return b ^ maskKey[index & 0x03]; });
}
// Extract data from payload
var data;
if (dataIsBinary) {
data = payloadData;
} else {
data = Squeak.bytesAsString(payloadData);
}
// Remove frame from send buffer
this.sendBuffer = this.sendBuffer.subarray(nextByteIndex);
this.webSocket.send(data);
// Send remaining frames
if (this.sendBuffer.byteLength > 0) {
this._performWebSocketSend();
}
},
connect: function (hostAddress, port) {
// check if we're connecting to the Croquet subnet otherwise assume it's http
var isCroquetAddress = hostAddress[0] === CROQUET_NETWORK[0] && hostAddress[1] === CROQUET_NETWORK[1];
if (isCroquetAddress) {
plugin.croquetConnect(this, hostAddress, port);
} else {
this.hostAddress = hostAddress;
this.host = plugin._reverseLookupNameForAddress(hostAddress);
this.port = port;
this.status = plugin.Socket_Connected;
this._signalConnSemaphore();
this._signalWriteSemaphore(); // Immediately ready to write
}
},
close: function () {
if (this.isCroquet) {
plugin.croquetClose(this);
} else if (this.status == plugin.Socket_Connected ||
this.status == plugin.Socket_OtherEndClosed ||
this.status == plugin.Socket_WaitingForConnection) {
if (this.webSocket) {
this.webSocket.close();
this.webSocket = null;
}
this.status = plugin.Socket_Unconnected;
this._signalConnSemaphore();
}
},
abort: function () {
if (this.isCroquet) {
plugin.croquetAbort(this);
} else this.close();
},
destroy: function () {
if (this.isCroquet) {
plugin.croquetDestroy(this);
}
this.status = plugin.Socket_InvalidSocket;
},
dataAvailable: function () {
if (this.status == plugin.Socket_InvalidSocket) return false;
if (this.status == plugin.Socket_Connected) {
if (this.webSocket) {
return this.response && this.response.length > 0;
} else {
if (this.response && this.response.length > 0) {
this._signalReadSemaphore();
return true;
}
if (this.responseSentCompletly) {
// Signal older Socket implementations that they reached the end
this.status = plugin.Socket_OtherEndClosed;
this._signalConnSemaphore();
}
}
}
return false;
},
recv: function (count) {
if (this.type == plugin.TCP_Socket_Type) {
return plugin.tcpRECEIVE(this, count);
}
if (this.response === null) return [];
var data = this.response[0];
if (data.length > count) {
var rest = data.subarray(count);
if (rest) {
this.response[0] = rest;
} else {
this.response.shift();
}
data = data.subarray(0, count);
} else {
this.response.shift();
}
if (this.responseReceived && this.response.length === 0 && !this.webSocket && !this.isCroquet) {
this.responseSentCompletly = true;
}
return data;
},
send: function (data, start, end) {
var newBytes = data.bytes.subarray(start, end);
if (this.isCroquet) {
return this.type === plugin.TCP_Socket_Type
? plugin.tcpSEND(this, newBytes)
: plugin.udpSend(this, newBytes);
}
if (this.sendTimeout !== null) {
self.clearTimeout(this.sendTimeout);
}
this.lastSend = Date.now();
if (this.sendBuffer === null) {
// Make copy of buffer otherwise the stream buffer will overwrite it on next call (inside Smalltalk image)
this.sendBuffer = newBytes.slice();
} else {
var newLength = this.sendBuffer.byteLength + newBytes.byteLength;
var newBuffer = new Uint8Array(newLength);
newBuffer.set(this.sendBuffer, 0);
newBuffer.set(newBytes, this.sendBuffer.byteLength);
this.sendBuffer = newBuffer;
}
// Give image some time to send more data before performing requests
this.sendTimeout = self.setTimeout(this._performRequest.bind(this), 50);
return newBytes.byteLength;
},
[Symbol.toPrimitive]: function () {
var name = this.name || "socket";
var details = "";
if (this.type === plugin.TCP_Socket_Type) details += " tcp";
else if (this.type === plugin.UDP_Socket_Type) details += " udp";
if (this.localPort) details += ":" + this.localPort;
if (this.port) details += " remote: " + (this.host || this.hostAddress.join(".")) + ":" + this.port;
details += " (" + this.statusString();
if (this.isCroquet && this.type === plugin.TCP_Socket_Type) details += "/" + this.tcpState;
details += ")";
return name + "[" + details.trim() + "]";
},
statusString: function () {
switch (this.status) {
case plugin.Socket_InvalidSocket: return 'invalid';
case plugin.Socket_Unconnected: return 'unconnected';
case plugin.Socket_WaitingForConnection: return 'waiting for connection';
case plugin.Socket_Connected: return 'connected';
case plugin.Socket_OtherEndClosed: return 'other end closed';
case plugin.Socket_ThisEndClosed: return 'this end closed';
default: return 'unknown ' + this.status;
}
}
};
},
primitiveHasSocketAccess: function (argCount) {
DEBUG > 2 && console.log("primitiveHasSocketAccess");
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.trueObject());
return true;
},
primitiveInitializeNetwork: function (argCount) {
DEBUG > 2 && console.log("primitiveInitializeNetwork");
if (argCount !== 1) return false;
this.lookupSemaIdx = this.interpreterProxy.stackIntegerValue(0);
this.status = this.Resolver_Ready;
this.interpreterProxy.pop(argCount); // Answer self
return true;
},
primitiveResolverNameLookupResult: function (argCount) {
if (argCount !== 0) return false;
var result = this.interpreterProxy.nilObject(); // Answer nil if no lookup was started
// Validate that lastLookup is in fact a name (and not an address)
if (typeof this.lastLookup === "string") {
// Retrieve result from cache
var address = this._getAddressFromLookupCache(this.lastLookup, true);
if (address) result = this.primHandler.makeStByteArray(address);
}
DEBUG > 2 && console.log("primitiveResolverNameLookupResult => " + result);
this.interpreterProxy.popthenPush(argCount + 1, result);
return true;
},
primitiveResolverStartNameLookup: function (argCount) {
if (argCount !== 1) return false;
// Start new lookup, ignoring if one is in progress
var lookup = this.lastLookup = this.interpreterProxy.stackValue(0).bytesAsString();
DEBUG > 2 && console.log("primitiveResolverStartNameLookup " + lookup);
// Perform lookup in local cache
var result = this._getAddressFromLookupCache(lookup, false);
if (result) {
this.status = this.Resolver_Ready;
this._signalLookupSemaphore();
} else {
// Perform DNS request
var dnsQueryURL = "https://9.9.9.9:5053/dns-query?name=" + encodeURIComponent(this.lastLookup) + "&type=A";
var queryStarted = false;
DEBUG > 2 && console.log("DNS query: " + dnsQueryURL);
if (self.fetch) {
var thisSocket = this;
var init = {
method: "GET",
mode: "cors",
credentials: "omit",
cache: "no-store", // do not use the browser cache for DNS requests (a separate cache is kept)
referrer: "no-referrer",
referrerPolicy: "no-referrer",
};
self.fetch(dnsQueryURL, init)
.then(function (response) {
return response.json();
})
.then(function (response) {
thisSocket._addAddressFromResponseToLookupCache(response);
})
.catch(function (error) {
console.error("Name lookup failed", error);
})
.then(function () {
// If no other lookup is started, signal the receiver (ie resolver) is ready
if (lookup === thisSocket.lastLookup) {
thisSocket.status = thisSocket.Resolver_Ready;
thisSocket._signalLookupSemaphore();
}
})
;
queryStarted = true;
} else {
var thisSocket = this;
var lookupReady = function () {
// If no other lookup is started, signal the receiver (ie resolver) is ready
if (lookup === thisSocket.lastLookup) {
thisSocket.status = thisSocket.Resolver_Ready;
thisSocket._signalLookupSemaphore();
}
};
var httpRequest = new XMLHttpRequest();
httpRequest.open("GET", dnsQueryURL, true);
httpRequest.timeout = 2000; // milliseconds
httpRequest.responseType = "json";
httpRequest.onload = function (oEvent) {
thisSocket._addAddressFromResponseToLookupCache(this.response);
lookupReady();
};
httpRequest.onerror = function () {
console.error("Name lookup failed", httpRequest.statusText);
lookupReady();
};
httpRequest.send();
queryStarted = true;
}
// Mark the receiver (ie resolver) is busy
if (queryStarted) {
this.status = this.Resolver_Busy;
this._signalLookupSemaphore();
}
}
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.nilObject());
return true;
},
primitiveResolverAddressLookupResult: function (argCount) {
DEBUG > 2 && console.log("primitiveResolverAddressLookupResult");
if (argCount !== 0) return false;
// Validate that lastLookup is in fact an address (and not a name)
if (!this.lastLookup || !this.lastLookup.every) {
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.nilObject());
return true;
}
// Retrieve result from cache
var name = this._reverseLookupNameForAddress(this.lastLookup);
var result = this.primHandler.makeStString(name);
this.interpreterProxy.popthenPush(argCount + 1, result);
return true;
},
primitiveResolverStartAddressLookup: function (argCount) {
DEBUG > 2 && console.log("primitiveResolverStartAddressLookup");
if (argCount !== 1) return false;
// Start new lookup, ignoring if one is in progress
this.lastLookup = this.interpreterProxy.stackBytes(0);
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.nilObject());
// Immediately signal the lookup is ready (since all lookups are done internally)