Skip to content
137 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1/**
2 * The notify service: live notifications, unread counts and browser push.
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.3 * One feed per person (src/feed.ts).
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)4 *
5 * Reached through service bindings only: `POST /rpc/<method>` with
6 * snake_case bodies (`notifyClient` in @g1t/contracts), and `GET /live`,
7 * the person's feed socket, which the site forwards after checking the
8 * session (NOTIFY_VIEWER_HEADER), with the counts it read (NOTIFY_SEED_HEADER).
9 *
10 * Who sends what:
11 * - chat: `deliver`, for every message (counts for each person in the
12 * conversation, a notification for those it is for) and every read;
13 * - events: `notify`, for every inbox item, and `set_inbox`, the count after
14 * items arrive or are marked, naming the person by username;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers15 * - the site: `subscribe`, `unsubscribe`, `status`, `set_preferences`,
16 * `test`, `presence` and `set_presence`, for the person signed in;
17 * - integrations, later: `set_presence` with a status of `source`
18 * `calendar` or `integration`, for the person they act for.
19 *
20 * Presence: each feed works out its person's from their tabs and tells
21 * one room per workspace (src/room.ts), which tells everyone there who is
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.22 * online. See docs.g1t.sh/guides/chat/, "Presence and status".
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)23 */
24import {
25 NOTIFY_VIEWER_HEADER,
26 identityClient,
27 type FeedDelivery,
28 type ServiceBinding,
29 type Viewer,
30} from "@g1t/contracts";
31
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers32import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)33
34export { Feed } from "./feed.ts";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers35export { Room } from "./room.ts";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)36
37type Env = FeedEnv & {
38 IDENTITY: ServiceBinding;
39 FEEDS: DurableObjectNamespace<Feed>;
40};
41
42function feed(env: Env, userId: string) {
43 return env.FEEDS.get(env.FEEDS.idFromName(userId));
44}
45
46/** A user id from the arguments, or one looked up by username. */
Agents' budget alerts and sessions waiting for approval reach the person they're for: notifications finds who to tell under target as well as at the top level.47async function userIdOf(env: Env, given: { user_id?: unknown; username?: unknown; target?: unknown }): Promise<string | null> {
48 // Who it's for, at the top level or under `target` as the agents service sends it.
49 const target = given.target && typeof given.target === "object" ? (given.target as { user_id?: unknown; username?: unknown }) : null;
50 const args = { user_id: given.user_id ?? target?.user_id, username: given.username ?? target?.username };
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)51 if (typeof args.user_id === "string" && args.user_id) return args.user_id;
52 if (typeof args.username !== "string" || !args.username) return null;
53 const user = await identityClient(env.IDENTITY)
54 .userByUsername(args.username)
55 .catch(() => null);
56 return user?.id ?? null;
57}
58
59/** The most deliveries in one call; chat sends one per person in the conversation. */
60const MAX_DELIVERIES = 1000;
61
62async function answer(env: Env, method: string, args: any): Promise<Response> {
63 if (method === "deliver") {
64 const items = (Array.isArray(args?.items) ? args.items : []).slice(0, MAX_DELIVERIES) as FeedDelivery[];
65 const byUser = new Map<string, FeedDelivery[]>();
66 for (const item of items) {
67 if (typeof item?.user_id !== "string" || !item.user_id) continue;
68 byUser.set(item.user_id, [...(byUser.get(item.user_id) ?? []), item]);
69 }
70 const results = await Promise.allSettled([...byUser].map(([id, mine]) => feed(env, id).deliver(mine)));
71 for (const result of results) if (result.status === "rejected") console.error("notify: a delivery failed", result.reason);
72 return Response.json({ ok: true });
73 }
74 const userId = await userIdOf(env, args ?? {});
75 if (!userId) return Response.json({ ok: false });
76 const stub = feed(env, userId);
77 switch (method) {
78 case "notify":
79 return Response.json(await stub.notify(args.notification));
80 case "set_inbox":
81 return Response.json(await stub.setInbox(Number(args.unread)));
82 case "subscribe":
83 return Response.json(await stub.subscribe(args.subscription, typeof args.user_agent === "string" ? args.user_agent : null));
84 case "unsubscribe":
85 return Response.json(await stub.unsubscribe(String(args.endpoint ?? "")));
86 case "status":
87 return Response.json(await stub.status(typeof args.endpoint === "string" ? args.endpoint : null));
88 case "set_preferences":
89 return Response.json(await stub.setPreferences(args.preferences));
90 case "test":
91 return Response.json(await stub.test(typeof args.username === "string" ? args.username : ""));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers92 case "presence":
93 return Response.json(await stub.own({ user_id: userId, username: typeof args.username === "string" ? args.username : "" }));
94 case "set_presence":
95 return Response.json(
96 await stub.setPresence({ user_id: userId, username: typeof args.username === "string" ? args.username : "" }, args.change ?? {}),
97 );
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)98 default:
99 return new Response("Unknown method\n", { status: 404 });
100 }
101}
102
103/**
104 * `GET /live`, upgraded: the viewer comes in NOTIFY_VIEWER_HEADER, set by
105 * the site after checking the session; trusted only because this Worker
106 * is reachable through service bindings alone.
107 */
108async function live(request: Request, env: Env): Promise<Response> {
109 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
110 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
111 }
112 let viewer: Viewer = null;
113 try {
114 viewer = JSON.parse(request.headers.get(NOTIFY_VIEWER_HEADER) ?? "null") as Viewer;
115 } catch {
116 viewer = null;
117 }
118 if (!viewer?.id) return new Response("Sign in first\n", { status: 401 });
119 const headers = new Headers(request.headers);
120 headers.delete(NOTIFY_VIEWER_HEADER);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers121 // Who the feed is for, so it can name them to the presence rooms.
122 headers.set(FEED_USER_ID_HEADER, viewer.id);
123 headers.set(FEED_USERNAME_HEADER, viewer.username);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)124 return feed(env, viewer.id).fetch(new Request(request.url, { method: "GET", headers }));
125}
126
127export default {
128 async fetch(request: Request, env: Env): Promise<Response> {
129 const url = new URL(request.url);
130 if (request.method === "GET" && url.pathname === "/live") return live(request, env);
131 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
132 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
133 const args = (await request.json().catch(() => ({}))) as any;
134 return answer(env, match[1], args);
135 },
136} satisfies ExportedHandler<Env>;
137

This file's history is long; its oldest lines are credited to the oldest commit read.