├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── rollup ├── bin.config.js ├── bin.patch.cjs ├── bin.size.cjs ├── client.config.js ├── client.patch.cjs └── readme.size.cjs ├── src ├── bootstrap.js ├── index.js ├── serializer.js ├── server.js ├── utils.js ├── window.js └── worker.js └── test ├── handler.js ├── package.json └── public ├── index.html └── workerful.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .nyc_output 3 | package-lock.json 4 | v8.log 5 | coverage/ 6 | node_modules/ 7 | src/*.mjs 8 | workerful.mjs 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | # workerful, @ungap/with-resolvers, @ungap/structured-clone, coincident, flatted, static-handler 4 | Copyright (c) Andrea Giammarchi, @WebReflection 5 | 6 | # open 7 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 8 | 9 | # ws 10 | Copyright (c) 2011 Einar Otto Stangvik 11 | Copyright (c) 2013 Arnout Kazemier and contributors 12 | Copyright (c) 2016 Luigi Pinca and contributors 13 | 14 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👷 workerful 2 | 3 | A *wonderful* [Electron](https://www.electronjs.org/) standalone alternative 🌈, based on both system (*Chrome/ium based*) browser and node presence, hence weighting **only 96.7KB to bootstrap**. 4 | 5 | ```js 6 | // test it via NodeJS 7 | npx workerful ~/project/folder/package.json 8 | ``` 9 | 10 |
11 | Background / Project goal 12 | 13 | This project goal is to provide a minimalistic *App Container* fully based on system software and it uses by default *ESM* and all the modern *Web Standards* features through the (currently) most capable browser: *Chrome/ium*. 14 | 15 | As the majority of Web developers and users most likely have *NodeJS* installed, and as pretty much everyone also has *Chrome* or *Chromium* installed on their machines, I've decided to give this approach a spin to hopefully see how much the community can create around its simple, yet extremely powerful, primitives that this tiny tool enables. 16 | 17 |
18 | 19 | - - - 20 | 21 | ## Quick Start 22 | 23 | Given a project folder with this minimal structure: 24 | 25 | ``` 26 | project/ 27 | ├▸ package.json 28 | ├▸ public/ 29 | │ ├▸ index.html 30 | │ └▸ workerful.js 31 | └▸ ... 32 | ``` 33 | 34 | and providing that `package.json` as optional argument, where the default one is retrieved out of the current working folder, this module will bootstrap, through the system *NodeJS* default version, an incognito instance out of any installed *Chrome/ium* system browser, confining all its data in the user's *home directory* under `~/.workerful/project-name`. 35 | 36 | ### package.json 37 | 38 | The `package.json` file is used to describe all desired *app* bootstrap features through its optional *workerful* namespace, eventually created if not already present and updated once the application is closed. 39 | 40 | ```json 41 | { 42 | "type": "module", 43 | "workerful": { 44 | "name": "your project name", 45 | "ip": "localhost", 46 | "port": 0, 47 | "centered": true, 48 | "kiosk": false, 49 | "serializer": "json", 50 | "server": "", 51 | "browser": { 52 | "name": "chrome", 53 | "flags": [] 54 | }, 55 | "window": { 56 | "size": [400, 220], 57 | "position": [520, 340] 58 | } 59 | } 60 | } 61 | ``` 62 | 63 |
64 | Fields description 65 | 66 | * **name** is your app name. This will be used as top bar name in your OS and recognized with such name among your running processes 67 | * **ip** is your app IP v4 address. By default it's `localhost` but it can be any other *IP* address. This field can be overridden via environment `WORKERFUL_IP` variable. 68 | * **port** is your app *port*. By default the project runs on any available port and it's completely transparent for your app. This field can be overridden via environment `WORKERFUL_PORT` variable. 69 | * **centered** which can be `true`, to center the *app* on its first bootstrap, `false` to run the *app* on top-left corner and then run where it was left last time, or `"always"` to always start the *app* centered, even if the user moved the window elsewhere. This field can be overridden via environment `WORKERFUL_CENTERED` variable, where `1`, `y`, `yes`, `ok` or `always` are valid values 70 | * **kiosk** to launch the *app* in *kiosk* mode (fullscreen). This field can be overridden via environment `WORKERFUL_KIOSK` variable, where `1`, `y`, `yes` or `ok` are valid values 71 | * **serializer** is the *stringify* / *parse* used to post messages between the *worker* and either the main *window* thread or the *server*. By default it's `"json"` but it can be also `"circular"`, based on [flatted](https://github.com/WebReflection/flatted?tab=readme-ov-file#flatted), or `"structured"`, based on [@ungap/structured-clone/json](https://github.com/ungap/structured-clone?tab=readme-ov-file#tojson). As quick summary: 72 | * **json** is the default serializer. It's the preferred method for DB related data exchanges or simple payloads (and it's also slightly faster than others) 73 | * **circular** is like *json* but it allows circular references within passed *data* among "*worlds*" 74 | * **structured** allows both circular references and extra types such as *Date*, *U/Int8Array*, *U/Int16Array*, *U/Int32Array* or *Float32Array*, *Error* and more 75 | * **server** to optionally specify a *request handler/listener* for the *app*" where `export default (req, res) => { res.writeHead(200); res.end() }` would be a valid, bare-minimal, implementation. The file default export would be awaited and invoked with default *NodeJS* server references and if it does not return `true` on success, the server will respond with a `404`. You can implement or orchestrate any logic you like through this handler but, if not specified, a default [static file handler](https://github.com/WebReflection/static-handler) is used instead 76 | * **browser** is your *app* browser name based on [open API](https://github.com/sindresorhus/open?tab=readme-ov-file#api). Currently only *chrome* is supported but in the future *firefox* and *edge* might be supported too. This field has two optional nested fields: 77 | * **name** which is currently only *chrome* 78 | * **flags** which allows extra flags to be passed on *app* bootstrap. See this curated [list of Chrome/ium flags](https://peter.sh/experiments/chromium-command-line-switches/) to know more and consider [many flags](./src/bootstrap.js#L9) are already in place. 79 | * **window** is your *app* UI size and position, reflected in the app via `window.screenX` and `window.screenY` for the position and `window.screen.width` plus `window.screen.height` for the size. This field has two optional nested fields, ignored when the *app* starts in *kiosk* mode: 80 | * **size** which is an array of `[width, height]` numbers 81 | * **position** which is an array of `[x, y]` numbers 82 | 83 |
84 | 85 | - - - 86 | 87 | ### public folder 88 | 89 | This is the expected folder the server will automatically handle per each request and where all *client* side related files should be, most notably the `index.html` file and a `workerful.js` file to allow the automatically bootstrapped *Worker* to communicate or handle both the main *window* world and the *server* one. 90 | 91 | #### workerful.js 92 | 93 | The `workerful.js` file is automated after [coincident/server](https://github.com/WebReflection/coincident?tab=readme-ov-file#server) and its minimal content would look like this: 94 | 95 | ```js 96 | import { server, window } from '/workerful'; // 🦄 97 | 98 | const message = 'This is Workerful 🌈'; 99 | 100 | // show the message in the main window's body 101 | window.document.body.append(message); 102 | 103 | // log the message in console through the server 104 | server.console.log(message); 105 | ``` 106 | 107 | These two primitives allow your worker code to send or receive data to show on the main thread UI or deal directly with anything available on the server, including importing modules or reaching out global references: 108 | 109 | ```js 110 | // import modules from the main thread 111 | const { render, html } = await window.import('https://esm.run/uhtml'); 112 | 113 | // import modules from the server 114 | const { default: os } = await server.import('os'); 115 | 116 | // or simply reach its globals 117 | const { process } = server; 118 | ``` 119 | 120 |
121 | Best practices 122 | 123 | Due inevitable roundtrip delay between the worker and the main thread or the server one, it's important to keep in mind that highly / real-time reactive changes on the main UI are better passed along via listeners or exposed functionalities within the main thread, where it would receive, as example, only data to update or take care about, and so it goes for the server. 124 | 125 | The rule of thumb here: delegate to respective domains heavy operations and expose utilities through dedicated modules which goal is to help the worker receive, or send, just data. This would be the *TL;DR* "*best practice*" of this *worker driven* pattern. 126 | 127 |
128 | 129 | - - - 130 | 131 | #### index.html 132 | 133 | This file is the main file launched out of the box when the application starts and its minimal content would look like this: 134 | 135 | ```html 136 | 137 | 138 | 139 | This is Workerful 🌈 140 | 141 | 142 | 143 | 144 | ``` 145 | 146 | The `/workerful` import on both main *window* thread and the *worker* is automatically disambiguated through the logic. 147 | 148 | On the **main** thread, it provides a minimal bootstrap logic that automatically bootstrap a *Worker* to drive the application but after that module, everything else is allowed just like any regular *Web Application*. 149 | 150 |
151 | Good to know 152 | 153 | Both *main* `/workerful` and *worker* `/workeful` imports are handled on the *NodeJS* side and these two requests will never leak through the provided handler. 154 | 155 | It is hence useless, or meaningless, to check for `req.url` and match against `/workerful` as that won't ever happen. 156 | 157 |
158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workerful", 3 | "version": "0.1.12", 4 | "type": "module", 5 | "bin": { 6 | "workerful": "workerful.mjs" 7 | }, 8 | "scripts": { 9 | "build": "npm run rollup:client && npm run client:patch && npm run rollup:bin && npm run bin:patch && chmod +x workerful.mjs; npm run size", 10 | "rollup:bin": "rollup --config rollup/bin.config.js", 11 | "rollup:client": "rollup --config rollup/client.config.js", 12 | "bin:patch": "node rollup/bin.patch.cjs", 13 | "client:patch": "node rollup/client.patch.cjs", 14 | "size": "echo \"size: $(node rollup/bin.size.cjs)\" && node rollup/readme.size.cjs", 15 | "test": "node ./workerful.mjs test/package.json" 16 | }, 17 | "files": [ 18 | "workerful.mjs", 19 | "LICENSE", 20 | "README.md" 21 | ], 22 | "keywords": [ 23 | "electron", 24 | "alternative", 25 | "worker" 26 | ], 27 | "author": "Andrea Giammarchi", 28 | "license": "MIT", 29 | "description": "A lightweight, worker driven, Electron alternative", 30 | "devDependencies": { 31 | "@rollup/plugin-commonjs": "^26.0.1", 32 | "@rollup/plugin-node-resolve": "^15.2.3", 33 | "@rollup/plugin-terser": "^0.4.4", 34 | "@ungap/structured-clone": "^1.2.0", 35 | "@ungap/with-resolvers": "^0.1.0", 36 | "coincident": "^2.1.4", 37 | "flatted": "^3.3.1", 38 | "open": "^10.1.0", 39 | "rollup": "^4.21.3", 40 | "static-handler": "^0.5.3", 41 | "ws": "^8.18.0" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/WebReflection/workerful.git" 46 | }, 47 | "bugs": { 48 | "url": "https://github.com/WebReflection/workerful/issues" 49 | }, 50 | "homepage": "https://github.com/WebReflection/workerful#readme" 51 | } 52 | -------------------------------------------------------------------------------- /rollup/bin.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import terser from '@rollup/plugin-terser'; 4 | 5 | const minify = process.env.NO_MIN ? [] : [terser()]; 6 | 7 | export default [ 8 | { 9 | input: './src/index.js', 10 | plugins: [ 11 | nodeResolve(), 12 | commonjs(), 13 | ].concat(minify), 14 | output: { 15 | esModule: true, 16 | file: './workerful.mjs', 17 | } 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /rollup/bin.patch.cjs: -------------------------------------------------------------------------------- 1 | const { readFileSync, writeFileSync } = require('node:fs'); 2 | const { join } = require('node:path'); 3 | 4 | const workerful = join(__dirname, '..', 'workerful.mjs'); 5 | 6 | writeFileSync( 7 | workerful, 8 | readFileSync(workerful).toString('utf-8').replace( 9 | '#!/usr/bin/env node', 10 | `#!/usr/bin/env node 11 | /*! 12 | ${readFileSync(join(__dirname, '..', 'LICENSE')).toString().trim().replace(/^/mg, ' * ')} 13 | */` 14 | ) 15 | ); 16 | -------------------------------------------------------------------------------- /rollup/bin.size.cjs: -------------------------------------------------------------------------------- 1 | const { statSync } = require('node:fs'); 2 | const { join } = require('node:path'); 3 | 4 | let { size } = statSync( 5 | join(__dirname, '..', 'workerful.mjs') 6 | ); 7 | 8 | while (size > 1024) size /= 1024; 9 | 10 | process.stdout.write(`${size.toFixed(1)}KB`); 11 | -------------------------------------------------------------------------------- /rollup/client.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | const minify = process.env.NO_MIN ? [] : [terser()]; 5 | const plugins = [ 6 | nodeResolve(), 7 | ].concat(minify); 8 | 9 | export default [ 10 | { 11 | input: './src/window.js', 12 | plugins, 13 | output: { 14 | esModule: false, 15 | file: './src/window.mjs', 16 | format: 'iife', 17 | } 18 | }, 19 | { 20 | input: './src/worker.js', 21 | plugins, 22 | output: { 23 | esModule: true, 24 | file: './src/worker.mjs', 25 | } 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /rollup/client.patch.cjs: -------------------------------------------------------------------------------- 1 | const { readFileSync, writeFileSync } = require('node:fs'); 2 | const { join } = require('node:path'); 3 | 4 | const createESM = path => { 5 | const file = join(__dirname, '..', 'src', `${path}.mjs`); 6 | writeFileSync( 7 | file, 8 | `export default ${ 9 | JSON.stringify( 10 | readFileSync(file).toString() 11 | ) 12 | };`, 13 | ); 14 | }; 15 | 16 | createESM('window'); 17 | createESM('worker'); 18 | -------------------------------------------------------------------------------- /rollup/readme.size.cjs: -------------------------------------------------------------------------------- 1 | const { readFileSync, writeFileSync } = require('node:fs'); 2 | const { execSync } = require('node:child_process'); 3 | const { join } = require('node:path'); 4 | 5 | const README = join(__dirname, '..', 'README.md'); 6 | 7 | writeFileSync( 8 | README, 9 | readFileSync(README).toString('utf-8').replace( 10 | /.*?<\/span>/, 11 | `${execSync(`node ${join(__dirname, 'bin.size.cjs')}`)}` 12 | ) 13 | ); 14 | -------------------------------------------------------------------------------- /src/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import { tmpdir } from 'node:os'; 3 | 4 | export default ({ name: projectName, workerful: { name, browser, window } }, app, kiosk) => { 5 | const browserName = browser?.name || 'chrome'; 6 | const flags = (browser?.flags || []).slice(0); 7 | const userDataDir = join(tmpdir(), '.workerful', crypto.randomUUID()); 8 | // ⚠️ TODO: only chrome is supported at this point 9 | switch (browserName) { 10 | case 'chrome': { 11 | if (!/^http:\/\/localhost:\d+\//.test(app)) 12 | flags.push(`--unsafely-treat-insecure-origin-as-secure=${app}`, `--test-type`); 13 | // https://peter.sh/experiments/chromium-command-line-switches/ 14 | flags.push( 15 | // '--disable-web-security', // this is trouble 16 | `--window-position=${(window?.position || [0, 0]).join(',')}`, 17 | `--window-size=${(window?.size || [640, 400]).join(',')}`, 18 | `--window-name=${name || projectName || 'unknown'}`, 19 | // this is mandatory to avoid inheriting other chrome/ium instances/state 20 | `--user-data-dir=${userDataDir}`, 21 | '--ignore-profile-directory-if-not-exists', 22 | '--enable-webgpu-developer-features', 23 | '--ignore-gpu-blocklist', 24 | '--disable-extensions', 25 | '--allow-running-insecure-content', 26 | '--allow-files-access-from-files', 27 | '--enable-features=SharedArrayBuffer', 28 | '--disable-first-run-ui', 29 | '--new-window', 30 | '--minimal', 31 | '--content-shell-hide-toolbar', 32 | '--incognito', 33 | '--no-first-run', 34 | '--no-default-browser-check', 35 | '--disable-default-apps', 36 | '--disable-cache', 37 | '--disable-popup-blocking', 38 | // not sure this does anything 39 | // '--disable-system-font-check', 40 | ); 41 | if (kiosk) flags.push('--kiosk', app); 42 | else flags.push(`--app=${app}`); 43 | break; 44 | } 45 | default: throw new Error(`Unsupported browser ${browserName}`); 46 | } 47 | return { name: browserName, dir: userDataDir, flags }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import '@ungap/with-resolvers'; 4 | 5 | import { writeFileSync, rm } from 'node:fs'; 6 | import { spawnSync } from 'node:child_process'; 7 | 8 | import { openApp, apps } from 'open'; 9 | 10 | import { parse, stringify, truthy } from './utils.js'; 11 | import { pkg, indent, json, create } from './server.js'; 12 | import bootstrap from './bootstrap.js'; 13 | 14 | import serializer from './serializer.js'; 15 | import client from './window.mjs'; 16 | import worker from './worker.mjs'; 17 | 18 | const workerful = json.workerful || (json.workerful = { 19 | ip: 'localhost', 20 | port: 0, 21 | centered: true, 22 | kiosk: false, 23 | window: {} 24 | }); 25 | 26 | const { 27 | WORKERFUL_CENTERED = !!workerful.centered, 28 | WORKERFUL_IP = workerful.ip || 'localhost', 29 | WORKERFUL_PORT = workerful.port || 0, 30 | WORKERFUL_KIOSK = workerful.kiosk || false, 31 | WORKERFUL_SERIALIZER = workerful.serializer || "json", 32 | WORKERFUL_HEADLESS = false, 33 | DEBUG = false, 34 | } = process.env; 35 | 36 | const WORKERFUL_SECRET = crypto.randomUUID(); 37 | 38 | const workerful_serializer = WORKERFUL_SERIALIZER.toLowerCase(); 39 | 40 | if (!(workerful_serializer in serializer)) 41 | throw new Error(`Serializer ${WORKERFUL_SERIALIZER} is not json, circular or structured`); 42 | 43 | const ok = (res, content = '') => { 44 | res.writeHead(200, { 'Content-Type': 'text/javascript;charset=utf-8' }); 45 | res.end(content); 46 | return true; 47 | }; 48 | 49 | let ws, summary = Promise.resolve(); 50 | 51 | const server = await create(serializer[workerful_serializer], (req, res) => { 52 | const { url, method, headers } = req; 53 | if (method === 'GET') { 54 | if (url === '/workerful') { 55 | let content, options = { 56 | serializer: workerful_serializer, 57 | }; 58 | if (headers.referer.endsWith('/workerful.js')) 59 | content = `globalThis.workerful=${stringify(options)};\n${worker}`; 60 | else { 61 | content = `globalThis.workerful=${stringify({ 62 | ...options, ws, 63 | secret: WORKERFUL_SECRET, 64 | centered: ( 65 | WORKERFUL_CENTERED === 'always' || 66 | truthy(WORKERFUL_CENTERED) 67 | ), 68 | })};\n${client}`; 69 | } 70 | return ok(res, content); 71 | } 72 | } 73 | else if (method === 'POST') { 74 | const secret = `/${WORKERFUL_SECRET}?`; 75 | if (url.startsWith(secret)) { 76 | if (WORKERFUL_KIOSK) return ok(res); 77 | const { promise, resolve } = Promise.withResolvers(); 78 | summary = promise; 79 | try { 80 | Object.assign( 81 | workerful.window, 82 | parse( 83 | decodeURIComponent( 84 | url.slice(secret.length) 85 | ) 86 | ) 87 | ); 88 | if (workerful.centered !== 'always') workerful.centered = false; 89 | writeFileSync(pkg, `${stringify(json, null, indent)}\n`); 90 | } 91 | finally { 92 | resolve(); 93 | } 94 | return ok(res); 95 | } 96 | } 97 | return false; 98 | }); 99 | 100 | server.listen(+WORKERFUL_PORT, WORKERFUL_IP, async function () { 101 | const { address, family, port } = this.address(); 102 | 103 | const APP = `//${family === 'IPv4' ? address : 'localhost'}:${port}/`; 104 | 105 | const { name, dir, flags } = bootstrap(json, `http:${APP}`, truthy(WORKERFUL_KIOSK)); 106 | 107 | ws = `ws:${APP}`; 108 | 109 | let bin = 'browser'; 110 | if (!truthy(WORKERFUL_HEADLESS)) { 111 | for (bin of [].concat(apps[name])) { 112 | if (!spawnSync(bin, ['--version']).error) break; 113 | bin = ''; 114 | } 115 | 116 | if (!bin) throw new Error(`Unable to start ${apps[name]}`); 117 | 118 | const app = await openApp(bin, { 119 | arguments: flags 120 | }); 121 | 122 | const drop = () => { 123 | rm(dir, { recursive: true }, e => { 124 | process.exit(+!!e); 125 | }); 126 | }; 127 | 128 | const { pid } = app; 129 | process.on('SIGINT', () => { 130 | drop(); 131 | process.kill(pid); 132 | }); 133 | 134 | app.on('close', () => { 135 | setTimeout(async () => { 136 | await summary; 137 | drop(); 138 | }, 250); 139 | }); 140 | } 141 | 142 | if (truthy(DEBUG)) { 143 | console.debug(`\x1b[1mworkerful app launcher\x1b[0m`); 144 | console.debug(`${bin} ${flags.join(' ')}`); 145 | console.debug(`\x1b[1mworkerful serializer\x1b[0m `, WORKERFUL_SERIALIZER); 146 | console.debug(`\x1b[1mworkerful server\x1b[0m `, `http://${APP}`); 147 | } 148 | }); 149 | -------------------------------------------------------------------------------- /src/serializer.js: -------------------------------------------------------------------------------- 1 | import { 2 | parse as circularParse, 3 | stringify as circularStringify 4 | } from 'flatted'; 5 | 6 | import { 7 | parse as structuredPars, 8 | stringify as structuredStringify 9 | } from '@ungap/structured-clone/json'; 10 | 11 | export default { 12 | json: JSON, 13 | circular: { 14 | parse: circularParse, 15 | stringify: circularStringify, 16 | }, 17 | structured: { 18 | parse: structuredPars, 19 | stringify: structuredStringify, 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'node:fs'; 2 | import { join, dirname, resolve } from 'node:path'; 3 | import { createServer } from 'node:http'; 4 | 5 | import { WebSocketServer } from 'ws'; 6 | import staticHandler from 'static-handler'; 7 | import coincident from 'coincident/server'; 8 | 9 | import { parse } from './utils.js'; 10 | 11 | const [...rest] = process.argv.slice(2); 12 | 13 | export const pkg = rest.at(0) || join(process.cwd(), 'package.json'); 14 | if (pkg === '--help' || !existsSync(pkg)) { 15 | const code = +(pkg !== '--help'); 16 | if (code) console.error(`\x1b[1mUnable to parse package.json\x1b[0m\n`); 17 | console[code ? 'error' : 'log'](` 18 | workerful [options] 19 | 20 | [options] 21 | --help # this message 22 | package.json # the package.json file at the root 23 | # of your workerful project 24 | `.trim()); 25 | process.exit(code); 26 | } 27 | 28 | const pkgContent = readFileSync(pkg).toString('utf-8').trim(); 29 | export const indent = /^([\t ]+)/m.test(pkgContent) ? RegExp.$1 : '\t'; 30 | 31 | export const json = parse(pkgContent); 32 | 33 | export const create = async (serializer, workerful) => { 34 | const listener = json.workerful?.server; 35 | const handler = listener ? 36 | (await import(resolve(dirname(pkg), listener))).default : 37 | staticHandler(join(dirname(pkg), 'public')) 38 | ; 39 | const server = createServer(async (req, res) => { 40 | let status = 0; 41 | try { 42 | if (workerful(req, res)) return; 43 | if (await handler(req, res)) return; 44 | status = 404; 45 | } 46 | catch (error) { 47 | console.error(error); 48 | status = 500; 49 | } 50 | res.writeHead(status); 51 | res.end(); 52 | }); 53 | const wss = new WebSocketServer({ server }); 54 | coincident({ wss, ...serializer }); 55 | return server; 56 | }; 57 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const truthy = value => /^(?:1|y|ok|yes|true)$/i.test(value); 2 | 3 | const { parse, stringify } = JSON; 4 | export { parse, stringify }; 5 | -------------------------------------------------------------------------------- /src/window.js: -------------------------------------------------------------------------------- 1 | import coincident from 'coincident/server/main'; 2 | import serializer from './serializer.js'; 3 | 4 | try { 5 | new SharedArrayBuffer(4); 6 | } 7 | 8 | catch ({ message }) { 9 | alert(message); 10 | close(); 11 | } 12 | 13 | // 😉 globalThis.workerful is provided server side 14 | const { workerful } = globalThis; 15 | delete globalThis.workerful; 16 | 17 | addEventListener('beforeunload', () => { 18 | navigator.sendBeacon(`/${workerful.secret}?${ 19 | encodeURIComponent( 20 | JSON.stringify({ 21 | position: [screenX, screenY], 22 | size: [innerWidth, innerHeight] 23 | }) 24 | ) 25 | }`); 26 | }); 27 | 28 | if (workerful.centered) { 29 | const { width, height } = screen; 30 | const x = (width - innerWidth) / 2; 31 | const y = (height - innerHeight) / 2; 32 | moveTo(x, y); 33 | } 34 | 35 | const { Worker } = coincident({ 36 | ws: workerful.ws, 37 | ...serializer[workerful.serializer] 38 | }); 39 | new Worker('/workerful.js'); 40 | -------------------------------------------------------------------------------- /src/worker.js: -------------------------------------------------------------------------------- 1 | import coincident from 'coincident/server/worker'; 2 | import serializer from './serializer.js'; 3 | 4 | // 😉 globalThis.workerful is provided server side 5 | const { workerful } = globalThis; 6 | delete globalThis.workerful; 7 | 8 | const { server, window } = await coincident( 9 | serializer[workerful.serializer] 10 | ); 11 | 12 | export { server, window }; 13 | -------------------------------------------------------------------------------- /test/handler.js: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import staticHandler from 'static-handler'; 3 | 4 | export default staticHandler(join(import.meta.dirname, 'public')); 5 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "workerful": { 4 | "name": "your project name", 5 | "ip": "localhost", 6 | "port": 0, 7 | "centered": "always", 8 | "kiosk": false, 9 | "server": "./handler.js", 10 | "serializer": "json", 11 | "browser": { 12 | "name": "chrome", 13 | "flags": [] 14 | }, 15 | "window": { 16 | "size": [ 17 | 521, 18 | 300 19 | ], 20 | "position": [ 21 | 459, 22 | 300 23 | ] 24 | } 25 | }, 26 | "dependencies": { 27 | "static-handler": "^0.5.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This is Workerful 5 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/public/workerful.js: -------------------------------------------------------------------------------- 1 | import { server, window } from '/workerful'; 2 | 3 | const { render, html } = await window.import('https://esm.run/uhtml'); 4 | const { default: os } = await server.import('os'); 5 | 6 | const [ 7 | platform, 8 | arch, 9 | cpus, 10 | totalmem, 11 | freemem, 12 | ] = [ 13 | os.platform(), 14 | os.arch(), 15 | os.cpus().length, 16 | os.totalmem(), 17 | os.freemem(), 18 | ]; 19 | 20 | render(window.document.body, html` 21 |

👷 workerful

22 |
    23 |
  • Platform: ${platform}
  • 24 |
  • Arch: ${arch}
  • 25 |
  • CPUS: ${cpus}
  • 26 |
  • RAM: ${totalmem}
  • 27 |
  • Free: ${freemem}
  • 28 |
29 | `); 30 | --------------------------------------------------------------------------------