From 188b23040c6da01f860fbe4f8e0ae5809dd1eb9a Mon Sep 17 00:00:00 2001 From: Devenio Date: Tue, 18 Aug 2026 19:09:38 +0330 Subject: [PATCH 1/2] refactor(email): update package installation scripts and link dependencies - Changed installation scripts in package.json to use a new script for nested installations. - Updated dependency references from 'file:' to 'link:' for better compatibility. - Added a new script to handle the installation of nested server and client packages, addressing issues with Bun's handling of dependencies on Windows. - Updated lockfiles for both server and client to reflect changes in dependency management. --- apps/email/client/bun.lock | 5 +- apps/email/client/package.json | 2 +- apps/email/package.json | 8 +-- apps/email/scripts/install-nested.ts | 98 ++++++++++++++++++++++++++++ apps/email/server/bun.lock | 1 + 5 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 apps/email/scripts/install-nested.ts diff --git a/apps/email/client/bun.lock b/apps/email/client/bun.lock index 3ba2ec1cb..5d52ebfe8 100644 --- a/apps/email/client/bun.lock +++ b/apps/email/client/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@zero/mail", @@ -36,7 +37,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", @@ -910,7 +911,7 @@ "@xobotyi/scrollbar-width": ["@xobotyi/scrollbar-width@1.9.5", "", {}, "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="], - "@zero/server": ["@zero/server@file:../server", { "dependencies": { "@hono/trpc-server": "^0.3.4", "@trpc/client": "^11.4.4", "@trpc/server": "^11.4.4", "cheerio": "1.1.0", "date-fns": "^4.1.0", "dedent": "^1.6.0", "drizzle-orm": "^0.45.1", "email-addresses": "^5.0.0", "hono": "^4.7.8", "imapflow": "^1.0.169", "mailparser": "^3.7.2", "mime-types": "3.0.1", "mimetext": "^3.0.27", "nanoid": "^5.0.9", "nodemailer": "^6.9.16", "sanitize-html": "^2.16.0", "string-strip-html": "^13.4.12", "superjson": "^2.2.2", "zod": "^4.3.6" }, "devDependencies": { "@types/bun": "^1.3.14", "@types/mailparser": "^3.4.6", "@types/mime-types": "^2.1.4", "@types/node": "^22.9.0", "@types/nodemailer": "^6.4.17", "@types/sanitize-html": "2.13.0", "drizzle-kit": "^0.31.9", "typescript": "^5.9.3" } }], + "@zero/server": ["@zero/server@link:../server", { "dependencies": { "@hono/trpc-server": "^0.3.4", "@trpc/client": "^11.4.4", "@trpc/server": "^11.4.4", "cheerio": "1.1.0", "date-fns": "^4.1.0", "dedent": "^1.6.0", "drizzle-orm": "^0.45.1", "email-addresses": "^5.0.0", "hono": "^4.7.8", "imapflow": "^1.0.169", "mailparser": "^3.7.2", "mime-types": "3.0.1", "mimetext": "^3.0.27", "nanoid": "^5.0.9", "nodemailer": "^6.9.16", "sanitize-html": "^2.16.0", "string-strip-html": "^13.4.12", "superjson": "^2.2.2", "zod": "^4.3.6" }, "devDependencies": { "@types/bun": "^1.3.14", "@types/mailparser": "^3.4.6", "@types/mime-types": "^2.1.4", "@types/node": "^22.9.0", "@types/nodemailer": "^6.4.17", "@types/sanitize-html": "2.13.0", "drizzle-kit": "^0.31.9", "typescript": "^5.9.3" } }], "@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.9", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.8", "libqp": "2.1.1" } }, "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA=="], diff --git a/apps/email/client/package.json b/apps/email/client/package.json index c516604a8..9010c96cf 100644 --- a/apps/email/client/package.json +++ b/apps/email/client/package.json @@ -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", diff --git a/apps/email/package.json b/apps/email/package.json index 366721a8a..9defc7052 100644 --- a/apps/email/package.json +++ b/apps/email/package.json @@ -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'", diff --git a/apps/email/scripts/install-nested.ts b/apps/email/scripts/install-nested.ts new file mode 100644 index 000000000..c0fcd09d7 --- /dev/null +++ b/apps/email/scripts/install-nested.ts @@ -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(); +} diff --git a/apps/email/server/bun.lock b/apps/email/server/bun.lock index 7dc2d93b6..bf5ed6ac7 100644 --- a/apps/email/server/bun.lock +++ b/apps/email/server/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@zero/server", From 8c31d19841a6b53ca075b744ee5a466de2ab2236 Mon Sep 17 00:00:00 2001 From: Devenio Date: Tue, 18 Aug 2026 19:16:18 +0330 Subject: [PATCH 2/2] fix(adapters): log that HTTP is live while SSL is still provisioning The 443 block is only emitted once the cert exists, so operators saw a ~1 min HTTPS gap and filed it as broken SSL. Issuance is best-effort by design; the silence was the bug. Co-authored-by: Cursor --- .../deployments/compose/deploy.service.ts | 10 ++- .../src/runtime/route-registration.test.ts | 84 ++++++++++++++++++- .../src/runtime/route-registration.ts | 21 ++++- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/apps/api/src/modules/deployments/compose/deploy.service.ts b/apps/api/src/modules/deployments/compose/deploy.service.ts index e24f60949..09f0da375 100644 --- a/apps/api/src/modules/deployments/compose/deploy.service.ts +++ b/apps/api/src/modules/deployments/compose/deploy.service.ts @@ -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 ` + diff --git a/packages/adapters/src/runtime/route-registration.test.ts b/packages/adapters/src/runtime/route-registration.test.ts index d1959c7d1..ba51a828c 100644 --- a/packages/adapters/src/runtime/route-registration.test.ts +++ b/packages/adapters/src/runtime/route-registration.test.ts @@ -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"; @@ -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 => ({ + 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 @@ -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(); }); @@ -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, @@ -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); } }); diff --git a/packages/adapters/src/runtime/route-registration.ts b/packages/adapters/src/runtime/route-registration.ts index de9803e3f..915a47084 100644 --- a/packages/adapters/src/runtime/route-registration.ts +++ b/packages/adapters/src/runtime/route-registration.ts @@ -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; @@ -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, @@ -294,4 +307,4 @@ export async function registerResolvedRoutes( } return warnings; -} \ No newline at end of file +}