Websockets in MemCP: Difference between revisions

From MemCP
Jump to navigation Jump to search
No edit summary
(Refresh MemCP documentation: accuracy, operational guidance, performance profile and maintained API reference)
 
(4 intermediate revisions by 2 users not shown)
Line 1: Line 1:
Websockets are an extension to the HTTP protocol where an open connection can be used as a TCP socket to communicate between client and server two-directional and in realtime.
<!-- Copyright (C) 2026 Carl-Philip Haensch -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
= Websockets in MemCP =


The advantage of MemCP is that you can define your websocket services inside the database itself (loaded as a module via <code>import</code>)
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.


=== How to use websockets ===
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.
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. You can of course use <code>json_encode</code>, <code>json_encode_assoc</code> and <code>json_decode</code> to transfer data between JavaScript and your database.


=== Example Code ===
== Server-side upgrade ==
(define http_handler (lambda (req res) (begin
        (set send ((res "websocket") (lambda (msg) (begin
                (print "I received: " msg)
                (send 1 (concat "I received: " msg))
        ) (lambda () (begin
                (print "user has left")
        ))
        (send 1 "Welcome to our server")
)))


=== Complex Example ===
<pre>
Here's an example application of a publish subscribe network for a game where player positions are streamed to other players. It basically creates 9 prepared SQL statements in advance and calls them during the lifetime of a websocket. The parameters are passed via the <code>session</code> object. Every user is authenticated by a MAC (message authentication code) so the client proves that he is really the owner of that player handle.
(set send ((res "websocket")
(print "")
(lambda (message) (print "message: " message))
(print "Welcome to our Worldserver")
(lambda () (print "connection closed"))))
(print "")
(send 1 "Hello from MemCP")
(print "set MEMCP and PORT in your environment")
</pre>
 
/* this can be overhooked */
The numeric first argument is the WebSocket frame opcode used by the current response API. Keep the sending function only for the connection lifetime.
(define http_handler (lambda (req res) (begin
 
        (print "request " req)
A browser client can connect to the routed endpoint with the standard API:
        ((res "header") "Content-Type" "text/plain")
 
        ((res "status") 404)
<pre>
        ((res "println") "404 not found")
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)));
(set MEMCP (env "MEMCP" "../../memcp"))
</pre>
(import (concat MEMCP "/lib/sql.scm"))
 
== Complete routed example ==
/* load config */
 
(set CONF (env "CONF" "../out/conf.json"))
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.
(set conf (json_decode (load CONF)))
 
(set macKey (((conf "Types") "Password") "encryptKey"))
<pre>
(define http_handler (begin
/* init database */
(set old_handler http_handler)
(settings "DefaultEngine" "sloppy")
(lambda (req res) (begin
(createdatabase "hardlife" true)
(match (req "path")
(define resultrow print)
"/minigame/ws" (begin
(define session (newsession))
(set send ((res "websocket")
(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))"))
(lambda (message) (begin
(eval (parse_sql "hardlife" "CREATE TABLE IF NOT EXISTS avatarListen(ID int, container int, UNIQUE KEY PRIMARY(ID, container))"))
(print "message: " message)
(eval (parse_sql "hardlife" "CREATE TABLE IF NOT EXISTS avatarDirty(ID int, other int, UNIQUE KEY PRIMARY(ID, other))"))
(send 1 (concat "echo: " message))))))
(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"))
(send 1 "Hello from MemCP"))
(set update_dirtylist_prepared (parse_sql "hardlife" "INSERT IGNORE INTO avatarDirty(ID, other) SELECT ID, @avatar FROM avatarListen WHERE ID != @avatar AND container = @l"))
(regex "^/minigame/(.*)$" path asset) (begin
(set clear_listen_prepared (parse_sql "hardlife" "DELETE FROM avatarListen WHERE ID = @avatar"))
((res "header") "Content-Type" "text/plain")
(set insert_listen_prepared (parse_sql "hardlife" "INSERT IGNORE INTO avatarListen(ID, container) VALUES (@avatar, @l2)"))
((res "status") 200)
(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"))
((res "print") "WebSocket client assets belong here"))
(set clear_dirty_prepared (parse_sql "hardlife" "DELETE FROM avatarDirty WHERE ID = @avatar"))
(old_handler req res))))))
</pre>
(define http_handler (begin
 
(set old_handler http_handler)
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.
(lambda (req res) (begin
 
/* hooked our additional paths to it */
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.
(match (req "path")
 
"/" (begin
== Database-backed publish/subscribe ==
(set session (newsession))
 
(set send ((res "websocket") (lambda (msg) (begin
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.
(set msg (json_decode msg))
 
(if
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.
(and (has_assoc? msg "l") (session "avatar")) (begin
 
/* position report */
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.
(if (and (session "l") (not (equal? (msg "l") (session "l"))))
 
(eval update_dirtylist_prepared)) /* update dirtylist if we leave a room */
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.
(session "x" (msg "x"))
 
(session "y" (msg "y"))
== Production considerations ==
(session "z" (msg "z"))
 
(session "r" (msg "r"))
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.
(session "l" (msg "l"))
 
(eval update_location_prepared)
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]].
(eval update_dirtylist_prepared)
/* send back dirty items */
(set resultrow (lambda (item) (begin
(send 1 (json_encode_assoc item))
)))
(eval get_listen_prepared)
/* flush all dirtyflags of this avatar
(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)
You can execute this code via <code>memcp my_script.scm</code>

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.