├── .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 | [](https://replit.com/new/github/SheepTester/primitive-cloud-server)
6 | [](https://codesandbox.io/p/github/SheepTester/primitive-cloud-server)
7 |
8 | Video tutorial:
9 |
10 | [](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 |
--------------------------------------------------------------------------------