Skip to content

Commit ef8b7b3

Browse files
Add host-based edge routing: docs proxy, landing page, mail app
docs.devartslab.com reverse-proxies the public Notion site so the notion.so URL never shows; apex/www serve a DevArts Lab landing page via Worker routes; mail. keeps serving the app via custom domain. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent e3d70be commit ef8b7b3

5 files changed

Lines changed: 179 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ That's it. The installer:
5454
3. runs `wrangler login` if needed (browser OAuth — that's the only interaction)
5555
4. provisions **D1**, runs **migrations**, uploads **secrets**, **deploys**, and creates the **Email Routing rule** `you@domain → worker`
5656

57+
## 🌐 Domains
58+
59+
| Hostname | Serves |
60+
| ------------------------ | ------------------------------------------------------------ |
61+
| `mail.devartslab.com` | This app (custom domain) |
62+
| `docs.devartslab.com` | Public Notion site, proxied — visitors never see `notion.so` |
63+
| `devartslab.com` / `www` | DevArts Lab landing page (Worker routes) |
64+
65+
All three are configured in `wrangler.jsonc` `routes` and provisioned on deploy.
66+
5767
## ⚙️ Unified config — one file
5868

5969
Everything lives in **`wrangler.jsonc`** — worker, assets, D1, `send_email`, cron, routes, and all app variables:
@@ -66,6 +76,8 @@ Everything lives in **`wrangler.jsonc`** — worker, assets, D1, `send_email`, c
6676
| `AUTO_ACCEPT_INVITES` | `"false"` | Auto-accept incoming invites |
6777
| `INVITE_REMINDER_MINUTES` | `"30"` | Reminder email before events (`0` = off) |
6878
| `SESSION_TTL_HOURS` | `"168"` | UI session length |
79+
| `DOCS_HOSTNAME` | `docs.devartslab.com` | Hostname that proxies the Notion site |
80+
| `NOTION_PAGE_ID` | `61c6b798...b070` | Root Notion page served at `docs./` |
6981

7082
Secrets live in `.dev.vars` locally / `wrangler secret` in prod — never committed.
7183

‎src/edge.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Host-based edge routing:
2+
// docs.<domain> -> reverse-proxy to the public Notion site
3+
// <domain> / www. -> DevArts Lab landing page
4+
// mail.<domain> / workers.dev -> the mail app (handled by caller)
5+
6+
import type { Env } from "./types";
7+
8+
const NOTION_ORIGIN = "https://www.notion.so";
9+
10+
const DROP_RESPONSE_HEADERS = new Set([
11+
"content-encoding",
12+
"content-length",
13+
"transfer-encoding",
14+
"content-security-policy",
15+
"content-security-policy-report-only",
16+
"set-cookie",
17+
"x-frame-options",
18+
"report-to",
19+
"nel",
20+
]);
21+
22+
// docs.<domain> -> Notion public site, served under our hostname.
23+
export async function proxyNotion(request: Request, env: Env): Promise<Response> {
24+
const url = new URL(request.url);
25+
const path = url.pathname === "/" ? `/${env.NOTION_PAGE_ID}` : url.pathname;
26+
const upstream = new URL(path + url.search, NOTION_ORIGIN);
27+
28+
const headers = new Headers();
29+
for (const [k, v] of request.headers) {
30+
const lk = k.toLowerCase();
31+
if (["host", "cookie", "cf-connecting-ip", "cf-ray", "cf-visitor", "x-forwarded-for", "x-forwarded-proto"].includes(lk)) continue;
32+
headers.set(k, v);
33+
}
34+
headers.set("host", "www.notion.so");
35+
36+
const upstreamResp = await fetch(upstream.toString(), {
37+
method: request.method === "POST" ? "GET" : request.method,
38+
headers,
39+
redirect: "follow",
40+
});
41+
42+
const outHeaders = new Headers();
43+
for (const [k, v] of upstreamResp.headers) {
44+
if (DROP_RESPONSE_HEADERS.has(k.toLowerCase())) continue;
45+
outHeaders.set(k, v);
46+
}
47+
const loc = upstreamResp.headers.get("location");
48+
if (loc) outHeaders.set("location", rewriteNotionUrl(loc, env.DOCS_HOSTNAME));
49+
50+
const ct = upstreamResp.headers.get("content-type") || "";
51+
if (ct.includes("text/html")) {
52+
const html = await upstreamResp.text();
53+
outHeaders.set("content-type", "text/html; charset=utf-8");
54+
return new Response(rewriteHtml(html, env.DOCS_HOSTNAME), {
55+
status: upstreamResp.status,
56+
headers: outHeaders,
57+
});
58+
}
59+
return new Response(upstreamResp.body, { status: upstreamResp.status, headers: outHeaders });
60+
}
61+
62+
function rewriteNotionUrl(value: string, docsHost: string): string {
63+
return value
64+
.replace(/https:\/\/(www\.)?notion\.so/gi, `https://${docsHost}`)
65+
.replace(/https:\/\/[a-z0-9-]+\.notion\.site/gi, `https://${docsHost}`);
66+
}
67+
68+
function rewriteHtml(html: string, docsHost: string): string {
69+
return rewriteNotionUrl(html, docsHost).replace(
70+
/<head>/i,
71+
'<head><link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2220%22 fill=%22%23f6821f%22/><text x=%2250%22 y=%2268%22 font-size=%2260%22 text-anchor=%22middle%22 fill=%22white%22 font-family=%22sans-serif%22 font-weight=%22bold%22>D</text></svg>">',
72+
);
73+
}
74+
75+
// apex / www -> minimal DevArts Lab landing page (template).
76+
export function landingPage(env: Env): Response {
77+
const html = `<!doctype html>
78+
<html lang="en">
79+
<head>
80+
<meta charset="utf-8">
81+
<meta name="viewport" content="width=device-width, initial-scale=1">
82+
<title>DevArts Lab</title>
83+
<meta name="description" content="DevArts Lab - independent engineering studio">
84+
<style>
85+
:root { --bg:#0b0d10; --panel:#12151a; --border:#23272e; --text:#e6e8eb; --muted:#8b9199; --accent:#f6821f; }
86+
* { box-sizing:border-box; margin:0; }
87+
body { background:var(--bg); color:var(--text); font:15px/1.6 -apple-system,"Inter","Segoe UI",system-ui,sans-serif;
88+
min-height:100vh; display:flex; flex-direction:column; }
89+
header { padding:20px 32px; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--border); }
90+
.logo { width:28px; height:28px; border-radius:8px; background:var(--accent); color:#fff; display:grid;
91+
place-items:center; font-weight:700; }
92+
main { flex:1; display:grid; place-items:center; padding:48px 24px; }
93+
.hero { text-align:center; max-width:560px; }
94+
h1 { font-size:40px; font-weight:700; letter-spacing:-0.02em; }
95+
h1 em { color:var(--accent); font-style:normal; }
96+
p.sub { color:var(--muted); margin:12px 0 36px; font-size:16px; }
97+
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; }
98+
.card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:20px;
99+
text-decoration:none; color:var(--text); transition:border-color .15s, transform .15s; }
100+
.card:hover { border-color:var(--accent); transform:translateY(-2px); }
101+
.card b { display:block; margin-bottom:4px; }
102+
.card span { color:var(--muted); font-size:13px; }
103+
footer { padding:18px 32px; border-top:1px solid var(--border); color:var(--muted); font-size:12px;
104+
display:flex; justify-content:space-between; }
105+
footer a { color:var(--muted); }
106+
</style>
107+
</head>
108+
<body>
109+
<header><div class="logo">D</div><b>DevArts Lab</b></header>
110+
<main>
111+
<div class="hero">
112+
<h1>Dev<em>Arts</em> Lab</h1>
113+
<p class="sub">Independent engineering studio. Self-hosted mail, calendar, and docs on Cloudflare.</p>
114+
<div class="cards">
115+
<a class="card" href="https://mail.${env.MAIL_DOMAIN}"><b>Mail</b><span>mail.${env.MAIL_DOMAIN}</span></a>
116+
<a class="card" href="https://${env.DOCS_HOSTNAME}"><b>Docs</b><span>${env.DOCS_HOSTNAME}</span></a>
117+
<a class="card" href="https://github.com/DevArtsLab"><b>GitHub</b><span>github.com/DevArtsLab</span></a>
118+
</div>
119+
</div>
120+
</main>
121+
<footer><span>&copy; ${new Date().getUTCFullYear()} DevArts Lab</span><a href="mailto:contact@${env.MAIL_DOMAIN}">contact@${env.MAIL_DOMAIN}</a></footer>
122+
</body>
123+
</html>`;
124+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
125+
}

‎src/index.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,44 @@ import type { Env } from "./types";
44
import { handleApi } from "./api";
55
import { handleInbound } from "./inbound";
66
import { runScheduled } from "./scheduled";
7+
import { proxyNotion, landingPage } from "./edge";
78

89
export default {
910
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1011
const url = new URL(request.url);
12+
const host = url.hostname.toLowerCase();
13+
14+
// Host-based edge routing (see src/edge.ts):
15+
// docs.<domain> -> Notion public site proxy
16+
// <domain>/www -> landing page
17+
// everything else (mail.<domain>, workers.dev) -> the app
18+
if (env.DOCS_HOSTNAME && host === env.DOCS_HOSTNAME.toLowerCase()) {
19+
return proxyNotion(request, env);
20+
}
21+
if (host === env.MAIL_DOMAIN || host === `www.${env.MAIL_DOMAIN}`) {
22+
return landingPage(env);
23+
}
24+
1125
if (url.pathname.startsWith("/api/")) {
1226
return handleApi(env, request);
1327
}
1428
// Everything else: static UI via Workers Assets.
1529
return env.ASSETS.fetch(request);
1630
},
1731

18-
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise<void> {
32+
async email(
33+
message: ForwardableEmailMessage,
34+
env: Env,
35+
ctx: ExecutionContext,
36+
): Promise<void> {
1937
await handleInbound(message, env, ctx);
2038
},
2139

22-
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
40+
async scheduled(
41+
controller: ScheduledController,
42+
env: Env,
43+
ctx: ExecutionContext,
44+
): Promise<void> {
2345
ctx.waitUntil(runScheduled(env));
2446
},
2547
} satisfies ExportedHandler<Env>;

‎src/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface Env {
66
// vars (wrangler.jsonc)
77
MAIL_DOMAIN: string;
88
PRIMARY_ADDRESS: string;
9+
DOCS_HOSTNAME: string;
10+
NOTION_PAGE_ID: string;
911
DISPLAY_NAME: string;
1012
CALENDAR_NAME: string;
1113
CALENDAR_TZ: string;

‎wrangler.jsonc‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,21 @@
1818
"compatibility_flags": ["nodejs_compat"],
1919

2020
// ------------------------------------------------------------------
21-
// Custom domain: serves the app at https://mail.devartslab.com
22-
// (custom_domain auto-provisions the DNS record + TLS cert)
21+
// Custom domains (auto-provision the DNS record + TLS cert):
22+
// mail. -> the mail/calendar app
23+
// docs. -> reverse-proxy of the public Notion site (NOTION_PAGE_ID)
24+
// apex+www-> DevArts Lab landing page
2325
// ------------------------------------------------------------------
24-
"routes": [{ "pattern": "mail.devartslab.com", "custom_domain": true }],
26+
"routes": [
27+
// custom_domain: creates its own DNS record + cert (requires the
28+
// hostname to have no other DNS records)
29+
{ "pattern": "mail.devartslab.com", "custom_domain": true },
30+
{ "pattern": "docs.devartslab.com", "custom_domain": true },
31+
// plain routes: run on the existing (proxied) apex/www DNS records,
32+
// intercepting all requests before the origin
33+
{ "pattern": "devartslab.com/*", "zone_name": "devartslab.com" },
34+
{ "pattern": "www.devartslab.com/*", "zone_name": "devartslab.com" },
35+
],
2536
// keep the workers.dev URL as a fallback alongside the custom domain
2637
"workers_dev": true,
2738

@@ -40,6 +51,8 @@
4051
// Domain and identity
4152
"MAIL_DOMAIN": "devartslab.com",
4253
"PRIMARY_ADDRESS": "amir@devartslab.com",
54+
"DOCS_HOSTNAME": "docs.devartslab.com", // proxied to the Notion site below
55+
"NOTION_PAGE_ID": "61c6b79808ce476290c753165851b070",
4356
"DISPLAY_NAME": "Amir | DevArts Lab",
4457
"CALENDAR_NAME": "DevArts Lab",
4558
"CALENDAR_TZ": "America/Los_Angeles",

0 commit comments

Comments
 (0)