Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
45 changes: 36 additions & 9 deletions src/landing/landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="min-h-screen bg-background text-foreground">
<Nav signedIn={signedIn} />
<Nav signedIn={signedIn} siteOnly={siteOnly} />
<main>
<Hero signedIn={signedIn} next={next} />
<Hero signedIn={signedIn} siteOnly={siteOnly} next={next} />
<Problem />
<HowItWorks />
<Workflows />
<OnYourMachine />
<GetStarted signedIn={signedIn} next={next} />
{siteOnly ? <ComingToMac /> : <GetStarted signedIn={signedIn} next={next} />}
</main>
<LoopFooter links={<FooterLinks />} />
</div>
Expand Down Expand Up @@ -88,7 +88,7 @@ function Section({
);
}

export function Nav({ signedIn }: { signedIn: boolean }) {
export function Nav({ signedIn, siteOnly = false }: { signedIn: boolean; siteOnly?: boolean }) {
return (
<header className="sticky top-0 z-10 border-b bg-background/85 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-6 px-6">
Expand All @@ -114,7 +114,7 @@ export function Nav({ signedIn }: { signedIn: boolean }) {
<Button variant="ghost" size="icon" nativeButton={false} render={<a href={REPO} aria-label="GitHub" />}>
<GithubMark />
</Button>
{signedIn ? (
{siteOnly ? null : signedIn ? (
<Button nativeButton={false} render={<Link to="/inbox" />}>
Open Loopable
<ArrowRight data-icon="inline-end" />
Expand Down Expand Up @@ -145,7 +145,7 @@ function SignInButtons({ next, stacked }: { next?: string; stacked?: boolean })
);
}

function Hero({ signedIn, next }: { signedIn: boolean; next?: string }) {
function Hero({ signedIn, siteOnly, next }: { signedIn: boolean; siteOnly: boolean; next?: string }) {
return (
<section className="mx-auto grid min-h-[calc(100svh-3.5rem)] max-w-6xl items-center gap-12 px-6 py-16 md:grid-cols-[1.25fr_1fr]">
<div>
Expand All @@ -155,7 +155,12 @@ function Hero({ signedIn, next }: { signedIn: boolean; next?: string }) {
</h1>
<p className="mt-5 max-w-xl text-lg text-muted-foreground text-pretty">{DESCRIPTION}</p>
<div className="mt-8">
{signedIn ? (
{siteOnly ? (
<Button size="lg" nativeButton={false} render={<a href="/#start" />}>
Coming to the Mac
<ArrowRight data-icon="inline-end" />
</Button>
) : signedIn ? (
<Button size="lg" nativeButton={false} render={<Link to="/inbox" />}>
Open Loopable
<ArrowRight data-icon="inline-end" />
Expand Down Expand Up @@ -338,6 +343,28 @@ function StepTitle({ n, children }: { n: number; children: React.ReactNode }) {
);
}

/**
* In place of signing in, while the hosted service is paused: Loopable is
* becoming an app for the Mac, and this says so, with where to follow it.
*/
function ComingToMac() {
return (
<Section
id="start"
kicker="Coming soon"
title="Loopable is becoming a Mac app."
lead="Your loops, inbox and agents in one app on your machine. No account, nothing hosted: the work never leaves your Mac except as the answers your loops write back."
>
<div className="flex flex-wrap items-center gap-2">
<Button size="lg" nativeButton={false} render={<a href={REPO} />}>
<GithubMark />
Follow on GitHub
</Button>
</div>
</Section>
);
}

function GetStarted({ signedIn, next }: { signedIn: boolean; next?: string }) {
return (
<Section
Expand Down
8 changes: 8 additions & 0 deletions src/lib/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ export function isHosted(): boolean {
const url = process.env.LOOPABLE_DATABASE_URL ?? "";
return url.startsWith("libsql:") || url.startsWith("https:") || url.startsWith("wss:");
}

/**
* Only the front page is served, while Loopable becomes a desktop app: no
* sign-in, no app, no dispatcher. Set on the Worker in wrangler.jsonc.
*/
export function isSiteOnly(): boolean {
return process.env.LOOPABLE_SITE_ONLY === "1";
}
9 changes: 5 additions & 4 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,19 @@ export const Route = createFileRoute("/")({
...(typeof search.error === "string" ? { error: search.error } : {}),
}),
beforeLoad: async ({ search }) => {
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,
});

function Front() {
const { next, error } = Route.useSearch();
const { signedIn } = Route.useRouteContext();
return <Landing signedIn={signedIn} next={next} error={error} />;
const { signedIn, siteOnly } = Route.useRouteContext();
return <Landing signedIn={signedIn} siteOnly={siteOnly} next={next} error={error} />;
}
9 changes: 6 additions & 3 deletions src/server/functions/account.ts
Original file line number Diff line number Diff line change
@@ -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<ViewerState> => ({ hosted: isHosted(), viewer: await viewerInScope() }),
async (): Promise<ViewerState> =>
isSiteOnly()
? { hosted: true, siteOnly: true, viewer: null }
: { hosted: isHosted(), siteOnly: false, viewer: await viewerInScope() },
);
7 changes: 5 additions & 2 deletions src/start.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -20,13 +21,12 @@ type DispatcherEnv = {
*/
export class DispatcherContainer extends Container<DispatcherEnv> {
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<void> {
this.renewActivityTimeout();
}
}

function dispatcherEnv(env: DispatcherEnv): Record<string, string> {
Expand Down Expand Up @@ -71,9 +71,36 @@ async function wakeDispatcher(env: DispatcherEnv): Promise<void> {
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<unknown>) => 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);
Expand All @@ -87,6 +114,7 @@ export default {
env: DispatcherEnv,
ctx: { waitUntil: (p: Promise<unknown>) => void },
) {
if (env.LOOPABLE_SITE_ONLY === "1") return;
bindProcessEnv(env);
ctx.waitUntil(wakeDispatcher(env));
},
Expand Down
10 changes: 8 additions & 2 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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": [
{
Expand Down
Loading