Skip to content

devrelopers/TownSquare

Β 
Β 

Repository files navigation

TownSquare

NOTE: This project has been mostly vibe-coded

TownSquare is a tiny presence layer for websites.

This repo currently contains a narrow but real slice:

  • embeddable browser widget
  • real-time shared presence
  • simple left/right walking
  • lightweight real-time chat
  • bench and tree props with simple seat interactions
  • no-account hosted site registration
  • ephemeral in-memory server state

The codebase is intentionally small. The main goal right now is to make the product boundary clear enough that TownSquare can be self-hosted cleanly and later grow into a hosted shared service without rewriting the core widget. Self-hosted should not mean forever disconnected: a self-hosted TownSquare may also choose to communicate with other TownSquares and become part of the wider network.

Repo shape

  • server.js β€” Node server for static assets, health checks, and WebSocket presence
  • public/townsquare.mjs β€” reusable embeddable widget mount API (public embed URL /townsquare.mjs)
  • public/widget/ β€” widget implementation modules (DOM, chat, presence, protocol, movement)
  • public/shared/ β€” protocol, scene, style, and map definitions shared with the server
  • public/widget.css β€” embeddable widget styling (scoped to #townsquare-root)
  • public/page.css β€” feature-specific layout for TownSquare host pages
  • public/design/ β€” canonical public-page tokens, base styles, and shared chrome
  • public/tokens.css β€” independent widget tokens (imported by widget.css)
  • public/lib/ β€” generic browser helpers shared across pages (e.g. ui-common.mjs)
  • public/hosted/ β€” hosted registration/admin pages and scripts, served at /register, /admin, /service-admin
  • public/map.html β€” public map of verified, enabled TownSquares, served at /map
  • public/dev/ β€” local dev tooling: dev.html (simulation, /dev) and walk-sandbox.html (/walk-sandbox)
  • public/staging.html β€” live widget demo for the staging instance, served at /staging (gated by ENABLE_STAGING_PAGE)
  • scripts/smoke-test.js β€” automated websocket smoke test
  • spec.md β€” product truth
  • roadmap.md β€” product-facing sequencing
  • docs/architecture.md β€” current boundaries and future hosted shape
  • docs/design-system.md β€” visual contract for TownSquare-owned public pages

The landing page, user documentation, and changelog live in the private TownSquare_landingpage repository. Set LANDING_ORIGIN to redirect those routes when this server is reached directly.

Public design foundations are canonical in public/design/ and copied into the landing repository with the ignored local helper at scripts/admin/sync-design.js. The helper can also check for drift and does not copy or modify widget styles.

Requirements

  • Node.js 18+
  • npm

Install

npm install

Run locally

npm start

Default local URL:

http://127.0.0.1:8787

Override host/port if needed:

HOST=0.0.0.0 PORT=8787 npm start

Health check:

http://127.0.0.1:8787/healthz

Development workflow

  1. Start the server:
 npm start
  1. Open the development scene:
 http://127.0.0.1:8787/dev
  1. Open it in two windows or two browsers.
  2. Verify the current slice manually:
  • two tabs from the same browser still share one visitor
  • a different browser or browser profile shows a second visitor
  • arrow keys move your figure left/right
  • tapping the stage walks there, while horizontal touch swipes walk by the swipe distance without blocking vertical page scrolling
  • pressing H shows a high-five emoji, and a nearby second visitor pressing H high-fives you
  • on touch devices, the jump and high-five buttons trigger the same actions
  • movement is reflected in the other window
  • pausing by the bench or tree settles the visitor into a seat
  • chat messages appear above the figure and also enter the recent-message tray
  • closing one tab does not remove the visitor if another tab from that browser is still open

For local scene stress testing with one controllable local user plus simulated visitors, use:

http://127.0.0.1:8787/dev?characters=24

For frame-by-frame walk-cycle review, use:

http://127.0.0.1:8787/walk-sandbox

Embed the widget into another site

A site can embed the widget by loading the CSS plus the module from the TownSquare server:

<link rel="stylesheet" href="https://your-townsquare-host/widget.css" />
<div id="townsquare-root"></div>
<script type="module">
  import { mountTownSquare } from "https://your-townsquare-host/townsquare.mjs";

  mountTownSquare(document.getElementById("townsquare-root"), {
    serverOrigin: "https://your-townsquare-host",
    socketPath: "/live",
    theme: "host"
  });
</script>

Notes:

  • serverOrigin is the realtime/backend origin the widget should connect to.
  • socketPath defaults to /live; set it explicitly when your reverse proxy exposes TownSquare on a different websocket path such as /townsquare/live.
  • siteKey is only needed when using one hosted TownSquare server for multiple registered sites.
  • theme: "host" syncs with common host-page dark mode signals such as html.dark, body.dark, html.dark-mode, body.dark-mode, data-theme, data-bs-theme, and data-color-mode, or an explicit color-scheme: light|dark on html/body. When none of those are present it stays on the light palette so macOS dark mode does not restyle the widget on a light page. Omit theme to use auto, which follows prefers-color-scheme.
  • To restyle the square, set the palette tokens (--scene, --page, --surface, --ink, --you, --tree-trunk, --tree-canopy, --other, --ground) on #townsquare-root in your own stylesheet. The widget writes no inline palette styles, so your CSS wins. See Customization.
  • The host page owns placement and surrounding layout.
  • TownSquare owns the scene, movement, chat, and realtime transport inside the mount root.

Hosted registration

User-facing guidance is maintained with the public site in the private TownSquare_landingpage repository.

TownSquare can also run as a tiny hosted service. Open:

https://your-townsquare-host/register

The flow is intentionally accountless:

  • enter a website URL
  • optionally allow the matching www/non-www version with one checkbox
  • receive an embed snippet with a public site key
  • receive a private admin token and admin link
  • paste the snippet into the website

The public siteKey routes visitors into that site's isolated scene. The private admin token is the password for settings and moderation. Save it; the admin page asks for it to sign back in later. Generated admin links keep the token in the URL fragment so it is not sent in HTTP requests. Only an admin token hash is stored in the site registry.

The admin page can:

  • show install/seen status
  • show active visitors
  • customize the scene (bench/tree/lamp/bird counts and placement), colors, and a clickable message board, with a live preview (see Customization)
  • mark an active visitor as the verified site owner (and unmark them)
  • kick, hide, or block active visitors
  • disable chat
  • disable the site
  • clear recent in-memory messages

Mark the site owner

Visitors are anonymous, so by default nothing distinguishes the owner from anyone else. To get a tamper-resistant owner badge (a πŸ‘‘ crown) on your own character:

  1. Open your own site so you appear as a live visitor. Add #townsquare-owner to the URL and the widget shows a hint with your visitor number (You're visitor #N …).
  2. Open the admin page and find that visitor in the active list.
  3. Click Make owner. Your character gains the crown live for everyone in the square, and keeps it on every future visit from that browser. Click Owner βœ“ to remove it.

The badge is server-issued, so it cannot be faked by typing a name or picking a color. Ownership is bound to the specific browser that was marked β€” it is verified by the same server-issued browserSecret that keeps a visitor's character stable across refreshes, so another browser asserting the same id without that secret gets no crown. Because the project stays accountless, a new device or cleared browser storage means marking owner once more (one click). You can mark more than one browser if you want the badge on several devices. Marked browser ids are stored per site under ownerBrowserIds in .data/sites.json.

Customization

Every square ships with a default hosted style β€” the palette baked into public/tokens.css (light and dark), which DEFAULT_SITE_STYLE in public/shared/site-config.mjs mirrors. No setup is needed to look good.

How customization reaches a site

There are two delivery channels, and the split is deliberate:

  1. Install-once snippet (identity only). buildEmbedSnippet emits a small snippet carrying just serverOrigin, siteKey, theme (and any plugin modules). The owner pastes it once. It does not change when they tweak the square, so it never needs re-pasting.

  2. Live by siteKey over the socket. Per-site content the owner edits in admin is delivered in the hello payload and re-pushed when it changes, so edits show up on embedded sites immediately:

    • Scene β€” bench/tree/lamp/bird counts and placement (sceneConfig), broadcast as a scene message on edit.
    • Connections β€” neighbouring-town links (connections), broadcast as a connections message.
    • Message board β€” an optional clickable note prop (messageBoard), broadcast as a messageBoard message. Title, body, art variant, accent, and position; visitors see a "!" badge until they open the current message (read-state kept per site in localStorage).

    Hosted sites (those with a siteKey) start with an empty scene and fill it in the moment the socket connects, so they never flash the stock default props.

Power-user overrides. Any of scene, connections, or messageBoard can be pinned directly in the snippet's mountTownSquare options. A field declared inline is a deliberate override: it wins and the widget stops applying live updates for that field (other fields stay dashboard-managed). This is the same "the dashboard generates it, but you own it if you want" idea as the colors CSS below β€” see ctx.applyLiveConfig / inlineConfig in public/townsquare.mjs.

Colors are the one exception, delivered as CSS rather than live. A palette per mode (light/dark) is saved in styleConfig and emitted as a small scoped CSS block (buildSiteCss) the owner pastes into their own stylesheet β€” so host-page CSS can override it, owners can hand-edit it, and the widget never writes inline palette styles that would fight the host. The admin/register pages generate this Customization CSS block from the swatch choices; re-copy it after changing colors (scene, connections, and the board do not need this).

The CSS sets these tokens, scoped to #townsquare-root for light, explicit dark, and prefers-color-scheme dark: --scene (background), --page (ground), --surface (buttons/tags), --ink (text/line work), --you (accent), --tree-trunk, --tree-canopy, --other, and --ground. Advanced owners can edit that block or write their own rules on the same tokens β€” the widget writes no inline palette styles for hosted embeds, so host CSS always wins.

Registered sites are stored in .data/sites.json by default. Set DATA_DIR if the registry should live somewhere else. Set PUBLIC_ORIGIN in production so generated snippets use the public HTTPS origin. Set LANDING_ORIGIN when this server should redirect /, /docs, and /changelog to a separately hosted public site. Set PLAUSIBLE_DOMAIN and PLAUSIBLE_SCRIPT_SRC to inject Plausible into every HTML page served by TownSquare. The landing repository loads the same tracker from its shared site.mjs on the canonical production hostname. Set AUTH_FAILURES_PER_HOUR to tune per-IP failed admin sign-in throttling; 0 disables it. Set SERVICE_ADMIN_PASSWORD to enable /service-admin, where the service operator can manage registered sites and paint the global /map scenery. The editor supports density-controlled tree scattering, freehand lakes, and curved rivers. Saved maps live in DATA_DIR/map-world.json; see the map modules for schema and validation details. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID to send a Telegram notification whenever a chat message is sent. Set INACTIVE_DISCONNECT_MS and INACTIVE_CHECK_INTERVAL_MS to control away/inactive disconnects (see .env.example). For local runs, copy .env.example to .env (or create .env directly); server.js loads it on startup. Real environment variables win over .env values.

Server feature plugins

TownSquare has a small in-process plugin registry for trusted feature modules. Public modules live in plugins/; a hosted bootstrap can register private modules through registerPlugin before requiring server.js. The supported hooks and full-stack composition contract are documented in docs/plugins.md. There is intentionally no remote install or external-extension system.

Realtime abuse limits

The server limits concurrent identities, joins, state-changing events, and chat per source IP and site. Configure the limits through the IP_* variables in .env.example; 0 disables an individual limit. Rate-limited sockets close with code 1008 and reason rate limited. server.js trusts X-Real-IP only from a loopback peer, matching the supported local reverse-proxy deployment.

The action quarantine is scoped to one source IP and site. It closes that IP's sockets only after multiple identities repeat the same action in synchronized rounds; repetition from one identity does not trigger it. Configure the detector and quarantine duration through the IP_SYNC_ACTION_* and IP_QUARANTINE_MS variables in .env.example.

Two identity-agnostic controls limit distributed abuse that rotates across many IPs and sites. MIN_HUMAN_SAY_MS drops chat messages sent within that window of joining (the scripted enter-say-leave pattern a human cannot reproduce). TELEGRAM_MAX_NOTIFICATIONS_PER_MIN caps outbound chat notifications per minute across all sites so a notification flood cannot form. Both are in .env.example.

Site owners can enable bot protection per site from the chat admin. When on, each visitor's browser must solve a small proof-of-work (crypto-grade SHA-256 in public/widget/pow.mjs) before the server accepts their join. A script that never runs the widget cannot solve it, and a scripted solver pays CPU per visitor, while real visitors see only a brief invisible delay. The server gates the join in allowIdentityInit/handleInit and grades the work with POW_DIFFICULTY_BITS (sent to the widget in the challenge, so it tunes without a widget redeploy). The verifier is pluggable β€” a hosted challenge (e.g. Turnstile) can later replace the proof-of-work behind the same per-site toggle.

For defense before a WebSocket reaches Node, install ops/nginx/townsquare-http-limits.conf in Nginx's http context and include ops/nginx/townsquare-server-limits.conf in the TownSquare server block. These limits apply only to /live; normal pages and assets are not counted.

The Nginx config also enforces a per-scene join rate (keyed on $arg_siteKey, zone townsquare_scene_joins, default 60 r/m burst 20). This caps how fast any one scene can accumulate new connections regardless of how many source IPs are involved, which is effective against distributed botnets that rotate IPs.

Each site has an owner-editable concurrent visitor connection limit in /admin, defaulting to 100. MAX_CONNECTIONS sets the fallback/default used for new and legacy site records.

Deploy updates

This repo includes a deployment helper:

cp .env.deploy.example .env.deploy.local
scripts/deploy.sh

On the shared host checkout, .env.deploy.local can use local mode so redeploys do not need SSH:

DEPLOY_MODE=local
DEPLOY_ROOT=/opt/townsquare
DEPLOY_SERVICE=townsquare.service
DEPLOY_PORT=8788

Useful flags:

scripts/deploy.sh --promote-main
scripts/deploy.sh --local
scripts/deploy.sh --skip-checks
scripts/deploy.sh --tag staging
scripts/deploy.sh --ref origin/main
scripts/deploy.sh --env-file ./ops/my-deploy.env

By default, the script deploys the local production tag. Use --promote-main to fetch origin/main, move the deploy tag to that commit, deploy it, and push the tag to origin after a successful deploy. Use --tag for another tag. It resolves only real Git tags, so annotated and lightweight tags both deploy the commit the tag points to. Keep --ref for explicit branch, SHA, or rollback deploys without retagging.

The script:

  • runs local syntax checks unless skipped
  • archives the chosen git tag or ref
  • uploads it to the server for remote deploys, or deploys directly in local mode
  • creates a new release under /opt/townsquare/releases
  • runs npm ci --omit=dev
  • flips /opt/townsquare/current
  • restarts townsquare.service
  • checks the local health endpoint
  • optionally checks a public health endpoint when HEALTHCHECK_URL is set

Remote mode expects a machine with working ssh and scp access to the server. Local mode expects permission to write the deploy root and restart the service, usually via root or sudo.

The checked-in .env.deploy.example is generic. Keep real deployment values in .env.deploy.local or another uncommitted env file.

Staging instance

Staging is a second, full copy of the app running a chosen branch on its own service, port, and data dir, served at https://staging.townsquare.cauenapier.com. Because it is a separate origin it stages the branch end to end β€” server, widget, and protocol β€” at the dedicated /staging demo page, which mounts the real widget against the staging instance's own /live scene.

One-time host setup:

  • install ops/systemd/townsquare-staging.service (port 8789, separate DEPLOY_ROOT=/opt/townsquare-staging and DATA_DIR, ENABLE_STAGING_PAGE=1)
  • install ops/nginx/townsquare-staging.conf and issue a TLS cert for the subdomain
  • cp .env.deploy.staging.example .env.deploy.staging and fill it in

Deploy any branch with the branch as the parameter:

scripts/admin/deploy-staging.sh feature/foo     # stages origin/feature/foo
scripts/admin/deploy-staging.sh                 # defaults to main
scripts/admin/deploy-staging.sh feature/foo --skip-checks

The ignored scripts/admin/deploy-staging.sh helper is a thin wrapper around deploy.sh: it fetches the branch from origin, then deploys origin/<branch> using .env.deploy.staging. The /staging page is gated behind ENABLE_STAGING_PAGE, so it stays off on the production instance.

Docker

Build and run:

docker build -t townsquare .
docker run --rm -p 8787:8787 townsquare

Then open:

http://127.0.0.1:8787

Checks

Syntax check the current code:

npm run check

Run the websocket smoke test in a second shell while the server is already running:

npm run smoke

The IP-limit path is covered by the same real-server smoke runner. Start a separate server with the low limits asserted in assertIpLimits:

PORT=8794 DATA_DIR=/tmp/townsquare-ip-test IP_MAX_IDENTITIES=2 IP_JOIN_LIMIT=2 IP_STATE_EVENT_LIMIT=3 IP_CHAT_EVENT_LIMIT=2 npm start

Then run:

TOWNSQUARE_WS_URL=ws://127.0.0.1:8794/live TOWNSQUARE_HTTP_ORIGIN=http://127.0.0.1:8794 DATA_DIR=/tmp/townsquare-ip-test TEST_IP_LIMITS=1 IP_MAX_IDENTITIES=2 IP_JOIN_LIMIT=2 IP_STATE_EVENT_LIMIT=3 IP_CHAT_EVENT_LIMIT=2 npm run smoke

assertIpActionQuarantine in scripts/smoke-test.js documents the low-limit environment used to exercise synchronized-action quarantine against a real server.

The smoke test verifies:

  • hello/initial peer snapshot
  • join
  • move
  • say
  • leave
  • hosted site isolation and admin token hashing
  • moderation tools (word filter, mute/unmute, hide/unhide, slow mode, moderation log)
  • service-admin map validation and persistence
  • per-IP identity, join, state-event, chat, and synchronized-action quarantine limits

To also verify inactive disconnect, restart the server with a short timeout and rerun smoke:

INACTIVE_DISCONNECT_MS=800 INACTIVE_CHECK_INTERVAL_MS=200 npm start
INACTIVE_DISCONNECT_MS=800 npm run smoke

Current scope

Included now:

  • one embeddable widget module
  • one default scene
  • presence
  • walking
  • bench and tree props with simple seat interactions
  • lightweight chat with small per-character recovery tray
  • self-hostable single-process server
  • accountless hosted site registration with isolated scenes
  • token-protected hosted admin/moderation page
  • verified site-owner badge (server-issued crown, bound to the owner's browser)

Not included yet:

  • persistence
  • accounts or admin-link recovery
  • heavy moderation systems
  • multiple scenes
  • cross-site travel
  • packaged integrations for major site builders

Direction

The next serious product boundary is:

  1. single-site self-hosting that feels clean
  2. clear separation between widget, realtime service, and site registration concerns
  3. only then a hosted multi-site TownSquare service

That means we should make the deployable single-site system good now, while keeping the protocol and embed boundary simple enough that a hosted shared version can be added later. It also means leaving room for self-hosted TownSquares to optionally communicate with each other and participate in the wider network without requiring a full centrally hosted model.

License

TBD

About

A social addition to web pages with stick figures representing visitors that can talk to each other

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • JavaScript 78.2%
  • CSS 14.1%
  • HTML 6.2%
  • Shell 1.4%
  • Dockerfile 0.1%