|
| 1 | +# Write |
| 2 | + |
| 3 | +Write is a free, open-source, local-first screenwriting app. No account, no server, no lock-in: your script lives in your browser, syncs peer-to-peer when you collaborate, and every edit is cryptographically signed so you always know who wrote what. |
| 4 | + |
| 5 | +Write is a project of **[Subscript](https://subscript.to)** — a home for screenwriters to get their scripts read, reviewed, and into festivals and contests. Write is Subscript's answer to "where do I actually write the thing?" It's free and open source because the tool you draft in shouldn't be a subscription. If you want feedback on a finished draft, a home for your script, or to submit to contests, that's what [subscript.to](https://subscript.to) is for. |
| 6 | + |
| 7 | +The hosted version lives at **[write.subscript.to](https://write.subscript.to)** — open it and start typing, no signup required. |
| 8 | + |
| 9 | +## What it does |
| 10 | + |
| 11 | +- **Local-first**: your project is stored in IndexedDB in your own browser. Nothing is uploaded unless you choose to share it. |
| 12 | +- **Peer-to-peer collaboration**: share a link and collaborators sync directly with you over WebRTC ([Yjs](https://yjs.dev/) + [y-webrtc](https://github.com/yjs/y-webrtc)). There's no central server that stores your document. |
| 13 | +- **Signed provenance**: every edit and annotation is signed with a device-local Ed25519 key, so you can always verify who wrote or commented on a passage. |
| 14 | +- **No account required**: a self-issued, device-bound identity is created automatically. Give your device a name and start writing. |
| 15 | +- **Screenplay-native editing**: scene headings, action, dialogue, and the rest of standard screenplay format, with Final Draft (FDX) import and export. |
| 16 | + |
| 17 | +## Developing |
| 18 | + |
| 19 | +If you want to develop or run this locally, you can! This project uses TypeScript, React, Vite, and can be optionally deployed (for very cheap) to Cloudflare Workers. |
| 20 | +Install dependencies and start the dev server with: |
| 21 | + |
| 22 | +```sh |
| 23 | +pnpm install |
| 24 | +pnpm dev |
| 25 | +``` |
| 26 | + |
| 27 | +This starts the app at `http://localhost:1430`. By default it connects to the public STUN server included below, so peer-to-peer collaboration works out of the box; run your own [signaling worker](./signaling) if you want to self-host that piece too. |
| 28 | + |
| 29 | +### Scripts |
| 30 | + |
| 31 | +| Command | What it does | |
| 32 | +| --- | --- | |
| 33 | +| `pnpm dev` | Start the Vite dev server | |
| 34 | +| `pnpm build` | Type-check and build a static production bundle to `app/dist` | |
| 35 | +| `pnpm test` | Run the unit test suite (Vitest) | |
| 36 | +| `pnpm lint` | Type-check and run Biome | |
| 37 | +| `pnpm signal` | Run the signaling worker locally with `wrangler dev` | |
| 38 | + |
| 39 | +## Configuration |
| 40 | + |
| 41 | +Copy `app/.env.example` to `app/.env.local` and adjust as needed: |
| 42 | + |
| 43 | +| Variable | Purpose | Default | |
| 44 | +| --- | --- | --- | |
| 45 | +| `VITE_HOMEPAGE` | Where the wordmark links | `https://subscript.to` | |
| 46 | +| `VITE_SIGNALING_URL` | WebRTC signaling endpoint | `ws://localhost:8787` (local `wrangler dev`) | |
| 47 | +| `VITE_STUN_URL` | STUN server for NAT traversal | `stun:stun.l.google.com:19302` | |
| 48 | +| `VITE_TURN_URL` / `VITE_TURN_USERNAME` / `VITE_TURN_CREDENTIAL` | Optional TURN relay for peers behind restrictive NATs | unset | |
| 49 | + |
| 50 | +## Embedding Write in another page |
| 51 | + |
| 52 | +Besides the standalone site, Write can be built as a drop-in component for a page you serve yourself — e.g. a Subscript page with its own `<head>` (analytics, meta tags, etc.) that just wants the editor somewhere in the body. Build it with: |
| 53 | + |
| 54 | +```sh |
| 55 | +pnpm --dir app build:embed |
| 56 | +``` |
| 57 | + |
| 58 | +This produces a single dependency-free ES module at `app/dist-embed/subscript-write.js` (React and friends bundled in, styles injected at runtime, nothing else required on the page). Two ways to use it: |
| 59 | + |
| 60 | +**As a custom element** — importing the module registers `<subscript-write>`: |
| 61 | + |
| 62 | +```html |
| 63 | +<script type="module" src="/assets/subscript-write.js"></script> |
| 64 | +<subscript-write |
| 65 | + style="display:block; height:100vh" |
| 66 | + homepage="https://subscript.to" |
| 67 | + signaling-url="wss://your-signaling-worker" |
| 68 | + idp-config-url="/write/identity/config" |
| 69 | +></subscript-write> |
| 70 | +``` |
| 71 | + |
| 72 | +Attributes map to the config options below (kebab-case); the element mounts on connect and unmounts on disconnect. |
| 73 | + |
| 74 | +**Programmatically**, from your own script: |
| 75 | + |
| 76 | +```js |
| 77 | +import { mount } from "/assets/subscript-write.js"; |
| 78 | + |
| 79 | +const editor = mount(document.getElementById("editor-slot"), { |
| 80 | + homepage: "https://subscript.to", |
| 81 | + signalingUrl: "wss://your-signaling-worker", |
| 82 | + idpConfigUrl: "/write/identity/config", |
| 83 | +}); |
| 84 | + |
| 85 | +// later, if the slot is torn down: |
| 86 | +editor.unmount(); |
| 87 | +``` |
| 88 | + |
| 89 | +`mount(element, config?)` returns `{ unmount }`. All config is optional and falls back to the same defaults as the standalone build (public STUN, subscript.to homepage, no IDP). See [`app/src/lib/config.ts`](./app/src/lib/config.ts) for the full `WriteConfig` shape — it's the same options as the `VITE_*` env vars below, just provided at runtime instead of build time, since an embedded bundle has no build-time env vars of its own. |
| 90 | + |
| 91 | +## Self-hosting |
| 92 | + |
| 93 | +Everything Write depends on can be run by you: |
| 94 | + |
| 95 | +- **The app** is a static site. Build it with `pnpm build` and serve `app/dist` from anywhere, or deploy it to Cloudflare Workers with `pnpm --dir app deploy` (see [`app/wrangler.jsonc`](./app/wrangler.jsonc)). |
| 96 | +- **Signaling** is a small, stateless Cloudflare Worker (see [`signaling/`](./signaling)) that only helps two browsers find each other over WebRTC — it never sees your document. Deploy your own with `pnpm --dir signaling deploy` and point `VITE_SIGNALING_URL` at it. |
| 97 | +- **STUN** defaults to Google's free public STUN server, which is enough for most direct peer connections and requires no setup. |
| 98 | +- **TURN** is optional and only needed for peers behind especially restrictive NATs/firewalls that can't connect directly. Point `VITE_TURN_URL` (plus username/credential) at any standard TURN server (e.g. [coturn](https://github.com/coturn/coturn)) if you need it. |
| 99 | + |
| 100 | +None of these components store your screenplay. Document content only ever lives in participants' browsers and travels directly between them. |
| 101 | + |
| 102 | +## Deploying |
| 103 | + |
| 104 | +The canonical [write.subscript.to](https://write.subscript.to) deployment is built and published by [`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml) on every push to `main`, via GitHub Pages with a custom domain (see [`app/public/CNAME`](./app/public/CNAME)). |
| 105 | + |
| 106 | +To deploy your own fork to Cloudflare instead: |
| 107 | + |
| 108 | +```sh |
| 109 | +cd app |
| 110 | +pnpm install |
| 111 | +pnpm run deploy:dry-run # sanity check |
| 112 | +pnpm run deploy |
| 113 | +``` |
| 114 | + |
| 115 | +Wrangler uses [`wrangler.jsonc`](./app/wrangler.jsonc) and serves the built Vite output from `dist/` with SPA fallback enabled. |
| 116 | + |
| 117 | +## Identity assertions |
| 118 | + |
| 119 | +By default, Write creates a self-issued Ed25519 assertion bound to a device-local key. It's cryptographically verifiable but marked `self-issued` — it identifies a device, not an account. |
| 120 | + |
| 121 | +To wire Write up to your own account system, set `VITE_EDITOR_IDP_CONFIG_URL` to a JSON endpoint that returns `issuer`, `audience`, `token_endpoint`, and `jwks_uri`. Its token endpoint must accept the device's public JWK plus a one-time nonce and return an RS256 assertion with a matching `cnf.jwk` claim. Write verifies issuer, audience, nonce, expiry, signing key, and device-key binding before trusting the result. External providers must use HTTPS in production; `http://localhost`, `http://127.0.0.1`, and `http://[::1]` are allowed in development only. |
| 122 | + |
| 123 | +## Project layout |
| 124 | + |
| 125 | +``` |
| 126 | +write/ |
| 127 | +├── app/ # The React + Vite screenplay editor (deployed as a static site / Cloudflare Worker) |
| 128 | +└── signaling/ # Standalone Cloudflare Worker for y-webrtc peer discovery |
| 129 | +``` |
| 130 | + |
| 131 | +## License |
| 132 | + |
| 133 | +[MIT](./LICENSE) — do what you like with it. If you build something on top of Write, we'd love to hear about it: [subscript.to](https://subscript.to). |
0 commit comments