Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/api/src/modules/deployments/compose/deploy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1651,9 +1651,13 @@ export async function deployComposeServices(
// Kept OUT of the try above so a cert failure can never be reported as a
// route-registration failure.
if (route.provisionSsl) {
logger.log(`Checking SSL for ${route.hostname}...\n`, "info", {
serviceName: svc.name,
});
logger.log(
`Route live on HTTP for ${route.hostname} — provisioning the certificate, HTTPS in ~1 min\n`,
"info",
{
serviceName: svc.name,
},
);
await routeContext.trackedSsl.provisionCert(route.hostname).catch((err) => {
logger.log(
`SSL provisioning failed for ${route.hostname} (route is up on HTTP, retry from ` +
Expand Down
5 changes: 3 additions & 2 deletions apps/email/client/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/email/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@trpc/client": "^11.4.4",
"@trpc/server": "^11.4.4",
"@trpc/tanstack-react-query": "^11.4.4",
"@zero/server": "file:../server",
"@zero/server": "link:../server",
"accept-language-parser": "^1.5.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"better-auth": "^1.5.4",
Expand Down
8 changes: 4 additions & 4 deletions apps/email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
"description": "Self-hosted email - iRedMail (engine) + Zero Email (server + client). Orchestrator package: install and run server + client together.",
"scripts": {
"resolve-catalogs": "bun run scripts/resolve-catalog-refs.ts",
"postinstall": "cd server && bun install && cd ../client && bun install",
"install:server": "cd server && bun install",
"install:client": "cd client && bun install",
"install:all": "bun run resolve-catalogs && bun run install:server && bun run install:client",
"postinstall": "bun run scripts/install-nested.ts",
"install:server": "bun run scripts/install-nested.ts server",
"install:client": "bun run scripts/install-nested.ts client",
"install:all": "bun run resolve-catalogs && bun run scripts/install-nested.ts",
"dev:server": "cd server && bun run dev",
"dev:client": "cd client && bun run dev",
"dev": "bash -c '(cd server && bun run dev) & (cd client && bun run dev) & trap \"kill 0\" EXIT INT TERM; wait'",
Expand Down
98 changes: 98 additions & 0 deletions apps/email/scripts/install-nested.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env bun
/**
* Install Zero's nested server + client packages.
*
* They are not root workspace members (`apps/*` matches `apps/email` only),
* so they keep their own lockfiles. The client depends on the sibling
* `@zero/server` package; Bun's `file:` protocol copies that tree and hits
* EPERM on Windows (oven-sh/bun#17006). We depend via `link:` and, if Bun
* still fails, create a directory junction / symlink ourselves.
*/
import { spawnSync } from 'node:child_process';
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const EMAIL_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SERVER_DIR = join(EMAIL_ROOT, 'server');
const CLIENT_DIR = join(EMAIL_ROOT, 'client');
const ZERO_SERVER_DEST = join(CLIENT_DIR, 'node_modules', '@zero', 'server');

const target = process.argv[2] ?? 'all';

function bunInstall(cwd: string): number {
const result = spawnSync('bun', ['install'], {
cwd,
stdio: 'inherit',
shell: true,
env: process.env,
});
return result.status ?? 1;
}

function removeDest(dest: string): void {
if (!existsSync(dest)) return;
const st = lstatSync(dest);
// Junctions/symlinks must be unlinked, not recursively removed — recursive
// rm can follow the link and delete apps/email/server itself on Windows.
if (st.isSymbolicLink()) {
unlinkSync(dest);
return;
}
rmSync(dest, { recursive: true, force: true });
}

function linkZeroServer(): void {
mkdirSync(join(CLIENT_DIR, 'node_modules', '@zero'), { recursive: true });
removeDest(ZERO_SERVER_DEST);
const type = process.platform === 'win32' ? 'junction' : 'dir';
symlinkSync(SERVER_DIR, ZERO_SERVER_DEST, type);
console.log(`Linked @zero/server -> ${SERVER_DIR} (${type})`);
}

function isZeroServerLinked(): boolean {
return existsSync(join(ZERO_SERVER_DEST, 'package.json'));
}

function clientLooksInstalled(): boolean {
return existsSync(join(CLIENT_DIR, 'node_modules', 'react', 'package.json'));
}

function installServer(): void {
const status = bunInstall(SERVER_DIR);
if (status !== 0) process.exit(status);
}

function installClient(): void {
const status = bunInstall(CLIENT_DIR);
if (status === 0) {
if (!isZeroServerLinked()) linkZeroServer();
return;
}
// Bun 1.3 on Windows cannot copy/symlink `file:`/`link:` deps
// (oven-sh/bun#17006). If the rest of the tree landed, recover by
// creating a directory junction (no admin / Developer Mode required).
if (!clientLooksInstalled()) process.exit(status);
try {
linkZeroServer();
} catch (err) {
console.error('Failed to link @zero/server after bun install error:', err);
process.exit(status);
}
if (!isZeroServerLinked()) {
console.error('bun install failed and @zero/server could not be linked');
process.exit(status);
}
console.warn(
'bun install could not link @zero/server; created a local junction/symlink instead.',
);
}

if (target === 'server') {
installServer();
} else if (target === 'client') {
installClient();
} else {
installServer();
installClient();
}
1 change: 1 addition & 0 deletions apps/email/server/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 80 additions & 4 deletions packages/adapters/src/runtime/route-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { explainEdgeDown } from "../system/edge-exec-error";

const logger = { log: vi.fn(), step: vi.fn() } as any;
const routeTarget = { targetUrl: "http://127.0.0.1:12345" };
const domain = (hostname: string): RoutedDomainInput => ({ hostname, tls: false, targetPort: 3000 });
const domain = (hostname: string): RoutedDomainInput => ({
hostname,
tls: false,
targetPort: 3000,
});

const RESTARTING =
"Error response from daemon: Container abc123 is restarting, wait until the container is running";
Expand Down Expand Up @@ -136,6 +140,60 @@ describe("registerResolvedRoutes — transient container-restart handling", () =
});
});

describe("registerResolvedRoutes — SSL provisioning is visible in the deploy log", () => {
beforeEach(() => vi.clearAllMocks());

const tlsDomain = (over: Partial<RoutedDomainInput> = {}): RoutedDomainInput => ({
hostname: "app.example.com",
tls: true,
provisionSsl: true,
targetPort: 3000,
...over,
});

it("logs that HTTP is live before requesting the cert, then issues it", async () => {
const order: string[] = [];
const routing = {
registerRoute: vi.fn(async () => {
order.push("register");
}),
} as any;
const ssl = {
provisionCert: vi.fn(async () => {
order.push("provision");
return { verified: true };
}),
} as any;

const warnings = await registerResolvedRoutes(logger, routing, ssl, [tlsDomain()], routeTarget);

expect(warnings).toEqual([]);
expect(order).toEqual(["register", "provision"]);
const logged = logger.log.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
expect(logged).toContain(
"Route live on HTTP for app.example.com — provisioning the certificate, HTTPS in ~1 min",
);
expect(ssl.provisionCert).toHaveBeenCalledWith("app.example.com");
});

it("does not claim a cert is coming when provisionSsl is off", async () => {
const routing = { registerRoute: vi.fn(async () => {}) } as any;
const ssl = { provisionCert: vi.fn(async () => ({ verified: true })) } as any;

await registerResolvedRoutes(
logger,
routing,
ssl,
[tlsDomain({ provisionSsl: false })],
routeTarget,
);

const logged = logger.log.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
expect(logged).not.toContain("HTTPS in ~1 min");
expect(ssl.provisionCert).not.toHaveBeenCalled();
});
});

/**
* Reverse-proxy tunables are a property of the PROJECT, not of any one upstream, so
* they arrive as a registration option and land on every domain's vhost. Threading
Expand Down Expand Up @@ -168,7 +226,13 @@ describe("registerResolvedRoutes — proxy tunables", () => {
it("omits `proxy` entirely when none is configured, so nginx defaults apply", async () => {
const routing = { registerRoute: vi.fn(async () => {}) } as any;

await registerResolvedRoutes(logger, routing, undefined, [domain("a.example.com")], routeTarget);
await registerResolvedRoutes(
logger,
routing,
undefined,
[domain("a.example.com")],
routeTarget,
);

expect(routing.registerRoute.mock.calls[0][0].proxy).toBeUndefined();
});
Expand Down Expand Up @@ -216,7 +280,13 @@ describe("registerResolvedRoutes — compiled vercel.json rules", () => {
},
],
redirects: [
{ path: "/blog/", exact: false, statusCode: 308, destination: "/news/$1", pattern: "/blog/(.*)" },
{
path: "/blog/",
exact: false,
statusCode: 308,
destination: "/news/$1",
pattern: "/blog/(.*)",
},
],
headerRules: [{ path: "/api/", headers: [{ key: "Cache-Control", value: "no-store" }] }],
cleanUrls: true,
Expand Down Expand Up @@ -274,7 +344,13 @@ describe("registerResolvedRoutes — compiled vercel.json rules", () => {
const bare = { registerRoute: vi.fn(async () => {}) } as any;
await registerResolvedRoutes(logger, bare, undefined, [domain("c.example.com")], routeTarget);
const cfg = bare.registerRoute.mock.calls[0][0];
for (const key of ["proxyLocations", "redirects", "headerRules", "cleanUrls", "trailingSlash"]) {
for (const key of [
"proxyLocations",
"redirects",
"headerRules",
"cleanUrls",
"trailingSlash",
]) {
expect(cfg).not.toHaveProperty(key);
}
});
Expand Down
21 changes: 17 additions & 4 deletions packages/adapters/src/runtime/route-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export async function registerResolvedRoutes(

const resolvedRouteTarget =
domain.targetPort !== undefined
? routeTargetsByPort?.get(domain.targetPort) ?? baseRouteTarget
? (routeTargetsByPort?.get(domain.targetPort) ?? baseRouteTarget)
: baseRouteTarget;
const targetUrl = (resolvedRouteTarget as { targetUrl?: string }).targetUrl;
const staticRoot = (resolvedRouteTarget as { staticRoot?: string }).staticRoot;
Expand Down Expand Up @@ -212,14 +212,27 @@ export async function registerResolvedRoutes(
if (options?.trailingSlash !== undefined) routeConfig.trailingSlash = options.trailingSlash;

// Add webhook proxy location if this domain is the project's webhook domain
if (options?.webhookDomain && domain.hostname === options.webhookDomain && options.webhookProxy) {
if (
options?.webhookDomain &&
domain.hostname === options.webhookDomain &&
options.webhookProxy
) {
routeConfig.webhookProxy = options.webhookProxy;
}

await routingProvider.registerRoute(routeConfig);

if (domain.provisionSsl && ssl) {
logger.log(`Checking SSL for ${domain.hostname}...\n`);
// The 443 block is only emitted once the cert exists, so there is a
// ~1 minute window where the site answers HTTP and nothing (or a
// bootstrap self-signed cert) on HTTPS. Issuance is best-effort by
// design — domains never fail a deploy — which is why the *silence*
// was the bug: operators filed it as broken SSL. Say so in the deploy
// log at registration, then let the tracked provider log when the
// cert lands (or fails).
logger.log(
`Route live on HTTP for ${domain.hostname} — provisioning the certificate, HTTPS in ~1 min\n`,
);
// SSL is best-effort. The HTTP route is already written to disk
// and reachable on port 80, which is what serves the ACME HTTP-01
// challenge — so even when certbot fails right now (rate limit,
Expand Down Expand Up @@ -294,4 +307,4 @@ export async function registerResolvedRoutes(
}

return warnings;
}
}