Open-source project
aozorae/Edgechat avatar
aozorae/Edgechat

Edgechat on Cloudflare Workers: a demo that fakes the back end, a container that runs wrangler dev

A full-featured team chat system built on Cloudflare Workers, with public/private groups, DMs, realtime messaging, file uploads, and an admin dashboard.

736 stars228 forksJavaScriptGPL-3.0

At a glance

What is it?
Edgechat is a self-hosted team chat built on Workers with D1, KV, R2 and Durable Objects, plus an admin panel and a Telegram bridge. Several places in the repository describe a production path the demo, the container and the schema tooling do not actually follow.
Who is it for?
Edgechat is a working answer for a team that already lives in the Cloudflare ecosystem and wants a chat server without a server, and its bridge and encryption design is more careful than the container setup suggests. Before adopting it, read the privacy note rather than the feature list: encryption is server side, the Worker decrypts after a session check, and anyone holding the keys can reach the content.
Can I use it commercially?
Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
Is it still maintained?
Yes. The repository last received commits 3 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 2, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The online demo runs the real front end against a simulated back end

Before you deploy anything, the repository offers a demo, and it is explicit about what that demo is not. It reuses the production Vue pages, routes, state management and realtime message logic. What it does not use is the production back end: the API, the WebSocket, file upload and the Telegram backflow are all simulated in browser memory. It is not a multi-user room. Refreshing the page, or clicking the reset control in the top right, returns it to the initial state, and nothing a visitor does there reaches the production Worker or writes to D1, KV or R2.

That separation is carried all the way into the build. The demo has its own Vite config, its own build and deploy scripts, and its own wrangler file:

bash
npm run dev:demo
npm run build:demo
npm run deploy:demo

`wrangler.demo.toml` and `.github/workflows/deploy-demo.yml` are dedicated to it, the Worker is named `edgechat-demo`, and that workflow can only be triggered by hand. It reads `DEMO_CLOUDFLARE_ACCOUNT_ID` and `DEMO_CLOUDFLARE_API_TOKEN` rather than the production pair, and it does not modify the production deploy workflow. The only thing the demo shares with a real instance is the front-end code, which means it can tell you what the interface looks like and nothing about how the Worker behaves.

The container image's entrypoint is the local wrangler dev server

DOCKER.md is one file, and the Dockerfile beside it is short enough to read in full:

dockerfile
FROM node:20
WORKDIR /app
RUN npm install -g wrangler
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
EXPOSE 8787
ENV NODE_ENV=production
CMD ["wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0", "--local"]

The entrypoint is `wrangler dev` with `--local`, which is the local development server rather than a deployed Worker. The image still sets `NODE_ENV=production` on the line above it, and the compose file sets the same variable on the container, so the environment claims production while the process listening on it is a dev server bound to `0.0.0.0` on port 8787. wrangler is installed globally and unpinned, so the image takes whatever wrangler version happens to be current at build time, unlike the front-end dependencies, which come from the committed lockfile through `npm ci`.

What makes the local mode workable is the volume. The compose file maps host port 8788 onto container port 8787 and mounts a named volume at `/app/.wrangler/state`, so the local state outlives a container restart, with `restart: unless-stopped` and a bridge network named `edgechat-network`. What you get is a self-contained local run of the Worker, which is a reasonable thing to want and a different thing from what the environment variable announces.

The one schema script in package.json names a database called cfchat-db

Migrations reach a deployment through the Actions workflow, which applies them before it publishes the Worker. The manual path in package.json does something much narrower:

json
"d1:apply": "wrangler d1 execute cfchat-db --file ./worker/schema.sql"

The database id in that script is `cfchat-db`, and it is not the name used anywhere else in the repository. The compose service is `edgechat`, the demo Worker is `edgechat-demo`, the package itself is `edgechat` at version 2.10.1, and production wrangler configuration is left to `wrangler.example.toml` in the tree. Only this one line hardcodes a database name, and the name it hardcodes comes from an earlier naming of the project.

The build hooks around it are more carefully arranged. Schema generation runs before anything that could want the manifest: `prebuild`, `pretest`, `prebuild:demo` and `predev:demo` all invoke `schema:generate`, which runs `.github/scripts/generate-schema-manifest.mjs`. The front-end only paths invoke `vditor:assets` from `prepare-vditor-assets.mjs` instead, including `prebuild:frontend` and `prebuild:capacitor:web`. There is a separate size check, `check:frontend-size`, that assembles the same assets before measuring them, and `check` runs `biome:ci` followed by the full build.

AES-256-GCM at rest, and a keyring whose old entries cannot be thrown away

New message bodies and newly uploaded attachments are encrypted at rest on the server with AES-256-GCM. Data written before that stays plaintext, reads accept both forms, and nothing backfills encryption in bulk during a deploy or a background job, so a long-lived instance keeps a mixed body. The admin panel has no entry point for reading message bodies in groups or direct messages, though it does show aggregate figures such as message counts. The page states the limit plainly: this is server-side encryption and not end-to-end, because the Worker decrypts after a session permission check, which means the Cloudflare runtime and whoever holds the keys still have to be trusted. Turning on the Telegram bridge widens that boundary further, because forwarded messages land in the corresponding Telegram conversation.

The key material is a keyring held as a GitHub Repository Secret:

json
{"activeKeyId":"v1","keys":{"v1":"BASE64_ENCODED_32_BYTE_KEY"}}

On a first deploy where the target Worker has no encryption Secret, the workflow generates a random 32 byte key, injects it as its own versioned Secret, and records the active key ID. Later ordinary deploys only check that these Secrets exist, and never regenerate or overwrite them. A rotation is explicit: run `Deploy Worker` by hand with `rotate_encryption_key` ticked, and the workflow adds one new versioned Secret and points the active key ID at it, leaving every old Secret and the old JSON keyring untouched. Existing ciphertext keeps decrypting because each envelope carries the key ID it was written with. The manual override `apply_encryption_keyring` wants the complete JSON instead, with every old key ID still referenced by historical ciphertext retained, and the two switches cannot be enabled in the same run. Dropping an old key makes that history permanently unreadable.

Cross-instance binding moves new plain text forward and never deletes what it already delivered

From 2.8.0 two separate Edgechat sites can be tied together. The admin panel's cross-instance binding screen lets an administrator pick a public, private or general group, and the handshake has three steps with a clock on the first: a ten minute one-time invitation, the far side claiming the group, and the originating side checking the claim and confirming it. Each group binds to at most one remote group, and direct messages cannot be bound at all.

The rules for what moves are narrow. Only the site's own original plain text is forwarded, together with nickname and source instance, and only messages created after the binding takes effect. History is never backfilled. Attachments, quote relationships, edits and recalls are all left behind. Pausing cancels whatever was queued, and resuming picks up only messages of the new generation. Unbinding stops new deliveries from starting, though messages already in flight may still land, and a copy that has been delivered is not removed automatically. The panel can show the queue, the reason a delivery failed and the discard count, which is the only way to find out where messages went.

Deployment is the other half. An ordinary Actions run applies the database migrations first, then publishes the Worker carrying the `INSTANCE_BRIDGE` Durable Object, needs no extra manual Secret, and must keep the existing encryption keyring. The protocol and its reliability promises are written down separately in `docs/api/instance-bridge-v1.md`.

Both bridges refuse to forward a message that already arrived through one

Telegram bridging and instance bridging arrived in the same release, and they share one rule that matters more than either feature list. From 2.8.0 each bridge forwards only messages originally written by an account on this site. A message that came in through one bridge is not relayed out through the other. Without that rule two connected sites and a Telegram group would echo each other without end, and the project chose truncation over deduplication.

The Telegram side is the older of the two and the simpler. An administrator can see the site's public and private groups in the admin panel and bind any of them to a Telegram group. A Telegram Bot then carries messages in both directions, so what is sent on the web appears in Telegram and what is written in the Telegram group comes back. One to one private messages are excluded from the bridge entirely, and voice messages cross it as well as text. That last point is worth weighing against the privacy note, since the bridge carries voice messages and the project states that once it is enabled, forwarded messages enter the corresponding Telegram conversation, which sits outside the local keyring and outside the admin panel's lack of a message viewer.

Two Android clients are in the tree, and the native one is the one being retired

The tree holds an `android/` directory and a `capacitor/` directory, and they are not at the same stage. The default distribution is the Capacitor client: its APK embeds the same Vue web interface the browser gets, with a small amount of Kotlin behind the system file picker, notifications, microphone permission, jumping to a session from a notification, and external links. The package is `com.aozorae.edgechat.web`, and the first launch asks for the HTTPS address of your own instance rather than pointing at any fixed deployment. After logging out the address can be changed, and switching instances clears the old instance's tokens.

Packaged builds come from GitHub Releases as `edgechat-*.apk`, with a `SHA256SUMS.txt` to check them against. Building locally needs JDK 21 and Android SDK 36, then `npm run build:capacitor`; the debug artifact lands at `capacitor/android/app/build/outputs/apk/debug/app-debug.apk`, and a separate workflow uploads it as `edgechat-capacitor-debug`. Releasing means pushing an `android-v*` tag or running `Android Release` by hand, with four `ANDROID_KEYSTORE_*` Secrets doing the signing.

The native Kotlin and Jetpack Compose client under `android/` is the one being dropped. Its source, its API v1 contract and its own CI stay in the tree for now, and it is no longer the default distribution. The trade is stated rather than implied: no Room offline database, no WorkManager outbox and no FCM, so the app does not promise a background notification while it is not receiving messages. The two most recent releases in the repository are both Android betas, and the older of the pair carries the name Legacy Native Compose.

The in-app update check depends on a public repository and an already pushed commit

Rather than a scheduled job, Edgechat checks for updates in the browser. The build records the current GitHub repository, branch and commit, and the panel compares that against the source directly through the GitHub Compare API. Two conditions follow from the design. The repository has to stay public, because the comparison is made from the browser, and a manual deploy has to be built inside the git working tree on a commit that has already been pushed, or the recorded commit is one nobody else can fetch. No scheduled task is created for it.

The same part of the page solves the non-interactive deploy problem by setting the token before the command:

powershell
$env:CLOUDFLARE_API_TOKEN = "your-token"
npm run deploy

The ordinary way in is the Actions workflow at `.github/workflows/deploy-worker.yml`, started by hand as `Deploy Worker` or triggered by a push to `master` or `main`. Temporary bans in the admin panel expire on their own after a day, an hour or a minute, and this too needs no extra scheduled task. The repository page in this README is written in Chinese, and the tree also carries `README.en.md` and `README.ja.md` for the other two languages.

Editorial conclusion

Edgechat is a working answer for a team that already lives in the Cloudflare ecosystem and wants a chat server without a server, and its bridge and encryption design is more careful than the container setup suggests. Before adopting it, read the privacy note rather than the feature list: encryption is server side, the Worker decrypts after a session check, and anyone holding the keys can reach the content. Never drop a key from the keyring, because the history it protects is not recoverable by rotating forward. For a private fork, the first things to fix are the Docker entrypoint, which starts a local dev server under a production environment label, and the `cfchat-db` name in the schema script.

Frequently asked questions

Is the Edgechat online demo a real chat room?

No. It reuses the production Vue pages, routes, state management and realtime message logic, but the API, WebSocket, file upload and Telegram backflow are simulated in browser memory. Demo actions do not reach the production Worker and do not write D1, KV or R2, and refreshing or using the reset control restores the initial state.

Does Edgechat use end-to-end encryption?

No. New message bodies and new attachments are encrypted at rest on the server with AES-256-GCM, and the Worker decrypts after a session permission check, so the Cloudflare runtime and the deployer holding the keys still have to be trusted. The admin panel has no message body viewer, but that is a product decision rather than a technical limit.

What happens to old messages when an Edgechat encryption key is rotated?

A rotation run of `Deploy Worker` with `rotate_encryption_key` adds a new versioned Secret and switches the active key ID, leaving old Secrets and the old keyring in place, so existing ciphertext keeps decrypting through the key ID stored in each envelope. Removing an old key instead makes that history permanently unreadable, which is why the manual `apply_encryption_keyring` override requires every retained key ID.

Can Edgechat sync an existing Telegram group?

Yes, and in both directions. An administrator binds any public or private group to a Telegram group from the admin panel, and a Telegram Bot forwards messages written on either side, including voice messages. One to one private messages are never bridged, and a message that arrived through a bridge is not forwarded out through the other bridge.

What does the Docker image for Edgechat actually start?

The image starts `wrangler dev --port 8787 --ip 0.0.0.0 --local`, the local development server, even though it sets `NODE_ENV=production`. The compose file maps host port 8788 to container port 8787 and mounts a named volume at `/app/.wrangler/state` so the local state survives a restart.

What can the Edgechat cross-instance group binding sync?

Only the site's own original plain text, plus nickname and source instance, and only for messages created after the binding takes effect. History is never backfilled, attachments, quote relationships, edits and recalls are not synced, and a delivered copy is not deleted when the group is unbound, though new deliveries stop starting.

Official sources

  1. aozorae/Edgechat on GitHub
  2. License: GPL-3.0
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/aozorae-edgechat.svg)](https://hysenlabs.com/projects/aozorae-edgechat)