@@ -0,0 +1,2 @@ | |||
node_modules | |||
public/bundle.* |
@@ -0,0 +1,68 @@ | |||
*Psst — looking for a shareable component template? Go here --> [sveltejs/component-template](https://github.com/sveltejs/component-template)* | |||
--- | |||
# svelte app | |||
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template. | |||
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit): | |||
```bash | |||
npx degit sveltejs/template svelte-app | |||
cd svelte-app | |||
``` | |||
*Note that you will need to have [Node.js](https://nodejs.org) installed.* | |||
## Get started | |||
Install the dependencies... | |||
```bash | |||
cd svelte-app | |||
npm install | |||
``` | |||
...then start [Rollup](https://rollupjs.org): | |||
```bash | |||
npm run dev | |||
``` | |||
Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes. | |||
## Deploying to the web | |||
### With [now](https://zeit.co/now) | |||
Install `now` if you haven't already: | |||
```bash | |||
npm install -g now | |||
``` | |||
Then, from within your project folder: | |||
```bash | |||
now | |||
``` | |||
As an alternative, use the [Now desktop client](https://zeit.co/download) and simply drag the unzipped project folder to the taskbar icon. | |||
### With [surge](https://surge.sh/) | |||
Install `surge` if you haven't already: | |||
```bash | |||
npm install -g surge | |||
``` | |||
Then, from within your project folder: | |||
```bash | |||
npm run build | |||
surge public | |||
``` |
@@ -0,0 +1,25 @@ | |||
{ | |||
"name": "svelte-app", | |||
"version": "1.0.0", | |||
"devDependencies": { | |||
"dev-refresh": "file:../../../../dev/dev-refresh", | |||
"npm-run-all": "^4.1.5", | |||
"rollup": "^1.10.1", | |||
"rollup-plugin-commonjs": "^9.3.4", | |||
"rollup-plugin-livereload": "^1.0.0", | |||
"rollup-plugin-node-resolve": "^4.2.3", | |||
"rollup-plugin-svelte": "^5.0.3", | |||
"rollup-plugin-terser": "^4.0.4", | |||
"svelte": "^3.0.0" | |||
}, | |||
"dependencies": { | |||
"sirv-cli": "^0.4.4" | |||
}, | |||
"scripts": { | |||
"build": "NODE_ENV=production rollup -c", | |||
"build:dev": "NODE_ENV=development rollup -c", | |||
"dev": "dev-refresh src --port 8080 --proxy 'http://localhost:8081' --cmd 'npm run build:dev' -n", | |||
"start": "sirv public --single", | |||
"start:dev": "sirv public --single --dev" | |||
} | |||
} |
@@ -0,0 +1,8 @@ | |||
html, body { | |||
margin: 0px; | |||
height: 100%; | |||
} | |||
* { | |||
box-sizing: border-box; | |||
} |
@@ -0,0 +1,8 @@ | |||
<?xml version="1.0" encoding="utf-8"?> | |||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> | |||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | |||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | |||
width="8px" height="8px" viewBox="0 0 8 8" enable-background="new 0 0 8 8" xml:space="preserve"> | |||
<rect x="-0.013" y="4.258" transform="matrix(-0.707 -0.7072 0.7072 -0.707 0.0891 10.1702)" width="4.33" height="1.618"/> | |||
<rect x="2.227" y="2.899" transform="matrix(-0.7071 0.7071 -0.7071 -0.7071 11.6877 2.6833)" width="6.121" height="1.726"/> | |||
</svg> |
@@ -0,0 +1,17 @@ | |||
<!doctype html> | |||
<html> | |||
<head> | |||
<meta charset='utf8'> | |||
<meta name='viewport' content='width=device-width'> | |||
<title>Shopping List</title> | |||
<link rel='icon' type='image/png' href='/favicon.png'> | |||
<link rel='stylesheet' href='/global.css'> | |||
<link rel='stylesheet' href='/bundle.css'> | |||
</head> | |||
<body> | |||
<script src='/bundle.js'></script> | |||
</body> | |||
</html> |
@@ -0,0 +1,39 @@ | |||
import svelte from 'rollup-plugin-svelte'; | |||
import resolve from 'rollup-plugin-node-resolve'; | |||
import commonjs from 'rollup-plugin-commonjs'; | |||
import { terser } from 'rollup-plugin-terser'; | |||
const production = !process.env.NODE_ENV || process.env.NODE_ENV == "production"; | |||
export default { | |||
input: 'src/main.js', | |||
output: { | |||
sourcemap: !production, | |||
format: 'iife', | |||
name: 'app', | |||
file: 'public/bundle.js' | |||
}, | |||
plugins: [ | |||
svelte({ | |||
// enable run-time checks when not in production | |||
dev: !production, | |||
// we'll extract any component CSS out into | |||
// a separate file — better for performance | |||
css: css => { | |||
css.write('public/bundle.css'); | |||
} | |||
}), | |||
// If you have external dependencies installed from | |||
// npm, you'll most likely need these plugins. In | |||
// some cases you'll need additional configuration — | |||
// consult the documentation for details: | |||
// https://github.com/rollup/rollup-plugin-commonjs | |||
resolve({ browser: true }), | |||
commonjs(), | |||
// If we're building for production (npm run build | |||
// instead of npm run dev), minify | |||
production && terser() | |||
], | |||
}; |
@@ -0,0 +1,134 @@ | |||
<script> | |||
import ListItem from './ListItem.svelte'; | |||
import ListInput from './ListInput.svelte'; | |||
import LoadingIcon from './LoadingIcon.svelte'; | |||
import { fade } from 'svelte/transition'; | |||
export let wsock; | |||
let words = {}; | |||
let items = {}; | |||
let pendingItems = []; | |||
let sortedItems = []; | |||
let connected = false; | |||
function sortItems() { | |||
let pending = []; | |||
for (let i in pendingItems) | |||
pending.push(pendingItems[i]); | |||
pending.sort((a, b) => b.index - a.index); | |||
let rest = []; | |||
for (let i in items) | |||
rest.push(items[i]); | |||
rest.sort((a, b) => b.index - a.index); | |||
sortedItems = []; | |||
for (let item of pending) | |||
sortedItems.push(item); | |||
for (let item of rest) | |||
sortedItems.push(item); | |||
} | |||
function onAdd(evt) { | |||
let content = evt.detail; | |||
let item = { pending: true, content, index: pendingItems.length }; | |||
pendingItems.push(item); | |||
sortItems(); | |||
wsock.send({ type: "item-add", content }).then(({ index }) => { | |||
delete pendingItems[item.index]; | |||
item.index = index; | |||
items[index] = item; | |||
if (words[content] == null) | |||
words[content] = 0; | |||
words[content] += 1; | |||
sortItems(); | |||
setTimeout(() => { item.pending = false; sortedItems = sortedItems; }, 50); | |||
}); | |||
} | |||
function onRemove(evt) { | |||
let item = evt.detail; | |||
if (item.pending) | |||
return; | |||
item.pending = true; | |||
sortedItems = sortedItems; | |||
wsock.send({ type: "item-del", index: item.index }).then(() => { | |||
delete items[item.index]; | |||
sortItems(); | |||
}); | |||
} | |||
export function onInitialData(data) { | |||
words = data.words; | |||
items = {}; | |||
for (let i in data.items) | |||
items[i] = { pending: false, content: data.items[i], index: i }; | |||
sortItems(); | |||
connected = true; | |||
} | |||
export function onDisconnect() { | |||
connected = false; | |||
} | |||
export function onAddFromServer(index, content) { | |||
let item = { pending: true, content, index }; | |||
items[index] = item; | |||
sortItems(); | |||
setTimeout(() => { item.pending = false; sortedItems = sortedItems; }, 10); | |||
} | |||
export function onRemoveFromServer(index) { | |||
delete items[index]; | |||
sortItems(); | |||
} | |||
</script> | |||
<style> | |||
.container { | |||
position: relative; | |||
margin: auto; | |||
text-align: center; | |||
width: 100%; | |||
max-width: 500px; | |||
height: 100%; | |||
} | |||
.items { | |||
margin-top: 24px; | |||
} | |||
.loading { | |||
display: flex; | |||
width: 100%; | |||
height: 100%; | |||
position: absolute; | |||
top: 0px; | |||
left: 0px; | |||
justify-content: center; | |||
padding-top: 25vh; | |||
background: rgba(0, 0, 0, 0.7); | |||
} | |||
</style> | |||
<div class="container"> | |||
<ListInput {words} on:add={onAdd} /> | |||
<div class="items"> | |||
{#each sortedItems as item (item)} | |||
<ListItem {item} on:remove={onRemove} /> | |||
{/each} | |||
</div> | |||
</div> | |||
{#if !connected} | |||
<div out:fade={{duration: 100}} class="loading"><LoadingIcon /></div> | |||
{/if} |
@@ -0,0 +1,123 @@ | |||
<script> | |||
import { createEventDispatcher } from 'svelte'; | |||
import { fade } from 'svelte/transition'; | |||
export let words; | |||
let dispatch = createEventDispatcher(); | |||
let inputEl; | |||
let hidden = true; | |||
let wordList = []; | |||
function updateWords() { | |||
wordList = []; | |||
for (let content in words) { | |||
if (content.includes(inputEl.value)) | |||
wordList.push({ content, score: words[content] }); | |||
} | |||
wordList.sort((a, b) => b.score - a.score); | |||
} | |||
function onBlur() { | |||
setTimeout(() => hidden = true, 50); | |||
} | |||
function onFocus() { | |||
updateWords(); | |||
hidden = false; | |||
} | |||
function onSubmit(evt) { | |||
let val = inputEl.value.toLowerCase(); | |||
if (!val) | |||
return; | |||
inputEl.value = ""; | |||
dispatch("add", val); | |||
} | |||
function add(content) { | |||
inputEl.value = ""; | |||
dispatch("add", content.toLowerCase()); | |||
} | |||
function capitalize(str) { | |||
var first = str[0].toUpperCase(); | |||
return first + str.substring(1); | |||
} | |||
</script> | |||
<style> | |||
.add { | |||
padding-top: 12px; | |||
} | |||
.add .content, | |||
.add .submit { | |||
box-sizing: content-box; | |||
-webkit-appearance: none; | |||
font-size: 16px; | |||
border: 1px solid #aaa; | |||
border-radius: 5px; | |||
background-color: #fff; | |||
height: 20px; | |||
padding: 6px; | |||
line-height: 0px; | |||
} | |||
.add .content { | |||
width: 78%; | |||
border-top-right-radius: 0px; | |||
border-bottom-right-radius: 0px; | |||
} | |||
.add .submit { | |||
width: 8%; | |||
background-color: #eee; | |||
border-top-left-radius: 0px; | |||
border-bottom-left-radius: 0px; | |||
} | |||
.add .submit:hover { | |||
background-color: #ddd; | |||
} | |||
.add .suggestions { | |||
z-index: 2; | |||
background: white; | |||
box-shadow: 0px 0px 10px 1px #ccc; | |||
position: absolute; | |||
width: 90%; | |||
margin: auto; | |||
left: 0px; | |||
right: 0px; | |||
margin-top: 15px; | |||
padding-top: 6px; | |||
padding-bottom: 6px; | |||
max-height: 70%; | |||
overflow-y: auto; | |||
overflow-x: hidden; | |||
} | |||
.add .suggestions .suggestion:last-child { | |||
border-bottom: none; | |||
} | |||
.add .suggestions .suggestion { | |||
padding: 12px; | |||
cursor: pointer; | |||
border-bottom: 1px solid #ccc; | |||
text-overflow: ellipsis; | |||
max-width: 100%; | |||
overflow: hidden; | |||
} | |||
</style> | |||
<form on:submit|preventDefault={onSubmit} class="add"> | |||
<input | |||
class="content" name="content" autocomplete="off" | |||
on:focus={onFocus} on:blur={onBlur} on:keydown={() => window.setTimeout(updateWords, 10)} bind:this={inputEl}> | |||
<button class="submit" type="submit">+</button> | |||
{#if !hidden} | |||
<div transition:fade={{duration: 50}} class="suggestions"> | |||
{#each wordList as word} | |||
<div class="suggestion" on:click={() => add(word.content)}> | |||
{capitalize(word.content)} | |||
</div> | |||
{/each} | |||
</div> | |||
{/if} | |||
</form> |
@@ -0,0 +1,108 @@ | |||
<script> | |||
import { createEventDispatcher } from 'svelte'; | |||
export let item; | |||
let dispatch = createEventDispatcher(); | |||
let emitRemove = () => dispatch("remove", item); | |||
function transIn(node) { | |||
if (!item.pending) return { delay: 0, duration: 0 }; | |||
let comp = getComputedStyle(node); | |||
let height = parseInt(comp.height); | |||
let paddingTop = parseInt(comp["padding-top"]); | |||
return { | |||
delay: 0, | |||
duration: 200, | |||
css: t => ` | |||
overflow: hidden; | |||
height: ${t * height}px; | |||
padding-top: ${t * paddingTop}px;` | |||
}; | |||
} | |||
function transOut(node) { | |||
let comp = getComputedStyle(node); | |||
let opacity = parseFloat(comp.opacity); | |||
let height = parseInt(comp.height); | |||
let paddingTop = parseInt(comp["padding-top"]); | |||
function part(t, from, to) { | |||
return Math.max((t - from), 0) / (to - from); | |||
} | |||
return { | |||
delay: 0, | |||
duration: 200, | |||
css: t => ` | |||
overflow: hidden; | |||
height: ${part(t, 0.3, 1) * height}px; | |||
padding-top: ${part(t, 0.3, 1) * paddingTop}px; | |||
transform: translateX(-${(1 - t) * 150}px); | |||
opacity: ${part(t, 0, 0.7) * opacity}` | |||
}; | |||
} | |||
function capitalize(str) { | |||
var first = str[0].toUpperCase(); | |||
return first + str.substring(1); | |||
} | |||
</script> | |||
<style> | |||
.item { | |||
transition: background-color 0.3s; | |||
padding-left: 12px; | |||
padding-top: 42px; | |||
position: relative; | |||
margin: auto; | |||
text-align: left; | |||
width: 100%; | |||
height: 70px; | |||
overflow: hidden; | |||
border-bottom: 1px solid #ccc; | |||
} | |||
.item.pending { | |||
background-color: #eee; | |||
} | |||
.item .name { | |||
position: relative; | |||
display: inline-block; | |||
max-width: calc(100% - 60px); | |||
overflow: hidden; | |||
text-overflow: ellipsis; | |||
} | |||
.item .ok { | |||
position: relative; | |||
display: inline-block; | |||
float: right; | |||
margin-right: 6px; | |||
height: 50px; | |||
width: 50px; | |||
bottom: 33px; | |||
background-image: url(/icon-check.svg); | |||
background-size: 24px 24px; | |||
background-repeat: no-repeat; | |||
background-position: center; | |||
cursor: pointer; | |||
border-radius: 100px; | |||
background-color: #eee; | |||
} | |||
.item .ok:hover { | |||
background-color: #ddd; | |||
} | |||
</style> | |||
<div in:transIn out:transOut class={`item ${item.pending ? 'pending' : ''}`}> | |||
<div class="name">{capitalize(item.content)}</div> | |||
{#if !item.pending} | |||
<span class="ok" on:click={emitRemove}></span> | |||
{/if} | |||
</div> |
@@ -0,0 +1,88 @@ | |||
<style> | |||
.lds-default { | |||
display: inline-block; | |||
position: relative; | |||
width: 64px; | |||
height: 64px; | |||
} | |||
.lds-default div { | |||
position: absolute; | |||
width: 5px; | |||
height: 5px; | |||
background: #fff; | |||
border-radius: 50%; | |||
animation: lds-default 1.2s linear infinite; | |||
} | |||
.lds-default div:nth-child(1) { | |||
animation-delay: 0s; | |||
top: 29px; | |||
left: 53px; | |||
} | |||
.lds-default div:nth-child(2) { | |||
animation-delay: -0.1s; | |||
top: 18px; | |||
left: 50px; | |||
} | |||
.lds-default div:nth-child(3) { | |||
animation-delay: -0.2s; | |||
top: 9px; | |||
left: 41px; | |||
} | |||
.lds-default div:nth-child(4) { | |||
animation-delay: -0.3s; | |||
top: 6px; | |||
left: 29px; | |||
} | |||
.lds-default div:nth-child(5) { | |||
animation-delay: -0.4s; | |||
top: 9px; | |||
left: 18px; | |||
} | |||
.lds-default div:nth-child(6) { | |||
animation-delay: -0.5s; | |||
top: 18px; | |||
left: 9px; | |||
} | |||
.lds-default div:nth-child(7) { | |||
animation-delay: -0.6s; | |||
top: 29px; | |||
left: 6px; | |||
} | |||
.lds-default div:nth-child(8) { | |||
animation-delay: -0.7s; | |||
top: 41px; | |||
left: 9px; | |||
} | |||
.lds-default div:nth-child(9) { | |||
animation-delay: -0.8s; | |||
top: 50px; | |||
left: 18px; | |||
} | |||
.lds-default div:nth-child(10) { | |||
animation-delay: -0.9s; | |||
top: 53px; | |||
left: 29px; | |||
} | |||
.lds-default div:nth-child(11) { | |||
animation-delay: -1s; | |||
top: 50px; | |||
left: 41px; | |||
} | |||
.lds-default div:nth-child(12) { | |||
animation-delay: -1.1s; | |||
top: 41px; | |||
left: 50px; | |||
} | |||
@keyframes lds-default { | |||
0%, 20%, 80%, 100% { | |||
transform: scale(1); | |||
} | |||
50% { | |||
transform: scale(1.5); | |||
} | |||
} | |||
</style> | |||
<div class="lds-default"> | |||
<div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div> | |||
</div> |
@@ -0,0 +1,46 @@ | |||
import App from './App.svelte'; | |||
import WSockMan from './ws.js'; | |||
let wsock = new WSockMan( | |||
`${location.protocol == "http:" ? "ws:" : "wss:"}//${location.host}/`); | |||
let app = new App({ target: document.body, props: { wsock }}); | |||
let key = localStorage.getItem("key"); | |||
if (!key) | |||
key = prompt("Key?"); | |||
function auth(k) { | |||
wsock.send({ type: "init", key: k }) | |||
.then(res => { | |||
localStorage.setItem("key", key); | |||
key = k; | |||
app.onInitialData(res.data); | |||
}) | |||
.catch(err => { | |||
console.trace(err); | |||
let k = prompt(err); | |||
if (k != null) | |||
auth(k); | |||
}); | |||
}; | |||
wsock.onconnect = () => auth(key); | |||
wsock.ondisconnect = app.onDisconnect.bind(app); | |||
wsock.onmessage = msg => { | |||
switch (msg.type) { | |||
case "item-del": | |||
app.onRemoveFromServer(msg.index); | |||
break; | |||
case "item-add": | |||
app.onAddFromServer(msg.index, msg.content); | |||
break; | |||
default: | |||
console.warn("Unknown message type", msg.type); | |||
} | |||
}; | |||
export default app; |
@@ -0,0 +1,83 @@ | |||
export default class WSockMan { | |||
constructor(url) { | |||
this.ready = false; | |||
this.sock = null; | |||
this.url = url; | |||
this.sendQ = []; | |||
this.ackQ = []; | |||
this.ackID = 0; | |||
this.ondisconnect = () => {}; | |||
this.onconnect = () => {}; | |||
this.onmessage = () => {}; | |||
this.createWS(); | |||
} | |||
send(obj, cb) { | |||
return new Promise((resolve, reject) => { | |||
if (this.ready) { | |||
return this.reallySend(obj, resolve, reject); | |||
} else { | |||
this.sendQ.push([obj, resolve, reject]); | |||
} | |||
}); | |||
} | |||
createWS() { | |||
if (this.sock) | |||
this.sock.close(); | |||
this.sock = new WebSocket(this.url); | |||
this.sock.onopen = () => { | |||
this.ready = true; | |||
this.ackID = 0; | |||
for (let i = 0; i < this.sendQ.length; ++i) | |||
this.reallySend(...this.sendQ[i]); | |||
this.sendQ = []; | |||
this.onconnect(); | |||
}; | |||
this.sock.onclose = () => { | |||
console.error("Connection closed."); | |||
this.ready = false; | |||
this.sock = null; | |||
for (let i = 0; i < this.ackQ.length; ++i) { | |||
let [ resolve, reject ] = this.ackQ[i]; | |||
reject("Lost connection."); | |||
} | |||
this.ackQ = []; | |||
this.ondisconnect(); | |||
setTimeout(() => this.createWS(), 1000); | |||
}; | |||
this.sock.onerror = evt => { | |||
console.error("WebSocket error:", evt); | |||
}; | |||
this.sock.onmessage = evt => { | |||
let obj = JSON.parse(evt.data); | |||
if (obj.type == "ack") { | |||
let [ resolve, reject ] = this.ackQ[obj.ackID]; | |||
if (obj.error) | |||
reject(obj.error); | |||
else | |||
resolve(obj); | |||
} else { | |||
this.onmessage(obj); | |||
} | |||
}; | |||
} | |||
reallySend(obj, resolve, reject) { | |||
let ackID = this.ackID++; | |||
this.ackQ[ackID] = [ resolve, reject ]; | |||
obj.ackID = ackID; | |||
this.sock.send(JSON.stringify(obj)); | |||
} | |||
} |
@@ -0,0 +1,2 @@ | |||
db.json | |||
node_modules |
@@ -0,0 +1,65 @@ | |||
let http = require("http"); | |||
let pathlib = require("path"); | |||
let fs = require("fs"); | |||
module.exports = function createFileServer(webroot) { | |||
let server = http.createServer((req, res) => { | |||
console.log(req.method+" "+req.url); | |||
if (req.method != "HEAD" && req.method != "GET") { | |||
req.writeHead(405); | |||
req.end(`Unexpected method: ${req.method}`); | |||
return; | |||
} | |||
let path = pathlib.normalize(pathlib.join(webroot, req.url)); | |||
if (!path.startsWith(webroot)) { | |||
res.writeHead(404); | |||
res.end(`404 Not Found: ${req.url}`); | |||
return; | |||
} | |||
(function read(path) { | |||
let rs = fs.createReadStream(path); | |||
rs.once("error", err => { | |||
if (err.code == "ENOENT") { | |||
res.writeHead(404); | |||
res.end(`404 Not Found: ${req.url}`); | |||
} else if (err.code == "EISDIR") { | |||
if (!req.url.endsWith("/")) { | |||
res.writeHead(302, { | |||
location: `${req.url}/`, | |||
}); | |||
res.end(`302 Found: ${req.url}/`); | |||
} else { | |||
read(path+"/index.html"); | |||
} | |||
} else { | |||
console.warn(`Failed to open ${path}`); | |||
console.trace(err); | |||
res.writeHead(500); | |||
res.end("500 Internal Server Error"); | |||
} | |||
}); | |||
rs.once("open", () => { | |||
let ctype = null; | |||
if (path.endsWith(".html")) | |||
ctype = "text/html"; | |||
else if (path.endsWith(".js")) | |||
ctype = "application/javascript"; | |||
else if (path.endsWith(".css")) | |||
ctype = "text/css"; | |||
if (ctype) | |||
res.writeHead(200, { "Content-Type": ctype }); | |||
else | |||
res.writeHead(200); | |||
rs.pipe(res); | |||
}); | |||
})(path); | |||
}); | |||
return server; | |||
} |
@@ -0,0 +1,33 @@ | |||
{ | |||
"name": "server", | |||
"version": "1.0.0", | |||
"lockfileVersion": 1, | |||
"requires": true, | |||
"dependencies": { | |||
"async-limiter": { | |||
"version": "1.0.0", | |||
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", | |||
"integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" | |||
}, | |||
"dev-refresh": { | |||
"version": "file:../../../../dev/dev-refresh", | |||
"dev": true, | |||
"requires": { | |||
"colors": "^1.3.3", | |||
"minimist": "^1.2.0", | |||
"node-watch": "^0.6.2", | |||
"open": "^6.4.0", | |||
"webframe": "^0.9.0", | |||
"ws": "^7.1.0" | |||
} | |||
}, | |||
"ws": { | |||
"version": "7.0.1", | |||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.0.1.tgz", | |||
"integrity": "sha512-ILHfMbuqLJvnSgYXLgy4kMntroJpe8hT41dOVWM8bxRuw6TK4mgMp9VJUNsZTEc5Bh+Mbs0DJT4M0N+wBG9l9A==", | |||
"requires": { | |||
"async-limiter": "^1.0.0" | |||
} | |||
} | |||
} | |||
} |
@@ -0,0 +1,18 @@ | |||
{ | |||
"name": "server", | |||
"version": "1.0.0", | |||
"description": "", | |||
"main": "index.js", | |||
"scripts": { | |||
"start": "node server.js'", | |||
"dev": "dev-refresh *.js --cmd 'KEY=test PORT=8081 node server.js'" | |||
}, | |||
"author": "", | |||
"license": "ISC", | |||
"dependencies": { | |||
"ws": "^7.0.1" | |||
}, | |||
"devDependencies": { | |||
"dev-refresh": "file:../../../../dev/dev-refresh" | |||
} | |||
} |
@@ -0,0 +1,129 @@ | |||
let WebSocket = require("ws"); | |||
let createFileServer = require("./filesrv"); | |||
let fs = require("fs"); | |||
let datafile = "db.json"; | |||
let scratchfile = ".db.scratch"; | |||
let key = process.env.KEY; | |||
if (!key) { | |||
console.log("Environmnt variable 'KEY' not set."); | |||
process.exit(1); | |||
} | |||
let server = createFileServer("../client/public"); | |||
let wss = new WebSocket.Server({ server }); | |||
function deserialize(defaults) { | |||
try { | |||
let json = fs.readFileSync(datafile); | |||
return JSON.parse(json); | |||
} catch (err) { | |||
return defaults; | |||
} | |||
} | |||
function serialize(data) { | |||
fs.writeFileSync(scratchfile, JSON.stringify(data)); | |||
fs.renameSync(scratchfile, datafile); | |||
} | |||
let data = deserialize({ | |||
items: { "0": "foo", "2": "bar" }, | |||
words: {}, | |||
nextIdx: 3, | |||
}); | |||
let conns = []; | |||
let connID = 0; | |||
function ack(conn, msg, reply = {}) { | |||
reply.type = "ack"; | |||
reply.ackID = msg.ackID; | |||
conn.sock.send(JSON.stringify(reply)); | |||
} | |||
function broadcast(from, obj) { | |||
for (let conn of conns) { | |||
if (!conn) continue; | |||
if (conn != from) | |||
conn.sock.send(JSON.stringify(obj)); | |||
} | |||
} | |||
function onMessage(conn, msg) { | |||
if (msg.type == "init") { | |||
if (!msg.key || msg.key != key) | |||
return ack(conn, msg, { error: "Invalid key." }); | |||
ack(conn, msg, { data }); | |||
conn.ready = true; | |||
conns[conn.id] = conn; | |||
return; | |||
} | |||
if (!conn.ready) | |||
return ack(conn, msg, { error: "Not initialized." }); | |||
switch (msg.type) { | |||
case "item-add": | |||
if (typeof msg.content != "string") | |||
return ack(conn, msg, { error: "Missing content." }); | |||
let content = msg.content.toLowerCase(); | |||
let index = data.nextIdx.toString(); | |||
data.nextIdx = (data.nextIdx + 1) % 2048; | |||
data.items[index] = content; | |||
if (data.words[content] == null) | |||
data.words[content] = 0; | |||
data.words[content] += 1; | |||
ack(conn, msg, { index }); | |||
broadcast(conn, { type: "item-add", content, index }); | |||
serialize(data); | |||
break; | |||
case "item-del": | |||
if (typeof msg.index != "string") | |||
return ack(conn, msg, { error: "Missing index." }); | |||
delete data.items[msg.index]; | |||
ack(conn, msg); | |||
broadcast(conn, { type: "item-del", index: msg.index }); | |||
serialize(data); | |||
break; | |||
} | |||
} | |||
wss.on("connection", sock => { | |||
let conn = { | |||
sock, | |||
ready: false, | |||
id: connID, | |||
}; | |||
connID = (connID + 1) % 2048; | |||
sock.on("close", () => { | |||
delete conns[conn.id]; | |||
}); | |||
sock.on("message", msg => { | |||
let obj; | |||
try { | |||
obj = JSON.parse(msg); | |||
} catch (err) { | |||
console.error("Client message has invalid JSON:", err); | |||
console.error(err); | |||
return; | |||
} | |||
onMessage(conn, obj); | |||
}); | |||
}); | |||
let port = process.env.PORT ? parseInt(process.env.PORT) : 8080; | |||
server.listen(port); | |||
console.log(`Listening on port ${port}.`); |