supabase-worker-previews 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +152 -0
- package/dist/cloudflare.d.ts +37 -0
- package/dist/cloudflare.js +95 -0
- package/dist/commands/branches.d.ts +62 -0
- package/dist/commands/branches.js +273 -0
- package/dist/commands/doctor.d.ts +24 -0
- package/dist/commands/doctor.js +289 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +132 -0
- package/dist/commands/pr.d.ts +37 -0
- package/dist/commands/pr.js +78 -0
- package/dist/commands/prune.d.ts +32 -0
- package/dist/commands/prune.js +110 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +105 -0
- package/dist/feedback.d.ts +64 -0
- package/dist/feedback.js +238 -0
- package/dist/github.d.ts +33 -0
- package/dist/github.js +78 -0
- package/dist/http.d.ts +7 -0
- package/dist/http.js +44 -0
- package/dist/jsonc.d.ts +2 -0
- package/dist/jsonc.js +56 -0
- package/dist/preview-name.d.ts +3 -0
- package/dist/preview-name.js +15 -0
- package/dist/run.d.ts +21 -0
- package/dist/run.js +59 -0
- package/dist/runtime.d.ts +56 -0
- package/dist/runtime.js +121 -0
- package/dist/style.d.ts +8 -0
- package/dist/style.js +21 -0
- package/dist/suggest.d.ts +2 -0
- package/dist/suggest.js +22 -0
- package/dist/supabase.d.ts +78 -0
- package/dist/supabase.js +168 -0
- package/dist/usage.d.ts +46 -0
- package/dist/usage.js +297 -0
- package/package.json +84 -9
- package/templates/default-privileges.sql +9 -0
- package/templates/supabase-previews-action.yml +29 -0
- package/templates/supabase-previews.yml +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Ruby
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1 +1,143 @@
|
|
|
1
|
-
|
|
1
|
+
# supabase-worker-previews
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/supabase-worker-previews)
|
|
4
|
+
[](https://github.com/mattruby/supabase-worker-previews/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**Vercel-style PR Previews for a Cloudflare Worker on Supabase, each on the right database, with a check that proves it.**
|
|
8
|
+
|
|
9
|
+
Every branch gets a [Worker Preview](https://developers.cloudflare.com/workers/previews/) on a shared Preview database. Every PR that changes `supabase/` gets its own database, migrated and seeded from the PR. One build serves all of them, and no Preview ever serves production.
|
|
10
|
+
|
|
11
|
+
## What you get
|
|
12
|
+
|
|
13
|
+
On every pull request, one comment that stays up to date:
|
|
14
|
+
|
|
15
|
+
> **Supabase Worker Preview** for `my-app`
|
|
16
|
+
>
|
|
17
|
+
> | Status | Preview | Database | Commit | Updated (UTC) |
|
|
18
|
+
> | :--------------------- | :----------------- | :--------------------------------------------- | :------------- | :--------------- |
|
|
19
|
+
> | **Passed** ([logs](#)) | [Visit Preview](#) | Shared [`preview`](#) (`abcdefghijklmnopqrst`) | [`1a2b3c4`](#) | 2026-10-01 03:10 |
|
|
20
|
+
|
|
21
|
+
Plus:
|
|
22
|
+
|
|
23
|
+
- a **View deployment** button on the PR, pointing at the Preview;
|
|
24
|
+
- a **Preview database** check that fails if the Preview serves the wrong database, and fails at once if it serves production;
|
|
25
|
+
- cleanup when the PR closes: the Preview and its own database are deleted, and the comment says so.
|
|
26
|
+
|
|
27
|
+
**You need this if** your app runs on Cloudflare Workers, deploys with [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), keeps its data in Supabase, and you want every PR to have a working preview that cannot touch production data. If you are on Vercel, Supabase's own integration already does this; see [Compared with Vercel](docs/vs-vercel.md).
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
```mermaid
|
|
32
|
+
flowchart LR
|
|
33
|
+
push["git push"] --> builds["Workers Builds"]
|
|
34
|
+
builds -- trunk --> prod["Production Worker"]
|
|
35
|
+
builds -- any other branch --> preview["Worker Preview<br/>npx wrangler preview"]
|
|
36
|
+
prod --> proddb[("Supabase project<br/>(production)")]
|
|
37
|
+
preview -- "previews.vars" --> shareddb[("Shared Preview database<br/>persistent branch 'preview'")]
|
|
38
|
+
pr["PR changes supabase/<br/>or has label isolated-db"] --> integ["Supabase GitHub integration"]
|
|
39
|
+
integ --> prdb[("The PR's own database")]
|
|
40
|
+
pr --> action["GitHub Actions: swp pr"]
|
|
41
|
+
action -- "SUPABASE_OVERRIDE secret" --> preview
|
|
42
|
+
preview -. "PRs with their own database" .-> prdb
|
|
43
|
+
action -- "swp check reads<br/>/.well-known/supabase-preview" --> preview
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`swp` adds no infrastructure. Cloudflare builds the Previews and Supabase makes and migrates the databases. `swp` connects the two:
|
|
47
|
+
|
|
48
|
+
1. **Every Preview starts on the shared Preview database**, a persistent Supabase branch that tracks your trunk. Its public URL and key live in your wrangler config's `previews.vars`.
|
|
49
|
+
2. **A PR that changes the schema gets its own database.** The Supabase GitHub integration creates it; `swp pr` points the PR's Preview at it with one secret, `SUPABASE_OVERRIDE`.
|
|
50
|
+
3. **The Worker reads its Supabase settings per request.** `withSupabasePreviews()` applies the override and injects the public config into each HTML page, so no build-time `VITE_SUPABASE_URL` pins a database into the bundle.
|
|
51
|
+
4. **`swp check` asks the Preview which database it serves** and fails the PR unless it is the right one.
|
|
52
|
+
|
|
53
|
+
New to Worker Previews or Supabase branching? [Concepts](docs/concepts.md) explains both in five minutes.
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
You need a Worker deployed by Workers Builds from a GitHub repo, wrangler 4.135.0 or later, Node 22 or later, and a Supabase project on a plan with branching.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install --save-dev supabase-worker-previews
|
|
61
|
+
npx supabase-worker-previews init --project-ref <production project ref>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`init` writes `swp.config.json`, a grants migration and the PR workflow, and prints the rest. Then:
|
|
65
|
+
|
|
66
|
+
1. Connect the Supabase GitHub integration (automatic branching on, deploy to production off).
|
|
67
|
+
2. Add a `previews` block to your wrangler config.
|
|
68
|
+
3. `npx supabase-worker-previews shared` creates the shared Preview database and prints its `previews.vars`.
|
|
69
|
+
4. Wrap the Worker:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { withSupabasePreviews } from "supabase-worker-previews";
|
|
73
|
+
import app from "./app";
|
|
74
|
+
|
|
75
|
+
export default withSupabasePreviews(app);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
and create the browser client from `readPublicConfig()`.
|
|
79
|
+
|
|
80
|
+
5. Turn on Preview builds in Workers Builds, add three Actions secrets, run `npx supabase-worker-previews doctor`, and open a PR.
|
|
81
|
+
|
|
82
|
+
The **[full quickstart](docs/quickstart.md)** walks through every step with the dashboard settings and the output to expect. For a complete working project, see [`examples/hono-notes`](examples/hono-notes).
|
|
83
|
+
|
|
84
|
+
## Commands
|
|
85
|
+
|
|
86
|
+
| Group | Command | Does |
|
|
87
|
+
| -------------- | ------------ | ------------------------------------------------------------------------------------ |
|
|
88
|
+
| **Set up** | `swp init` | Scaffold `swp.config.json`, the grants migration and the workflow (never overwrites) |
|
|
89
|
+
| | `swp doctor` | Check the whole setup, offline and (with a token) against Supabase |
|
|
90
|
+
| | `swp shared` | Create or repair the shared Preview database; prints its `previews.vars` |
|
|
91
|
+
| **Per branch** | `swp up` | Give a branch's Preview its own database |
|
|
92
|
+
| | `swp check` | Fail unless the Preview serves the right database |
|
|
93
|
+
| | `swp down` | Delete a branch's Preview and its own database |
|
|
94
|
+
| **In CI** | `swp pr` | All of the above for a `pull_request` workflow, plus the PR comment and deployment |
|
|
95
|
+
| | `swp prune` | List leftovers of deleted branches and closed PRs; delete them with `--yes` |
|
|
96
|
+
|
|
97
|
+
`swp` is the short name once the package is installed; the full name `supabase-worker-previews` runs the same CLI. With `npx`, always use the full name: `npx swp` on a machine without this package installed downloads [an unrelated npm package named `swp`](https://www.npmjs.com/package/swp) that deletes dependency and build folders.
|
|
98
|
+
|
|
99
|
+
`swp <command> --help` shows one command's flags and examples, and `swp --version` prints the version. Flags, environment variables, every `swp.config.json` field and the GitHub Action inputs are in the [configuration reference](docs/configuration.md).
|
|
100
|
+
|
|
101
|
+
## Safety
|
|
102
|
+
|
|
103
|
+
- `swp` never writes to, repoints or deletes the production project, and never deletes a persistent branch.
|
|
104
|
+
- `swp check` fails the first time a Preview serves production, with no retry.
|
|
105
|
+
- `swp doctor` fails if `previews.vars` names production or holds a secret.
|
|
106
|
+
- `swp prune` only lists until you pass `--yes`, and every command that changes something takes `--dry-run`.
|
|
107
|
+
|
|
108
|
+
[Security](docs/security.md) covers what `swp` can touch and what each token can reach.
|
|
109
|
+
|
|
110
|
+
## Docs
|
|
111
|
+
|
|
112
|
+
| Page | For |
|
|
113
|
+
| ------------------------------------------ | ------------------------------------------------------------------ |
|
|
114
|
+
| [Concepts](docs/concepts.md) | Worker Previews, Supabase branches, and how `swp` joins them |
|
|
115
|
+
| [Quickstart](docs/quickstart.md) | From an existing Worker to the first working PR Preview |
|
|
116
|
+
| [Frameworks](docs/README.md#frameworks) | TanStack Start, Hono, React Router v7, Astro |
|
|
117
|
+
| [Troubleshooting](docs/troubleshooting.md) | Symptoms, causes and fixes |
|
|
118
|
+
| [How it works](docs/how-it-works.md) | Every moving part and why it is built that way |
|
|
119
|
+
| [Configuration](docs/configuration.md) | Commands, flags, `swp.config.json`, the GitHub Action, the runtime |
|
|
120
|
+
| [Tokens](docs/tokens.md) | Least-privilege Cloudflare, Supabase and GitHub credentials |
|
|
121
|
+
| [Security](docs/security.md) | What `swp` can touch, what is public, what is secret |
|
|
122
|
+
| [Compared with Vercel](docs/vs-vercel.md) | When Vercel and its Supabase integration are the better fit |
|
|
123
|
+
|
|
124
|
+
The full index is [docs/README.md](docs/README.md).
|
|
125
|
+
|
|
126
|
+
## Claude Code plugin
|
|
127
|
+
|
|
128
|
+
The repo is also a Claude Code plugin. Its skill carries the [measured platform behaviour](skills/supabase-worker-previews/references/gotchas.md) behind every design choice, so Claude can set up and debug Previews with it:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
/plugin marketplace add mattruby/supabase-worker-previews
|
|
132
|
+
/plugin install supabase-worker-previews
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Status
|
|
136
|
+
|
|
137
|
+
**Beta.** Cloudflare [launched Worker Previews on 2026-09-22](https://developers.cloudflare.com/changelog/post/2026-09-22-worker-previews/) as an open beta, and this package is 0.x. Expect both the platform and the CLI to change. Bug reports and platform findings are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
138
|
+
|
|
139
|
+
Not affiliated with Supabase or Cloudflare.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE)
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { CloudflareApi } from "./cloudflare.js";
|
|
5
|
+
import { loadConfig, migrationFiles } from "./config.js";
|
|
6
|
+
import { check, down, shared, up } from "./commands/branches.js";
|
|
7
|
+
import { doctor, formatReport } from "./commands/doctor.js";
|
|
8
|
+
import { init } from "./commands/init.js";
|
|
9
|
+
import { prune } from "./commands/prune.js";
|
|
10
|
+
import { annotation, pr, readEvent, skipReason } from "./commands/pr.js";
|
|
11
|
+
import { makeRunner, sleep } from "./run.js";
|
|
12
|
+
import { err as errStyle, out } from "./style.js";
|
|
13
|
+
import { SupabaseApi } from "./supabase.js";
|
|
14
|
+
import { formatError, missingEnvMessage, resolveInvocation, version } from "./usage.js";
|
|
15
|
+
function git(args) {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function currentBranch(flag) {
|
|
24
|
+
if (typeof flag === "string")
|
|
25
|
+
return flag;
|
|
26
|
+
if (process.env.WORKERS_CI_BRANCH)
|
|
27
|
+
return process.env.WORKERS_CI_BRANCH;
|
|
28
|
+
if (process.env.GITHUB_HEAD_REF)
|
|
29
|
+
return process.env.GITHUB_HEAD_REF;
|
|
30
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
31
|
+
if (!branch)
|
|
32
|
+
throw new Error("Cannot tell the git branch here (not a git checkout?). Pass --branch <name>.");
|
|
33
|
+
if (branch === "HEAD")
|
|
34
|
+
throw new Error("HEAD is detached, so there is no branch to use. Pass --branch <name>.");
|
|
35
|
+
return branch;
|
|
36
|
+
}
|
|
37
|
+
/** `owner/name` from a github.com origin remote, over https or ssh. */
|
|
38
|
+
function originRepo() {
|
|
39
|
+
const url = git(["remote", "get-url", "origin"]);
|
|
40
|
+
return url?.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/)?.[1];
|
|
41
|
+
}
|
|
42
|
+
function env(name) {
|
|
43
|
+
return process.env[name] || `<${name}>`;
|
|
44
|
+
}
|
|
45
|
+
function str(v) {
|
|
46
|
+
return typeof v === "string" ? v : undefined;
|
|
47
|
+
}
|
|
48
|
+
function prNumber(flag) {
|
|
49
|
+
if (flag === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
const n = Number(flag);
|
|
52
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
53
|
+
throw new Error(`--pr takes a pull request number, not "${String(flag)}"`);
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
async function main(argv) {
|
|
57
|
+
const invocation = resolveInvocation(argv, out);
|
|
58
|
+
if (invocation.kind === "help") {
|
|
59
|
+
console.log(invocation.text);
|
|
60
|
+
return invocation.code;
|
|
61
|
+
}
|
|
62
|
+
if (invocation.kind === "version") {
|
|
63
|
+
console.log(version());
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
if (invocation.kind === "error") {
|
|
67
|
+
console.error(`${errStyle.red("swp:")} ${invocation.message}`);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
const { command, flags } = invocation;
|
|
71
|
+
const dryRun = flags["dry-run"] === true;
|
|
72
|
+
const envFile = str(flags.dotenv) ?? (existsSync(".env.swp") ? ".env.swp" : undefined);
|
|
73
|
+
if (envFile) {
|
|
74
|
+
if (!existsSync(envFile))
|
|
75
|
+
throw new Error(`--dotenv ${envFile}: no such file`);
|
|
76
|
+
process.loadEnvFile(envFile);
|
|
77
|
+
}
|
|
78
|
+
if (command === "init") {
|
|
79
|
+
init({
|
|
80
|
+
supabaseProjectRef: str(flags["project-ref"]),
|
|
81
|
+
trunk: str(flags.trunk),
|
|
82
|
+
action: flags.action === true,
|
|
83
|
+
dryRun,
|
|
84
|
+
log: (line) => console.log(line.startsWith("+ ") ? out.green(line) : line.startsWith("- ") ? out.dim(line) : line),
|
|
85
|
+
});
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
const overrides = {
|
|
89
|
+
worker: str(flags.worker),
|
|
90
|
+
supabaseProjectRef: str(flags["project-ref"]),
|
|
91
|
+
trunk: str(flags.trunk),
|
|
92
|
+
prComment: flags["no-comment"] ? false : undefined,
|
|
93
|
+
githubDeployments: flags["no-deployments"] ? false : undefined,
|
|
94
|
+
};
|
|
95
|
+
if (command === "doctor") {
|
|
96
|
+
const token = process.env.SUPABASE_ACCESS_TOKEN;
|
|
97
|
+
const report = formatReport(await doctor(overrides, token ? new SupabaseApi(token) : undefined), out);
|
|
98
|
+
console.log(report.text);
|
|
99
|
+
return report.pass ? 0 : 1;
|
|
100
|
+
}
|
|
101
|
+
const config = loadConfig(overrides);
|
|
102
|
+
const prNum = prNumber(flags.pr);
|
|
103
|
+
const event = command === "pr" ? readEvent() : undefined;
|
|
104
|
+
const skip = event && skipReason(event);
|
|
105
|
+
if (skip) {
|
|
106
|
+
console.log(annotation(skip));
|
|
107
|
+
return skip.level === "error" ? 1 : 0;
|
|
108
|
+
}
|
|
109
|
+
const repo = command === "prune" ? (str(flags.repo) ?? process.env.GITHUB_REPOSITORY ?? originRepo()) : undefined;
|
|
110
|
+
if (command === "prune" && !repo)
|
|
111
|
+
throw new Error("Cannot tell the GitHub repo: pass --repo <owner/name>, or set GITHUB_REPOSITORY.");
|
|
112
|
+
const missing = missingEnvMessage(command, dryRun);
|
|
113
|
+
if (missing)
|
|
114
|
+
throw new Error(missing);
|
|
115
|
+
const runner = makeRunner(dryRun, (line) => console.log(line.startsWith(" $ ") ? out.dim(line) : line));
|
|
116
|
+
if (dryRun)
|
|
117
|
+
runner.log(out.dim("Dry run: reads only, changes nothing."));
|
|
118
|
+
const deps = {
|
|
119
|
+
config,
|
|
120
|
+
runner,
|
|
121
|
+
supabase: new SupabaseApi(env("SUPABASE_ACCESS_TOKEN")),
|
|
122
|
+
cloudflare: new CloudflareApi(runner, { apiToken: env("CLOUDFLARE_API_TOKEN"), accountId: env("CLOUDFLARE_ACCOUNT_ID") }, config.worker),
|
|
123
|
+
migrationCount: () => migrationFiles(config).length,
|
|
124
|
+
sleep,
|
|
125
|
+
fetchImpl: fetch,
|
|
126
|
+
};
|
|
127
|
+
switch (command) {
|
|
128
|
+
case "shared":
|
|
129
|
+
await shared(deps);
|
|
130
|
+
break;
|
|
131
|
+
case "up":
|
|
132
|
+
await up(currentBranch(flags.branch), deps, prNum);
|
|
133
|
+
break;
|
|
134
|
+
case "check":
|
|
135
|
+
await check(currentBranch(flags.branch), flags.isolated === true, deps, prNum);
|
|
136
|
+
break;
|
|
137
|
+
case "down":
|
|
138
|
+
await down(currentBranch(flags.branch), deps, prNum);
|
|
139
|
+
break;
|
|
140
|
+
case "pr":
|
|
141
|
+
await pr(event, { ...deps, githubToken: env("GITHUB_TOKEN") });
|
|
142
|
+
break;
|
|
143
|
+
case "prune":
|
|
144
|
+
await prune({ ...deps, githubToken: env("GITHUB_TOKEN"), repo: repo, yes: flags.yes === true });
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
150
|
+
console.error(`${errStyle.red("swp:")} ${formatError(err)}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Runner } from "./run.js";
|
|
2
|
+
export type CloudflareAuth = {
|
|
3
|
+
apiToken: string;
|
|
4
|
+
accountId: string;
|
|
5
|
+
};
|
|
6
|
+
export type PreviewRecord = {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
urls: string[];
|
|
11
|
+
deployed_on: string | null;
|
|
12
|
+
};
|
|
13
|
+
/** Workers Builds names a Preview after the raw git branch; Cloudflare derives the slug and hostname. */
|
|
14
|
+
export declare function matchPreview(previews: PreviewRecord[], gitBranch: string): PreviewRecord | undefined;
|
|
15
|
+
/** Preview operations go through wrangler; only the subdomain lookup is a direct API call. */
|
|
16
|
+
export declare class CloudflareApi {
|
|
17
|
+
private runner;
|
|
18
|
+
private auth;
|
|
19
|
+
private worker;
|
|
20
|
+
private fetchImpl;
|
|
21
|
+
private subdomain?;
|
|
22
|
+
constructor(runner: Runner, auth: CloudflareAuth, worker: string, fetchImpl?: typeof fetch);
|
|
23
|
+
private get env();
|
|
24
|
+
private wrangler;
|
|
25
|
+
private api;
|
|
26
|
+
workersSubdomain(): Promise<string>;
|
|
27
|
+
listPreviews(): Promise<PreviewRecord[]>;
|
|
28
|
+
findPreview(gitBranch: string): Promise<PreviewRecord | undefined>;
|
|
29
|
+
/** Writing to a Preview creates a new deployment of it. */
|
|
30
|
+
putPreviewSecrets(preview: string, secrets: Record<string, string>): void;
|
|
31
|
+
listPreviewSecrets(preview: string): string[];
|
|
32
|
+
/** Creates a new deployment of the Preview, like any secret write. */
|
|
33
|
+
deletePreviewSecret(preview: string, key: string): void;
|
|
34
|
+
/** Merges: keys absent from `secrets` stay. Only Previews created afterwards copy it. */
|
|
35
|
+
putBaseSecrets(secrets: Record<string, string>): void;
|
|
36
|
+
deletePreview(preview: string): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { apiErrorMessage } from "./http.js";
|
|
2
|
+
import { previewName } from "./preview-name.js";
|
|
3
|
+
/** Workers Builds names a Preview after the raw git branch; Cloudflare derives the slug and hostname. */
|
|
4
|
+
export function matchPreview(previews, gitBranch) {
|
|
5
|
+
return (previews.find((p) => p.name === gitBranch) ?? previews.find((p) => p.slug === previewName(gitBranch)));
|
|
6
|
+
}
|
|
7
|
+
/** Preview operations go through wrangler; only the subdomain lookup is a direct API call. */
|
|
8
|
+
export class CloudflareApi {
|
|
9
|
+
runner;
|
|
10
|
+
auth;
|
|
11
|
+
worker;
|
|
12
|
+
fetchImpl;
|
|
13
|
+
subdomain;
|
|
14
|
+
constructor(runner, auth, worker, fetchImpl = fetch) {
|
|
15
|
+
this.runner = runner;
|
|
16
|
+
this.auth = auth;
|
|
17
|
+
this.worker = worker;
|
|
18
|
+
this.fetchImpl = fetchImpl;
|
|
19
|
+
}
|
|
20
|
+
get env() {
|
|
21
|
+
return { CLOUDFLARE_API_TOKEN: this.auth.apiToken, CLOUDFLARE_ACCOUNT_ID: this.auth.accountId };
|
|
22
|
+
}
|
|
23
|
+
wrangler(args, opts = {}) {
|
|
24
|
+
return this.runner.exec("npx", ["wrangler", ...args, "--worker-name", this.worker], {
|
|
25
|
+
env: this.env,
|
|
26
|
+
...opts,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async api(path) {
|
|
30
|
+
const res = await this.fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${this.auth.accountId}${path}`, {
|
|
31
|
+
headers: { authorization: `Bearer ${this.auth.apiToken}` },
|
|
32
|
+
});
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
const json = parseJson(text);
|
|
35
|
+
if (!res.ok || json?.result === undefined)
|
|
36
|
+
throw new Error(apiErrorMessage(`Cloudflare API GET ${path}`, res.status, text, "CLOUDFLARE_API_TOKEN"));
|
|
37
|
+
return json.result;
|
|
38
|
+
}
|
|
39
|
+
async workersSubdomain() {
|
|
40
|
+
this.subdomain ??= (await this.api("/workers/subdomain")).subdomain;
|
|
41
|
+
return this.subdomain;
|
|
42
|
+
}
|
|
43
|
+
async listPreviews() {
|
|
44
|
+
const all = [];
|
|
45
|
+
for (let page = 1;; page += 1) {
|
|
46
|
+
const batch = await this.api(`/workers/workers/${this.worker}/previews?per_page=100&page=${page}`);
|
|
47
|
+
all.push(...batch);
|
|
48
|
+
if (batch.length < 100)
|
|
49
|
+
return all;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async findPreview(gitBranch) {
|
|
53
|
+
return matchPreview(await this.listPreviews(), gitBranch);
|
|
54
|
+
}
|
|
55
|
+
/** Writing to a Preview creates a new deployment of it. */
|
|
56
|
+
putPreviewSecrets(preview, secrets) {
|
|
57
|
+
if (this.runner.dryRun) {
|
|
58
|
+
this.runner.log(` $ npx wrangler preview secret bulk --name ${preview} (${Object.keys(secrets).join(", ")})`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.wrangler(["preview", "secret", "bulk", "--name", preview], { input: JSON.stringify(secrets) });
|
|
62
|
+
}
|
|
63
|
+
listPreviewSecrets(preview) {
|
|
64
|
+
const out = this.wrangler(["preview", "secret", "list", "--name", preview, "--json"], {
|
|
65
|
+
captureStderr: true,
|
|
66
|
+
});
|
|
67
|
+
const start = out.search(/^\[/m);
|
|
68
|
+
return start === -1 ? [] : JSON.parse(out.slice(start)).map((s) => s.name);
|
|
69
|
+
}
|
|
70
|
+
/** Creates a new deployment of the Preview, like any secret write. */
|
|
71
|
+
deletePreviewSecret(preview, key) {
|
|
72
|
+
this.wrangler(["preview", "secret", "delete", key, "--name", preview, "--skip-confirmation"], {
|
|
73
|
+
captureStderr: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Merges: keys absent from `secrets` stay. Only Previews created afterwards copy it. */
|
|
77
|
+
putBaseSecrets(secrets) {
|
|
78
|
+
if (this.runner.dryRun) {
|
|
79
|
+
this.runner.log(` $ npx wrangler preview base-config secret bulk (${Object.keys(secrets).join(", ")})`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.wrangler(["preview", "base-config", "secret", "bulk"], { input: JSON.stringify(secrets) });
|
|
83
|
+
}
|
|
84
|
+
deletePreview(preview) {
|
|
85
|
+
this.wrangler(["preview", "delete", "--name", preview, "--skip-confirmation"], { captureStderr: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function parseJson(text) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CloudflareApi } from "../cloudflare.js";
|
|
2
|
+
import type { Config } from "../config.js";
|
|
3
|
+
import type { Runner } from "../run.js";
|
|
4
|
+
import { type Branch, type SupabaseApi } from "../supabase.js";
|
|
5
|
+
import { type SupabaseValues } from "../runtime.js";
|
|
6
|
+
export type Deps = {
|
|
7
|
+
config: Config;
|
|
8
|
+
runner: Runner;
|
|
9
|
+
supabase: SupabaseApi;
|
|
10
|
+
cloudflare: CloudflareApi;
|
|
11
|
+
migrationCount: () => number;
|
|
12
|
+
sleep: (ms: number) => Promise<void>;
|
|
13
|
+
fetchImpl: typeof fetch;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
};
|
|
16
|
+
/** The non-default Supabase branch tied to a git branch, made by the GitHub integration or by `up`. */
|
|
17
|
+
export declare function branchFor(gitBranch: string, deps: Deps): Promise<Branch | undefined>;
|
|
18
|
+
/** The Preview a git branch deploys to: named after the branch, or `pr-<number>` when configured. */
|
|
19
|
+
export declare function previewKey(gitBranch: string, config: Config, pr?: number): string;
|
|
20
|
+
/** A branch `swp` may delete: never production, the shared database, the trunk's or a persistent one. */
|
|
21
|
+
export declare function isDisposable(branch: Branch, config: Config): boolean;
|
|
22
|
+
/** Never repoint, write to or delete the production project through a branch record. */
|
|
23
|
+
export declare function assertIsolated(branch: Branch, parent: string): void;
|
|
24
|
+
export declare function supabaseValues(ref: string, deps: Deps): Promise<SupabaseValues>;
|
|
25
|
+
/**
|
|
26
|
+
* The shared Preview database: a persistent branch that tracks the trunk, so
|
|
27
|
+
* the GitHub integration migrates it on every trunk push. Its public values
|
|
28
|
+
* belong in wrangler `previews.vars`; only its secret key goes in the Preview
|
|
29
|
+
* base config.
|
|
30
|
+
*/
|
|
31
|
+
export declare function shared(deps: Deps): Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Gives a git branch's Preview its own database. The GitHub integration makes
|
|
34
|
+
* one for any PR that changes supabase/; for other branches this asks for one,
|
|
35
|
+
* which Supabase still migrates and seeds from that git branch.
|
|
36
|
+
*/
|
|
37
|
+
export declare function up(gitBranch: string, deps: Deps, pr?: number): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Puts a Preview back on the shared database once its PR no longer needs its
|
|
40
|
+
* own: drops the override, then deletes the branch `up` made for it. The
|
|
41
|
+
* integration only makes branches for PRs that change supabase/, which never
|
|
42
|
+
* get here.
|
|
43
|
+
*/
|
|
44
|
+
export declare function release(gitBranch: string, deps: Deps, pr?: number): Promise<void>;
|
|
45
|
+
/** Which database a deployed Preview serves, from the identity route or, failing that, its HTML. */
|
|
46
|
+
export declare function servedRef(baseUrl: string, deps: Deps): Promise<{
|
|
47
|
+
ref?: string;
|
|
48
|
+
seen: string;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* Fails unless the Preview serves the database it should: its own branch
|
|
52
|
+
* when isolated, else the shared one. Production fails at once; anything
|
|
53
|
+
* else is retried while Workers Builds and `up` finish.
|
|
54
|
+
*/
|
|
55
|
+
export declare function check(gitBranch: string, isolated: boolean, deps: Deps, pr?: number): Promise<void>;
|
|
56
|
+
/** Removes the Preview and, if the integration has not already, its own database. */
|
|
57
|
+
export declare function down(gitBranch: string, deps: Deps, pr?: number): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Ready when the branch holds every migration in this checkout. Branch status
|
|
60
|
+
* runs ahead of the migrations, so the status alone is not enough.
|
|
61
|
+
*/
|
|
62
|
+
export declare function waitForMigrations(ref: string, deps: Deps): Promise<void>;
|