diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 95f0cf670..87b9db498 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -22,6 +22,7 @@ import { Debug } from './modules/Debug.js'; import { PSocket, wispInfo } from './modules/networking/PSocket.js'; import { PTLSSocket } from "./modules/networking/PTLS.js" import { PWispHandler } from './modules/networking/PWispHandler.js'; +import { make_http_api } from './lib/http.js'; // TODO: This is for a safe-guard below; we should check if we can // generalize this behavior rather than hard-coding it. @@ -320,7 +321,12 @@ window.puter = (function() { await this.services.wait_for_init(['api-access']); this.p_can_request_rao_.resolve(); })(); + + // TODO: This should be separated into modules called "Net" and "Http". + // Modules need to be refactored first because right now they + // are too tightly-coupled with authentication state. (async () => { + // === puter.net === const { token: wispToken, server: wispServer } = (await (await fetch(this.APIOrigin + '/wisp/relay-token/create', { method: 'POST', headers: { @@ -336,6 +342,12 @@ window.puter = (function() { TLSSocket: PTLSSocket } } + + // === puter.http === + this.http = make_http_api( + { Socket: this.net.Socket, DEFAULT_PORT: 80 }); + this.https = make_http_api( + { Socket: this.net.tls.TLSSocket, DEFAULT_PORT: 443 }); })(); diff --git a/src/puter-js/src/lib/EventListener.js b/src/puter-js/src/lib/EventListener.js index b7fa78217..ebad0e5df 100644 --- a/src/puter-js/src/lib/EventListener.js +++ b/src/puter-js/src/lib/EventListener.js @@ -33,6 +33,7 @@ export default class EventListener { return; } this.#eventListeners[eventName].push(callback); + return this; } off(eventName, callback) { @@ -45,5 +46,6 @@ export default class EventListener { if (index !== -1) { listeners.splice(index, 1); } + return this; } } \ No newline at end of file diff --git a/src/puter-js/src/lib/http.js b/src/puter-js/src/lib/http.js new file mode 100644 index 000000000..413f8cca2 --- /dev/null +++ b/src/puter-js/src/lib/http.js @@ -0,0 +1,153 @@ +import EventListener from "./EventListener"; + +// TODO: this inheritance is an anti-pattern; we should use +// a trait or mixin for event emitters. +export class HTTPRequest extends EventListener { + constructor ({ options, callback }) { + super(['data','end','error']); + this.options = options; + this.callback = callback; + } + end () { + // + } +} + +export const make_http_api = ({ Socket, DEFAULT_PORT }) => { + // Helper to create an EventEmitter-like object + + const api = {}; + + api.request = (options, callback) => { + const sock = new Socket(options.hostname, options.port ?? DEFAULT_PORT); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + // Request object + const req = new HTTPRequest([ + 'data', + 'end', + 'error', + ]); + + // Response object + const res = new EventListener([ + 'data', + 'end', + 'error', + ]); + res.headers = {}; + res.statusCode = null; + res.statusMessage = ''; + + let buffer = ''; + + let amount = 0; + const TRANSFER_CONTENT_LENGTH = { + data: data => { + const contentLength = parseInt(res.headers['content-length'], 10); + if ( buffer ) { + const bin = encoder.encode(buffer); + data = new Uint8Array([...bin, ...data]); + buffer = ''; + } + amount += data.length; + res.emit('data', decoder.decode(data)); + if (amount >= contentLength) { + sock.close(); + } + } + }; + const TRANSFER_CHUNKED = { + data: data => { + // TODO + throw new Error('Chunked transfer encoding not implemented'); + } + }; + let transfer = null; + + const STATE_HEADERS = { + data: data => { + data = decoder.decode(data); + + buffer += data; + const headerEndIndex = buffer.indexOf('\r\n\r\n'); + if ( headerEndIndex === -1 ) return; + + // Parse headers + const headersString = buffer.substring(0, headerEndIndex); + const headerLines = headersString.split('\r\n'); + + // Remove headers from buffer + buffer = buffer.substring(headerEndIndex + 4); + + // Parse status line + const [httpVersion, statusCode, ...statusMessageParts] = headerLines[0].split(' '); + res.statusCode = parseInt(statusCode, 10); + res.statusMessage = statusMessageParts.join(' '); + + // Parse headers + for (let i = 1; i < headerLines.length; i++) { + const [key, ...valueParts] = headerLines[i].split(':'); + if (key) { + res.headers[key.toLowerCase().trim()] = valueParts.join(':').trim(); + } + } + + + if ( res.headers['transfer-encoding'] === 'chunked' ) { + transfer = TRANSFER_CHUNKED; + } else if ( res.headers['transfer-encoding'] ) { + throw new Error('Unsupported transfer encoding'); + } else if ( res.headers['content-length'] ) { + transfer = TRANSFER_CONTENT_LENGTH; + } else { + throw new Error('No content length or transfer encoding'); + } + state = STATE_BODY; + + callback(res); + } + }; + const STATE_BODY = { + data: data => { + transfer.data(data); + } + }; + let state = STATE_HEADERS; + + sock.on('data', (data) => { + state.data(data); + }); + + sock.on('error', (err) => { + req.emit('error', err); + }); + + sock.on('close', () => { + res.emit('end'); + }); + + // Construct and send HTTP request + const method = options.method || 'GET'; + const path = options.path || '/'; + const headers = options.headers || {}; + headers['Host'] = options.hostname; + + let requestString = `${method} ${path} HTTP/1.1\r\n`; + for (const [key, value] of Object.entries(headers)) { + requestString += `${key}: ${value}\r\n`; + } + requestString += '\r\n'; + + if (options.data) { + requestString += options.data; + } + + sock.write(encoder.encode(requestString)); + + return req; + }; + + return api; +}; \ No newline at end of file