@rexezuge/tooling 0.0.0-stage → 1.0.0
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 +297 -2
- package/dist/eslint.d.ts +120 -0
- package/dist/eslint.d.ts.map +1 -0
- package/dist/eslint.js +551 -0
- package/dist/eslint.js.map +1 -0
- package/dist/functions/pages-proxy.d.ts +113 -0
- package/dist/functions/pages-proxy.d.ts.map +1 -0
- package/dist/functions/pages-proxy.js +131 -0
- package/dist/functions/pages-proxy.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/scripts/backup/d1-target.d.ts +35 -0
- package/dist/scripts/backup/d1-target.d.ts.map +1 -0
- package/dist/scripts/backup/d1-target.js +31 -0
- package/dist/scripts/backup/d1-target.js.map +1 -0
- package/dist/scripts/backup/destination-config.d.ts +61 -0
- package/dist/scripts/backup/destination-config.d.ts.map +1 -0
- package/dist/scripts/backup/destination-config.js +57 -0
- package/dist/scripts/backup/destination-config.js.map +1 -0
- package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
- package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
- package/dist/scripts/backup/encrypt-backup.js +98 -0
- package/dist/scripts/backup/encrypt-backup.js.map +1 -0
- package/dist/scripts/backup/naming.d.ts +44 -0
- package/dist/scripts/backup/naming.d.ts.map +1 -0
- package/dist/scripts/backup/naming.js +58 -0
- package/dist/scripts/backup/naming.js.map +1 -0
- package/dist/scripts/check-god-files.d.ts +164 -0
- package/dist/scripts/check-god-files.d.ts.map +1 -0
- package/dist/scripts/check-god-files.js +271 -0
- package/dist/scripts/check-god-files.js.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.js +30 -0
- package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
- package/dist/scripts/init-secrets.d.ts +63 -0
- package/dist/scripts/init-secrets.d.ts.map +1 -0
- package/dist/scripts/init-secrets.js +240 -0
- package/dist/scripts/init-secrets.js.map +1 -0
- package/dist/scripts/lib/cli-args.d.ts +78 -0
- package/dist/scripts/lib/cli-args.d.ts.map +1 -0
- package/dist/scripts/lib/cli-args.js +116 -0
- package/dist/scripts/lib/cli-args.js.map +1 -0
- package/dist/scripts/lib/github-actions.d.ts +26 -0
- package/dist/scripts/lib/github-actions.d.ts.map +1 -0
- package/dist/scripts/lib/github-actions.js +38 -0
- package/dist/scripts/lib/github-actions.js.map +1 -0
- package/dist/scripts/lib/wrangler-table.d.ts +46 -0
- package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
- package/dist/scripts/lib/wrangler-table.js +99 -0
- package/dist/scripts/lib/wrangler-table.js.map +1 -0
- package/dist/scripts/migrations-lock.d.ts +3 -0
- package/dist/scripts/migrations-lock.d.ts.map +1 -0
- package/dist/scripts/migrations-lock.js +46 -0
- package/dist/scripts/migrations-lock.js.map +1 -0
- package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
- package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
- package/dist/scripts/prepare-wrangler-config.js +50 -0
- package/dist/scripts/prepare-wrangler-config.js.map +1 -0
- package/dist/scripts/spa-shell.d.ts +41 -0
- package/dist/scripts/spa-shell.d.ts.map +1 -0
- package/dist/scripts/spa-shell.js +155 -0
- package/dist/scripts/spa-shell.js.map +1 -0
- package/dist/scripts/validate-locales.d.ts +26 -0
- package/dist/scripts/validate-locales.d.ts.map +1 -0
- package/dist/scripts/validate-locales.js +350 -0
- package/dist/scripts/validate-locales.js.map +1 -0
- package/dist/scripts/verify-migrations.d.ts +62 -0
- package/dist/scripts/verify-migrations.d.ts.map +1 -0
- package/dist/scripts/verify-migrations.js +302 -0
- package/dist/scripts/verify-migrations.js.map +1 -0
- package/dist/scripts/verify-spa-shell.d.ts +3 -0
- package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
- package/dist/scripts/verify-spa-shell.js +53 -0
- package/dist/scripts/verify-spa-shell.js.map +1 -0
- package/dist/scripts/wrangler-config/cli.d.ts +22 -0
- package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/cli.js +51 -0
- package/dist/scripts/wrangler-config/cli.js.map +1 -0
- package/dist/scripts/wrangler-config/patches.d.ts +51 -0
- package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/patches.js +140 -0
- package/dist/scripts/wrangler-config/patches.js.map +1 -0
- package/dist/scripts/wrangler-config/resources.d.ts +70 -0
- package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/resources.js +290 -0
- package/dist/scripts/wrangler-config/resources.js.map +1 -0
- package/dist/scripts/wrangler-config/types.d.ts +103 -0
- package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/types.js +49 -0
- package/dist/scripts/wrangler-config/types.js.map +1 -0
- package/dist/test/integration-migrations.d.ts +167 -0
- package/dist/test/integration-migrations.d.ts.map +1 -0
- package/dist/test/integration-migrations.js +171 -0
- package/dist/test/integration-migrations.js.map +1 -0
- package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
- package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
- package/dist/test/mocks/cloudflare-workers.js +90 -0
- package/dist/test/mocks/cloudflare-workers.js.map +1 -0
- package/dist/vite.d.ts +117 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +125 -0
- package/dist/vite.js.map +1 -0
- package/dist/vitest-web.d.ts +73 -0
- package/dist/vitest-web.d.ts.map +1 -0
- package/dist/vitest-web.js +72 -0
- package/dist/vitest-web.js.map +1 -0
- package/dist/vitest.d.ts +92 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +128 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +58 -3
- package/src/eslint.test.ts +175 -0
- package/src/eslint.ts +640 -0
- package/src/functions/pages-proxy.test.ts +72 -0
- package/src/functions/pages-proxy.ts +187 -0
- package/src/github/actions/retry-step/action.yml +39 -0
- package/src/github/actions/setup-env/action.yml +20 -0
- package/src/github/dependabot.yml +30 -0
- package/src/github/workflows/backup-main.yml +46 -0
- package/src/github/workflows/continuous-deployment.yml +188 -0
- package/src/github/workflows/continuous-integration.yml +259 -0
- package/src/github/workflows/scheduled-version-update.yml +38 -0
- package/src/github/workflows/upstream-sync.yml +56 -0
- package/src/index.ts +42 -0
- package/src/scripts/backup/backup-rules.test.ts +105 -0
- package/src/scripts/backup/d1-target.ts +54 -0
- package/src/scripts/backup/destination-config.ts +92 -0
- package/src/scripts/backup/encrypt-backup.ts +107 -0
- package/src/scripts/backup/naming.ts +62 -0
- package/src/scripts/check-god-files.test.ts +131 -0
- package/src/scripts/check-god-files.ts +327 -0
- package/src/scripts/ensure-spa-shell-stub.ts +34 -0
- package/src/scripts/init-secrets.ts +265 -0
- package/src/scripts/lib/cli-args.ts +154 -0
- package/src/scripts/lib/github-actions.ts +41 -0
- package/src/scripts/lib/wrangler-table.ts +105 -0
- package/src/scripts/migrations-lock.ts +52 -0
- package/src/scripts/prepare-wrangler-config.ts +51 -0
- package/src/scripts/spa-shell.test.ts +91 -0
- package/src/scripts/spa-shell.ts +179 -0
- package/src/scripts/validate-locales.test.ts +89 -0
- package/src/scripts/validate-locales.ts +380 -0
- package/src/scripts/verify-migrations.test.ts +71 -0
- package/src/scripts/verify-migrations.ts +364 -0
- package/src/scripts/verify-spa-shell.ts +56 -0
- package/src/scripts/wrangler-config/cli.ts +51 -0
- package/src/scripts/wrangler-config/patches.ts +157 -0
- package/src/scripts/wrangler-config/resources.ts +330 -0
- package/src/scripts/wrangler-config/types.ts +113 -0
- package/src/test/integration-migrations.test.ts +169 -0
- package/src/test/integration-migrations.ts +267 -0
- package/src/test/mocks/cloudflare-workers.ts +115 -0
- package/src/vite.test.ts +83 -0
- package/src/vite.ts +202 -0
- package/src/vitest-web.ts +109 -0
- package/src/vitest.ts +185 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createPagesProxy } from './pages-proxy';
|
|
3
|
+
import type { PagesProxyEnv } from './pages-proxy';
|
|
4
|
+
|
|
5
|
+
function fakeEnv(): { env: PagesProxyEnv; seen: { url: string; method: string; headers: Headers; body: string | null }[] } {
|
|
6
|
+
const seen: { url: string; method: string; headers: Headers; body: string | null }[] = [];
|
|
7
|
+
const env: PagesProxyEnv = {
|
|
8
|
+
API_WORKER: {
|
|
9
|
+
async fetch(request: Request): Promise<Response> {
|
|
10
|
+
seen.push({
|
|
11
|
+
url: request.url,
|
|
12
|
+
method: request.method,
|
|
13
|
+
headers: new Headers(request.headers),
|
|
14
|
+
body: request.method === 'GET' || request.method === 'HEAD' ? null : await request.text(),
|
|
15
|
+
});
|
|
16
|
+
return new Response('ok', { status: 200 });
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
return { env, seen };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const onRequest = createPagesProxy();
|
|
24
|
+
|
|
25
|
+
describe('createPagesProxy', () => {
|
|
26
|
+
it('forwards the URL verbatim and rewrites the three X-Forwarded headers', async () => {
|
|
27
|
+
const { env, seen } = fakeEnv();
|
|
28
|
+
const request = new Request('https://pages.example.com/user/volumes?page=2', { method: 'GET' });
|
|
29
|
+
const response = await onRequest({ request, env } as never);
|
|
30
|
+
expect(response.status).toBe(200);
|
|
31
|
+
expect(seen[0]?.url).toBe('https://pages.example.com/user/volumes?page=2');
|
|
32
|
+
expect(seen[0]?.headers.get('X-Forwarded-Host')).toBe('pages.example.com');
|
|
33
|
+
expect(seen[0]?.headers.get('X-Forwarded-Proto')).toBe('https');
|
|
34
|
+
expect(seen[0]?.headers.get('X-Forwarded-Uri')).toBe('/user/volumes?page=2');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('forwards a body for non-GET/HEAD', async () => {
|
|
38
|
+
const { env, seen } = fakeEnv();
|
|
39
|
+
await onRequest({
|
|
40
|
+
request: new Request('https://pages.example.com/user/volumes', { method: 'PUT', body: '{"a":1}' }),
|
|
41
|
+
env,
|
|
42
|
+
} as never);
|
|
43
|
+
expect(seen[0]?.body).toBe('{"a":1}');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('replaces X-Forwarded-For from CF-Connecting-IP, never trusting a caller value', async () => {
|
|
47
|
+
const { env, seen } = fakeEnv();
|
|
48
|
+
// The client IP arrives as a request header (the edge sets it), and any
|
|
49
|
+
// caller-supplied X-Forwarded-For must be displaced by it, never kept.
|
|
50
|
+
const headers = new Headers({ 'X-Forwarded-For': '1.2.3.4', 'CF-Connecting-IP': '9.9.9.9' });
|
|
51
|
+
await onRequest({ request: new Request('https://pages.example.com/', { headers }), env } as never);
|
|
52
|
+
expect(seen[0]?.headers.get('X-Forwarded-For')).toBe('9.9.9.9');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('strips hop-by-hop and client-forwarding headers', async () => {
|
|
56
|
+
const { env, seen } = fakeEnv();
|
|
57
|
+
const headers = new Headers({
|
|
58
|
+
Connection: 'keep-alive',
|
|
59
|
+
'Proxy-Authorization': 'Basic x',
|
|
60
|
+
Forwarded: 'for=1.2.3.4',
|
|
61
|
+
'X-Real-Ip': '1.2.3.4',
|
|
62
|
+
'True-Client-IP': '1.2.3.4',
|
|
63
|
+
});
|
|
64
|
+
await onRequest({ request: new Request('https://pages.example.com/', { headers }), env } as never);
|
|
65
|
+
const forwarded = seen[0]?.headers;
|
|
66
|
+
expect(forwarded?.get('Connection')).toBeNull();
|
|
67
|
+
expect(forwarded?.get('Proxy-Authorization')).toBeNull();
|
|
68
|
+
expect(forwarded?.get('Forwarded')).toBeNull();
|
|
69
|
+
expect(forwarded?.get('X-Real-Ip')).toBeNull();
|
|
70
|
+
expect(forwarded?.get('True-Client-IP')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cloudflare Pages catch-all proxy.
|
|
3
|
+
*
|
|
4
|
+
* Provenance: the `functions/[[path]].ts` shim duplicated in Durable-DAV,
|
|
5
|
+
* Mail-Meow and Edge-Git (and present in nine repos in total). Each copy is an
|
|
6
|
+
* `onRequest` handler that forwards the request to the API worker through a
|
|
7
|
+
* service binding, rewriting the three headers the worker routes on.
|
|
8
|
+
*
|
|
9
|
+
* What each source contributes:
|
|
10
|
+
*
|
|
11
|
+
* - **Durable-DAV / Edge-Git** — the core shape: set `X-Forwarded-Host`,
|
|
12
|
+
* `X-Forwarded-Proto` and `X-Forwarded-Uri` from the original URL, forward the
|
|
13
|
+
* body for non-GET/HEAD methods, and hand the request to `env.API_WORKER`.
|
|
14
|
+
* - **Mail-Meow** — the hop-by-hop header strip, and the decision to *always*
|
|
15
|
+
* delete `X-Forwarded-For` before conditionally setting it from
|
|
16
|
+
* `CF-Connecting-IP`. Setting it only when the header was present left a
|
|
17
|
+
* caller-supplied value intact on any request that reached Pages without
|
|
18
|
+
* `CF-Connecting-IP`, so the origin would trust an attacker-chosen client IP.
|
|
19
|
+
* - **Durable-DAV-Router** — `duplex: 'half'`, required whenever the forwarded
|
|
20
|
+
* body is a stream. Without it this threw a `TypeError` on every non-GET
|
|
21
|
+
* request rather than proxying it.
|
|
22
|
+
* - **Edge-Sonic** — the client-forwarding-header strip list, which additionally
|
|
23
|
+
* removes `Forwarded`, `True-Client-IP` and `X-Real-Ip`. Edge-Sonic goes
|
|
24
|
+
* further and *never* sets `X-Forwarded-For` at all, because its worker keys
|
|
25
|
+
* the rate limiter on `CF-Connecting-IP` and refuses `X-Forwarded-For` on
|
|
26
|
+
* purpose.
|
|
27
|
+
*
|
|
28
|
+
* ### The convergence decision on `X-Forwarded-For`
|
|
29
|
+
*
|
|
30
|
+
* The two families disagree, and the disagreement is about which worker is
|
|
31
|
+
* behind the binding. Mail-Meow's behaviour is kept as the default because it is
|
|
32
|
+
* the only one that preserves the client IP for the audit trail, which five repos
|
|
33
|
+
* read. A repo whose worker refuses the header — Edge-Sonic's — passes
|
|
34
|
+
* `forwardClientIp: false`, which deletes it and sets nothing. What is *not*
|
|
35
|
+
* available is the source repos' conditional `if (clientIp) set(...)`: that is
|
|
36
|
+
* the defect Mail-Meow recorded fixing, and a factory that could reintroduce it
|
|
37
|
+
* would be a factory that carries the bug forward.
|
|
38
|
+
*
|
|
39
|
+
* ### `duplex: 'half'` rides with the body
|
|
40
|
+
*
|
|
41
|
+
* Required by the Fetch spec whenever a `Request` is constructed with a **stream**
|
|
42
|
+
* body. workerd does not throw without it, which is why the line was absent from
|
|
43
|
+
* every Pages copy — and Node does, which is why `test/pages-proxy.test.ts` in
|
|
44
|
+
* Edge-Sonic could not drive the handler at all. It is spread only where a body
|
|
45
|
+
* is forwarded, because a `RequestInit` carrying `duplex` and no body is a shape
|
|
46
|
+
* no runtime documents; the cast is `as RequestInit` either way, since `duplex`
|
|
47
|
+
* is not in the DOM lib's type.
|
|
48
|
+
*
|
|
49
|
+
* The result is a handler factory, so a consumer's `functions/[[path]].ts` is a
|
|
50
|
+
* one-line re-export. Structural `Fetcher` and `PagesFunction` types are declared
|
|
51
|
+
* here rather than imported from `@cloudflare/workers-types`, so the kit stays
|
|
52
|
+
* zero-dependency and this module typechecks without the generated
|
|
53
|
+
* `worker-configuration.d.ts`.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The subset of Cloudflare's `Fetcher` this proxy uses.
|
|
58
|
+
*
|
|
59
|
+
* Structural, not imported: the shim only ever calls `fetch`, and requiring
|
|
60
|
+
* `@cloudflare/workers-types` for one method would couple the kit to a generated
|
|
61
|
+
* types file that only exists inside a Wrangler project.
|
|
62
|
+
*/
|
|
63
|
+
export interface Fetcher {
|
|
64
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The environment a Pages Function is invoked with.
|
|
69
|
+
*/
|
|
70
|
+
export interface PagesProxyEnv {
|
|
71
|
+
readonly API_WORKER: Fetcher;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The context Cloudflare passes to `onRequest`.
|
|
76
|
+
*
|
|
77
|
+
* Only the fields this proxy reads are declared. `params` and `waitUntil` are
|
|
78
|
+
* omitted because the catch-all does not use them — a shim that named them would
|
|
79
|
+
* be claiming a contract it does not exercise.
|
|
80
|
+
*/
|
|
81
|
+
export interface PagesFunctionContext {
|
|
82
|
+
readonly request: Request;
|
|
83
|
+
readonly env: PagesProxyEnv;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A Pages Function handler.
|
|
88
|
+
*/
|
|
89
|
+
export type PagesFunction = (context: PagesFunctionContext) => Promise<Response>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Headers a *client* may have set, stripped before the request crosses the
|
|
93
|
+
* binding.
|
|
94
|
+
*
|
|
95
|
+
* These describe the connection between the client and the proxy. Forwarding
|
|
96
|
+
* them would let the origin believe the proxy's view of the client is the
|
|
97
|
+
* client's own claim about itself. The three the proxy sets itself are set from
|
|
98
|
+
* values it reads off the request, not from the client's headers.
|
|
99
|
+
*/
|
|
100
|
+
const CLIENT_FORWARDING_HEADERS: readonly string[] = [
|
|
101
|
+
'connection',
|
|
102
|
+
'keep-alive',
|
|
103
|
+
'proxy-authenticate',
|
|
104
|
+
'proxy-authorization',
|
|
105
|
+
'te',
|
|
106
|
+
'trailer',
|
|
107
|
+
'transfer-encoding',
|
|
108
|
+
'upgrade',
|
|
109
|
+
'X-Forwarded-For',
|
|
110
|
+
'X-Forwarded-Host',
|
|
111
|
+
'X-Forwarded-Proto',
|
|
112
|
+
'X-Forwarded-Uri',
|
|
113
|
+
'Forwarded',
|
|
114
|
+
'True-Client-IP',
|
|
115
|
+
'X-Real-Ip',
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/** The header Cloudflare sets with the socket's peer address. */
|
|
119
|
+
const CF_CONNECTING_IP_HEADER = 'CF-Connecting-IP';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What `createPagesProxy` accepts.
|
|
123
|
+
*/
|
|
124
|
+
export interface PagesProxyOptions {
|
|
125
|
+
/**
|
|
126
|
+
* Set `X-Forwarded-For` from `CF-Connecting-IP`.
|
|
127
|
+
*
|
|
128
|
+
* Default `true` — Mail-Meow's behaviour, which five repos rely on for their
|
|
129
|
+
* audit trail. Pass `false` for a worker that refuses the header outright
|
|
130
|
+
* (Edge-Sonic's does), and the header is deleted and nothing set.
|
|
131
|
+
*/
|
|
132
|
+
readonly forwardClientIp?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `onRequest` handler for `functions/[[path]].ts`.
|
|
137
|
+
*
|
|
138
|
+
* Usage — the consumer's `functions/[[path]].ts` becomes two lines:
|
|
139
|
+
*
|
|
140
|
+
* ```ts
|
|
141
|
+
* import { createPagesProxy } from '@rexezuge/tooling/functions/pages-proxy';
|
|
142
|
+
* export const onRequest = createPagesProxy();
|
|
143
|
+
* ```
|
|
144
|
+
*
|
|
145
|
+
* @param options see {@link PagesProxyOptions}. Omitted gives the family's
|
|
146
|
+
* majority behaviour.
|
|
147
|
+
*/
|
|
148
|
+
export function createPagesProxy(options: PagesProxyOptions = {}): PagesFunction {
|
|
149
|
+
const forwardClientIp = options.forwardClientIp ?? true;
|
|
150
|
+
|
|
151
|
+
return async ({ request, env }: PagesFunctionContext): Promise<Response> => {
|
|
152
|
+
const originalUrl: URL = new URL(request.url);
|
|
153
|
+
|
|
154
|
+
const headers: Headers = new Headers(request.headers);
|
|
155
|
+
for (const header of CLIENT_FORWARDING_HEADERS) headers.delete(header);
|
|
156
|
+
|
|
157
|
+
// Set from values read off the request rather than from the client: only the
|
|
158
|
+
// proxy knows which host it served, and a service binding hands over a
|
|
159
|
+
// rewritten URL, so the original path is what has to be carried across.
|
|
160
|
+
headers.set('X-Forwarded-Host', originalUrl.host);
|
|
161
|
+
headers.set('X-Forwarded-Proto', originalUrl.protocol.replace(':', ''));
|
|
162
|
+
headers.set('X-Forwarded-Uri', `${originalUrl.pathname}${originalUrl.search}`);
|
|
163
|
+
|
|
164
|
+
if (forwardClientIp) {
|
|
165
|
+
const clientIp: string | null = request.headers.get(CF_CONNECTING_IP_HEADER);
|
|
166
|
+
if (clientIp) headers.set('X-Forwarded-For', clientIp);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `GET`/`HEAD` carry no body, and a `Request` with one is rejected outright
|
|
170
|
+
// for those methods. The body is forwarded as a *stream*, so a large upload
|
|
171
|
+
// does not have to fit in memory on the Pages side.
|
|
172
|
+
const hasBody: boolean = request.method !== 'GET' && request.method !== 'HEAD';
|
|
173
|
+
|
|
174
|
+
const proxyRequest: Request = new Request(originalUrl.href, {
|
|
175
|
+
method: request.method,
|
|
176
|
+
headers,
|
|
177
|
+
body: hasBody ? request.body : undefined,
|
|
178
|
+
// See the header: `duplex` is not in the DOM lib's `RequestInit` type, and
|
|
179
|
+
// removing the cast would mean not forwarding bodies at all, which is a
|
|
180
|
+
// worse answer than one assertion.
|
|
181
|
+
...(hasBody && { duplex: 'half' }),
|
|
182
|
+
redirect: request.redirect,
|
|
183
|
+
} as RequestInit);
|
|
184
|
+
|
|
185
|
+
return env.API_WORKER.fetch(proxyRequest);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Retry Step
|
|
2
|
+
description: Retry a bash command on failure
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
command:
|
|
6
|
+
description: The bash command to run
|
|
7
|
+
required: true
|
|
8
|
+
max_attempts:
|
|
9
|
+
description: Maximum number of attempts
|
|
10
|
+
required: false
|
|
11
|
+
default: '3'
|
|
12
|
+
delay:
|
|
13
|
+
description: Seconds to wait between attempts
|
|
14
|
+
required: false
|
|
15
|
+
default: '10'
|
|
16
|
+
|
|
17
|
+
runs:
|
|
18
|
+
using: composite
|
|
19
|
+
steps:
|
|
20
|
+
- shell: bash
|
|
21
|
+
run: |
|
|
22
|
+
attempt=0
|
|
23
|
+
max=${{ inputs.max_attempts }}
|
|
24
|
+
delay=${{ inputs.delay }}
|
|
25
|
+
while true; do
|
|
26
|
+
attempt=$(( attempt + 1 ))
|
|
27
|
+
printf '::notice title=Retry Step::Attempt %d of %d\n' "$attempt" "$max"
|
|
28
|
+
if eval "$RETRY_COMMAND"; then
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
if [ "$attempt" -ge "$max" ]; then
|
|
32
|
+
printf '::error title=Retry Step::Command failed after %d attempts\n' "$max"
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
printf '::warning title=Retry Step::Command failed, retrying in %ds...\n' "$delay"
|
|
36
|
+
sleep "$delay"
|
|
37
|
+
done
|
|
38
|
+
env:
|
|
39
|
+
RETRY_COMMAND: ${{ inputs.command }}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Setup Environment
|
|
2
|
+
description: Install pnpm, set up Node.js with pnpm store cache, and install workspace dependencies
|
|
3
|
+
|
|
4
|
+
runs:
|
|
5
|
+
using: composite
|
|
6
|
+
steps:
|
|
7
|
+
- name: Install pnpm
|
|
8
|
+
uses: pnpm/action-setup@v4
|
|
9
|
+
|
|
10
|
+
- name: Setup Node.js
|
|
11
|
+
uses: actions/setup-node@v4
|
|
12
|
+
with:
|
|
13
|
+
node-version: '24'
|
|
14
|
+
cache: pnpm
|
|
15
|
+
|
|
16
|
+
- name: Install Dependencies
|
|
17
|
+
uses: ./.github/actions/retry-step
|
|
18
|
+
with:
|
|
19
|
+
command: pnpm install
|
|
20
|
+
max_attempts: '3'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Dependabot — weekly dependency updates, grouped so one PR covers the
|
|
2
|
+
# toolchain rather than twelve separate ones. @rexezuge/* updates land in
|
|
3
|
+
# their own group, which is the kit's upgrade path: a bump PR per repo, and
|
|
4
|
+
# the repo's own CI is the gate.
|
|
5
|
+
|
|
6
|
+
version: 2
|
|
7
|
+
updates:
|
|
8
|
+
- package-ecosystem: 'npm'
|
|
9
|
+
directory: '/'
|
|
10
|
+
schedule:
|
|
11
|
+
interval: 'weekly'
|
|
12
|
+
open-pull-requests-limit: 5
|
|
13
|
+
labels:
|
|
14
|
+
- 'dependencies'
|
|
15
|
+
groups:
|
|
16
|
+
kit:
|
|
17
|
+
patterns:
|
|
18
|
+
- '@rexezuge/*'
|
|
19
|
+
toolchain:
|
|
20
|
+
patterns:
|
|
21
|
+
- 'typescript'
|
|
22
|
+
- 'vitest'
|
|
23
|
+
- 'vite'
|
|
24
|
+
- 'eslint'
|
|
25
|
+
- 'typescript-eslint'
|
|
26
|
+
- 'prettier'
|
|
27
|
+
- 'wrangler'
|
|
28
|
+
cloudflare:
|
|
29
|
+
patterns:
|
|
30
|
+
- '@cloudflare/*'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Backup Main Branch — mirror the repository to secondary git forges.
|
|
2
|
+
#
|
|
3
|
+
# Converged from the family's backup-main.yml. Destinations are opt-in by
|
|
4
|
+
# secret: with neither BACKUP_REPO_AZURE_DEVOPS_SSH_KEY nor
|
|
5
|
+
# BACKUP_REPO_GITLAB_* configured, the check job reports both false and every
|
|
6
|
+
# downstream job is skipped. Push to main seeds the mirror; the monthly
|
|
7
|
+
# schedule re-verifies it.
|
|
8
|
+
|
|
9
|
+
name: Backup Main Branch
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches:
|
|
17
|
+
- main
|
|
18
|
+
schedule:
|
|
19
|
+
- cron: '0 0 5 * *'
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
check-secrets:
|
|
23
|
+
name: Check Secrets
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
outputs:
|
|
26
|
+
azure-configured: ${{ steps.check.outputs.azure }}
|
|
27
|
+
gitlab-configured: ${{ steps.check.outputs.gitlab }}
|
|
28
|
+
steps:
|
|
29
|
+
- name: Detect Configured Destinations
|
|
30
|
+
id: check
|
|
31
|
+
env:
|
|
32
|
+
AZURE_KEY: ${{ secrets.BACKUP_REPO_AZURE_DEVOPS_SSH_KEY }}
|
|
33
|
+
AZURE_URL: ${{ secrets.BACKUP_REPO_AZURE_DEVOPS_REPO_URL }}
|
|
34
|
+
GITLAB_KEY: ${{ secrets.BACKUP_REPO_GITLAB_SSH_KEY }}
|
|
35
|
+
GITLAB_URL: ${{ secrets.BACKUP_REPO_GITLAB_REPO_URL }}
|
|
36
|
+
run: |
|
|
37
|
+
if [ -n "$AZURE_KEY" ] && [ -n "$AZURE_URL" ]; then
|
|
38
|
+
echo "azure=true" >> "$GITHUB_OUTPUT"
|
|
39
|
+
else
|
|
40
|
+
echo "azure=false" >> "$GITHUB_OUTPUT"
|
|
41
|
+
fi
|
|
42
|
+
if [ -n "$GITLAB_KEY" ] && [ -n "$GITLAB_URL" ]; then
|
|
43
|
+
echo "gitlab=true" >> "$GITHUB_OUTPUT"
|
|
44
|
+
else
|
|
45
|
+
echo "gitlab=false" >> "$GITHUB_OUTPUT"
|
|
46
|
+
fi
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Continuous Deployment — deploy the worker after CI passes on main.
|
|
2
|
+
#
|
|
3
|
+
# Converged from the eight repos that deploy (Records-Provider and CalDAV-Bridge
|
|
4
|
+
# deploy differently and are left out of this merge). The shape: a `workflow_run`
|
|
5
|
+
# trigger so the deploy runs against the SHA CI actually approved, then the
|
|
6
|
+
# wrangler config materialized from the template — no committed secrets — then the
|
|
7
|
+
# secrets provisioned, then the database migrated, then a dry run as a gate, then
|
|
8
|
+
# the deploy.
|
|
9
|
+
#
|
|
10
|
+
# PLACEHOLDERS:
|
|
11
|
+
# * REPLACE_ME_SCOPE the npm scope, in `pnpm --filter @REPLACE_ME_SCOPE/web build`.
|
|
12
|
+
# * REPLACE_ME_D1_BINDING the D1 binding `wrangler d1 migrations apply` targets.
|
|
13
|
+
# * REPLACE_ME_PAGES the Cloudflare Pages project name, for repos with a SPA.
|
|
14
|
+
#
|
|
15
|
+
# The two workspace variables the config step reads (`WRANGLER_JSONC`,
|
|
16
|
+
# `WRANGLER_PATCH_JSON`, `WRANGLER_VARS_PATCH_JSON`) are how a repo with several
|
|
17
|
+
# environments supplies per-environment settings without a second template file.
|
|
18
|
+
|
|
19
|
+
name: Continuous Deployment
|
|
20
|
+
|
|
21
|
+
permissions:
|
|
22
|
+
contents: read
|
|
23
|
+
|
|
24
|
+
on:
|
|
25
|
+
workflow_run:
|
|
26
|
+
workflows:
|
|
27
|
+
- Continuous Integration
|
|
28
|
+
branches:
|
|
29
|
+
- main
|
|
30
|
+
types:
|
|
31
|
+
- completed
|
|
32
|
+
repository_dispatch:
|
|
33
|
+
types:
|
|
34
|
+
- Upstream Sync Deployment
|
|
35
|
+
workflow_dispatch:
|
|
36
|
+
|
|
37
|
+
concurrency:
|
|
38
|
+
group: continuous-deployment-${{ github.event.workflow_run.head_branch || github.event.client_payload.branch || github.ref }}
|
|
39
|
+
cancel-in-progress: false
|
|
40
|
+
|
|
41
|
+
jobs:
|
|
42
|
+
deploy-worker:
|
|
43
|
+
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
|
44
|
+
name: Deploy Cloudflare Worker
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
|
|
47
|
+
steps:
|
|
48
|
+
- name: Checkout Repository
|
|
49
|
+
uses: actions/checkout@v6
|
|
50
|
+
with:
|
|
51
|
+
# The SHA CI approved, not the tip of main: a push that lands while the
|
|
52
|
+
# workflow_run is in flight must not be what gets deployed.
|
|
53
|
+
ref: ${{ github.event.workflow_run.head_sha || github.event.client_payload.sha || github.sha }}
|
|
54
|
+
|
|
55
|
+
- name: Setup Environment
|
|
56
|
+
uses: ./.github/actions/setup-env
|
|
57
|
+
|
|
58
|
+
- name: Prepare Wrangler Configuration
|
|
59
|
+
uses: ./.github/actions/retry-step
|
|
60
|
+
with:
|
|
61
|
+
command: pnpm exec tsx scripts/prepare-wrangler-config.ts
|
|
62
|
+
max_attempts: '3'
|
|
63
|
+
env:
|
|
64
|
+
WRANGLER_JSONC: ${{ vars.WRANGLER_JSONC }}
|
|
65
|
+
WRANGLER_PATCH_JSON: ${{ vars.WRANGLER_PATCH_JSON }}
|
|
66
|
+
WRANGLER_VARS_PATCH_JSON: ${{ vars.WRANGLER_VARS_PATCH_JSON }}
|
|
67
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
68
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
69
|
+
|
|
70
|
+
# A `secrets_store_secrets` binding fails the deploy unless the named secret
|
|
71
|
+
# already exists. Nothing creates it — Cloudflare will not generate a value —
|
|
72
|
+
# so this is the only thing standing between a fresh account and a deploy that
|
|
73
|
+
# cannot succeed. Create-if-absent: an existing value still seals every stored
|
|
74
|
+
# credential and is never replaced by a redeploy.
|
|
75
|
+
- name: Initialize Cloudflare Secrets
|
|
76
|
+
uses: ./.github/actions/retry-step
|
|
77
|
+
with:
|
|
78
|
+
command: pnpm exec tsx scripts/init-secrets.ts
|
|
79
|
+
max_attempts: '3'
|
|
80
|
+
env:
|
|
81
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
82
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
83
|
+
|
|
84
|
+
# PLACEHOLDER: the D1 binding to migrate.
|
|
85
|
+
- name: Apply Database Migration
|
|
86
|
+
uses: ./.github/actions/retry-step
|
|
87
|
+
with:
|
|
88
|
+
command: pnpm exec wrangler d1 migrations apply REPLACE_ME_D1_BINDING --remote
|
|
89
|
+
max_attempts: '3'
|
|
90
|
+
env:
|
|
91
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
92
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
93
|
+
|
|
94
|
+
# PLACEHOLDER: the workspace filter for the SPA. The API worker serves the
|
|
95
|
+
# shell this build embeds, so the build is a precondition of the deploy, not
|
|
96
|
+
# a convenience.
|
|
97
|
+
- name: Build Web App
|
|
98
|
+
run: pnpm --filter @REPLACE_ME_SCOPE/web build
|
|
99
|
+
|
|
100
|
+
# A gate, not a rehearsal. Separate from the deploy below and deliberately
|
|
101
|
+
# **not** retried: a dry run that fails is a real defect — a bundle over the
|
|
102
|
+
# size limit, a binding the template no longer declares — and retrying it
|
|
103
|
+
# three times only delays the report while making the log harder to read.
|
|
104
|
+
- name: Dry Run Deploy with Wrangler
|
|
105
|
+
run: pnpm exec wrangler deploy --dry-run
|
|
106
|
+
env:
|
|
107
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
108
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
109
|
+
|
|
110
|
+
- name: Deploy with Wrangler
|
|
111
|
+
uses: ./.github/actions/retry-step
|
|
112
|
+
with:
|
|
113
|
+
command: pnpm exec wrangler deploy
|
|
114
|
+
max_attempts: '3'
|
|
115
|
+
env:
|
|
116
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
117
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
118
|
+
|
|
119
|
+
# Repos with a Pages front end keep this job; it is the reason
|
|
120
|
+
# `functions/[[path]].ts` exists — Pages serves `dist/` as static files, and
|
|
121
|
+
# `/user/...` would resolve to a 404 without the catch-all proxy to the worker.
|
|
122
|
+
deploy-pages:
|
|
123
|
+
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
|
124
|
+
name: Deploy Cloudflare Pages
|
|
125
|
+
runs-on: ubuntu-latest
|
|
126
|
+
|
|
127
|
+
steps:
|
|
128
|
+
- name: Checkout Repository
|
|
129
|
+
uses: actions/checkout@v6
|
|
130
|
+
with:
|
|
131
|
+
ref: ${{ github.event.workflow_run.head_sha || github.event.client_payload.sha || github.sha }}
|
|
132
|
+
|
|
133
|
+
- name: Setup Environment
|
|
134
|
+
uses: ./.github/actions/setup-env
|
|
135
|
+
|
|
136
|
+
# PLACEHOLDER: the workspace filter for the SPA.
|
|
137
|
+
- name: Build Frontend
|
|
138
|
+
run: pnpm --filter @REPLACE_ME_SCOPE/web build
|
|
139
|
+
|
|
140
|
+
- name: Configure Pages Service Bindings
|
|
141
|
+
run: cp apps/web/wrangler.template.jsonc wrangler.jsonc
|
|
142
|
+
|
|
143
|
+
# PLACEHOLDER: the Pages project name. Created if absent, and then polled
|
|
144
|
+
# until it is visible, because a project that exists but is not yet listed
|
|
145
|
+
# makes the deploy below fail with an error that names the deploy rather
|
|
146
|
+
# than the propagation delay.
|
|
147
|
+
- name: Ensure Pages Project Exists
|
|
148
|
+
env:
|
|
149
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
150
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
151
|
+
PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME || 'REPLACE_ME_PAGES' }}
|
|
152
|
+
DEPLOY_BRANCH: ${{ github.event.workflow_run.head_branch || github.event.client_payload.branch || github.ref_name }}
|
|
153
|
+
run: |
|
|
154
|
+
if CREATE_OUTPUT="$(pnpm exec wrangler pages project create "${PROJECT}" --production-branch "${DEPLOY_BRANCH}" 2>&1)"; then
|
|
155
|
+
echo "Created Pages project \"${PROJECT}\"."
|
|
156
|
+
elif echo "${CREATE_OUTPUT}" | grep -qi "already exists"; then
|
|
157
|
+
echo "Pages project \"${PROJECT}\" already exists."
|
|
158
|
+
else
|
|
159
|
+
echo "${CREATE_OUTPUT}"
|
|
160
|
+
echo "Failed to create Pages project \"${PROJECT}\"." >&2
|
|
161
|
+
exit 1
|
|
162
|
+
fi
|
|
163
|
+
for _ in $(seq 1 18); do
|
|
164
|
+
if pnpm exec wrangler pages project list 2>/dev/null | grep -qE "(^|[[:space:]])${PROJECT}([[:space:]]|$)"; then
|
|
165
|
+
echo "Pages project \"${PROJECT}\" is ready."
|
|
166
|
+
exit 0
|
|
167
|
+
fi
|
|
168
|
+
sleep 5
|
|
169
|
+
done
|
|
170
|
+
echo "Pages project \"${PROJECT}\" is still not visible after polling." >&2
|
|
171
|
+
exit 1
|
|
172
|
+
|
|
173
|
+
- name: Deploy with Wrangler Pages
|
|
174
|
+
uses: ./.github/actions/retry-step
|
|
175
|
+
with:
|
|
176
|
+
command: >
|
|
177
|
+
pnpm exec wrangler pages deploy apps/web/dist
|
|
178
|
+
--project-name "${PROJECT}"
|
|
179
|
+
--branch "${DEPLOY_BRANCH}"
|
|
180
|
+
--commit-hash "${DEPLOY_SHA}"
|
|
181
|
+
--commit-message "$(git log -1 --pretty=%s)"
|
|
182
|
+
max_attempts: '3'
|
|
183
|
+
env:
|
|
184
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
185
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
186
|
+
PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME || 'REPLACE_ME_PAGES' }}
|
|
187
|
+
DEPLOY_BRANCH: ${{ github.event.workflow_run.head_branch || github.event.client_payload.branch || github.ref_name }}
|
|
188
|
+
DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.event.client_payload.sha || github.sha }}
|