diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6943e92 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.github +node_modules +npm-debug.log* +coverage +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..909fe3a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:24-alpine AS deps-server +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +FROM node:24-alpine AS deps-test +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM node:24-alpine AS server +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps-server /app/node_modules ./node_modules +COPY --chown=node:node server.coffee ./ +COPY --chown=node:node config.cson ./ +COPY --chown=node:node lib ./lib +COPY --chown=node:node web ./web +USER node +EXPOSE 8080 +EXPOSE 9910 +EXPOSE 9909/udp +CMD ["node", "-r", "coffeescript/register", "server.coffee"] + +FROM node:24-alpine AS test +WORKDIR /app +ENV NODE_ENV=test +COPY --from=deps-test /app/node_modules ./node_modules +COPY . . +CMD ["npm", "test"] diff --git a/Makefile b/Makefile index 6ecde2e..6d837b9 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ build: npm rebuild run-develop: - supervisor server.coffee + npm run dev clean: find node_modules -name .gitignore -exec rm -v {} \; diff --git a/README.md b/README.md index 9894def..36248d7 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,16 @@ UDP and WebSocket packet abstraction for multiple DMX controllers. ## Installing -First, get Node.js and npm. On Debian and related distros you most likely -want the `nodejs-legacy` and `npm` packages. +Use Node.js 24 LTS and npm. + +With nvm: + + nvm install 24 + nvm use 24 Fetch the server's dependencies using npm. - npm install + npm ci ## Configuring @@ -21,3 +25,26 @@ unconfigured. npm start +## Development + + npm run dev + +## Client examples + +Light client examples and run instructions are in [examples/README.md](examples/README.md). + +## Docker + +Build server image: + + docker build --target server -t effectserver:server . + +Run server container: + + docker run --rm --read-only --security-opt no-new-privileges --cap-drop ALL -p 8080:8080 -p 9910:9910 -p 9909:9909/udp effectserver:server + +Build and run test target: + + docker build --target test -t effectserver:test . + docker run --rm effectserver:test + diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c4f8d01 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,53 @@ +# Examples + +This folder contains example clients that communicate with `effectserver`. + +## Prerequisites + +From the project root: + + npm ci + npm start + +By default examples target: + +- UDP host `127.0.0.1` +- UDP port `9909` +- WebSocket port `9910` + +You can override UDP destination with environment variables: + + export EFFECTSERVER_HOST=127.0.0.1 + export EFFECTSERVER_UDP_PORT=9909 + +## CoffeeScript examples + +Run from the project root with Node.js + CoffeeScript register hook: + + node -r coffeescript/register examples/raw.coffee + node -r coffeescript/register examples/client.coffee + node -r coffeescript/register examples/keyboard.coffee + node -r coffeescript/register examples/cli-dashboard.coffee + +`cli-dashboard.coffee` also accepts optional positional arguments: + + node -r coffeescript/register examples/cli-dashboard.coffee + +## Python examples + +Python 3 is required. + + python3 examples/python.py + python3 examples/instanssi.py + +`instanssi.py` runs an infinite loop until interrupted: + + Ctrl+C + +## WebSocket browser example + +1. Start server (`npm start`). +2. Open `examples/websocket.html` in a browser. +3. Optional query params: + + examples/websocket.html?host=127.0.0.1&port=9910 diff --git a/examples/cli-dashboard.coffee b/examples/cli-dashboard.coffee index 8ca0b0b..257341a 100644 --- a/examples/cli-dashboard.coffee +++ b/examples/cli-dashboard.coffee @@ -7,10 +7,11 @@ dgram = require('dgram') - -tty = require('tty') +readline = require('readline') process.stdin.resume() -tty.setRawMode(true) +readline.emitKeypressEvents(process.stdin) +if process.stdin.isTTY + process.stdin.setRawMode(true) randInt = (min, max) -> @@ -46,7 +47,7 @@ class EffectClient @packet.push b send: -> - buf = new Buffer @packet + buf = Buffer.from @packet @client.send buf, 0, buf.length, @port, @ip @reset() @@ -118,8 +119,8 @@ main = -> min: 0 max: 35 nick: process.env.TAG or "epe" - ip: process.argv[2] or "172.18.12.2" - port: 9909 + ip: process.argv[2] or process.env.EFFECTSERVER_HOST or "127.0.0.1" + port: Number(process.argv[3] or process.env.EFFECTSERVER_UDP_PORT or 9909) player = new Player client diff --git a/examples/client.coffee b/examples/client.coffee index 04bb9b0..e110d95 100644 --- a/examples/client.coffee +++ b/examples/client.coffee @@ -35,7 +35,7 @@ class EffectPlayer @position = @program.length @udp = dgram.createSocket "udp4" - @buf = new Buffer [ + @buf = Buffer.from [ 1 # spec version , 1 # Type. 1 means light , 0 # light id @@ -54,7 +54,7 @@ class EffectPlayer @buf[4] = color.r @buf[5] = color.g @buf[6] = color.b - @udp.send @buf, 0, @buf.length, 9909, "172.18.12.2" + @udp.send @buf, 0, @buf.length, @port, @host loop: => @@ -87,8 +87,8 @@ if require.main is module # recorder.pause 1000 player = new EffectPlayer - host: "127.0.0.1" - port: 9909 + host: process.env.EFFECTSERVER_HOST or "127.0.0.1" + port: Number(process.env.EFFECTSERVER_UDP_PORT or 9909) program: recorder.program player.loop() diff --git a/examples/instanssi.py b/examples/instanssi.py index e5887cb..aadc990 100644 --- a/examples/instanssi.py +++ b/examples/instanssi.py @@ -1,8 +1,9 @@ -#!/usr/bin/python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- import socket import time +import os class Instanssi(object): @@ -52,7 +53,11 @@ def send(self): -valot = Instanssi("instanssilainen", "172.18.12.2", 9909) +host = os.environ.get("EFFECTSERVER_HOST", "127.0.0.1") +port = int(os.environ.get("EFFECTSERVER_UDP_PORT", "9909")) +nick = os.environ.get("TAG", "instanssilainen") + +valot = Instanssi(nick, host, port) # Sinistä kansalle. Aseta kaikki valot sinisiksi @@ -78,6 +83,6 @@ def send(self): i += 1 i = i % 36 - print i + print(i) diff --git a/examples/keyboard.coffee b/examples/keyboard.coffee index c1cacd6..27639f1 100644 --- a/examples/keyboard.coffee +++ b/examples/keyboard.coffee @@ -3,13 +3,13 @@ # Execute with Node.js and CoffeeScript ### - - -tty = require('tty') +readline = require('readline') dgram = require('dgram') process.stdin.resume() -tty.setRawMode(true) +readline.emitKeypressEvents(process.stdin) +if process.stdin.isTTY + process.stdin.setRawMode(true) class RGB @@ -34,7 +34,7 @@ class RGB @packet.push b send: -> - buf = new Buffer @packet + buf = Buffer.from @packet console.log buf @client.send buf, 0, buf.length, @port, @ip @reset() @@ -71,7 +71,11 @@ mapping = n: 27 m: 28 -rgb = new RGB "esa", "172.18.12.2", 9909 +rgb = new RGB( + process.env.TAG or "esa" + process.env.EFFECTSERVER_HOST or "127.0.0.1" + Number(process.env.EFFECTSERVER_UDP_PORT or 9909) +) rgb.set 1, 255, 0, 0 console.log rgb.packet diff --git a/examples/python.py b/examples/python.py index bd7839d..f497ef0 100644 --- a/examples/python.py +++ b/examples/python.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Yksinkertainen UDP-paketti Instanssin valojen ohjailuun Pythonilla. @@ -21,6 +21,7 @@ import socket +import os udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -58,5 +59,8 @@ ]) -udp_socket.sendto(packet, ('172.18.12.2', 9909)) +host = os.environ.get("EFFECTSERVER_HOST", "127.0.0.1") +port = int(os.environ.get("EFFECTSERVER_UDP_PORT", "9909")) + +udp_socket.sendto(packet, (host, port)) diff --git a/examples/raw.coffee b/examples/raw.coffee index fe924d0..3130d3e 100644 --- a/examples/raw.coffee +++ b/examples/raw.coffee @@ -2,8 +2,10 @@ dgram = require('dgram') client = dgram.createSocket("udp4") +host = process.env.EFFECTSERVER_HOST or "127.0.0.1" +port = Number(process.env.EFFECTSERVER_UDP_PORT or 9909) -message = new Buffer [ +message = Buffer.from [ 1 # spec version , 1 # Type. 1 means light @@ -29,7 +31,7 @@ message = new Buffer [ ] -client.send message, 0, message.length, 9909, "172.18.12.2", (err, bytes) -> +client.send message, 0, message.length, port, host, (err, bytes) -> console.log "sent" console.log err, bytes diff --git a/examples/websocket.html b/examples/websocket.html index 43986a2..15172f4 100644 --- a/examples/websocket.html +++ b/examples/websocket.html @@ -8,8 +8,9 @@ + + + + + + diff --git a/web/webserver.coffee b/web/webserver.coffee index f0f3dff..4536185 100644 --- a/web/webserver.coffee +++ b/web/webserver.coffee @@ -1,74 +1,82 @@ fs = require "fs" +http = require "http" +path = require "path" express = require "express" hbs = require "hbs" -piler = require "piler" +CoffeeScript = require "coffeescript" +stylus = require "stylus" +{Server} = require "socket.io" -app = express.createServer() +app = express() +server = http.createServer app -io = require("socket.io").listen app -io.set "log level", 0 - - -css = piler.createCSSManager() -js = piler.createJSManager() +io = new Server server rootDir = __dirname +clientDir = path.join rootDir, "client" +clientTmplDir = path.join rootDir, "views", "client" templateCache = {} -app.configure "production", -> +if process.env.NODE_ENV is "production" console.log "Production mode detected!" for filename in fs.readdirSync clientTmplDir - templateCache[filename] = fs.readFileSync(clientTmplDir + filename).toString() - -app.configure -> - - console.log "Production mode detected!" - - # We want use same templating engine for the client and the server. We have - # to workarount bit so that we can get uncompiled Handlebars templates - # through Handlebars - hbs.registerHelper "clientTemplate", (name) -> - source = templateCache[name + ".hbs"] - if not source - # Synchronous file reading is bad, but it doesn't really matter here since - # we can cache it in production - source = fs.readFileSync rootDir + "/views/client/#{ name }.hbs" + templateCache[filename] = fs.readFileSync(path.join(clientTmplDir, filename)).toString() - "\n" +console.log "Production mode detected!" -hbs.registerHelper "renderScriptTags", (pile) -> - js.renderTags pile -hbs.registerHelper "renderStyleTags", (pile) -> - css.renderTags pile +# We want use same templating engine for the client and the server. We have +# to workarount bit so that we can get uncompiled Handlebars templates +# through Handlebars +hbs.registerHelper "clientTemplate", (name) -> + source = templateCache[name + ".hbs"] + if not source + # Synchronous file reading is bad, but it doesn't really matter here since + # we can cache it in production + source = fs.readFileSync(path.join(rootDir, "views", "client", "#{ name }.hbs"), "utf8") -css.bind app -js.bind app + "\n" +app.set "views", path.join(rootDir, "views") +app.set "view engine", "hbs" -app.configure -> - app.set "views", __dirname + "/views" - app.set 'view engine', 'hbs' +app.use "/client/vendor", express.static(path.join(clientDir, "vendor")) - js.addFile __dirname + "/client/vendor/jquery.js" - js.addFile __dirname + "/client/vendor/handlebars.js" - js.addFile __dirname + "/client/vendor/underscore.js" - js.addFile __dirname + "/client/vendor/backbone.js" - js.addFile __dirname + "/client/helpers.coffee" - js.addFile __dirname + "/client/main.coffee" - css.addFile __dirname + "/client/style.styl" +app.get "/client/:script.js", (req, res, next) -> + sourcePath = path.join clientDir, "#{ req.params.script }.coffee" + fs.readFile sourcePath, "utf8", (err, source) -> + if err + return next() if err.code is "ENOENT" + return next err -app.configure "development", -> - js.liveUpdate css, io + try + compiled = CoffeeScript.compile source, + bare: true + res.type "application/javascript" + res.send compiled + catch compileErr + next compileErr +app.get "/client/style.css", (req, res, next) -> + sourcePath = path.join clientDir, "style.styl" + fs.readFile sourcePath, "utf8", (err, source) -> + if err + return next err + stylus.render source, { filename: sourcePath }, (renderErr, css) -> + return next renderErr if renderErr + res.type "text/css" + res.send css app.get "/", (req, res) -> res.render "index", app.config.servers +webserver = app +webserver.listen = (port, cb) -> + server.listen port, cb -exports.webserver = app +exports.webserver = webserver exports.websocket = io