Skip to content
135 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.
3 * One feed per person (src/feed.ts). Plan: docs/WORKSPACE.md, "Live
4 * notifications".
5 *
6 * Reached through service bindings only: `POST /rpc/<method>` with
7 * snake_case bodies (`notifyClient` in @g1t/contracts), and `GET /live`,
8 * the person's feed socket, which the site forwards after checking the
9 * session (NOTIFY_VIEWER_HEADER), with the counts it read (NOTIFY_SEED_HEADER).
10 *
11 * Who sends what:
12 * - chat: `deliver`, for every message (counts for each person in the
13 * conversation, a notification for those it is for) and every read;
14 * - events: `notify`, for every inbox item, and `set_inbox`, the count after
15 * 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 managers16 * - the site: `subscribe`, `unsubscribe`, `status`, `set_preferences`,
17 * `test`, `presence` and `set_presence`, for the person signed in;
18 * - integrations, later: `set_presence` with a status of `source`
19 * `calendar` or `integration`, for the person they act for.
20 *
21 * Presence: each feed works out its person's from their tabs and tells
22 * one room per workspace (src/room.ts), which tells everyone there who is
23 * online. See docs/WORKSPACE.md, "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)24 */
25import {
26 NOTIFY_VIEWER_HEADER,
27 identityClient,
28 type FeedDelivery,
29 type ServiceBinding,
30 type Viewer,
31} from "@g1t/contracts";
32
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers33import { 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)34
35export { 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 managers36export { 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)37
38type Env = FeedEnv & {
39 IDENTITY: ServiceBinding;
40 FEEDS: DurableObjectNamespace<Feed>;
41};
42
43function feed(env: Env, userId: string) {
44 return env.FEEDS.get(env.FEEDS.idFromName(userId));
45}
46
47/** A user id from the arguments, or one looked up by username. */
48async function userIdOf(env: Env, args: { user_id?: unknown; username?: unknown }): Promise<string | null> {
49 if (typeof args.user_id === "string" && args.user_id) return args.user_id;
50 if (typeof args.username !== "string" || !args.username) return null;
51 const user = await identityClient(env.IDENTITY)
52 .userByUsername(args.username)
53 .catch(() => null);
54 return user?.id ?? null;
55}
56
57/** The most deliveries in one call; chat sends one per person in the conversation. */
58const MAX_DELIVERIES = 1000;
59
60async function answer(env: Env, method: string, args: any): Promise<Response> {
61 if (method === "deliver") {
62 const items = (Array.isArray(args?.items) ? args.items : []).slice(0, MAX_DELIVERIES) as FeedDelivery[];
63 const byUser = new Map<string, FeedDelivery[]>();
64 for (const item of items) {
65 if (typeof item?.user_id !== "string" || !item.user_id) continue;
66 byUser.set(item.user_id, [...(byUser.get(item.user_id) ?? []), item]);
67 }
68 const results = await Promise.allSettled([...byUser].map(([id, mine]) => feed(env, id).deliver(mine)));
69 for (const result of results) if (result.status === "rejected") console.error("notify: a delivery failed", result.reason);
70 return Response.json({ ok: true });
71 }
72 const userId = await userIdOf(env, args ?? {});
73 if (!userId) return Response.json({ ok: false });
74 const stub = feed(env, userId);
75 switch (method) {
76 case "notify":
77 return Response.json(await stub.notify(args.notification));
78 case "set_inbox":
79 return Response.json(await stub.setInbox(Number(args.unread)));
80 case "subscribe":
81 return Response.json(await stub.subscribe(args.subscription, typeof args.user_agent === "string" ? args.user_agent : null));
82 case "unsubscribe":
83 return Response.json(await stub.unsubscribe(String(args.endpoint ?? "")));
84 case "status":
85 return Response.json(await stub.status(typeof args.endpoint === "string" ? args.endpoint : null));
86 case "set_preferences":
87 return Response.json(await stub.setPreferences(args.preferences));
88 case "test":
89 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 managers90 case "presence":
91 return Response.json(await stub.own({ user_id: userId, username: typeof args.username === "string" ? args.username : "" }));
92 case "set_presence":
93 return Response.json(
94 await stub.setPresence({ user_id: userId, username: typeof args.username === "string" ? args.username : "" }, args.change ?? {}),
95 );
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)96 default:
97 return new Response("Unknown method\n", { status: 404 });
98 }
99}
100
101/**
102 * `GET /live`, upgraded: the viewer comes in NOTIFY_VIEWER_HEADER, set by
103 * the site after checking the session; trusted only because this Worker
104 * is reachable through service bindings alone.
105 */
106async function live(request: Request, env: Env): Promise<Response> {
107 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
108 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
109 }
110 let viewer: Viewer = null;
111 try {
112 viewer = JSON.parse(request.headers.get(NOTIFY_VIEWER_HEADER) ?? "null") as Viewer;
113 } catch {
114 viewer = null;
115 }
116 if (!viewer?.id) return new Response("Sign in first\n", { status: 401 });
117 const headers = new Headers(request.headers);
118 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 managers119 // Who the feed is for, so it can name them to the presence rooms.
120 headers.set(FEED_USER_ID_HEADER, viewer.id);
121 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)122 return feed(env, viewer.id).fetch(new Request(request.url, { method: "GET", headers }));
123}
124
125export default {
126 async fetch(request: Request, env: Env): Promise<Response> {
127 const url = new URL(request.url);
128 if (request.method === "GET" && url.pathname === "/live") return live(request, env);
129 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
130 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
131 const args = (await request.json().catch(() => ({}))) as any;
132 return answer(env, match[1], args);
133 },
134} satisfies ExportedHandler<Env>;
135

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