Websockets in MemCP: Difference between revisions

From MemCP
Jump to navigation Jump to search
(Created page with "In MemCP, you can call <code>(set send ((res "websocket") onReceiveFunction onCloseFunction))</code>to upgrade a http session into a websocket (<code>res</code> is the response object from your http handler, see serve). The <code>onReceiveFunction</code> will take message as it's parameter. <code>onCloseFunction</code> will be called when the socket is closed. You can call <code>(send 1 "my message")</code> to send a string to th...")
 
(Refresh MemCP documentation: accuracy, operational guidance, performance profile and maintained API reference)
 
(5 intermediate revisions by 2 users not shown)
Line 1: Line 1:
In MemCP, you can call <code>(set send ((res "websocket") onReceiveFunction onCloseFunction))</code>to upgrade a http session into a websocket (<code>res</code> is the response object from your http handler, see [[In-Database WebApps and REST Services|serve]]). The <code>onReceiveFunction</code> will take message as it's parameter. <code>onCloseFunction</code> will be called when the socket is closed. You can call <code>(send 1 "my message")</code> to send a string to the client.
<!-- Copyright (C) 2026 Carl-Philip Haensch -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
= Websockets in MemCP =


Here's an example application of a publish subscribe network for a game where player positions are streamed to other players:
An embedded HTTP handler can upgrade a request through the response object's WebSocket facility and receive messages through a callback. The <code>apps/minigame.scm</code> example in the repository demonstrates the current API and routing pattern.
(print "")
 
(print "Welcome to our Worldserver")
WebSockets keep one bidirectional connection open for live dashboards, notifications, collaborative state, games, or change feeds. They are a delivery channel, not a durable message queue: reconnect, replay, acknowledgement, ordering, and missed-message semantics belong to the application.
(print "")
 
(print "set MEMCP and PORT in your environment")
== Server-side upgrade ==
 
/* this can be overhooked */
<pre>
(define http_handler (lambda (req res) (begin
(set send ((res "websocket")
        (print "request " req)
(lambda (message) (print "message: " message))
        ((res "header") "Content-Type" "text/plain")
(lambda () (print "connection closed"))))
        ((res "status") 404)
(send 1 "Hello from MemCP")
        ((res "println") "404 not found")
</pre>
)))
 
The numeric first argument is the WebSocket frame opcode used by the current response API. Keep the sending function only for the connection lifetime.
(set MEMCP (env "MEMCP" "../../memcp"))
 
(import (concat MEMCP "/lib/sql.scm"))
A browser client can connect to the routed endpoint with the standard API:
 
/* load config */
<pre>
(set CONF (env "CONF" "../out/conf.json"))
const socket = new WebSocket("wss://example.test/minigame/ws");
(set conf (json_decode (load CONF)))
socket.addEventListener("open", () => socket.send(JSON.stringify({type: "hello"})));
(set macKey (((conf "Types") "Password") "encryptKey"))
socket.addEventListener("message", event => console.log(JSON.parse(event.data)));
</pre>
/* init database */
 
(settings "DefaultEngine" "sloppy")
== Complete routed example ==
(createdatabase "hardlife" true)
 
(define resultrow print)
The repository's <code>apps/minigame.scm</code> shows the complete routing pattern: preserve the previous handler, serve an HTML/JavaScript client below one prefix, and upgrade only the WebSocket path.
(define session (newsession))
 
(eval (parse_sql "hardlife" "CREATE TABLE IF NOT EXISTS avatar(ID int, x double, y double, z double, r double, location int, UNIQUE KEY PRIMARY(ID))"))
<pre>
(eval (parse_sql "hardlife" "CREATE TABLE IF NOT EXISTS avatarListen(ID int, container int, UNIQUE KEY PRIMARY(ID, container))"))
(define http_handler (begin
(eval (parse_sql "hardlife" "CREATE TABLE IF NOT EXISTS avatarDirty(ID int, other int, UNIQUE KEY PRIMARY(ID, other))"))
(set old_handler http_handler)
(set update_location_prepared (parse_sql "hardlife" "INSERT INTO avatar(ID, x, y, z, r, location) VALUES (@avatar, @x, @y, @z, @r, @l) ON DUPLICATE KEY UPDATE x = @x, y = @y, z = @z, r = @r, location = @l"))
(lambda (req res) (begin
(set update_dirtylist_prepared (parse_sql "hardlife" "INSERT IGNORE INTO avatarDirty(ID, other) SELECT ID, @avatar FROM avatarListen WHERE ID != @avatar AND container = @l"))
(match (req "path")
(set clear_listen_prepared (parse_sql "hardlife" "DELETE FROM avatarListen WHERE ID = @avatar"))
"/minigame/ws" (begin
(set insert_listen_prepared (parse_sql "hardlife" "INSERT IGNORE INTO avatarListen(ID, container) VALUES (@avatar, @l2)"))
(set send ((res "websocket")
(set get_listen_prepared (parse_sql "hardlife" "SELECT avatar.ID, avatar.x, avatar.y, avatar.z, avatar.r, avatar.location AS l FROM avatarDirty, avatar WHERE avatar.ID = avatarDirty.other AND avatarDirty.ID = @avatar"))
(lambda (message) (begin
(set clear_dirty_prepared (parse_sql "hardlife" "DELETE FROM avatarDirty WHERE ID = @avatar"))
(print "message: " message)
(send 1 (concat "echo: " message))))))
(define http_handler (begin
(send 1 "Hello from MemCP"))
(set old_handler http_handler)
(regex "^/minigame/(.*)$" path asset) (begin
(lambda (req res) (begin
((res "header") "Content-Type" "text/plain")
/* hooked our additional paths to it */
((res "status") 200)
(match (req "path")
((res "print") "WebSocket client assets belong here"))
"/" (begin
(old_handler req res))))))
(set session (newsession))
</pre>
(set send ((res "websocket") (lambda (msg) (begin
 
(set msg (json_decode msg))
When this module is imported by <code>lib/main.scm</code>, the existing server uses the wrapped handler. A standalone application can call <code>serve</code> explicitly. Keep the receive callback and its connection-local <code>send</code> closure together; never store that closure after close.
(if
 
(and (has_assoc? msg "l") (session "avatar")) (begin
Prefer JSON messages with an explicit type and version. Validate the decoded shape before reading fields, cap message size, and define how the client resubscribes or resumes after reconnect.
/* position report */
 
(if (and (session "l") (not (equal? (msg "l") (session "l"))))
== Database-backed publish/subscribe ==
(eval update_dirtylist_prepared)) /* update dirtylist if we leave a room */
 
(session "x" (msg "x"))
Prepare fixed queries during module startup, create a session per connection, and bind authenticated identity and subscription state there. A receive callback can update state atomically; query results can be emitted through <code>send</code>. On close, release subscriptions and other references so they do not keep sessions or sending functions alive.
(session "y" (msg "y"))
 
(session "z" (msg "z"))
For fan-out, use bounded per-client queues and a policy for slow consumers (drop/coalesce messages or disconnect). A database table can persist events for replay, but a <code>memory</code> or <code>cache</code> table cannot provide durable delivery after restart.
(session "r" (msg "r"))
 
(session "l" (msg "l"))
The earlier hardlife.io example used this structure at larger scale: prepare position-update, listener-membership and dirty-list queries once; authenticate a player handle with a message authentication code; keep the connection's player/location state in a session; mark affected listeners dirty after a position change; send only changed avatars; and remove listener state on disconnect. The complete historical listing coupled credentials, global <code>sloppy</code> defaults and application-specific tables, so reproducing it as a copy-and-paste server would be unsafe. The pipeline remains a useful design example.
(eval update_location_prepared)
 
(eval update_dirtylist_prepared)
Hosting the handler beside the database removes an application/database socket hop and repeated parsing for prepared shapes. That is a performance opportunity, not proof that every embedded WebSocket service is faster than a separate Node.js or Go service; benchmark the authenticated end-to-end path and account for the shared failure domain.
/* flush all dirtyflags of this avatar
 
/*(set resultrow (lambda (item)
== Production considerations ==
(send 1 json_encode_assoc '(
 
))*/
Authenticate the upgrade, validate browser Origin, limit message and queue sizes, apply backpressure, and remove subscriptions when the connection closes. A slow client must not create an unbounded in-memory backlog. Long database operations should observe cancellation. Use TLS at a trusted proxy or transport layer and do not expose default credentials.
 
/* send back dirty items */
Storage ENGINE choice is independent of WebSocket delivery: <code>sloppy</code> can lose recent writes, and <code>memory</code>/<code>cache</code> are not durable. See [[In-Database WebApps and REST Services]] and [[Persistency and Performance Guarantees]].
(set resultrow (lambda (item) (begin
(send 1 (json_encode_assoc item))
)))
(eval get_listen_prepared)
(eval clear_dirty_prepared)
)
(and (has_assoc? msg "listen") (session "avatar")) (begin
/* update listener list */
(eval clear_listen_prepared)
(map (msg "listen") (lambda (container) (begin
(session "l2" container)
(eval insert_listen_prepared)
)))
)
(has_assoc? msg "authenticate") (begin
(if (equal? (bin2hex (password (concat "a:" (msg "authenticate") ":" macKey))) (msg "mac")) (begin
(print "authenicated avatar " (msg "authenticate"))
(session "avatar" (msg "authenticate"))
) (send 1 (json_encode_assoc '("error" "could not authenticate to avatar"))))
)
(print "unhandled message: " (json_encode msg))
)
)) (lambda () (print "player has left"))))
(send 1 "\"Hello World from server\"")
)
/* default */
(old_handler req res))
))
))
/* '''read'''  http_handler fresh from the '''environment''' */
(set port (env "PORT" "8001"))
(serve port (lambda (req res) (http_handler req res)))
(print "listening on <nowiki>http://localhost</nowiki>:" port)

Latest revision as of 12:14, 28 August 2026

Websockets in MemCP

An embedded HTTP handler can upgrade a request through the response object's WebSocket facility and receive messages through a callback. The apps/minigame.scm example in the repository demonstrates the current API and routing pattern.

WebSockets keep one bidirectional connection open for live dashboards, notifications, collaborative state, games, or change feeds. They are a delivery channel, not a durable message queue: reconnect, replay, acknowledgement, ordering, and missed-message semantics belong to the application.

Server-side upgrade

(set send ((res "websocket")
	(lambda (message) (print "message: " message))
	(lambda () (print "connection closed"))))
(send 1 "Hello from MemCP")

The numeric first argument is the WebSocket frame opcode used by the current response API. Keep the sending function only for the connection lifetime.

A browser client can connect to the routed endpoint with the standard API:

const socket = new WebSocket("wss://example.test/minigame/ws");
socket.addEventListener("open", () => socket.send(JSON.stringify({type: "hello"})));
socket.addEventListener("message", event => console.log(JSON.parse(event.data)));

Complete routed example

The repository's apps/minigame.scm shows the complete routing pattern: preserve the previous handler, serve an HTML/JavaScript client below one prefix, and upgrade only the WebSocket path.

(define http_handler (begin
	(set old_handler http_handler)
	(lambda (req res) (begin
		(match (req "path")
			"/minigame/ws" (begin
				(set send ((res "websocket")
					(lambda (message) (begin
						(print "message: " message)
						(send 1 (concat "echo: " message))))))
				(send 1 "Hello from MemCP"))
			(regex "^/minigame/(.*)$" path asset) (begin
				((res "header") "Content-Type" "text/plain")
				((res "status") 200)
				((res "print") "WebSocket client assets belong here"))
			(old_handler req res))))))

When this module is imported by lib/main.scm, the existing server uses the wrapped handler. A standalone application can call serve explicitly. Keep the receive callback and its connection-local send closure together; never store that closure after close.

Prefer JSON messages with an explicit type and version. Validate the decoded shape before reading fields, cap message size, and define how the client resubscribes or resumes after reconnect.

Database-backed publish/subscribe

Prepare fixed queries during module startup, create a session per connection, and bind authenticated identity and subscription state there. A receive callback can update state atomically; query results can be emitted through send. On close, release subscriptions and other references so they do not keep sessions or sending functions alive.

For fan-out, use bounded per-client queues and a policy for slow consumers (drop/coalesce messages or disconnect). A database table can persist events for replay, but a memory or cache table cannot provide durable delivery after restart.

The earlier hardlife.io example used this structure at larger scale: prepare position-update, listener-membership and dirty-list queries once; authenticate a player handle with a message authentication code; keep the connection's player/location state in a session; mark affected listeners dirty after a position change; send only changed avatars; and remove listener state on disconnect. The complete historical listing coupled credentials, global sloppy defaults and application-specific tables, so reproducing it as a copy-and-paste server would be unsafe. The pipeline remains a useful design example.

Hosting the handler beside the database removes an application/database socket hop and repeated parsing for prepared shapes. That is a performance opportunity, not proof that every embedded WebSocket service is faster than a separate Node.js or Go service; benchmark the authenticated end-to-end path and account for the shared failure domain.

Production considerations

Authenticate the upgrade, validate browser Origin, limit message and queue sizes, apply backpressure, and remove subscriptions when the connection closes. A slow client must not create an unbounded in-memory backlog. Long database operations should observe cancellation. Use TLS at a trusted proxy or transport layer and do not expose default credentials.

Storage ENGINE choice is independent of WebSocket delivery: sloppy can lose recent writes, and memory/cache are not durable. See In-Database WebApps and REST Services and Persistency and Performance Guarantees.