# @leumas/hosting-upload

What gets deployed and what never does — the upload rules shared by Studio's drag-and-drop uploader and the agent lane, plus the Node walker and pooled uploader.


**What gets deployed, and what never does — decided once.**

The same decisions have to be made in two places that share no runtime: Studio's drag-and-drop
uploader (a browser holding `File` handles) and the agent lane (a Node process on somebody's laptop
holding paths). Both must strip the same wrapper folder, skip the same directories and refuse the
same files.

The consequence of them disagreeing is not cosmetic. **`.env` is on the skip list because a bundle
directory is served to the public internet** — a second implementation that forgets it publishes a
secret, and does it silently. So the rules live here once, in a module that imports nothing.

## The one contract that made it sharable

An entry is `{ rel, size, file?, path? }` — **not** `{ rel, file }`. The planner reads `size` and
carries everything else through untouched, so a browser passes `{ file, size: file.size, rel }` and
the Node walker passes `{ path, size, rel }`. Neither runtime gets to decide policy.

## Exports

| Import | What |
|---|---|
| `@leumas/hosting-upload` | the rules. Pure, no `node:` imports, safe in a browser bundle |
| `@leumas/hosting-upload/node` | `walkDir`, `uploadOne`, `uploadPlan` — real files, real bytes |

`planUpload(entries)` → `{ files, skipped, stripped, buildDir, oversized, bytes, count }`.
`rerootToBuildDir(plan)` accepts the `buildDir` offer. `looksLikeSpa(files)` answers the one
question that actually breaks deploys in the field.

**The barrel is the rules only, deliberately** — `./node` imports `node:fs`, and a browser bundling
the barrel would pull it in.

## Why `looksLikeSpa` exists

`target.spa` rewrites unknown paths to `index.html`. Getting it wrong is *the* failure this feature
has in production: the home page is perfect and every deep link 404s, so nothing catches it — not a
smoke test, not a glance at the deployed site. It answers yes only to the shape a single-page app
actually has (one `.html`, at the root), because a multi-page static site with SPA on turns every
404 into the home page — the opposite failure, and equally silent.

## Why one file per request

The upload route takes a single file as a raw stream; `express.json()` is deliberately **not**
mounted on it, which is the only reason a build can travel at all. A base64 tarball through a tool
argument is capped by the `/mcp` body limit and inflated 4/3 by the encoding on top — this repo has
already paid for that once, at 78 MB against a 2 MB limit.

`uploadPlan` therefore runs a bounded pool and reports `ok` only when **every** file landed. A caller
must not point `target.dir` at a partially uploaded build: it serves a broken site with no error
anywhere. Two refusals stop the run early rather than repeating — `storage_full` and a rejected
credential both make every remaining request fail identically, and a hundred identical errors buries
the one line that says what to do.

## Limits it respects

- **25 MB per file** (`MAX_UPLOAD_BYTES`, mirrors the server, enforced there twice)
- **Per site**: the owner's `imperium.siteStorageBytes` — 50 MB free, 250 MB on the plan
- `DENY_EXT` / `DENY_NAME` are a **mirror** of the server's list, checked client-side to fail fast
  with a better message. Never the guard.


---
Source: shared/packages/hosting-upload/README.md
Canonical: https://docs.leumas.tech/p/packages/hosting-upload
