diff --git a/src/backend/package.json b/src/backend/package.json index fcab1986a..f0f56a605 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -4,7 +4,8 @@ "description": "Backend/Kernel for Puter", "main": "exports.js", "scripts": { - "test": "npx mocha src/**/*.test.js && node ./tools/test.js" + "test": "npx mocha src/**/*.test.js && node ./tools/test.js", + "build:worker": "cd src/services/worker && npm run build" }, "dependencies": { "@anthropic-ai/sdk": "^0.26.1", diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index 31f0134c1..06238c6e8 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -391,6 +391,9 @@ const install = async ({ services, app, useapi, modapi }) => { const { ChatAPIService } = require('./services/ChatAPIService'); services.registerService('__chat-api', ChatAPIService); + + const { WorkerService } = require('./services/worker/WorkerService'); + services.registerService("worker-service", WorkerService) } const install_legacy = async ({ services }) => { diff --git a/src/backend/src/services/worker/.gitignore b/src/backend/src/services/worker/.gitignore new file mode 100644 index 000000000..77738287f --- /dev/null +++ b/src/backend/src/services/worker/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/src/backend/src/services/worker/README.md b/src/backend/src/services/worker/README.md new file mode 100644 index 000000000..3c525f604 --- /dev/null +++ b/src/backend/src/services/worker/README.md @@ -0,0 +1,53 @@ +# Worker Service + +This directory contains the worker service components for Puter's server-to-web (s2w) worker functionality. + +## Build Process + +The `dist/workerPreamble.js` file is **generated** by webpack and c-preprocessor and should not be edited directly. Instead, edit the source files in the `src/` directory and rebuild. + +### Building + +To build the worker preamble: + +```bash +# From this directory +npm install +npm run build +``` + +Or from the backend root: + +```bash +npm run build:worker +``` + +### Development + +For development with auto-rebuild: + +```bash +npm run build:watch +``` + +This will watch for changes in the source files and automatically rebuild the `workerPreamble.js`. + +## Source Files + +- `template/puter-portable.js` - Puter portable API wrapper +- `src/s2w-router.js` - Server-to-web router implementation +- `src/index.js` - Main entry point that combines both components + +## Dependencies + +- `path-to-regexp` - URL pattern matching library used by the s2w router + +## Generated Output + +The webpack build process creates `dist/workerPreamble.js` which contains: +1. The bundled `path-to-regexp` library +2. The puter portable API +3. The s2w router with proper initialization +4. Initialization code that sets up both systems + +This file is then read by `WorkerService.js` and injected into worker environments. \ No newline at end of file diff --git a/src/backend/src/services/worker/WorkerService.js b/src/backend/src/services/worker/WorkerService.js new file mode 100644 index 000000000..9eea64583 --- /dev/null +++ b/src/backend/src/services/worker/WorkerService.js @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const configurable_auth = require("../../middleware/configurable_auth"); +const { Endpoint } = require("../../util/expressutil"); +const BaseService = require("../BaseService"); +const fs = require("node:fs"); + +const { createWorker, setCloudflareKeys, deleteWorker } = require("./workerUtils/cloudflareDeploy"); +const { getUserInfo } = require("./workerUtils/puterUtils"); + +// This file is generated by webpack. To rebuild: cd to this directory and run `npm run build` +let preamble; +try { + preamble = fs.readFileSync(__dirname + "/dist/workerPreamble.js", "utf-8"); +} catch (e) { + preamble = ""; + console.error("WORKERS ERROR: Preamble has not been built! Workers will not have access to puter.js\nTo fix this cd into src/backend/src/worker and run npm run build") +} +const PREAMBLE_LENGTH = preamble.split("\n").length - 1 + +class WorkerService extends BaseService { + ['__on_install.routes'](_, { app }) { + setCloudflareKeys(this.config); + + } + static IMPLEMENTS = { + ['workers']: { + async create({ fileData, workerName, authorization }) { + try { + const userData = await getUserInfo(authorization, this.global_config.api_base_url); + return await createWorker(userData, authorization, workerName, preamble + fileData, PREAMBLE_LENGTH); + } catch (e) { + return {success: false, e} + } + }, + async destroy({ workerName, authorization }) { + try { + const userData = await getUserInfo(authorization, this.global_config.api_base_url); + return await deleteWorker(userData, authorization, workerName); + } catch (e) { + return {success: false, e} + } + }, + async startLogs({ workerName, authorization }) { + return await this.exec_({ runtime, code }); + }, + async endLogs({ workerName, authorization }) { + return await this.exec_({ runtime, code }); + }, + } + } + async ['__on_driver.register.interfaces']() { + const svc_registry = this.services.get('registry'); + const col_interfaces = svc_registry.get('interfaces'); + + col_interfaces.set('workers', { + description: 'Execute code with various languages.', + methods: { + create: { + description: 'Create a backend worker', + parameters: { + fileData: { + type: "string", + description: "The code of the worker to upload" + }, + workerName: { + type: "string", + description: "The name of the worker you want to upload" + }, + authorization: { + type: "string", + description: "Puter token" + } + }, + result: { type: 'json' }, + }, + startLogs: { + description: 'Get logs for your backend worker', + parameters: { + workerName: { + type: "string", + description: "The name of the worker you want the logs of" + }, + authorization: { + type: "string", + description: "Puter token" + } + }, + result: { type: 'json' }, + }, + endLogs: { + description: 'Get logs for your backend worker', + parameters: { + workerName: { + type: "string", + description: "The name of the worker you want the logs of" + }, + authorization: { + type: "string", + description: "Puter token" + } + }, + result: { type: 'json' }, + }, + destroy: { + description: 'Get rid of your backend worker', + parameters: { + workerName: { + type: "string", + description: "The name of the worker you want to destroy" + }, + authorization: { + type: "string", + description: "Puter token" + } + }, + result: { type: 'json' }, + }, + } + }); + } +} + +module.exports = { + WorkerService, +}; diff --git a/src/backend/src/services/worker/package.json b/src/backend/src/services/worker/package.json new file mode 100644 index 000000000..e91793aa3 --- /dev/null +++ b/src/backend/src/services/worker/package.json @@ -0,0 +1,24 @@ +{ + "name": "@heyputer/worker-service", + "version": "1.0.0", + "description": "Worker service components for Puter", + "main": "src/index.js", + "scripts": { + "build": "webpack --mode production && npm run preprocess", + "preprocess": "c-preprocessor template/puter-portable.js dist/workerPreamble.js" + }, + "dependencies": { + "c-preprocessor": "^0.2.13", + "path-to-regexp": "^8.2.0" + }, + "devDependencies": { + "imports-loader": "^5.0.0", + "raw-loader": "^4.0.2", + "script-loader": "^0.7.2", + "terser-webpack-plugin": "^5.3.14", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.1" + }, + "author": "Puter Technologies Inc.", + "license": "AGPL-3.0-only" +} diff --git a/src/backend/src/services/worker/src/index.js b/src/backend/src/services/worker/src/index.js new file mode 100644 index 000000000..85d3131c2 --- /dev/null +++ b/src/backend/src/services/worker/src/index.js @@ -0,0 +1,4 @@ +import inits2w from './s2w-router.js'; +// Initialize s2w router +inits2w(); + diff --git a/src/backend/src/services/worker/src/s2w-router.js b/src/backend/src/services/worker/src/s2w-router.js new file mode 100644 index 000000000..b73e76912 --- /dev/null +++ b/src/backend/src/services/worker/src/s2w-router.js @@ -0,0 +1,64 @@ +import { match } from 'path-to-regexp'; + +function inits2w() { + // s2w router itself: Not part of any package, just a simple router. + const s2w = { + routing: true, + map: new Map(), + custom(eventName, route, eventListener) { + const matchExp = match(route); + if (!this.map.has(eventName)) { + this.map.set(eventName, [[matchExp, eventListener]]) + } else { + this.map.get(eventName).push([matchExp, eventListener]) + } + }, + get(...args) { + this.custom("GET", ...args) + }, + post(...args) { + this.custom("POST", ...args) + }, + options(...args) { + this.custom("OPTIONS", ...args) + }, + put(...args) { + this.custom("PUT", ...args) + }, + delete(...args) { + this.custom("DELETE", ...args) + }, + async route(event) { + if (!globalThis.puter) { + console.log("Puter not loaded, initializing..."); + const success = init_puter_portable(globalThis.puter_auth, globalThis.puter_endpoint || "https://api.puter.com"); + console.log("Puter.js initialized successfully"); + } + + const mappings = this.map.get(event.request.method); + const url = new URL(event.request.url); + try { + for (const mapping of mappings) { + // return new Response(JSON.stringify(mapping)) + const results = mapping[0](url.pathname) + if (results) { + event.params = results.params; + return mapping[1](event); + } + } + } catch (e) { + return new Response(e, {status: 500, statusText: "Server Error"}) + } + + return new Response("Path not found", {status: 404, statusText: "Not found"}); + } + } + globalThis.s2w = s2w; + self.addEventListener("fetch", (event)=> { + if (!s2w.routing) + return false; + event.respondWith(s2w.route(event)); + }) +} + +export default inits2w; \ No newline at end of file diff --git a/src/backend/src/services/worker/template/puter-portable.js b/src/backend/src/services/worker/template/puter-portable.js new file mode 100644 index 000000000..9e8222240 --- /dev/null +++ b/src/backend/src/services/worker/template/puter-portable.js @@ -0,0 +1,33 @@ +// This file is not actually in the webpack project, it is handled seperately. + +if (globalThis.Cloudflare) { + // Cloudflare Workers has a faulty EventTarget implementation which doesn't bind "this" to the event handler + // This is a workaround to bind "this" to the event handler + // https://github.com/cloudflare/workerd/issues/4453 + const __cfEventTarget = EventTarget; + globalThis.EventTarget = class EventTarget extends __cfEventTarget { + constructor(...args) { + super(...args) + } + addEventListener(type, listener, options) { + super.addEventListener(type, listener.bind(this), options); + } + } +} + +globalThis.init_puter_portable = (auth, apiOrigin) => { + console.log("Starting puter.js initialization"); + + // Who put C in my JS?? + /* + * This is a hack to include the puter.js file. + * It is not a good idea to do this, but it is the only way to get the puter.js file to work. + * The puter.js file is handled by the C preprocessor here because webpack cant behave with already minified files. + * The C preprocessor basically just includes the file and then we can use the puter.js file in the worker. + */ + #include "../../../../../puter-js/dist/puter.js" + puter.setAPIOrigin(apiOrigin); + puter.setAuthToken(auth); +} +#include "../dist/webpackPreamplePart.js" + diff --git a/src/backend/src/services/worker/webpack.config.js b/src/backend/src/services/worker/webpack.config.js new file mode 100644 index 000000000..e454e6e72 --- /dev/null +++ b/src/backend/src/services/worker/webpack.config.js @@ -0,0 +1,57 @@ +const path = require('path'); +const webpack = require('webpack'); + +module.exports = { + entry: './src/index.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'webpackPreamplePart.js', + library: { + type: 'var', + name: 'WorkerPreamble' + }, + globalObject: 'this' + }, + mode: 'production', + target: 'webworker', + resolve: { + extensions: ['.js'], + }, + externals: { + 'https://puter-net.b-cdn.net/rustls.js': 'undefined' + }, + optimization: { + minimize: true, + minimizer: [ + new (require('terser-webpack-plugin'))({ + terserOptions: { + keep_fnames: true, + mangle: { + keep_fnames: true + }, + compress: { + keep_fnames: true + } + } + }) + ] + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /puter\.js$/, + parser: { + dynamicImports: false + } + } + ] + }, + plugins: [ + new webpack.BannerPlugin({ + banner: '// This file is pasted before user code', + raw: false, + entryOnly: false + }) + ] +}; \ No newline at end of file diff --git a/src/backend/src/services/worker/workerUtils/cloudflareDeploy.js b/src/backend/src/services/worker/workerUtils/cloudflareDeploy.js new file mode 100644 index 000000000..956801460 --- /dev/null +++ b/src/backend/src/services/worker/workerUtils/cloudflareDeploy.js @@ -0,0 +1,99 @@ +const fs = require('fs') +const { calculateWorkerName } = require("./nameUtils.js"); +let config = {}; +// Constants +const CF_BASE_URL = "https://api.cloudflare.com/" +let WORKERS_BASE_URL; +// Workers for Platforms support + +function cfFetch(url, method = "GET", body, givenHeaders) { + const headers = { "Authorization": "Bearer " + config["XAUTHKEY"] }; + if (givenHeaders) { + for (const header of givenHeaders) { + headers[header[0]] = header[1]; + } + } + return fetch(url, { headers, method, body }) +} +async function getWorker(userData, authorization, workerId) { + await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerName(userData.username, workerId)}`, "GET"); +} +async function createWorker(userData, authorization, workerId, body, PREAMBLE_LENGTH) { + console.log(body) + const formData = new FormData(); + + const workerMetaData = { + + body_part: "swCode", + bindings: [ + { + type: "secret_text", + name: "puter_auth", + text: authorization + }, + { + type: "plain_text", + name: "puter_endpoint", + text: config.internetExposedUrl || "https://api.puter.com" + }, + + ] + + } + formData.append("metadata", JSON.stringify(workerMetaData)); + formData.append("swCode", body); + const cfReturnCodes = await (await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerName(userData.username, workerId)}/`, "PUT", formData)).json(); + + if (cfReturnCodes.success) { + return JSON.stringify({ success: true, errors: [], url: `${calculateWorkerName(userData.username, workerId)}.puter.work` }); + } else { + const parsedErrors = []; + for (const error of cfReturnCodes.errors) { + const message = error.message; + let finalMessage = "" + const lines = message.split("\n"); + finalMessage += lines.shift() + "\n" + try { + // throw new Error("test") + for (const line of lines) { + if (line.includes("at worker.js:")) { + let positions = line.trimStart().replace("at worker.js:", "").split(":"); + positions[0] = parseInt(positions[0]) - PREAMBLE_LENGTH; + finalMessage += ` at worker.js:${positions.join(":")}\n`; + } else { + finalMessage += line + "\n" + } + } + } catch (e) { + console.error("Failed to parse V8 Stack trace\n" + message); + finalMessage = message; + } + + parsedErrors.push(finalMessage) + } + return JSON.stringify({ success: false, errors: parsedErrors, url: null, body }); + } +} +function setPreambleLength(length) { + +} +function setCloudflareKeys(givenConfig) { + config = givenConfig; + WORKERS_BASE_URL = CF_BASE_URL + `client/v4/accounts/${config.ACCOUNTID}/workers`; + if (config.namespace) { + WORKERS_BASE_URL += `/dispatch/namespaces/${config.namespace}`; + } + +} + +async function deleteWorker(userData, authorization, workerId) { + return await (await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerName(userData.username, workerId)}/`, "DELETE")).json(); + +} + +module.exports = { + createWorker, + deleteWorker, + getWorker, + setCloudflareKeys +}; diff --git a/src/backend/src/services/worker/workerUtils/nameUtils.js b/src/backend/src/services/worker/workerUtils/nameUtils.js new file mode 100644 index 000000000..c0db12d23 --- /dev/null +++ b/src/backend/src/services/worker/workerUtils/nameUtils.js @@ -0,0 +1,14 @@ +// import crypto from 'node:crypto' +const crypto = require("node:crypto"); + +function sha1(input) { + return crypto.createHash('sha1').update(input, 'utf8').digest().toString("hex").slice(0, 7) +} +function calculateWorkerName(username, workerId) { + return `${username}-${sha1(workerId).slice(0, 7)}` +} + +module.exports = { + sha1, + calculateWorkerName +} \ No newline at end of file diff --git a/src/backend/src/services/worker/workerUtils/puterUtils.js b/src/backend/src/services/worker/workerUtils/puterUtils.js new file mode 100644 index 000000000..dbb388b5b --- /dev/null +++ b/src/backend/src/services/worker/workerUtils/puterUtils.js @@ -0,0 +1,14 @@ +function getUserInfo(authorization, apiBase = "https://puter.com") { + return fetch(apiBase + "/whoami", { headers: { authorization, origin: "https://docs.puter.com" } }).then(async res => { + if (res.status != 200) { + throw ("User data endpoint returned error code " + await res.text()); + return; + } + + return res.json(); + }) +} + +module.exports = { + getUserInfo +} \ No newline at end of file diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index c72a47af5..16fcc61f5 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -23,13 +23,15 @@ import { PTLSSocket } from "./modules/networking/PTLS.js" import Threads from './modules/Threads.js'; import Perms from './modules/Perms.js'; import { pFetch } from './modules/networking/requests.js'; +import localStorageMemory from './lib/polyfills/localStorage.js' +import xhrshim from './lib/polyfills/xhrshim.js' // TODO: This is for a safe-guard below; we should check if we can // generalize this behavior rather than hard-coding it. // (using defaultGUIOrigin breaks locally-hosted apps) const PROD_ORIGIN = 'https://puter.com'; -export default window.puter = (function() { +export default globalThis.puter = (function() { 'use strict'; class Puter{ @@ -123,15 +125,48 @@ export default window.puter = (function() { context.services = this.services; // Holds the query parameters found in the current URL - let URLParams = new URLSearchParams(window.location.search); + let URLParams = new URLSearchParams(globalThis.location?.search); // Figure out the environment in which the SDK is running if (URLParams.has('puter.app_instance_id')) this.env = 'app'; - else if(window.puter_gui_enabled === true) + else if(globalThis.puter_gui_enabled === true) this.env = 'gui'; - else + else if (globalThis.WorkerGlobalScope) { + if (globalThis.ServiceWorkerGlobalScope) { + this.env = 'service-worker' + if (!globalThis.XMLHttpRequest) { + globalThis.XMLHttpRequest = xhrshim + } + if (!globalThis.location) { + globalThis.location = new URL("https://puter.site/"); + } + // XHRShimGlobalize here + } else { + this.env = 'web-worker' + } + if (!globalThis.localStorage) { + globalThis.localStorage = localStorageMemory; + } + } else if (globalThis.process) { + this.env = 'nodejs'; + if (!globalThis.localStorage) { + globalThis.localStorage = localStorageMemory; + } + if (!globalThis.XMLHttpRequest) { + globalThis.XMLHttpRequest = xhrshim + } + if (!globalThis.location) { + globalThis.location = new URL("https://nodejs.puter.site/"); + } + if (!globalThis.addEventListener) { + globalThis.addEventListener = () => {} // API Stub + } + } else { this.env = 'web'; + } + + // There are some specific situations where puter is definitely loaded in GUI mode // we're going to check for those situations here so that we don't break anything unintentionally @@ -294,6 +329,8 @@ export default window.puter = (function() { // Handle the error here console.error('Error accessing localStorage:', error); } + } else if (this.env === 'web-worker' || this.env === 'service-worker' || this.env === 'nodejs') { + this.initSubmodules(); } // Add prefix logger (needed to happen after modules are initialized) @@ -368,7 +405,8 @@ export default window.puter = (function() { const resp = await fetch(this.APIOrigin + '/rao', { method: 'POST', headers: { - Authorization: `Bearer ${this.authToken}` + Authorization: `Bearer ${this.authToken}`, + Origin: location.origin // This is ignored in the browser but needed for workers and nodejs } }); return await resp.json(); @@ -454,7 +492,7 @@ export default window.puter = (function() { statusCode = 1; } - window.parent.postMessage({ + globalThis.parent.postMessage({ msg: "exit", appInstanceID: this.appInstanceID, statusCode, @@ -505,7 +543,6 @@ export default window.puter = (function() { return new Promise((resolve, reject) => { const xhr = utils.initXhr('/whoami', this.APIOrigin, this.authToken, 'get'); - // set up event handlers for load and error events utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject); @@ -548,7 +585,7 @@ export default window.puter = (function() { return puterobj; }()); -window.addEventListener('message', async (event) => { +globalThis.addEventListener('message', async (event) => { // if the message is not from Puter, then ignore it if(event.origin !== puter.defaultGUIOrigin) return; diff --git a/src/puter-js/src/lib/polyfills/localStorage.js b/src/puter-js/src/lib/polyfills/localStorage.js new file mode 100644 index 000000000..f09804392 --- /dev/null +++ b/src/puter-js/src/lib/polyfills/localStorage.js @@ -0,0 +1,92 @@ +// https://github.com/gr2m/localstorage-memory under MIT + +const root = {}; +var localStorageMemory = {} +var cache = {} + +/** + * number of stored items. + */ +localStorageMemory.length = 0 + +/** + * returns item for passed key, or null + * + * @para {String} key + * name of item to be returned + * @returns {String|null} + */ +localStorageMemory.getItem = function (key) { + if (key in cache) { + return cache[key] + } + + return null +} + +/** + * sets item for key to passed value, as String + * + * @para {String} key + * name of item to be set + * @para {String} value + * value, will always be turned into a String + * @returns {undefined} + */ +localStorageMemory.setItem = function (key, value) { + if (typeof value === 'undefined') { + localStorageMemory.removeItem(key) + } else { + if (!(cache.hasOwnProperty(key))) { + localStorageMemory.length++ + } + + cache[key] = '' + value + } +} + +/** + * removes item for passed key + * + * @para {String} key + * name of item to be removed + * @returns {undefined} + */ +localStorageMemory.removeItem = function (key) { + if (cache.hasOwnProperty(key)) { + delete cache[key] + localStorageMemory.length-- + } +} + +/** + * returns name of key at passed index + * + * @para {Number} index + * Position for key to be returned (starts at 0) + * @returns {String|null} + */ +localStorageMemory.key = function (index) { + return Object.keys(cache)[index] || null +} + +/** + * removes all stored items and sets length to 0 + * + * @returns {undefined} + */ +localStorageMemory.clear = function () { + cache = {} + localStorageMemory.length = 0 +} + +if (typeof exports === 'object') { + module.exports = localStorageMemory +} else { + root.localStorage = localStorageMemory +} + + +export default localStorageMemory; + + diff --git a/src/puter-js/src/lib/polyfills/xhrshim.js b/src/puter-js/src/lib/polyfills/xhrshim.js new file mode 100644 index 000000000..75ef7ffb3 --- /dev/null +++ b/src/puter-js/src/lib/polyfills/xhrshim.js @@ -0,0 +1,189 @@ +// https://www.npmjs.com/package/xhr-shim under MIT + +/* global module */ +/* global EventTarget, AbortController, DOMException */ + +const sReadyState = Symbol("readyState"); +const sHeaders = Symbol("headers"); +const sRespHeaders = Symbol("response headers"); +const sAbortController = Symbol("AbortController"); +const sMethod = Symbol("method"); +const sURL = Symbol("URL"); +const sMIME = Symbol("MIME"); +const sDispatch = Symbol("dispatch"); +const sErrored = Symbol("errored"); +const sTimeout = Symbol("timeout"); +const sTimedOut = Symbol("timedOut"); +const sIsResponseText = Symbol("isResponseText"); + +const XMLHttpRequestShim = class XMLHttpRequest extends EventTarget { + onreadystatechange() { + + } + + set readyState(value) { + this[sReadyState] = value; + this.dispatchEvent(new Event("readystatechange")); + this.onreadystatechange(new Event("readystatechange")); + + } + get readyState() { + return this[sReadyState]; + } + + constructor() { + super(); + this.readyState = this.constructor.UNSENT; + this.response = null; + this.responseType = ""; + this.responseURL = ""; + this.status = 0; + this.statusText = ""; + this.timeout = 0; + this.withCredentials = false; + this[sHeaders] = Object.create(null); + this[sHeaders].accept = "*/*"; + this[sRespHeaders] = Object.create(null); + this[sAbortController] = new AbortController(); + this[sMethod] = ""; + this[sURL] = ""; + this[sMIME] = ""; + this[sErrored] = false; + this[sTimeout] = 0; + this[sTimedOut] = false; + this[sIsResponseText] = true; + } + static get UNSENT() { + return 0; + } + static get OPENED() { + return 1; + } + static get HEADERS_RECEIVED() { + return 2; + } + static get LOADING() { + return 3; + } + static get DONE() { + return 4; + } + upload = { + addEventListener() { + // stub, doesn't do anything since its not possible to monitor with fetch and http/1.1 + } + } + get responseText() { + if (this[sErrored]) return null; + if (this.readyState < this.constructor.HEADERS_RECEIVED) return ""; + if (this[sIsResponseText]) return this.response; + throw new DOMException("Response type not set to text", "InvalidStateError"); + } + get responseXML() { + throw new Error("XML not supported"); + } + [sDispatch](evt) { + const attr = `on${evt.type}`; + if (typeof this[attr] === "function") { + this.addEventListener(evt.type, this[attr].bind(this), { + once: true + }); + } + this.dispatchEvent(evt); + } + abort() { + this[sAbortController].abort(); + this.status = 0; + this.readyState = this.constructor.UNSENT; + } + open(method, url) { + this.status = 0; + this[sMethod] = method; + this[sURL] = url; + this.readyState = this.constructor.OPENED; + } + setRequestHeader(header, value) { + header = String(header).toLowerCase(); + if (typeof this[sHeaders][header] === "undefined") { + this[sHeaders][header] = String(value); + } else { + this[sHeaders][header] += `, ${value}`; + } + } + overrideMimeType(mimeType) { + this[sMIME] = String(mimeType); + } + getAllResponseHeaders() { + if (this[sErrored] || this.readyState < this.constructor.HEADERS_RECEIVED) return ""; + return Array.from(this[sRespHeaders].entries().map(([header, value]) => `${header}: ${value}`)).join("\r\n"); + } + getResponseHeader(headerName) { + const value = this[sRespHeaders].get(String(headerName).toLowerCase()); + return typeof value === "string" ? value : null; + } + send(body = null) { + if (this.timeout > 0) { + this[sTimeout] = setTimeout(() => { + this[sTimedOut] = true; + this[sAbortController].abort(); + }, this.timeout); + } + const responseType = this.responseType || "text"; + this[sIsResponseText] = responseType === "text"; + + this.setRequestHeader('user-agent', "puter-js/1.0") + this.setRequestHeader('origin', "https://puter.work"); + this.setRequestHeader('referer', "https://puter.work/"); + + fetch(this[sURL], { + method: this[sMethod] || "GET", + signal: this[sAbortController].signal, + headers: this[sHeaders], + credentials: this.withCredentials ? "include" : "same-origin", + body + }).then(async resp => { + this.responseURL = resp.url; + this.status = resp.status; + this.statusText = resp.statusText; + this[sRespHeaders] = resp.headers; + const finalMIME = this[sMIME] || this[sRespHeaders].get("content-type") || "text/plain"; + switch (responseType) { + case "text": + this.response = await resp.text(); + break; + case "blob": + this.response = new Blob([await resp.arrayBuffer()], { type: finalMIME }); + break; + case "arraybuffer": + this.response = await resp.arrayBuffer(); + break; + case "json": + this.response = await resp.json(); + break; + } + this.readyState = this.constructor.DONE; + this[sDispatch](new CustomEvent("load")); + }, err => { + let eventName = "abort"; + if (err.name !== "AbortError") { + this[sErrored] = true; + eventName = "error"; + } else if (this[sTimedOut]) { + eventName = "timeout"; + } + this.readyState = this.constructor.DONE; + this[sDispatch](new CustomEvent(eventName)); + }).finally(() => this[sDispatch](new CustomEvent("loadend"))).finally(() => { + clearTimeout(this[sTimeout]); + this[sDispatch](new CustomEvent("loadstart")); + }); + } +} + +if (typeof module === "object" && module.exports) { + module.exports = XMLHttpRequestShim; +} else { + (globalThis || self).XMLHttpRequestShim = XMLHttpRequestShim; +} + +export default XMLHttpRequestShim \ No newline at end of file diff --git a/src/puter-js/src/modules/Debug.js b/src/puter-js/src/modules/Debug.js index bb1ca21a0..3a4c79bd9 100644 --- a/src/puter-js/src/modules/Debug.js +++ b/src/puter-js/src/modules/Debug.js @@ -17,9 +17,9 @@ export class Debug { this.context.puter.logger.on(category); } - window.addEventListener('message', async e => { + globalThis.addEventListener('message', async e => { // Ensure message is from parent window - if ( e.source !== window.parent ) return; + if ( e.source !== globalThis.parent ) return; // (parent window is allowed to be anything) // Check if it's a debug message diff --git a/src/puter-js/src/modules/FileSystem/operations/upload.js b/src/puter-js/src/modules/FileSystem/operations/upload.js index cd4f6de70..849d15173 100644 --- a/src/puter-js/src/modules/FileSystem/operations/upload.js +++ b/src/puter-js/src/modules/FileSystem/operations/upload.js @@ -4,6 +4,10 @@ import path from "../../../lib/path.js" const upload = async function(items, dirPath, options = {}){ return new Promise(async (resolve, reject) => { + const DataTransferItem = globalThis.DataTransfer || (class DataTransferItem {}); + const FileList = globalThis.FileList || (class FileList {}); + const DataTransferItemList = globalThis.DataTransferItemList || (class DataTransferItemList {}); + // If auth token is not provided and we are in the web environment, // try to authenticate with Puter if(!puter.authToken && puter.env === 'web'){ diff --git a/src/puter-js/src/modules/PuterDialog.js b/src/puter-js/src/modules/PuterDialog.js index 5203fccb8..78e9e6ad5 100644 --- a/src/puter-js/src/modules/PuterDialog.js +++ b/src/puter-js/src/modules/PuterDialog.js @@ -1,4 +1,4 @@ -class PuterDialog extends HTMLElement { +class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall back to only extending Object in environments without a DOM /** * Detects if the current page is loaded using the file:// protocol. * @returns {boolean} True if using file:// protocol, false otherwise. @@ -475,6 +475,7 @@ class PuterDialog extends HTMLElement { this.shadowRoot.querySelector('dialog').close(); } } -customElements.define('puter-dialog', PuterDialog); +if (PuterDialog.__proto__ === globalThis.HTMLElement) + customElements.define('puter-dialog', PuterDialog); export default PuterDialog; diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 4e5ef2d7d..ebc0995fb 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -53,7 +53,7 @@ class AppConnection extends EventListener { // TODO: Set this.#puterOrigin to the puter origin - window.addEventListener('message', event => { + (globalThis.document) && window.addEventListener('message', event => { if (event.data.msg === 'messageToApp') { if (event.data.appInstanceID !== this.targetAppInstanceID) { // Message is from a different AppConnection; ignore it. @@ -261,7 +261,7 @@ class UI extends EventListener { }, '*'); // When this app's window is focused send a message to the host environment - window.addEventListener('focus', (e) => { + (globalThis.document) && window.addEventListener('focus', (e) => { this.messageTarget?.postMessage({ msg: "windowFocused", appInstanceID: this.appInstanceID, @@ -270,7 +270,7 @@ class UI extends EventListener { // Bind the message event listener to the window let lastDraggedOverElement = null; - window.addEventListener('message', async (e) => { + (globalThis.document) && window.addEventListener('message', async (e) => { // `error` if(e.data.error){ throw e.data.error; @@ -520,7 +520,7 @@ class UI extends EventListener { // and the host environment needs to know the mouse position to show these elements correctly. // The host environment can't just get the mouse position since when the mouse is over an iframe it // will not be able to get the mouse position. So we need to send the mouse position to the host environment. - document.addEventListener('mousemove', async (event)=>{ + globalThis.document?.addEventListener('mousemove', async (event)=>{ // Get the mouse position from the event object this.mouseX = event.clientX; this.mouseY = event.clientY; @@ -535,7 +535,7 @@ class UI extends EventListener { }); // click - document.addEventListener('click', async (event)=>{ + globalThis.document?.addEventListener('click', async (event)=>{ // Get the mouse position from the event object this.mouseX = event.clientX; this.mouseY = event.clientY; @@ -563,7 +563,7 @@ class UI extends EventListener { // This should also be done only the very first time the callback is set (hence the if(!this.#onItemsOpened) check) since // the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times. if(!this.#onItemsOpened){ - let URLParams = new URLSearchParams(window.location.search); + let URLParams = new URLSearchParams(globalThis.location.search); if(URLParams.has('puter.item.name') && URLParams.has('puter.item.uid') && URLParams.has('puter.item.read_url')){ let fpath = URLParams.get('puter.item.path'); @@ -591,7 +591,7 @@ class UI extends EventListener { // Check if the app was launched with items // This is useful for apps that are launched with items (e.g. when a file is opened with the app) wasLaunchedWithItems = function() { - const URLParams = new URLSearchParams(window.location.search); + const URLParams = new URLSearchParams(globalThis.location.search); return URLParams.has('puter.item.name') && URLParams.has('puter.item.uid') && URLParams.has('puter.item.read_url'); @@ -604,7 +604,7 @@ class UI extends EventListener { // This should also be done only the very first time the callback is set (hence the if(!this.#onLaunchedWithItems) check) since // the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times. if(!this.#onLaunchedWithItems){ - let URLParams = new URLSearchParams(window.location.search); + let URLParams = new URLSearchParams(globalThis.location.search); if(URLParams.has('puter.item.name') && URLParams.has('puter.item.uid') && URLParams.has('puter.item.read_url')){ let fpath = URLParams.get('puter.item.path'); @@ -648,7 +648,10 @@ class UI extends EventListener { } showDirectoryPicker = function(options, callback){ - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + if (!globalThis.open) { + return reject("This API is not compatible in Web Workers."); + } const msg_id = this.#messageID++; if(this.env === 'app'){ this.messageTarget?.postMessage({ @@ -675,7 +678,10 @@ class UI extends EventListener { } showOpenFilePicker = function(options, callback){ - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + if (!globalThis.open) { + return reject("This API is not compatible in Web Workers."); + } const msg_id = this.#messageID++; if(this.env === 'app'){ @@ -714,7 +720,10 @@ class UI extends EventListener { } showSaveFilePicker = function(content, suggestedName, type){ - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + if (!globalThis.open) { + return reject("This API is not compatible in Web Workers."); + } const msg_id = this.#messageID++; if ( ! type && Object.prototype.toString.call(content) === '[object URL]' ) { type = 'url'; diff --git a/src/puter-js/src/modules/Util.js b/src/puter-js/src/modules/Util.js index f3c5aa525..03fd58347 100644 --- a/src/puter-js/src/modules/Util.js +++ b/src/puter-js/src/modules/Util.js @@ -17,7 +17,7 @@ export default class Util { class UtilRPC { constructor () { this.callbackManager = new CallbackManager(); - this.callbackManager.attach_to_source(window); + this.callbackManager.attach_to_source(globalThis); } getDehydrator () { diff --git a/src/puter-js/src/services/Filesystem.js b/src/puter-js/src/services/Filesystem.js index 7b9832ac1..f60d25b90 100644 --- a/src/puter-js/src/services/Filesystem.js +++ b/src/puter-js/src/services/Filesystem.js @@ -42,7 +42,7 @@ export class FilesystemService extends putility.concepts.Service { init_app_fs_ () { this.fs_nocache_ = new PostMessageFilesystem({ - messageTarget: window.parent, + messageTarget: globalThis.parent, rpc: this._.context.util.rpc, }).as(TFilesystem); this.filesystem = this.fs_nocache_; diff --git a/src/puter-js/src/services/NoPuterYet.js b/src/puter-js/src/services/NoPuterYet.js index 004a47203..6f9d0456e 100644 --- a/src/puter-js/src/services/NoPuterYet.js +++ b/src/puter-js/src/services/NoPuterYet.js @@ -1,19 +1,19 @@ import putility from "@heyputer/putility"; /** - * Runs commands on the special `window.when_puter_happens` global, for + * Runs commands on the special `globalThis.when_puter_happens` global, for * situations where the `puter` global doesn't exist soon enough. */ export class NoPuterYetService extends putility.concepts.Service { _init () { - if ( ! window.when_puter_happens ) return; + if ( ! globalThis.when_puter_happens ) return; if ( puter && puter.env !== 'gui' ) return; - if ( ! Array.isArray(window.when_puter_happens) ) { - window.when_puter_happens = [window.when_puter_happens]; + if ( ! Array.isArray(globalThis.when_puter_happens) ) { + globalThis.when_puter_happens = [globalThis.when_puter_happens]; } - for ( const fn of window.when_puter_happens ) { + for ( const fn of globalThis.when_puter_happens ) { fn({ context: this._.context }); } } diff --git a/src/puter-js/src/services/XDIncoming.js b/src/puter-js/src/services/XDIncoming.js index b8e97b928..108031922 100644 --- a/src/puter-js/src/services/XDIncoming.js +++ b/src/puter-js/src/services/XDIncoming.js @@ -12,7 +12,7 @@ export class XDIncomingService extends putility.concepts.Service { } _init () { - window.addEventListener('message', async event => { + globalThis.addEventListener('message', async event => { for ( const fn of this.filter_listeners_ ) { const tp = new TeePromise(); fn(event, tp);