Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ and every call is a door.

![Inside main(): the call room, with the if/else lanes ahead behind their tinted door frames](docs/media/hero.jpg)

Drop a zip of any JavaScript or TypeScript repository on the page, or pick a
folder. Nothing is uploaded: the code is parsed in your browser tab.
Paste the URL of any public JavaScript or TypeScript repository on GitHub, or
walk a local folder. The code is parsed in your browser tab, and a folder is
never uploaded. Once a repository is open, the address bar is its share link,
for example
[`?repo=valuecodes/code-backrooms`](https://code-backrooms.valuecodes.fi/?repo=valuecodes/code-backrooms).

A GitHub repository comes through this site's Cloudflare Worker as one zip
download from GitHub, after a Cloudflare Turnstile check that keeps bots from
using it as a proxy.

## What you are walking through

Expand Down Expand Up @@ -67,17 +74,17 @@ Then open http://localhost:3000.

## How it works

Everything runs in the browser tab. Code decides which rooms and doors exist.
Parsing and building run in the browser tab. Code decides which rooms and doors exist.
A seed taken from a hash of the source decides only sizes and placement, so
the same code always gives the same world.

```mermaid
flowchart TD
subgraph load["Load: apps/web"]
A["Zip, folder, or bundled example"] --> B["Source filter<br/>JS/TS files and manifests,<br/>size and count caps"]
A["GitHub repository, folder,<br/>or bundled example"] --> B["Source filter<br/>JS/TS files and manifests,<br/>size and count caps"]
end

subgraph analyse["Analyse: Web Worker for a zip or folder"]
subgraph analyse["Analyse: Web Worker for a repository or folder"]
B --> C["@repo/parser<br/>Babel AST per file"]
C --> D["CodeGraph<br/>modules, functions, calls,<br/>control-flow trees, imports"]
end
Expand Down
50 changes: 31 additions & 19 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ not be laid out`. The HUD reads `2/7 modules · last 14 ms` on the way;
whole world, on `?view=map` too; wider than 600 m the map hides rooms and
area labels and draws zone names larger, each within its band.
- Milestone 20: repository loading (Phase 7). A bare URL opens a
landing page: drop or pick a `.zip` (GitHub's "Download ZIP" works as
is), pick a folder, or follow Demo (`?code=self`, this repository's own
landing page: drop or pick a `.zip` (removed in Milestone 23), pick a
folder, or follow Demo (`?code=self`, this repository's own
sources) or a bundled example. Files are read in the tab and never leave
it, and a reload returns to the landing page. `source-filter.ts` keeps
sources (`.ts .tsx .mts .cts .js .jsx .mjs .cjs`) and manifests
Expand All @@ -175,10 +175,10 @@ not be laid out`. The HUD reads `2/7 modules · last 14 ms` on the way;
toward its first doorway and back while its gaze sways
(`landing-backdrop.tsx`, `driftPose` in `backdrop.ts`). It is skipped
without WebGL2 (it runs under `prefers-reduced-motion` too), swallowed by an error
boundary if the canvas fails, and dropped as soon as a read starts. The
boundary if the canvas fails, and darkened while a read runs. The
world view (`world-app.tsx`) and the backdrop are lazy chunks, so the
landing page paints before three.js loads.
- Milestone 22 (current): a public GitHub repository by URL. The landing
- Milestone 22: a public GitHub repository by URL. The landing
page takes `github.com/owner/repo`, `owner/repo` or a
`/tree/<ref>/<folder>` URL (`github-url.ts`; refs with `/` are not
supported). One load is one request to GitHub: the page calls this site's
Expand All @@ -191,16 +191,22 @@ not be laid out`. The HUD reads `2/7 modules · last 14 ms` on the way;
only once a URL is typed). Each IP is rate-limited to 5 loads a minute.
Answers are edge-cached for 10 minutes, and zips over 64 MB are refused or
cut off. A loaded repository writes `?repo=` so a reload or a shared link
fills the field in again. The visitor still presses open, because each
Turnstile token is single-use. Without a site key at build time the field
fills the field in again. Without a site key at build time the field
is hidden, so production never falls back to the test key.
- Milestone 23 (current): GitHub first. The landing page drops the zip
drop zone and picker: its card is the GitHub field, with a quiet "walk a
local folder" link below (the card's only button when the build has no
Turnstile key). A `?repo=` link opens by itself as soon as its check
passes, once, unless the field is edited first; the token is spent
through a ref so an auto-open and a click cannot send it twice. While a
read runs the backdrop stays and darkens over `FADE_MS`, and no read hands
over sooner, so the page fades into the loading screen instead of
cutting to black.

---

## Workspace Commands

| Task | Command |
| ---------- | ------------------------------ |
| Task | Command |
| ---------- | ------------------------------ |
| Dev | `pnpm --filter web dev` |
Expand All @@ -224,14 +230,20 @@ Or run repo-wide via root `pnpm typecheck` / `pnpm test` / `pnpm build`.

### Deploying (Cloudflare Workers Builds, repository connected in the dashboard)

- **Build settings:** set the root directory to the repository root. The build
command is `pnpm --filter web build`, and the deploy command is
`pnpm --filter web exec wrangler deploy`.
- **Build variables:** `PNPM_VERSION=12.4.2` and `NODE_VERSION=24.21.0`, since the
build image's pnpm default is older. Also set `VITE_TURNSTILE_SITE_KEY` to the
widget's site key. Vite inlines it at build time, so it is a _build_ variable.
- **Runtime secret:** `TURNSTILE_SECRET` under the Worker's Variables and Secrets.
`wrangler.jsonc` declares no value for it, so deploys keep it.
- **Worker:** `code-backrooms`, the `name` in `wrangler.jsonc`. Workers Builds
expects the connected Worker's name to match, and local `wrangler secret` and
`wrangler tail` target it without `--name`.
- **Build settings:** root directory `apps/web`, build command `pnpm run build`,
deploy command `npx wrangler deploy`.
- **Build variables:** `VITE_TURNSTILE_SITE_KEY`, the widget's site key. Vite
inlines it at build time, so it is a _build_ variable. Also set
`NODE_VERSION=24.21.0` and `PNPM_VERSION=12.4.2`: the version files sit
outside the `apps/web` root, and the build image's defaults are older.
- **Runtime secret:** `TURNSTILE_SECRET` under the Worker's Settings → Variables
and Secrets, type Secret, or `pnpm --filter web exec wrangler secret put
TURNSTILE_SECRET`. Never a build variable: those exist only during the build,
and the Worker then answers every GitHub load with 503 "not set up on this
server". `wrangler.jsonc` declares no value for it, so deploys keep it.
- **Turnstile widget:** create it in the Cloudflare dashboard. Its hostnames must
include the production domain, plus the `workers.dev` domain if preview builds
are on. A preview without its hostname refuses GitHub loads and everything else
Expand All @@ -252,7 +264,7 @@ src/
app.tsx landing page, loading screen, or the world as a lazy chunk
world-app.tsx the world: full-screen <Canvas>, seed and current-room
state, pointer lock, HUD
landing.tsx the bare URL's page: zip drop and pickers, GitHub URL, Demo, examples
landing.tsx the bare URL's page: GitHub URL, folder picker, Demo, examples
github-url.ts a GitHub URL -> owner, repo, ref, folder; checks shared with the Worker, pure
github-files.ts a GitHub repository -> sources: one call to the Worker, then zip-files
github-form.tsx the landing page's GitHub URL field, check and open button, markup only
Expand All @@ -263,10 +275,10 @@ src/
landing-backdrop.tsx the demo world drifting behind the landing page, lazy
backdrop.ts when the backdrop runs and where its camera is, pure
backdrop-world.ts the demo world for the backdrop, every area built
local-reads.ts one read at a time, zip and folder labels, read errors, pure
local-reads.ts one read at a time, folder labels, read errors, pure
source-request.ts what to build: nothing, a URL example, or local files, pure
source-filter.ts which zip or folder entries become sources, and the caps, pure
zip-files.ts a zip -> sources (fflate's sync API, two passes)
zip-files.ts a GitHub zip -> sources (fflate's sync API, two passes)
folder-files.ts a folder pick -> sources, reading only the kept files
use-source-files.ts a request's files: bundled at once, `self` as its own chunk
world-setup.ts a request -> everything a page needs to generate its world
Expand Down
81 changes: 52 additions & 29 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,24 @@ where the current function was entered, and R to the entrance.

## Loading a repository

A bare URL opens the landing page. Drop or pick a `.zip` (GitHub's
"Download ZIP" works as is) or pick a folder. Files are read in the browser
tab and never uploaded. Only JavaScript and TypeScript sources and manifests
are kept, and dependency, build and test directories are skipped.

You can also paste a public GitHub repository URL, such as
`github.com/owner/repo` or `github.com/owner/repo/tree/main/src` for one
folder. After a Cloudflare Turnstile check, this site's Worker downloads the
repository's zip from GitHub in one request and the page reads it like a
dropped zip. `?repo=owner/repo` fills the field in. Running this locally
needs `pnpm --filter web dev:worker` beside `dev`. See `AGENTS.md` for that
and for the deploy settings.
A bare URL opens the landing page. Paste a public GitHub repository URL, such
as `github.com/owner/repo`, or `github.com/owner/repo/tree/main/src` for one
folder, and press open once the Cloudflare Turnstile check passes. This
site's Worker downloads the repository's zip from GitHub in one request, and
the page reads it in the tab. Only JavaScript and TypeScript sources and
manifests are kept, and dependency, build and test directories are skipped.

A loaded repository's address is its share link: `?repo=owner/repo` fills the
field in and opens the repository as soon as the visitor's check passes,
usually with no click. Running this locally needs `pnpm --filter web
dev:worker` beside `dev`. See `AGENTS.md` for that.

For local or private code, "walk a local folder" picks a folder instead. It is
read in the browser tab and never uploaded.

| Limit | Value |
| ------------------- | ------ |
| Zip size | 64 MB |
| GitHub zip size | 64 MB |
| Kept source files | 400 |
| Kept bytes in total | 8 MB |
| One file | 256 KB |
Expand Down Expand Up @@ -96,23 +98,23 @@ at startup (`packages/renderer/src/textures.ts`), so there are no image assets.

## Deploy (Cloudflare)

The app deploys as static assets on a Cloudflare Worker. `wrangler.jsonc` serves
`dist/`, and `public/_headers` (copied into `dist/` by Vite) sets the security and
cache headers. `wrangler` is a pinned devDependency, so the deploy uses the lockfile
version rather than `npx`.
The app deploys as a Cloudflare Worker named `code-backrooms` (`wrangler.jsonc`).
It serves `dist/` as static assets and runs code only for `/api/*`, the GitHub
zip route (`src/api/`). `public/_headers` (copied into `dist/` by Vite) sets the
security and cache headers.

There is no CI deploy job. To connect the repo in the Cloudflare dashboard
(Workers & Pages → Create → Import a repository):

| Setting | Value |
| -------------- | --------------------------- |
| Root directory | `apps/web` |
| Build command | `pnpm build` |
| Deploy command | `pnpm exec wrangler deploy` |
| Setting | Value |
| -------------- | --------------------- |
| Root directory | `apps/web` |
| Build command | `pnpm run build` |
| Deploy command | `npx wrangler deploy` |

Also set these build variables (Settings → Build → Variables), so Cloudflare installs
dependencies with the repo's toolchain. The install runs before the build command,
so pinning versions there would be too late:
Also set these build variables (Settings → Build → Variables). With `apps/web`
as the root, the repository's `.nvmrc` and `packageManager` sit outside it, so the
build image is not guaranteed to pick them up, and its defaults are older:

| Variable | Value |
| -------------- | --------- |
Expand All @@ -121,10 +123,31 @@ so pinning versions there would be too late:

Keep them in step with `.nvmrc` and the root `packageManager` field.

### Turnstile

Loading from GitHub needs a Cloudflare Turnstile widget. Create one in the
dashboard (Turnstile → Add widget) with the production domain as a hostname,
then set its two keys in two different places:

| Key | Where |
| ------------------------- | ----------------------------------------------------------- |
| `VITE_TURNSTILE_SITE_KEY` | Settings → Build → Variables: Vite inlines it into the page |
| `TURNSTILE_SECRET` | Settings → Variables and Secrets, type Secret |

The secret must be a runtime secret, not a build variable: build variables exist
only while the site builds, and without the secret the Worker answers every
GitHub load with "loading from GitHub is not set up on this server". It can also
be set with `pnpm --filter web exec wrangler secret put TURNSTILE_SECRET`.
Without the site key the GitHub field is hidden and the folder picker is the
page's only loader. The Worker verifies each token with Turnstile's `siteverify`
before it fetches, and rate-limits each IP to 5 loads a minute.

To deploy from your machine instead, run `pnpm --filter web exec wrangler login` once, then
`pnpm --filter web deploy`.

The Content-Security-Policy in `public/_headers` allows only this origin plus
Cloudflare Web Analytics. Loading scripts, fonts, images or APIs from any other
origin needs a matching entry there, or the browser blocks it in production (the
Vite dev server does not apply `_headers`).
The Content-Security-Policy in `public/_headers` allows only this origin,
Cloudflare Web Analytics and Turnstile (`challenges.cloudflare.com` as a script
and a frame). Loading scripts, fonts, images or APIs from any other origin needs a
matching entry there, or the browser blocks it in production (the Vite dev server
does not apply `_headers`). Cloudflare's Bot Fight Mode injects an inline script
this policy blocks, so keep it off for this domain.
2 changes: 1 addition & 1 deletion apps/web/src/github-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { LocalFiles } from "~/zip-files";

// A public GitHub repository to the files of a code world: one request to
// this site's Worker (`api/github-zip.ts`), which fetches the repository's zip from
// GitHub, then the same zip reader as a dropped file.
// GitHub, then `zip-files.ts` reads it in the tab.

type Fetch = (url: string, init: RequestInit) => Promise<Response>;

Expand Down
54 changes: 35 additions & 19 deletions apps/web/src/landing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ const render = (
<LandingView
reading={state.reading ?? null}
error={state.error ?? null}
dragging={false}
onZip={ignore}
onFolder={ignore}
onDragging={ignore}
github={
state.github === undefined
? undefined
Expand All @@ -46,20 +43,42 @@ describe("LandingView", () => {
}
});

it("offers a zip picker and a folder picker", () => {
const html = render();
expect(html).toContain('accept=".zip,application/zip"');
expect(html).toMatch(/<input[^>]*webkitdirectory=""[^>]*>/);
expect(html).not.toContain('disabled=""');
const GITHUB = { url: "", valid: false, ready: false };

it("offers no zip picker, and one folder picker either way", () => {
for (const html of [render(), render({ github: GITHUB })]) {
expect(html).not.toContain(".zip");
expect(html.match(/<input[^>]*webkitdirectory=""[^>]*>/g)).toHaveLength(
1
);
expect(html).not.toMatch(/<input[^>]*disabled=""/);
}
});

it("makes the folder the main action without a Turnstile key", () => {
expect(render()).toContain("choose a folder");
expect(render()).toContain("never uploaded");
const html = render({ github: GITHUB });
expect(html).not.toContain("choose a folder");
expect(html).toContain("walk a local folder");
expect(html).toContain("public repositories");
});

it("disables both pickers and drops the links while reading", () => {
const html = render({ reading: "repo-main.zip" });
expect(html).toContain("reading repo-main.zip…");
expect(html.match(/<input[^>]*disabled=""/g)).toHaveLength(2);
it("disables the picker, shows the status and drops the links while reading", () => {
const html = render({ reading: "reading my-repo…" });
expect(html).toContain("reading my-repo…");
expect(html.match(/<input[^>]*disabled=""/g)).toHaveLength(1);
expect(html).not.toContain("href=");
});

it("darkens the backdrop while reading instead of dropping it", () => {
const backdrop = <canvas id="drift" />;
expect(render({ backdrop })).toContain("opacity-45");
const html = render({ backdrop, reading: "reading my-repo…" });
expect(html).toContain('<canvas id="drift">');
expect(html).toContain("opacity-90");
});

it("draws a backdrop behind the page, hidden from screen readers", () => {
expect(render()).not.toContain("aria-hidden");
const html = render({ backdrop: <canvas id="drift" /> });
Expand All @@ -69,10 +88,6 @@ describe("LandingView", () => {
expect(html).toContain('href="?code=self"');
});

it("says that nothing is uploaded", () => {
expect(render()).toContain("nothing is uploaded");
});

it("has no GitHub field when the build has no Turnstile key", () => {
expect(render()).not.toContain("GitHub repository URL");
});
Expand Down Expand Up @@ -101,17 +116,18 @@ describe("LandingView", () => {

it("disables the GitHub field and hides the check while reading", () => {
const html = render({
reading: "o/r",
reading: "downloading o/r from GitHub…",
github: { url: "o/r", valid: true, ready: true },
});
expect(html).toContain("downloading o/r from GitHub…");
expect(html).toMatch(/<button type="submit"[^>]*disabled=""/);
expect(html).not.toContain('id="check"');
});

it("shows a failed read's message as an alert", () => {
const html = render({ error: "not a zip file" });
const html = render({ error: "o/r not found, or it is private" });
expect(html).toContain(
'<p role="alert" class="text-red-300">not a zip file</p>'
'<p role="alert" class="text-red-300">o/r not found, or it is private</p>'
);
});
});
Loading
Loading