diff --git a/modules/network/http/http.js b/modules/network/http/http.js index deddd47dfb..1cfb07a23e 100644 --- a/modules/network/http/http.js +++ b/modules/network/http/http.js @@ -115,7 +115,7 @@ export class Request { } close() { - this.socket?.close(); + if( 16 & this.flags == 0) this.socket?.close(); delete this.socket; delete this.buffers; delete this.callback; @@ -452,7 +452,7 @@ function done(error = false, data) { state: 0 - connecting - 1 - receieving request status + 1 - receiving request status 2 - receiving request headers @@ -661,8 +661,10 @@ function server(message, value, etc) { const status = response?.status ?? 200; const message = response?.reason?.toString() ?? reason(status); const parts = ["HTTP/1.1 ", status.toString(), " ", message, "\r\n", - "connection: ", "close\r\n"]; - + "connection: ", "close\r\n"]; + if ( status === 101 ) { + this.flags = 16; + } if (response) { let byteLength; @@ -682,11 +684,13 @@ function server(message, value, etc) { this.flags = 4; } else { - this.flags = 1; - let count = 0; - if (this.body) - count = ("string" === typeof this.body) ? this.body.length : this.body.byteLength; //@@ utf-8 hell - parts.push("content-length: ", count.toString(), "\r\n"); + if ( this.flags !== 16 ) { + this.flags = 1; + let count = 0; + if (this.body) + count = ("string" === typeof this.body) ? this.body.length : this.body.byteLength; //@@ utf-8 hell + parts.push("content-length: ", count.toString(), "\r\n"); + } } } else @@ -702,6 +706,11 @@ function server(message, value, etc) { if (count > (socket.write() - ((2 & this.flags) ? 8 : 0))) return; } + + if ( this.flags === 16 ) { + this.state = 10; + return; + } } if (8 === this.state) { let body = this.body; @@ -741,7 +750,7 @@ function server(message, value, etc) { } finally { this.server.connections.splice(this.server.connections.indexOf(this), 1); - this.close(); + if ( this.flags !== 16 ) this.close(); } } } diff --git a/modules/network/http/middleware/hotspot.js b/modules/network/http/middleware/hotspot.js new file mode 100644 index 0000000000..0efaf4cdbf --- /dev/null +++ b/modules/network/http/middleware/hotspot.js @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2016-2021 Moddable Tech, Inc. + * Copyright (c) Wilberforce + * + * This file is part of the Moddable SDK. + * + * This work is licensed under the + * Creative Commons Attribution 4.0 International License. + * To view a copy of this license, visit + * . + * or send a letter to Creative Commons, PO Box 1866, + * Mountain View, CA 94042, USA. + * + */ + +import { Middleware, Server } from "middleware/server"; +import Net from "net" + +const hotspot = new Map; + +// iOS 8/9 +hotspot.set("/library/test/success.html",{status: 302,body: "Success"}); +hotspot.set("/hotspot-detect.html",{status: 302,body: "Success"}); + +// Windows +hotspot.set("/ncsi.txt",{status: 302,body: "Microsoft NCSI"}); +hotspot.set("/connecttest.txt",{status: 302,body: "Microsoft Connect Test"}); +hotspot.set("/redirect",{status: 302,body: ""}); // Win 10 + +// Android +hotspot.set("/mobile/status.php", {status:302}); // Android 8.0 (Samsung s9+) +hotspot.set("/generate_204", {status:302}); // Android actual redirect +hotspot.set("/gen_204", {status:204}); // Android 9.0 + +export class MiddlewareHotspot extends Middleware { + constructor() { + super(); + } + handler(req, message, value, etc) { + switch (message) { + case Server.status: + + req.redirect=hotspot.get(value); // value is path + if ( req.redirect) return; // Hotspot url match + delete req.redirect; + return this.next?.handler(req, message, value, etc); + case Server.header: { + if ( "host" === value ) { + req.host=etc; + trace(`MiddlewareHotspot: http://${req.host}${req.path}\n`); + } + return this.next?.handler(req, message, value, etc); + } + case Server.prepareResponse: + + if( req.redirect) { + let apIP=Net.get("IP", "ap"); + let redirect={ + headers: [ "Content-type", "text/plain", "Location",`http://${apIP}`], + ...req.redirect + }; + trace(`Hotspot match: http://${req.host}${req.path}\n`); + trace(JSON.stringify(redirect),'\n'); + + return redirect; + } + } + return this.next?.handler(req, message, value, etc); + } +} +Object.freeze(hotspot); + +/* TO DO +add dns constructor flag. then becomes self contained. +*/ diff --git a/modules/network/http/middleware/manifest.json b/modules/network/http/middleware/manifest.json new file mode 100644 index 0000000000..eb9699ec3b --- /dev/null +++ b/modules/network/http/middleware/manifest.json @@ -0,0 +1,23 @@ +{ + "modules": { + "*": [ + "$(MODULES)/files/zip/*", + "$(MODULES)/network/http/*", + "$(MODULES)/data/base64/*", + "$(MODULES)/data/hex/*", + "$(MODULES)/data/logical/*" + + ], + "dns/server": "$(MODULES)/network/dns/dnsserver", + "websocket/websocket": "$(MODULES)/network/websocket/websocket" + }, + "preload": [ + "http", + "dns/server", + "websocket/websocket", + "base64", + "hex", + "logical", + "websocket" + ] +} diff --git a/modules/network/http/middleware/rewritespa.js b/modules/network/http/middleware/rewritespa.js new file mode 100644 index 0000000000..a29962dcc8 --- /dev/null +++ b/modules/network/http/middleware/rewritespa.js @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2016-2021 Moddable Tech, Inc. + * Copyright (c) Wilberforce + * + * This file is part of the Moddable SDK. + * + * This work is licensed under the + * Creative Commons Attribution 4.0 International License. + * To view a copy of this license, visit + * . + * or send a letter to Creative Commons, PO Box 1866, + * Mountain View, CA 94042, USA. + * + */ + +import { Middleware, Server } from "middleware/server"; + +// Single Page application. Map page requests back to / + +export class MiddlewareRewriteSPA extends Middleware { + #routes + constructor(routes) { + super(); + this.#routes=routes + } + + handler(req, message, value, etc) { + + switch (message) { + case Server.status: + if ( this.#routes.includes(value) ) { // To do: possibly use Set? + trace(`Rewrite: ${value}\n`); + value='/'; + trace(`Rewrite now: ${value}\n`); + } + break; + } + return this.next?.handler(req, message, value, etc); + } +} diff --git a/modules/network/http/middleware/server.js b/modules/network/http/middleware/server.js new file mode 100644 index 0000000000..d0e2b595d9 --- /dev/null +++ b/modules/network/http/middleware/server.js @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2016-2021 Moddable Tech, Inc. + * Copyright (c) Wilberforce + * + * This file is part of the Moddable SDK. + * + * This work is licensed under the + * Creative Commons Attribution 4.0 International License. + * To view a copy of this license, visit + * . + * or send a letter to Creative Commons, PO Box 1866, + * Mountain View, CA 94042, USA. + * + */ + +import {Server} from "http" + +class Middleware { + #next; + constructor() { + this.#next = null + } + get next() { + return this.#next; + } + set next(n) { + this.#next = n; + } +} + +class WebServer extends Server { + #last; + #first; + + constructor(options) { + super(options); + + this.#first = null; + this.#last = null; + } + + use( handler ) { + // chaining here, join new to previous + if( this.#first === null ) { + this.#first=this.#last=handler; + } else { + this.#last.next=handler; + this.#last=handler; + } + // Advise middleware of http parent - needed by websocket for connection management + if ( 'parent' in handler ) + handler.parent=this; + } + + callback(message, value, etc) { + switch (message) { + case Server.status: + this.path=value; + this.method = etc; + break; + case Server.header: + value=value.toLowerCase(); // Header field names are case-insensitive - force lower for easy compare + } + return this.server.#first?.handler(this, message, value, etc); + } + + close() { + if ( this.connections ) + super.close(); // Close any http connections + } +} + +Object.freeze(WebServer.prototype); + +export { WebServer as Server, Middleware }; diff --git a/modules/network/http/middleware/staticzip.js b/modules/network/http/middleware/staticzip.js new file mode 100644 index 0000000000..b370b87d48 --- /dev/null +++ b/modules/network/http/middleware/staticzip.js @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2016-2021 Moddable Tech, Inc. + * Copyright (c) Wilberforce + * + * This file is part of the Moddable SDK. + * + * This work is licensed under the + * Creative Commons Attribution 4.0 International License. + * To view a copy of this license, visit + * . + * or send a letter to Creative Commons, PO Box 1866, + * Mountain View, CA 94042, USA. + * + */ + +import { Middleware, Server } from "middleware/server"; +import {ZIP} from "zip" + +const mime = new Map; +mime.set("js", "application/javascript"); +mime.set("css", "text/css"); +mime.set("ico", "image/vnd.microsoft.icon"); +mime.set("txt", "text/plain"); +mime.set("htm", "text/html"); +mime.set("html", "text/html"); +mime.set("svg", "image/svg+xml"); +mime.set("png", "image/png"); +mime.set("gif", "image/gif"); +mime.set("webp", "image/webp"); +mime.set("jpg", "image/jpeg"); +mime.set("jpeg", "image/jpeg"); + +export class MiddlewareStaticZip extends Middleware { + constructor(archive) { + super(); + + this.archive = new ZIP(archive); + } + + handler(req, message, value, etc) { + switch (message) { + case Server.status: + // redirect home page + if (value === '/') value='/index.html'; + req.path = value; + try { + req.data = this.archive.file(req.path.slice(1)); // drop leading / to match zip content + req.etag = "mod-" + req.data.crc.toString(16); + } + catch { + delete req.data; + delete req.etag; + return this.next?.handler(req, message, value, etc); + } + break; + + case Server.header: + req.match ||= ("if-none-match" === value) && (req.etag === etc); + return this.next?.handler(req, message, value, etc); + + case Server.prepareResponse: + if (req.match) { + return { + status: 304, + headers: [ + "ETag", req.etag, + ] + }; + } + if (!req.data) { + trace(`prepareResponse: missing file ${req.path}\n`); + + return this.next?.handler(req, message, value, etc); + } + + req.data.current = 0; + const result = { + headers: [ + "Content-type", mime.get(req.path.split('.').pop()) ?? "text/plain", + "Content-length", req.data.length, + "ETag", req.etag, + "Cache-Control", "max-age=60" + ], + body: true + } + if (8 === req.data.method) // Compression Method + result.headers.push("Content-Encoding", "deflate"); + return result; + + case Server.responseFragment: + if (req.data.current >= req.data.length) + return; + + const chunk = req.data.read(ArrayBuffer, (value > 1536) ? 1536 : value); + req.data.current += chunk.byteLength; + return chunk; + } + } +} + +Object.freeze(mime); \ No newline at end of file diff --git a/modules/network/http/middleware/status.js b/modules/network/http/middleware/status.js new file mode 100644 index 0000000000..0fb57584c6 --- /dev/null +++ b/modules/network/http/middleware/status.js @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2016-2021 Moddable Tech, Inc. + * Copyright (c) Wilberforce + * + * This file is part of the Moddable SDK. + * + * This work is licensed under the + * Creative Commons Attribution 4.0 International License. + * To view a copy of this license, visit + * . + * or send a letter to Creative Commons, PO Box 1866, + * Mountain View, CA 94042, USA. + * + */ + +import { Middleware, Server } from "middleware/server"; + +export class MiddlewareStatus extends Middleware { + #status; + constructor(status=404) { + super(); + this.#status=status + } + handler(req, message, value, etc) { + switch (message) { + + case Server.prepareResponse: + return { + status: this.#status + }; + } + } +} diff --git a/modules/network/http/middleware/websocket.js b/modules/network/http/middleware/websocket.js new file mode 100644 index 0000000000..924b467a53 --- /dev/null +++ b/modules/network/http/middleware/websocket.js @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2016-2021 Moddable Tech, Inc. + * Copyright (c) Wilberforce + * + * This file is part of the Moddable SDK. + * + * This work is licensed under the + * Creative Commons Attribution 4.0 International License. + * To view a copy of this license, visit + * . + * or send a letter to Creative Commons, PO Box 1866, + * Mountain View, CA 94042, USA. + * + */ + +import { Middleware, Server as HTTPServer } from "middleware/server"; + +// https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers +// https://github.com/Moddable-OpenSource/moddable/blob/public/modules/network/websocket/websocket.js#L375-L398 + +import Base64 from "base64"; +import {Digest} from "crypt"; +import {Client as WebSocketClient} from "websocket/websocket" + +class WebSocketUpgrade extends WebSocketClient { + constructor(opts) { + super(opts); + + this.doMask = false; + this.state = WebSocketClient.receive; + opts.socket=null; // Nuke so http server does not close + } + + send(packet) { + packet=JSON.stringify(packet); + let encoded = this.callback.call( this, MiddlewareWebsocket.encode, packet); + if (encoded) + packet = encoded; + this.write(packet); + } +} + +class MiddlewareWebsocket extends Middleware { + #path; + #webserver; + constructor( path='/') { + super(); + this.#path=path + } + + get parent() { + return this.#webserver; + } + set parent(p) { + + this.#webserver = p; + } + + callbackHandler( message, value ) { + if ( message === WebSocketClient.receive ) { + let decoded = this.callback.call( this, MiddlewareWebsocket.decode, value); + if (decoded) { + value = decoded; + } + } + return this.callback.call( this, message, value ); // call app callback; + } + + broadcast(message, except) { + for (let i = 0, connections = this.#webserver.connections; i < connections.length; i++) { + const connection = connections[i]; + + if (except === connection) // Don't broadcast to self + continue; + if (connection instanceof WebSocketUpgrade) { + try { + connection.send(message); + } catch { + connections.splice(i, 1); + } + } + } + } + + close() { + this.#webserver.close(); + } + + handler(req, message, value, etc) { + switch (message) { + case HTTPServer.status: + if ( value === this.#path ) { + req.ws = { flags: 0 }; + } + break; + case HTTPServer.header: { + if ( req.ws ) { + if ( value==='connection' && etc === 'Upgrade') { + req.ws.flags |= 1 + } + if ( value==='sec-websocket-version') { + req.ws.flags |= (etc === '13') ? 2 : 0; + } + if ( ( value==='upgrade' && etc === 'websocket' ) ) { + req.ws.flags |= 4 + } + if ( value==='sec-websocket-key') { + req.ws.flags |= 8; + req.ws.key = etc; + this.callback( WebSocketClient.connect ); // tell app we have a new connection + } + if ( value==='sec-websocket-protocol') { + let data = etc.split(","); + for (let i = 0; i < data.length; ++i) + data[i] = data[i].trim().toLowerCase(); + let protocol = this.callback(MiddlewareWebsocket.subprotocol, data); + if (protocol) + req.ws.protocol = protocol; + } + } + break; // continue chaining headers + } + case HTTPServer.prepareResponse: + + if ( req.ws && req.ws.flags === 15 ) { + let sha1 = new Digest("SHA1"); + + sha1.write(req.ws.key); + delete req.ws.key; + delete req.ws.flags; + sha1.write("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + let accept = Base64.encode(sha1.close()); + let response = { + headers: [ + "Sec-WebSocket-Accept", accept, + "Upgrade","websocket" + ], + status: 101 + }; + if (req.ws.protocol) { + response.headers.push("Sec-WebSocket-Protocol",req.ws.protocol); + } + return response; + } + // ws handshake failure - continue chain + delete req.ws; + break; + case HTTPServer.responseComplete: + if ( req.ws ) { + + const websocket = new WebSocketUpgrade({socket:req.socket}); + websocket.callback=this.callbackHandler.bind(this); + + req.server.connections.push(websocket); + this.callback.call( websocket, WebSocketClient.handshake ); // tell app we have handshake complete + return; + } + break; + } + return this.next?.handler(req, message, value, etc); + } +} + +MiddlewareWebsocket.connect = 1; +MiddlewareWebsocket.handshake = 2; +MiddlewareWebsocket.receive = 3; +MiddlewareWebsocket.disconnect = 4; +MiddlewareWebsocket.subprotocol = 5; +MiddlewareWebsocket.encode = 6; // Websocket encode packet +MiddlewareWebsocket.decode = 7; // Websocket decode packet +Object.freeze(MiddlewareWebsocket.prototype); + +export { MiddlewareWebsocket }; + +/* TO DO + +flags -> use constants ? also http module + +method === 'GET' + +*/