├── .replit ├── cloud-vars └── README.md ├── .codesandbox └── tasks.json ├── static ├── 404.html ├── index.html └── styles.css ├── src ├── util.js ├── index.js ├── server.js └── cloud-server.js ├── CHANGELOG.md ├── package.json ├── LICENSE ├── .gitignore └── README.md /.replit: -------------------------------------------------------------------------------- 1 | run="npm install; npm start" 2 | entrypoint = "src/index.js" 3 | -------------------------------------------------------------------------------- /cloud-vars/README.md: -------------------------------------------------------------------------------- 1 | Cloud variables are stored in this folder as JSON files named after the project ID. 2 | -------------------------------------------------------------------------------- /.codesandbox/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "setupTasks": [ 3 | { 4 | "name": "npm installation", 5 | "command": "npm install" 6 | } 7 | ], 8 | // Adding Start task 9 | "tasks": { 10 | "startServer": { 11 | "name": "Start", 12 | "command": "npm start", 13 | "preview": { 14 | "port": 3000 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /static/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Primitive cloud server 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

404: Page not found

14 |

If you know some basic HTML, you can edit 404.html in the static folder to change what shows here.

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | module.exports.readFile = filePath => { 4 | return new Promise((resolve, reject) => { 5 | fs.readFile(filePath, 'utf8', (err, data) => { 6 | if (err) reject(err) 7 | else resolve(data) 8 | }) 9 | }) 10 | } 11 | 12 | module.exports.writeFile = (filePath, data) => { 13 | return new Promise((resolve, reject) => { 14 | fs.writeFile(filePath, data, err => { 15 | if (err) reject(err) 16 | else resolve() 17 | }) 18 | }) 19 | } 20 | 21 | module.exports.exists = filePath => { 22 | return new Promise(resolve => { 23 | fs.access(filePath, err => { 24 | resolve(!err) 25 | }) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Primitive cloud server 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Congratulations, your cloud server works!

14 |

You can replace index.html in the static folder with your own HTML file to change what shows here.

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2021-06-15 4 | 5 | - The cloud server will not stop if there is an error from the WebSocket server. 6 | - `--per-message-deflate` option to match Scratch's cloud server; disabled by default. 7 | 8 | ## 2021-03-16 9 | 10 | - The web server now serves files from the static/ folder. static/404.html is used as the 404 page. 11 | - Automatically determines your public and private IP (and fancy colours in the console!). 12 | - No longer clears a corrupt JSON file of cloud variables. 13 | - **DEPRECATED**: index.html in the project folder. You should move this to static/index.html (feel free to overwrite the placeholder file there). 14 | 15 | ## 2020-12-31 16 | 17 | - The cloud server is now also a web server that serves index.html. 18 | 19 | ## 2019-12-25 20 | 21 | - Cloud server functionality 22 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | /* CSS shamelessly taken from https://www.example.com/ */ 2 | body { 3 | background-color: #f0f0f2; 4 | margin: 0; 5 | padding: 0; 6 | font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', 7 | 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 8 | } 9 | div { 10 | width: 600px; 11 | margin: 5em auto; 12 | padding: 2em; 13 | background-color: #fdfdff; 14 | border-radius: 0.5em; 15 | box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02); 16 | } 17 | a:link, 18 | a:visited { 19 | color: #2196f3; 20 | text-decoration: none; 21 | } 22 | a:hover { 23 | text-decoration: underline; 24 | } 25 | code { 26 | background-color: #e9e9ec; 27 | padding: 2px 5px; 28 | border-radius: 2px; 29 | } 30 | @media (max-width: 700px) { 31 | body { 32 | background-color: #fdfdff; 33 | } 34 | div { 35 | margin: 0 auto; 36 | width: auto; 37 | box-shadow: none; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const parseArgs = require('minimist') 2 | 3 | const startServer = require('./server.js') 4 | 5 | const { 6 | port = 3000, 7 | lock, 8 | 'per-message-deflate': perMessageDeflate, 9 | help 10 | } = parseArgs(process.argv.slice(2), { 11 | boolean: ['lock', 'per-message-deflate', 'help'], 12 | alias: { 13 | p: 'port', 14 | l: 'lock', 15 | D: 'per-message-deflate', 16 | h: 'help' 17 | } 18 | }) 19 | 20 | if (help) { 21 | console.log('npm start -- [OPTIONS]') 22 | console.log('--port= (-p )\n\tSet the port for the server. (Default 3000)') 23 | console.log('--lock (-l)\n\tDisables the ability to rename and delete cloud variables. (Enabled by default)') 24 | console.log('--per-message-deflate (-D)\n\tEnable permessage-deflate compression, which has a slight impact on performance (Disabled by default)') 25 | console.log('--help (-h)\n\tDisplay help') 26 | process.exit(0) 27 | } else { 28 | startServer({ port, lockVars: lock, perMessageDeflate }) 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "primitive-cloud-server", 3 | "version": "2.0.0", 4 | "description": "A primitive Node server for Scratch 3.0 cloud variables; it's not made for large-scale projects and can easily be tricked by those pesky JavaScript programmers", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node src/index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/SheepTester/primitive-cloud-server.git" 13 | }, 14 | "keywords": [ 15 | "scratch", 16 | "cloud", 17 | "variables", 18 | "server" 19 | ], 20 | "author": "SheepTester", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/SheepTester/primitive-cloud-server/issues" 24 | }, 25 | "homepage": "https://github.com/SheepTester/primitive-cloud-server#readme", 26 | "dependencies": { 27 | "colors": "^1.4.0", 28 | "express": "^4.17.1", 29 | "express-ws": "^4.0.0", 30 | "internal-ip": "^6.2.0", 31 | "minimist": "^1.2.5", 32 | "public-ip": "^4.0.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sean 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const publicIp = require('public-ip') 3 | const internalIp = require('internal-ip') 4 | const express = require('express') 5 | const expressWs = require('express-ws') 6 | const colours = require('colors/safe') 7 | 8 | const CloudServer = require('./cloud-server.js') 9 | const fsUtil = require('./util.js') 10 | 11 | async function startServer ({ port, lockVars, perMessageDeflate }) { 12 | const app = express() 13 | const cloudServer = new CloudServer({ lockVars }) 14 | 15 | app.disable('x-powered-by') 16 | expressWs(app, undefined, { 17 | wsOptions: { perMessageDeflate } 18 | }) 19 | 20 | const oldIndexHtmlPath = path.resolve(__dirname, '../index.html') 21 | if (await fsUtil.exists(oldIndexHtmlPath)) { 22 | app.get('/', (req, res, next) => { 23 | res.sendFile(oldIndexHtmlPath) 24 | }) 25 | } 26 | 27 | app.use(express.static(path.resolve(__dirname, '../static/'), { 28 | extensions: ['html', 'htm'] 29 | })) 30 | 31 | app.ws('/', cloudServer.handleWsConnection) 32 | 33 | app.use((req, res) => { 34 | res.status(404).sendFile(path.resolve(__dirname, '../static/404.html')) 35 | }) 36 | 37 | app.listen(port, async () => { 38 | console.log(colours.green('I\'m now running your cloud server!')) 39 | console.log('You can access it...') 40 | console.log(` • on your computer at ${colours.cyan(`ws://localhost:${port}/`)} (use this for testing)`) 41 | const privateIp = await internalIp.v4().catch(() => null) 42 | if (privateIp) { 43 | console.log(` • locally within your network at ${colours.blue(`ws://${privateIp}:${port}/`)} (maybe)`) 44 | } 45 | const ip = await publicIp.v4().catch(() => null) 46 | if (ip) { 47 | console.log(` • publicly at ${colours.blue(`ws://${ip}:${port}/`)}, but ONLY if you've set up port forwarding on your router`) 48 | } 49 | console.log(colours.yellow(`I'm also serving files from the static/ folder, which you can access in your browser at ${colours.blue(`http://localhost:${port}/`)}.`)) 50 | console.log(colours.red('Press control+C to stop the server.')) 51 | }) 52 | } 53 | 54 | module.exports = startServer 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Cloud variable data 2 | cloud-vars/*.json 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # primitive-cloud-server 2 | 3 | A primitive Node server for Scratch 3.0 cloud variables; it's not made for large-scale projects and can easily be tricked by those pesky JavaScript programmers. 4 | 5 | [![Run on Repl.it](https://replit.com/badge/github/SheepTester/primitive-cloud-server)](https://replit.com/new/github/SheepTester/primitive-cloud-server) 6 | [![Edit in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/p/github/SheepTester/primitive-cloud-server) 7 | 8 | Video tutorial: 9 | 10 | [![thumbnail of video tutorial](https://img.youtube.com/vi/xVJWqN264fM/0.jpg)](https://www.youtube.com/watch?v=xVJWqN264fM) 11 | 12 | 1. You'll need to have [Node](https://nodejs.org/en/) installed first. 13 | 14 | 2. ```sh 15 | git clone https://github.com/SheepTester/primitive-cloud-server.git 16 | cd primitive-cloud-server 17 | npm install 18 | npm start 19 | ``` 20 | 21 | This makes a local WebSocket server available at `ws://localhost:3000/` and an HTTP server at http://localhost:3000/. 22 | 23 | Cloud variables get stored in the [cloud-vars/ folder](./cloud-vars/). 24 | 25 | You can pass a few command line arguments; do `npm start -- --help` for a list. 26 | 27 | ## Updating 28 | 29 | You can update to a new version by doing 30 | 31 | ```sh 32 | git stash 33 | git pull 34 | git stash pop 35 | git checkout --theirs -- . 36 | git reset HEAD 37 | npm install 38 | ``` 39 | 40 | This "stashes" your local changes, pulls (downloads) the new files from GitHub, then tries to bring back the files from the stash. This might cause issues, so this then checks out "their" files ("their" refers to the stash that you're bringing back). For some reason, Git might get confused after this, so we just unstage all the files. Finally, it installs new dependencies from NPM. 41 | 42 | ## Details 43 | 44 | All WebSocket messages are JSON strings; the server may send multiple JSON objects separated by a newline character, but the client may not do that back. 45 | 46 | When the client first connects to the server, Scratch sends a "handshake" message, which I think lets the server know which project it is on so the server can then send a series of "set" messages to initialize the client's cloud variables. 47 | 48 | ```json 49 | // client -> server 50 | { "method": "handshake", "project_id": "104" } 51 | 52 | // server -> client 53 | { "method": "set", "name": "☁ cool cloud variable", "value": "45643563456" } 54 | { "method": "set", "name": "☁ epic cloud variable", "value": "10239489031" } 55 | { "method": "set", "name": "☁ newish variable", "value": "0" } 56 | ``` 57 | 58 | After that the client can send a "set" message (same structure) to the server, which will broadcast it to the other clients on the project. 59 | -------------------------------------------------------------------------------- /src/cloud-server.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const { readFile, writeFile } = require('./util.js') 4 | 5 | const validProjectId = /^\w+$/ 6 | 7 | class CloudServer { 8 | constructor ({ lockVars = false } = {}) { 9 | this.projects = new Map() 10 | this.lockVars = lockVars 11 | 12 | this.handleWsConnection = this.handleWsConnection.bind(this) 13 | } 14 | 15 | async getProject (id) { 16 | const project = this.projects.get(id) 17 | if (project) return project 18 | 19 | if (!validProjectId.test(id)) return null 20 | 21 | const savePath = path.resolve(__dirname, `../cloud-vars/${id}.json`) 22 | let variables 23 | try { 24 | variables = JSON.parse(await readFile(savePath).catch(() => '{}')) 25 | } catch (err) { 26 | console.error(`Encountered an error parsing the cloud variable data at cloud-vars/${id}.json:`) 27 | console.error(err) 28 | console.error('This might mean that the file is corrupt, but it may be recoverable.') 29 | return null 30 | } 31 | const connections = new Set() 32 | let saveTimeout = null 33 | const projectData = { 34 | variables, 35 | connections, 36 | save: () => { 37 | if (saveTimeout) return 38 | saveTimeout = setTimeout(() => { 39 | writeFile(savePath, JSON.stringify(variables)) 40 | saveTimeout = null 41 | }, 1000) 42 | }, 43 | announce: (announcer, messages) => { 44 | for (const ws of connections) { 45 | if (ws !== announcer) { 46 | this.reply(ws, messages) 47 | } 48 | } 49 | } 50 | } 51 | this.projects.set(id, projectData) 52 | return projectData 53 | } 54 | 55 | reply (ws, messages) { 56 | ws.send(messages.map(message => JSON.stringify(message) + '\n').join('')) 57 | } 58 | 59 | handleWsConnection (ws) { 60 | let handshaken = false 61 | let project = null 62 | 63 | ws.on('message', data => { 64 | let message 65 | try { 66 | message = JSON.parse(data) 67 | } catch (err) { 68 | console.error('I received invalid JSON over the Websocket connection.') 69 | console.error(data) 70 | console.error(err) 71 | console.error('This might mean that someone is trying to tamper with your server.') 72 | return 73 | } 74 | switch (message.method) { 75 | case 'handshake': 76 | if (!handshaken) { 77 | handshaken = true 78 | this.getProject(message.project_id).then(projectData => { 79 | if (projectData) { 80 | project = projectData 81 | project.connections.add(ws) 82 | const changes = Object.entries(project.variables).map(([variable, value]) => ({ 83 | method: 'set', 84 | name: variable, 85 | value 86 | })) 87 | this.reply(ws, changes) 88 | } 89 | }) 90 | } 91 | break 92 | case 'create': 93 | case 'set': 94 | if (project) { 95 | project.variables[message.name] = message.value 96 | project.announce(ws, [{ 97 | method: 'set', 98 | name: message.name, 99 | value: message.value 100 | }]) 101 | project.save() 102 | } 103 | break 104 | case 'rename': 105 | if (project && !this.lockVars) { 106 | project.variables[message.new_name] = project.variables[message.name] 107 | delete project[message.name] 108 | project.announce(ws, [{ 109 | method: 'set', 110 | name: message.new_name, 111 | value: message.value 112 | }]) 113 | project.save() 114 | } 115 | break 116 | case 'delete': 117 | if (project && !this.lockVars) { 118 | delete project.variables[message.name] 119 | project.save() 120 | } 121 | break 122 | default: 123 | console.error(`I received an unknown method ${message.method}.`) 124 | } 125 | }) 126 | 127 | ws.on('error', console.error) 128 | 129 | ws.on('close', () => { 130 | if (project) { 131 | project.connections.delete(ws) 132 | } 133 | }) 134 | } 135 | } 136 | 137 | module.exports = CloudServer 138 | --------------------------------------------------------------------------------