Skip to content

sdelcore/shared

Repository files navigation

shared

One small Go server that hosts many static sites with subdomain routing, an rsync-style deploy flow, and a batteries-included client-side JS API — a document DB with realtime subscriptions, an AI chat proxy, file uploads, websocket channels, and identity. Drop a <script src="/shared.js"> into any page and go.

No auth. Single user, trusted network only. Anyone who can reach the port can read and write everything. Run it on your LAN or behind a VPN, not the open internet.

Attribution

This project is a self-hosted reimplementation of the ideas behind Quick, Shopify's internal hosting platform that "lets anyone at Shopify ship a site in seconds." The architecture (deliberately simple single-server hosting, FTP-style deploys, a fixed client API with db/ai/uploads/websockets/identity, no permissions by design) comes from their engineering write-up; this codebase is an independent from-scratch implementation for personal use, and is not affiliated with Shopify.

Quickstart (NixOS)

direnv allow          # or: nix develop

# run the server
go run ./cmd/sharedd

# in another shell: build the CLI and deploy the example site
go build -o bin/ ./...
bin/shared deploy examples/hello --name hello

# open it
xdg-open http://hello.localhost:8787

The homepage at http://localhost:8787 lists all deployed sites, each with its on-disk size and last-updated time. Each site card also has a × button that deletes the site and all its data (same as shared rm).

Install

Two parts: sharedd, the server you host on one Linux box on your network, and shared, the CLI you run from any machine to deploy to it. The server only ships for Linux; the CLI ships for Linux and Windows.

Hosting the server

NixOS / Nix. The flake packages both binaries:

nix profile install github:sdelcore/shared

On NixOS, use the bundled module instead — it runs sharedd as a hardened systemd service with state in /var/lib/shared:

{
  inputs.shared.url = "github:sdelcore/shared";
}
{ inputs, ... }:
{
  imports = [ inputs.shared.nixosModules.default ];

  services.shared = {
    enable = true;
    baseHost = "shared.tap";     # sites at <name>.shared.tap
    openFirewall = true;
    # environmentFile = "/run/secrets/shared.env";  # OPENAI_BASE_URL, OPENAI_API_KEY
  };
}

Ubuntu / other Linux. Download a static binary tarball from the releases page and install it:

tar -xzf shared_*_linux_amd64.tar.gz
sudo install -m 0755 sharedd shared /usr/local/bin/

Or build from source. This needs Go 1.24+, which is newer than what Ubuntu 24.04's apt ships — install it from go.dev/dl:

CGO_ENABLED=0 go install github.com/sdelcore/shared/cmd/...@latest

The binaries are pure Go and fully static; nothing else is required. To run the server under systemd, drop this in /etc/systemd/system/shared.service:

[Unit]
Description=shared site platform
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/sharedd
Environment=SHARED_ADDR=:8787
Environment=SHARED_DATA=/var/lib/shared
Environment=SHARED_BASE_HOST=localhost
DynamicUser=yes
StateDirectory=shared
WorkingDirectory=/var/lib/shared
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now shared

Installing the CLI

Any machine on the network can deploy to the server; point the CLI at it with --server or $SHARED_SERVER (default http://localhost:8787).

Linux / macOS. The linux release tarball above includes the CLI, or:

go install github.com/sdelcore/shared/cmd/shared@latest

Windows (CLI only — the server is not supported on Windows). Download shared_<version>_windows_amd64.zip (or arm64) from the releases page, unzip shared.exe somewhere on PATH, and set the server once:

setx SHARED_SERVER http://shared.tap
shared list

Or let your agent do it. The repo ships an install-shared-cli agent skill that picks the right method for the current OS. Copy it into your agent's skill directory and ask it to install the CLI:

# Claude Code
git clone --depth 1 https://github.com/sdelcore/shared /tmp/shared-skill
cp -r /tmp/shared-skill/skills/install-shared-cli ~/.claude/skills/

CLI

The shared binary talks to the server over HTTP (--server, default http://localhost:8787 or $SHARED_SERVER).

shared init [dir]                 # scaffold index.html + a shared-sites agent skill
shared deploy [dir] --name NAME   # pack a directory and deploy it (--force skips
                                  # the overwrite check)
shared list                       # deployed sites with size, views, last deployer
shared open NAME                  # print and open a site URL
shared versions NAME              # a site's saved versions, newest first
shared rollback NAME              # roll back to the newest saved version
shared rm NAME                    # delete a site and all its data
shared backup [file]              # download a gzipped tarball of all server data

shared init writes a minimal index.html and .claude/skills/shared-sites/SKILL.md (an agent skill documenting the client API), refusing to overwrite existing files. shared backup defaults to shared-backup-<yyyymmdd-hhmmss>.tar.gz in the current directory.

Subdomain routing

Each site lives at http://<name>.<base-host><port>/. Modern browsers resolve any *.localhost name to 127.0.0.1 without /etc/hosts entries, so http://hello.localhost:8787 just works out of the box. To serve under a real domain on your LAN, set SHARED_BASE_HOST (e.g. shared.lan) and point a wildcard DNS record at the box.

Per-site data endpoints (db, uploads, ws) scope strictly to the first label of the request's Host header — one site cannot reach another site's data, even from a visitor's browser. The deploy endpoint takes its target from ?site=. Site names must match ^[a-z0-9][a-z0-9-]{0,62}$.

Client API

Every site can load the shared client library:

<script src="/shared.js"></script>

The library scopes everything to the current site automatically.

shared.db

A JSON document store. Documents get server-managed id, createdAt, and updatedAt fields.

const posts = shared.db.collection('posts');

const doc  = await posts.create({ title: 'hi', body: '...' });
const all  = await posts.list();                  // sorted by createdAt
const one  = await posts.get(doc.id);
await posts.update(doc.id, { title: 'hello' });
await posts.delete(doc.id);

// realtime: fires on created / updated / deleted
const sub = posts.subscribe({
  onCreate: doc => console.log('created', doc),
  onUpdate: doc => console.log('updated', doc),
  onDelete: doc => console.log('deleted', doc),
});
// sub.close() to stop. On reconnect after a drop, subscribe re-syncs and
// replays any create/update/delete missed while disconnected.

shared.ai

A proxy to an OpenAI-compatible chat API (OpenAI, or a gateway like LiteLLM) — your key stays on the server.

const reply = await shared.ai.chat('Summarize this in one line: ...');

// or full control:
const res = await shared.ai.chat('hi', {
  system: 'Be terse.',
  max_tokens: 256,
});

// streaming: pass stream + onToken to receive content deltas as they arrive.
// Still resolves with the full assembled string.
const full = await shared.ai.chat('Write a haiku about the sea.', {
  stream: true,
  onToken: text => process.stdout.write(text),
});

Image generation returns a persistent upload URL for the current site:

const { url } = await shared.ai.image('a watercolor fox, minimal');
img.src = url;   // served from /uploads/<site>/<rand>-ai.png

// or with options:
const res = await shared.ai.image({ prompt: 'a logo', size: '512x512' });

Requires OPENAI_BASE_URL and OPENAI_API_KEY to be set on the server; image generation also needs SHARED_AI_IMAGE_MODEL (or a model in the call). Otherwise calls fail with an error message.

shared.uploads

const { url } = await shared.uploads.upload(fileInput.files[0]);
img.src = url;   // served from /uploads/<site>/<rand>-<name>

shared.ws

Per-site broadcast channels. Each message is relayed to every other member of the same channel.

const room = shared.ws.channel('lobby');
room.onMessage(msg => console.log(msg));
room.send('hello everyone');

shared.identity

const me = await shared.identity();   // { email, name }

Returns the contents of data/identity.json if present, otherwise a default derived from SHARED_USER (or $USER).

HTTP API

Method Path Description
POST /api/deploy?site=N Body: gzipped tarball of site dir → {"site","url","version"}; 409 on version conflict (see below)
POST /api/rollback?site=N Restore the newest saved version → {"site","url","version"}
GET /api/versions?site=N {"current":{"seq","time","deployer","source"},"versions":[{"timestamp"}]}, newest first
GET /api/sites {"sites":[{"name","updatedAt","bytes","views","deployer","deploys"}]} (bytes = total on-disk size)
DELETE /api/sites/{name} Remove a site and all its data → {"deleted":true}
GET /api/export Streams a gzipped tarball of the entire data directory
GET /api/db/{col} {"docs":[...]}
POST /api/db/{col} JSON body → created doc (201)
GET /api/db/{col}/{id} Doc, or 404 {"error":...}
PUT /api/db/{col}/{id} JSON body → updated doc
DELETE /api/db/{col}/{id} {"deleted":true}
GET /api/db/{col}/subscribe WebSocket; pushes {"type","doc"} events
POST /api/ai/chat {"messages",...}{"content","model","stop_reason"}; with "stream":true streams OpenAI SSE
POST /api/ai/image {"prompt","model?","size?"}{"url"} (201, saved to site uploads)
POST /api/uploads multipart field file{"url"} (201)
GET /api/identity {"email","name"}
GET /api/ws?channel=C WebSocket broadcast channel

Per-site endpoints (db, uploads, ws) take the site from the request's subdomain.

Environment variables

Variable Default Purpose
SHARED_ADDR :8787 Listen address
SHARED_DATA ./data Data directory
SHARED_BASE_HOST localhost Base host for subdomain routing
SHARED_KEEP_VERSIONS 3 Prior deploys to keep per site for rollback; 0 disables versioning
OPENAI_BASE_URL OpenAI-compatible base URL (e.g. http://llm.tools.tap/v1); enables /api/ai/chat
OPENAI_API_KEY API key/token for the above (e.g. LiteLLM master key)
SHARED_AI_MODEL claude-opus-4-8 Default AI model (e.g. zen/kimi-k2.6)
SHARED_AI_IMAGE_MODEL Model for /api/ai/image (e.g. gpt-image-1); required unless passed per request
SHARED_AI_RATE 30 Per-site AI requests/min (burst 10); 0 disables the limiter
SHARED_USER $USER Name/email for the default identity

Data layout

data/
  sites/<name>/            deployed static files, one dir per site
  versions/<site>/<ts>/    prior deploys kept for rollback, named by unix time
  db/<site>/<col>.json     document store, one JSON file per collection
  uploads/<site>/          uploaded files
  meta/<site>.json         deploy history + view counts
  identity.json            optional identity override: {"email","name"}

Everything is plain files — back it up with whatever you already use.

Deletion and rollback

Each replacement deploy moves the previous site into versions/<site>/<unix-timestamp>/ and prunes to the newest SHARED_KEEP_VERSIONS (default 3; set 0 to just discard the old copy).

shared versions mysite    # list saved versions, newest first
shared rollback mysite     # swap in the newest version; the current one is
                           # kept as a new version, so rollback is reversible
shared rm mysite           # delete the site, its db, uploads, and versions

shared rm (or DELETE /api/sites/<name>) removes everything for a site and evicts its in-memory collection state, closing any open subscriptions.

Deploy attribution and overwrite protection

Every deploy and rollback is recorded in meta/<site>.json with a sequence number, timestamp, and deployer identity, sent by the CLI as X-Shared-Deployer: the git email configured for the deployed directory plus the machine, e.g. you@example.com (user@hostname), or just user@hostname without git. shared list and shared versions show who deployed last.

Deploys are also guarded against accidental overwrites, git --force-with-lease style: the CLI remembers the version it last deployed (in .shared/state.json next to the site, never uploaded) and sends it as X-Shared-Prev-Version. If someone else deployed in between, the server answers 409 with their identity and the CLI asks before overwriting:

hello was deployed by alice@dayman at 2026-07-14T23:40:23Z since your last deploy — overwrite? [y/N]

With no local state (fresh checkout, new machine) the CLI instead asks the server who deployed last and warns if it wasn't you. --force (or the X-Shared-Force: 1 header) skips both checks; requests without a prev-version header (curl, older CLIs) are accepted unchecked.

Metrics

The server counts page views per site — GET requests for HTML documents, not assets or API calls — batched in memory and flushed to meta/<site>.json every 30 seconds, with per-day buckets kept for 30 days. Totals show up in shared list, on the homepage, and in GET /api/sites.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors