Spaces:
Running
Running
/* | |
* 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"; | |
return { | |
getModuleName: function() { return 'SocketPlugin (http-only)'; }, | |
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, | |
// Constants | |
TCP_Socket_Type: 0, | |
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.primHandler = this.interpreterProxy.vm.primHandler; | |
return true; | |
}, | |
_signalSemaphore: function(semaIndex) { | |
if (semaIndex <= 0) return; | |
this.primHandler.signalSemaphoreWithIndex(semaIndex); | |
}, | |
_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) { | |
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 thisHandle = this; | |
var result = null; | |
Object.keys(this.lookupCache).some(function(name) { | |
if (thisHandle._compareAddresses(address, thisHandle.lookupCache[name].address)) { | |
result = name; | |
return true; | |
} | |
return false; | |
}); | |
return result || address.join("."); | |
}, | |
// A socket handle emulates socket behavior | |
_newSocketHandle: function(sendBufSize, connSemaIdx, readSemaIdx, writeSemaIdx) { | |
var plugin = this; | |
return { | |
hostAddress: null, | |
host: null, | |
port: null, | |
connSemaIndex: connSemaIdx, | |
readSemaIndex: readSemaIdx, | |
writeSemaIndex: writeSemaIdx, | |
webSocket: null, | |
sendBuffer: null, | |
sendTimeout: null, | |
response: null, | |
responseReadUntil: 0, | |
responseReceived: false, | |
status: plugin.Socket_Unconnected, | |
_signalConnSemaphore: function() { plugin._signalSemaphore(this.connSemaIndex); }, | |
_signalReadSemaphore: function() { plugin._signalSemaphore(this.readSemaIndex); }, | |
_signalWriteSemaphore: function() { plugin._signalSemaphore(this.writeSemaIndex); }, | |
_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 || Squeak.defaultCORSProxy; | |
} | |
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 thisHandle = 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(thisHandle._handleFetchAPIResponse.bind(thisHandle)) | |
.catch(function (e) { | |
var url = thisHandle._getURL(targetURL, true); | |
console.warn('Retrying with CORS proxy: ' + url); | |
fetch(url, init) | |
.then(function(res) { | |
console.log('Success: ' + url); | |
thisHandle._handleFetchAPIResponse(res); | |
plugin.needProxy.add(thisHandle._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'); | |
thisHandle._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 thisHandle = this; | |
return reader.read().then(function (result) { | |
if (result.done) { | |
thisHandle.responseReceived = true; | |
return; | |
} | |
thisHandle.response.push(result.value); | |
thisHandle._signalReadSemaphore(); | |
return thisHandle._readIncremental(reader); | |
}); | |
}, | |
_performXMLHTTPRequest: function(targetURL, httpMethod, data, requestLines){ | |
var thisHandle = 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) { | |
thisHandle._handleXMLHTTPResponse(this); | |
}; | |
httpRequest.onerror = function(e) { | |
var url = thisHandle._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); | |
thisHandle._handleXMLHTTPResponse(this); | |
plugin.needProxy.add(thisHandle._hostAndPort()); | |
}; | |
retry.onerror = function() { | |
thisHandle._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 thisHandle = this; | |
this.webSocket.onopen = function() { | |
if (thisHandle.status !== plugin.Socket_Connected) { | |
thisHandle.status = plugin.Socket_Connected; | |
thisHandle._signalConnSemaphore(); | |
thisHandle._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); | |
thisHandle._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) { | |
thisHandle._performWebSocketReceive(event.data); | |
}; | |
this.webSocket.onerror = function(e) { | |
thisHandle._otherEndClosed(); | |
console.error("Error in WebSocket:", e); | |
}; | |
this.webSocket.onclose = function() { | |
thisHandle._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) { | |
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.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(); | |
} | |
}, | |
destroy: function() { | |
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.response === null) return []; | |
var data = this.response[0] || new Uint8Array(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.responseSentCompletly = true; | |
} | |
return data; | |
}, | |
send: function(data, start, end) { | |
if (this.sendTimeout !== null) { | |
self.clearTimeout(this.sendTimeout); | |
} | |
this.lastSend = Date.now(); | |
var newBytes = data.bytes.subarray(start, end); | |
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; | |
} | |
}; | |
}, | |
primitiveHasSocketAccess: function(argCount) { | |
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.trueObject()); | |
return true; | |
}, | |
primitiveInitializeNetwork: function(argCount) { | |
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; | |
// Validate that lastLookup is in fact a name (and not an address) | |
if (!this.lastLookup || !this.lastLookup.substr) { | |
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.nilObject()); | |
return true; | |
} | |
// Retrieve result from cache | |
var address = this._getAddressFromLookupCache(this.lastLookup, true); | |
this.interpreterProxy.popthenPush(argCount + 1, address ? | |
this.primHandler.makeStByteArray(address) : | |
this.interpreterProxy.nilObject() | |
); | |
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(); | |
// 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; | |
if (self.fetch) { | |
var thisHandle = 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) { | |
thisHandle._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 === thisHandle.lastLookup) { | |
thisHandle.status = thisHandle.Resolver_Ready; | |
thisHandle._signalLookupSemaphore(); | |
} | |
}) | |
; | |
queryStarted = true; | |
} else { | |
var thisHandle = this; | |
var lookupReady = function() { | |
// If no other lookup is started, signal the receiver (ie resolver) is ready | |
if (lookup === thisHandle.lastLookup) { | |
thisHandle.status = thisHandle.Resolver_Ready; | |
thisHandle._signalLookupSemaphore(); | |
} | |
}; | |
var httpRequest = new XMLHttpRequest(); | |
httpRequest.open("GET", dnsQueryURL, true); | |
httpRequest.timeout = 2000; // milliseconds | |
httpRequest.responseType = "json"; | |
httpRequest.onload = function(oEvent) { | |
thisHandle._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) { | |
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) { | |
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) | |
this.status = this.Resolver_Ready; | |
this._signalLookupSemaphore(); | |
return true; | |
}, | |
primitiveResolverStatus: function(argCount) { | |
if (argCount !== 0) return false; | |
this.interpreterProxy.popthenPush(argCount + 1, this.status); | |
return true; | |
}, | |
primitiveResolverAbortLookup: function(argCount) { | |
if (argCount !== 0) return false; | |
// Unable to abort send request (although future browsers might support AbortController), | |
// just cancel the handling of the request by resetting the lastLookup value | |
this.lastLookup = null; | |
this.status = this.Resolver_Ready; | |
this._signalLookupSemaphore(); | |
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.nilObject()); | |
return true; | |
}, | |
primitiveSocketRemoteAddress: function(argCount) { | |
if (argCount !== 1) return false; | |
var handle = this.interpreterProxy.stackObjectValue(0).handle; | |
if (handle === undefined) return false; | |
this.interpreterProxy.popthenPush(argCount + 1, handle.hostAddress ? | |
this.primHandler.makeStByteArray(handle.hostAddress) : | |
this.interpreterProxy.nilObject() | |
); | |
return true; | |
}, | |
primitiveSocketRemotePort: function(argCount) { | |
if (argCount !== 1) return false; | |
var handle = this.interpreterProxy.stackObjectValue(0).handle; | |
if (handle === undefined) return false; | |
this.interpreterProxy.popthenPush(argCount + 1, handle.port); | |
return true; | |
}, | |
primitiveSocketConnectionStatus: function(argCount) { | |
if (argCount !== 1) return false; | |
var handle = this.interpreterProxy.stackObjectValue(0).handle; | |
if (handle === undefined) return false; | |
var status = handle.status; | |
if (status === undefined) status = this.Socket_InvalidSocket; | |
this.interpreterProxy.popthenPush(argCount + 1, status); | |
return true; | |
}, | |
primitiveSocketConnectToPort: function(argCount) { | |
if (argCount !== 3) return false; | |
var handle = this.interpreterProxy.stackObjectValue(2).handle; | |
if (handle === undefined) return false; | |
var hostAddress = this.interpreterProxy.stackBytes(1); | |
var port = this.interpreterProxy.stackIntegerValue(0); | |
handle.connect(hostAddress, port); | |
this.interpreterProxy.popthenPush(argCount + 1, | |
this.interpreterProxy.nilObject()); | |
return true; | |
}, | |
primitiveSocketCloseConnection: function(argCount) { | |
if (argCount !== 1) return false; | |
var handle = this.interpreterProxy.stackObjectValue(0).handle; | |
if (handle === undefined) return false; | |
handle.close(); | |
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.nilObject()); | |
return true; | |
}, | |
primitiveSocketCreate3Semaphores: function(argCount) { | |
if (argCount !== 7) return false; | |
var writeSemaIndex = this.interpreterProxy.stackIntegerValue(0); | |
var readSemaIndex = this.interpreterProxy.stackIntegerValue(1); | |
var semaIndex = this.interpreterProxy.stackIntegerValue(2); | |
var sendBufSize = this.interpreterProxy.stackIntegerValue(3); | |
var socketType = this.interpreterProxy.stackIntegerValue(5); | |
if (socketType !== this.TCP_Socket_Type) return false; | |
var name = '{SqueakJS Socket #' + (++this.handleCounter) + '}'; | |
var sqHandle = this.primHandler.makeStString(name); | |
sqHandle.handle = this._newSocketHandle(sendBufSize, semaIndex, | |
readSemaIndex, writeSemaIndex); | |
this.interpreterProxy.popthenPush(argCount + 1, sqHandle); | |
return true; | |
}, | |
primitiveSocketDestroy: function(argCount) { | |
if (argCount !== 1) return false; | |
var handle = this.interpreterProxy.stackObjectValue(0).handle; | |
if (handle === undefined) return false; | |
handle.destroy(); | |
this.interpreterProxy.popthenPush(argCount + 1, handle.status); | |
return true; | |
}, | |
primitiveSocketReceiveDataAvailable: function(argCount) { | |
if (argCount !== 1) return false; | |
var handle = this.interpreterProxy.stackObjectValue(0).handle; | |
if (handle === undefined) return false; | |
var ret = this.interpreterProxy.falseObject(); | |
if (handle.dataAvailable()) { | |
ret = this.interpreterProxy.trueObject(); | |
} | |
this.interpreterProxy.popthenPush(argCount + 1, ret); | |
return true; | |
}, | |
primitiveSocketReceiveDataBufCount: function(argCount) { | |
if (argCount !== 4) return false; | |
var handle = this.interpreterProxy.stackObjectValue(3).handle; | |
if (handle === undefined) return false; | |
var target = this.interpreterProxy.stackObjectValue(2); | |
var start = this.interpreterProxy.stackIntegerValue(1) - 1; | |
var count = this.interpreterProxy.stackIntegerValue(0); | |
if ((start + count) > target.bytes.length) return false; | |
var bytes = handle.recv(count); | |
target.bytes.set(bytes, start); | |
this.interpreterProxy.popthenPush(argCount + 1, bytes.length); | |
return true; | |
}, | |
primitiveSocketSendDataBufCount: function(argCount) { | |
if (argCount !== 4) return false; | |
var handle = this.interpreterProxy.stackObjectValue(3).handle; | |
if (handle === undefined) return false; | |
var data = this.interpreterProxy.stackObjectValue(2); | |
var start = this.interpreterProxy.stackIntegerValue(1) - 1; | |
if (start < 0 ) return false; | |
var count = this.interpreterProxy.stackIntegerValue(0); | |
var end = start + count; | |
if (end > data.length) return false; | |
var res = handle.send(data, start, end); | |
this.interpreterProxy.popthenPush(argCount + 1, res); | |
return true; | |
}, | |
primitiveSocketSendDone: function(argCount) { | |
if (argCount !== 1) return false; | |
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.trueObject()); | |
return true; | |
}, | |
primitiveSocketListenWithOrWithoutBacklog: function(argCount) { | |
if (argCount < 2) return false; | |
this.interpreterProxy.popthenPush(argCount + 1, this.interpreterProxy.nilObject()); | |
return true; | |
}, | |
}; | |
} | |
function registerSocketPlugin() { | |
if (typeof Squeak === "object" && Squeak.registerExternalModule) { | |
Squeak.registerExternalModule('SocketPlugin', SocketPlugin()); | |
} else self.setTimeout(registerSocketPlugin, 100); | |
}; | |
registerSocketPlugin(); | |