diff --git a/docs/hosting.md b/docs/hosting.md index 4347819..31666f5 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -2,6 +2,12 @@ How the hosted service at loop.goodone.si is set up and deployed. This is for whoever runs the service; people using Loopable only need [runners.md](runners.md). +## Paused: only the site runs + +Since October 2026 the hosted service is paused while Loopable becomes a desktop app ([desktop.md](desktop.md)). `LOOPABLE_SITE_ONLY=1` in `wrangler.jsonc` makes the Worker serve the front page and its legal pages and nothing else: the app's pages go to the front page, sign-in and the runner API answer 410 with where Loopable went, and nothing reads the database. The cron is gone, nothing wakes the dispatcher, and the container stops after ten idle minutes, so it costs nothing. + +Everything else is kept as it was: the database and its data, the Worker's secrets, the container's definition and the deploy workflow. To run the hosted service again, remove `LOOPABLE_SITE_ONLY`, put the cron `* * * * *` back, and deploy. The dispatcher should then keep itself awake again, as `onActivityExpired` renewing its timer used to. + ## What runs where | Part | Where | What it does | diff --git a/src/landing/landing.tsx b/src/landing/landing.tsx index 70797e9..3bff53a 100644 --- a/src/landing/landing.tsx +++ b/src/landing/landing.tsx @@ -35,28 +35,28 @@ export const landingHead = () => ({ ], }); -type Props = { signedIn: boolean; next?: string; error?: string }; +type Props = { signedIn: boolean; siteOnly?: boolean; next?: string; error?: string }; /** * The front page, in the console's own type, colours and parts, so arriving * in the app after signing in feels like the same product. Signed in, it is * still here to read, with a way back into the app instead of sign-in buttons. */ -export function Landing({ signedIn, next, error }: Props) { +export function Landing({ signedIn, siteOnly = false, next, error }: Props) { useEffect(() => { if (error) toast.error(error); }, [error]); return (
-
@@ -88,7 +88,7 @@ function Section({ ); } -export function Nav({ signedIn }: { signedIn: boolean }) { +export function Nav({ signedIn, siteOnly = false }: { signedIn: boolean; siteOnly?: boolean }) { return (
@@ -114,7 +114,7 @@ export function Nav({ signedIn }: { signedIn: boolean }) { - {signedIn ? ( + {siteOnly ? null : signedIn ? ( + ) : signedIn ? ( +
+ + ); +} + function GetStarted({ signedIn, next }: { signedIn: boolean; next?: string }) { return (
{ - const { hosted, viewer } = await getViewer(); + const { hosted, siteOnly, viewer } = await getViewer(); + if (siteOnly) return { signedIn: false, siteOnly: true }; const next = search.next?.startsWith("/") && !search.next.startsWith("//") ? search.next : null; if (!hosted) throw redirect({ to: next ?? "/inbox" }); if (viewer && next) throw redirect({ to: next }); - return { signedIn: Boolean(viewer) }; + return { signedIn: Boolean(viewer), siteOnly: false }; }, head: landingHead, component: Front, @@ -25,6 +26,6 @@ export const Route = createFileRoute("/")({ function Front() { const { next, error } = Route.useSearch(); - const { signedIn } = Route.useRouteContext(); - return ; + const { signedIn, siteOnly } = Route.useRouteContext(); + return ; } diff --git a/src/server/functions/account.ts b/src/server/functions/account.ts index e89b8c6..7929001 100644 --- a/src/server/functions/account.ts +++ b/src/server/functions/account.ts @@ -1,10 +1,13 @@ import { createServerFn } from "@tanstack/react-start"; -import { isHosted } from "#/lib/hosted.ts"; +import { isHosted, isSiteOnly } from "#/lib/hosted.ts"; import { viewerInScope, type Viewer } from "../accounts.ts"; -export type ViewerState = { hosted: boolean; viewer: Viewer | null }; +export type ViewerState = { hosted: boolean; siteOnly: boolean; viewer: Viewer | null }; /** Whether this is the hosted service, and who is signed in to it. */ export const getViewer = createServerFn({ method: "GET" }).handler( - async (): Promise => ({ hosted: isHosted(), viewer: await viewerInScope() }), + async (): Promise => + isSiteOnly() + ? { hosted: true, siteOnly: true, viewer: null } + : { hosted: isHosted(), siteOnly: false, viewer: await viewerInScope() }, ); diff --git a/src/start.ts b/src/start.ts index a70bff6..fe6cd32 100644 --- a/src/start.ts +++ b/src/start.ts @@ -1,5 +1,5 @@ import { appOrigin, isLoopbackHost } from "#/lib/callback.ts"; -import { isHosted } from "#/lib/hosted.ts"; +import { isHosted, isSiteOnly } from "#/lib/hosted.ts"; import { sessionFor, sessionTokenFrom } from "#/server/accounts.ts"; import { LOCAL_WORKSPACE, withWorkspace } from "#/server/workspace.ts"; import { @@ -37,7 +37,10 @@ function isPageRequest(request: Request, pathname: string): boolean { * everything is the one local workspace. */ const appAccess = createMiddleware({ type: "request" }).server(async ({ next, request, pathname }) => { - await ensureMigrated(); + if (!isSiteOnly()) await ensureMigrated(); + + // Only the site: nobody signs in, and the database is not asked. + if (isSiteOnly()) return next(); if (isHosted()) { // The session first, even on public paths: starting a connection's diff --git a/src/worker.ts b/src/worker.ts index f11b1af..3610616 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -12,6 +12,7 @@ type DispatcherEnv = { LOOPABLE_GITHUB_CLIENT_SECRET?: string; LOOPABLE_GMAIL_CLIENT_ID?: string; LOOPABLE_GMAIL_CLIENT_SECRET?: string; + LOOPABLE_SITE_ONLY?: string; }; /** @@ -20,13 +21,12 @@ type DispatcherEnv = { */ export class DispatcherContainer extends Container { defaultPort = 8080; - sleepAfter = "24h"; + // Asleep after ten idle minutes. While the hosted service ran, the cron + // woke it every minute and it renewed its own timer, so it never slept; + // with only the site running nothing wakes it, and this lets it stop. + sleepAfter = "10m"; enableInternet = true; pingEndpoint = "/health"; - - async onActivityExpired(): Promise { - this.renewActivityTimeout(); - } } function dispatcherEnv(env: DispatcherEnv): Record { @@ -71,9 +71,36 @@ async function wakeDispatcher(env: DispatcherEnv): Promise { await dispatcher.fetch("http://dispatcher/health"); } +/** The pages the site still serves, and what they need to load. */ +const SITE_PATHS = new Set(["/", "/privacy", "/terms"]); + +/** + * Only the site: the front page and its legal pages. The app's pages go to + * the front page; sign-in and the runner API say plainly where Loopable + * went, which is what an older runner prints. Nothing touches the database. + */ +function siteOnly(request: Request): Response | null { + const { pathname } = new URL(request.url); + if (SITE_PATHS.has(pathname) || pathname.startsWith("/_serverFn")) return null; + if (pathname.startsWith("/api/")) { + return Response.json( + { + error: + "The hosted Loopable is paused while Loopable becomes a desktop app. See https://loop.goodone.si.", + }, + { status: 410 }, + ); + } + return Response.redirect(new URL("/", request.url).href, 302); +} + export default { async fetch(request: Request, env: DispatcherEnv, ctx: { waitUntil: (p: Promise) => void }) { bindProcessEnv(env); + if (env.LOOPABLE_SITE_ONLY === "1") { + process.env.LOOPABLE_SITE_ONLY = "1"; + return siteOnly(request) ?? handler.fetch(request); + } ctx.waitUntil( wakeDispatcher(env).catch((error: unknown) => { console.error("dispatcher wake failed", error); @@ -87,6 +114,7 @@ export default { env: DispatcherEnv, ctx: { waitUntil: (p: Promise) => void }, ) { + if (env.LOOPABLE_SITE_ONLY === "1") return; bindProcessEnv(env); ctx.waitUntil(wakeDispatcher(env)); }, diff --git a/wrangler.jsonc b/wrangler.jsonc index 0bddd86..ad1fd9a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,7 +9,12 @@ // The App Worker never migrates; the dispatcher container does, once. "LOOPABLE_SKIP_MIGRATE": "1", // Where OAuth callbacks and sign-in come back to, and the address runners join. - "LOOPABLE_BASE_URL": "https://loop.goodone.si" + "LOOPABLE_BASE_URL": "https://loop.goodone.si", + // Only the front page and its legal pages are served; the app, sign-in + // and the runner API answer that Loopable moved to the desktop, and the + // dispatcher is never woken. Remove it, and put the cron back, to run + // the hosted service again; see docs/hosting.md. + "LOOPABLE_SITE_ONLY": "1" }, // The site lives here now: the front page is the app's /. Secrets // (database, master key, OAuth apps) are set with wrangler secret put; @@ -20,8 +25,9 @@ // in a row, and each one crossing an ocean is what made pages slow. "placement": { "mode": "targeted", "region": "aws:us-west-2" }, "preview_urls": false, + // No cron while the site is all that runs: it only woke the dispatcher. "triggers": { - "crons": ["* * * * *"] + "crons": [] }, "containers": [ {