# fishi

> fishi ships a Node.js + SQLite app to a public URL with one HTTP call. No signup, no `npm install`.

Base URL: `https://fishi.dev` — set `FISHI=https://fishi.dev` for the commands below.

## Ship

```bash
cat > index.js <<'EOF'
const express = require("express");
const Database = require("better-sqlite3");

const db = new Database(process.env.DATABASE_PATH);
db.exec("CREATE TABLE IF NOT EXISTS entries (id INTEGER PRIMARY KEY AUTOINCREMENT, message TEXT NOT NULL)");

const app = express();
app.use(express.json());

app.get("/", (req, res) => {
  const entries = db.prepare("SELECT id, message FROM entries ORDER BY id").all();
  res.json({ entries });
});

app.post("/entries", (req, res) => {
  const message = req.body?.message ?? "";
  db.prepare("INSERT INTO entries (message) VALUES (?)").run(message);
  res.status(201).json({ ok: true });
});

app.listen(process.env.PORT, () => console.log("listening"));
EOF

curl -sS -X POST $FISHI/ship \
  -H "Content-Type: application/javascript" \
  --data-binary @index.js
# expect: status=running
```

The body above is source text, never an archive — don't `tar`/`zip` it. This raw form ships exactly one file, saved as `index.js`; for more files, or a `package.json`, use the JSON form below.

Response:

```json
{
  "app": { "name": "quiet-otter-a1b2", "url": "https://quiet-otter-a1b2.fishi.dev" },
  "token": "fs_9f2c4b1e...",
  "status": "running",
  "logs": "listening\n",
  "manage": {
    "status": "GET /apps/quiet-otter-a1b2",
    "logs": "GET /apps/quiet-otter-a1b2/logs",
    "env": "PUT /apps/quiet-otter-a1b2/env",
    "delete": "DELETE /apps/quiet-otter-a1b2",
    "redeploy": "POST /ship with Authorization: Bearer <token>"
  }
}
```

The app is live at `app.url` immediately. Save `token` now — it is shown exactly once. Anyone who has it can redeploy or delete this app. The commands below use `$TOKEN` and `$NAME` for `token` and `app.name` from this response.

## Rules the app must follow

- Listen on `process.env.PORT`, on `0.0.0.0`, within 10 seconds of boot.
- Store data at `process.env.DATABASE_PATH` (or anywhere under `process.env.DATA_DIR`, i.e. `/data`). It survives redeploys and restarts. `/tmp` is also writable (not persisted). Everything else is read-only.
- `process.env.FISHI_URL` is the app's public URL, for absolute links.
- No `npm install`. Only these packages are preinstalled, plus Node 24 builtins (including `node:sqlite`):
  `@hono/node-server@2.1.1`, `bcryptjs@3.0.3`, `better-sqlite3@13.0.3`, `cookie-parser@1.4.7`, `cors@2.8.6`, `express@5.2.1`, `hono@4.13.8`, `marked@18.0.14`, `nanoid@6.0.1`, `ws@8.21.3`, `zod@4.6.5`.
- Entry file is auto-detected: `index.js`, `server.js`, `app.js`, `index.mjs`, `index.cjs`, `index.ts`, or a `package.json` with `"main"` pointing at one of your files.
- Files are CommonJS by default. For ESM, add `"type": "module"` to `package.json`, or use a `.mjs` entry.
- In a shipped `package.json`, only `"type"` and `"main"` are read. A `"dependencies"` field is ignored — nothing is installed; ship only the preinstalled packages above.
- Outbound requests can only reach the public internet — no private/internal IP ranges.

## Statuses

`ship` and redeploy both return `status`:

| status | meaning | what to do |
|---|---|---|
| `running` | booted and listening | nothing — it's live |
| `crashed` | the process exited | read `logs` and `hint`, fix, redeploy |
| `unhealthy` | didn't bind to the port in time | read `logs` and `hint`, fix, redeploy |
| `syntax_error` (HTTP 422) | a file didn't parse | fix the line shown in `logs`, redeploy — nothing was deployed (on a redeploy the previous version keeps running); no `token`/`manage` in this response |

Debug loop: read `logs` and `hint` in the ship response first. Need more? `GET /apps/:name` returns `recent_errors`; `GET /apps/:name/logs` returns raw container output. Fix, then redeploy with the same token. A response header `X-Fishi-Cold-Start: 1` on a request to the app just means it was asleep and woke up (well under a second) — not an error.

## Multiple files, redeploy, logs, env, delete

`POST /ship` with `Content-Type: application/json` to include multiple files or set env:

```json
{
  "files": {
    "index.js": "...",
    "lib/db.js": "...",
    "package.json": "{ \"type\": \"module\" }"
  },
  "env": { "API_KEY": "sk-example" }
}
```

Every app gets a random name like `quiet-otter-a1b2`; choosing your own name is not available. `files` maps relative paths to file content. `env` is optional.

Redeploy by sending the app's token; the URL and everything under `/data` are kept:

```bash
curl -sS -X POST $FISHI/ship \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files":{"index.js":"require(\"node:http\").createServer((_q,r)=>r.end(\"ok v2\")).listen(process.env.PORT);"}}'
# expect: status=running
```

Status and recent requests/errors:

```bash
curl -sS "$FISHI/apps/$NAME?token=$TOKEN"
# expect: status=running
```

Raw container logs:

```bash
curl -sS "$FISHI/apps/$NAME/logs?tail=200&token=$TOKEN"
```

Set secrets — this replaces the whole env set and redeploys:

```bash
curl -sS -X PUT "$FISHI/apps/$NAME/env" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"API_KEY":"sk-example"}'
# expect: status=running
```

Keys are uppercase letters, digits and underscores. `PORT`, `NODE_OPTIONS`, `PATH`, `HOME` and any key starting with `LD_` are reserved and rejected.

Delete:

```bash
curl -sS -X DELETE "$FISHI/apps/$NAME" -H "Authorization: Bearer $TOKEN"
# expect: deleted=$NAME
```

## Limits (free, unclaimed)

| limit | value |
|---|---|
| create | 10/hour, 30/day per IP |
| redeploys | 60/hour per app |
| live apps | 20 per IP |
| source | 2 MB, 50 files |
| data | 100 MB |
| request body | 10 MB |
| per-app resources | 128 MB memory, 0.5 CPU |
| idle | sleeps after 5 min, wakes on the next request |

Apps with no traffic for 7 days are deleted. Claiming an app to keep it past that is coming soon.

## Errors

Every non-2xx response is `{ "error": "...", "hint": "..." }` — the hint says what to do next. On `429`, wait for the number of seconds in the `Retry-After` header before retrying. `410` means the app is over its data limit — delete it and ship again. `451` means the app was blocked by the operator.

## If you can only fetch URLs (no POST)

Coming soon.
