pi-roundtable-web 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +165 -12
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-ghq105t4.js +1 -0
- package/dist/chunk-action-menu-me0g9qsc.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
- package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
- package/dist/chunk-character-creator-bd426yjw.js +1 -0
- package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
- package/dist/chunk-confirm-p3mts8mx.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
- package/dist/chunk-conversations-0e0vbxwr.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
- package/dist/chunk-folder-list-era3tksh.js +1 -0
- package/dist/chunk-folds-hvws0bd5.js +1 -0
- package/dist/chunk-gate-hc7dd0fx.js +1 -0
- package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
- package/dist/chunk-group-table-fqmxgdfw.js +1 -0
- package/dist/chunk-host-icons-4xde9ntk.js +1 -0
- package/dist/chunk-host-picture-014yzk20.js +1 -0
- package/dist/chunk-host-state-t56e5sdy.js +1 -0
- package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
- package/dist/chunk-index-0fmmvgee.js +2 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xh24y25.js +2 -0
- package/dist/chunk-index-2yebd9tb.js +426 -0
- package/dist/chunk-index-4t0r3t3b.js +2 -0
- package/dist/chunk-index-5yzfrf7b.js +138 -0
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
- package/dist/chunk-index-73gh7kgr.js +920 -0
- package/dist/chunk-index-7xffskfa.js +8 -0
- package/dist/chunk-index-aqw7cy5j.js +1 -0
- package/dist/chunk-index-b6ca02e8.js +2 -0
- package/dist/chunk-index-b8nvhcx5.js +2 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
- package/dist/chunk-index-czdketze.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
- package/dist/chunk-index-eg55mqqe.js +1 -0
- package/dist/chunk-index-gr6e0hse.js +2 -0
- package/dist/chunk-index-gyary7np.js +13 -0
- package/dist/chunk-index-j214tczc.js +1 -0
- package/dist/chunk-index-jd6632ef.js +2 -0
- package/dist/chunk-index-jw7dtshr.js +2 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
- package/dist/chunk-index-kwcx62wb.js +4 -0
- package/dist/chunk-index-m7fvw92f.js +2 -0
- package/dist/chunk-index-mmg788a1.js +2 -0
- package/dist/chunk-index-mq0dacwr.js +2 -0
- package/dist/chunk-index-p53ak25v.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
- package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
- package/dist/chunk-index-rnxj3z8k.js +2 -0
- package/dist/chunk-index-snzzfhk3.js +2 -0
- package/dist/chunk-index-v3bmr0nw.js +2 -0
- package/dist/chunk-index-vcyzwtk2.js +2 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
- package/dist/chunk-index-w31c4yjv.js +2 -0
- package/dist/chunk-index-wrm8w165.js +2 -0
- package/dist/chunk-index-xh0mg9cw.js +2 -0
- package/dist/chunk-index-ybtvvx85.js +2 -0
- package/dist/chunk-index-yzsgsvnp.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
- package/dist/chunk-index-zrvg23jd.js +2 -0
- package/dist/chunk-index-zynq0b0w.js +50 -0
- package/dist/chunk-lang-css-88v3hp3d.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
- package/dist/chunk-lang-html-07d47es4.js +1 -0
- package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
- package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
- package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
- package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
- package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
- package/dist/chunk-note-row-kd0fy7ee.js +75 -0
- package/dist/chunk-notes-yb8f057c.js +1 -0
- package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
- package/dist/chunk-notices-x07da5bb.js +1 -0
- package/dist/chunk-onboarding-csrkbh9z.js +1 -0
- package/dist/chunk-overview-avc2d30t.js +1 -0
- package/dist/chunk-palette-9nhp95bz.js +1 -0
- package/dist/chunk-placement-items-q694dpgk.js +1 -0
- package/dist/chunk-profile-6qemf3m7.js +1 -0
- package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
- package/dist/chunk-prompts-nv6mrn0q.js +1 -0
- package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
- package/dist/chunk-room-menu-rr6nzap1.js +1 -0
- package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
- package/dist/chunk-schedules-rw57nr0m.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
- package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
- package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
- package/dist/chunk-skills-tnr1h16h.js +1 -0
- package/dist/chunk-switcher-list-17enhrse.js +1 -0
- package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
- package/dist/chunk-table-r12cja6p.js +1 -0
- package/dist/chunk-table-seats-g3t392zz.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
- package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
- package/dist/chunk-transcript-n2s6dher.js +1 -0
- package/dist/chunk-where-f3scdydc.js +1 -0
- package/dist/chunk-workspace-xs023bxm.js +1 -0
- package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +356 -4
- package/src/app/app.ts +201 -32
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/presence.ts +2 -2
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +153 -14
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +168 -28
- package/src/app/socket.ts +349 -81
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/threads.ts +31 -2
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +77 -8
- package/src/auth/login-page.ts +4 -2
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +62 -0
- package/src/auth/oidc.ts +649 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +173 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +102 -12
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +112 -0
- package/src/host/context.ts +188 -0
- package/src/host/forms.ts +240 -0
- package/src/host/frames.ts +349 -0
- package/src/host/host-app.ts +178 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +396 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +429 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +106 -0
- package/src/i18n/zh-tw.ts +139 -0
- package/src/index.ts +15 -1
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +534 -34
- package/src/page-presentation.ts +47 -2
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/pwa.ts +32 -4
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +718 -0
- package/src/web-plugin.ts +279 -26
- package/dist/chunk-action-menu-qe839g4b.js +0 -1
- package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
- package/dist/chunk-character-creator-6yf29xwx.js +0 -1
- package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
- package/dist/chunk-confirm-xrw7d732.js +0 -1
- package/dist/chunk-conversations-ry228aes.js +0 -1
- package/dist/chunk-folder-list-5cp8stna.js +0 -1
- package/dist/chunk-folds-hff104tk.js +0 -1
- package/dist/chunk-group-scene-h228jdat.js +0 -1
- package/dist/chunk-index-0p5h2kdz.js +0 -2
- package/dist/chunk-index-0pt08yd9.js +0 -1
- package/dist/chunk-index-12bw9yj8.js +0 -2
- package/dist/chunk-index-2ad17eh4.js +0 -2
- package/dist/chunk-index-31wh5twj.js +0 -52
- package/dist/chunk-index-4dy94pbz.js +0 -2
- package/dist/chunk-index-4ecjtdy8.js +0 -13
- package/dist/chunk-index-4v1b7ks9.js +0 -2
- package/dist/chunk-index-5d8yjj4m.js +0 -2
- package/dist/chunk-index-89ajmxh7.js +0 -2
- package/dist/chunk-index-b9ny4623.js +0 -2
- package/dist/chunk-index-e1dr6g10.js +0 -2
- package/dist/chunk-index-gpkzhq1g.js +0 -2
- package/dist/chunk-index-hk3xyy20.js +0 -2
- package/dist/chunk-index-jm23vn03.js +0 -4
- package/dist/chunk-index-k7gh0jvz.js +0 -2
- package/dist/chunk-index-ky42ev0b.js +0 -2
- package/dist/chunk-index-pa2yyqh0.js +0 -2
- package/dist/chunk-index-pcvt668q.js +0 -2
- package/dist/chunk-index-pqw27eye.js +0 -1
- package/dist/chunk-index-wmbmmrd4.js +0 -302
- package/dist/chunk-index-ymbjrsp6.js +0 -2
- package/dist/chunk-lang-css-t85r8rz0.js +0 -1
- package/dist/chunk-lang-html-n6jparge.js +0 -1
- package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
- package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
- package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
- package/dist/chunk-layout-actions-bv0chj41.js +0 -1
- package/dist/chunk-notes-9b7pgqfe.js +0 -1
- package/dist/chunk-overview-8yw71esz.js +0 -1
- package/dist/chunk-palette-frms7axe.js +0 -1
- package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
- package/dist/chunk-prompt-history-yrqehads.js +0 -1
- package/dist/chunk-prompts-g6609tqx.js +0 -1
- package/dist/chunk-room-confirms-0a2y0925.js +0 -1
- package/dist/chunk-room-menu-g11q97br.js +0 -1
- package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
- package/dist/chunk-schedules-ezmacrbj.js +0 -1
- package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
- package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
- package/dist/chunk-skills-gqcfhq56.js +0 -1
- package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
- package/dist/chunk-table-s8cde1xz.js +0 -1
- package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
- package/dist/chunk-transcript-c59c6mpm.js +0 -1
- package/dist/chunk-workspace-0cyspkk4.js +0 -1
- package/dist/chunk-zh-tw-nc93jm10.js +0 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a push is about; a sender renders its words from it. A `clear` row has no words: it tells
|
|
6
|
+
* the devices a notice is gone (close its notification, set the count), and a transport that has
|
|
7
|
+
* no such thing skips it.
|
|
8
|
+
*/
|
|
9
|
+
export type OutboxKind = "card" | "reply" | "notice" | "test" | "clear";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One push to one person, queued for a sender. It carries what a sender needs to word and address
|
|
13
|
+
* the push, and no host-room content: `title` and `body` are present only with `push.content`
|
|
14
|
+
* `full`.
|
|
15
|
+
*/
|
|
16
|
+
export interface OutboxRow {
|
|
17
|
+
id: number;
|
|
18
|
+
kind: OutboxKind;
|
|
19
|
+
/** Pushes with one tag replace each other on a device; a notice's tag is its dedupe key. */
|
|
20
|
+
tag: string;
|
|
21
|
+
principalId: string;
|
|
22
|
+
/** Where a tap goes: a path under the app's origin. */
|
|
23
|
+
open: string;
|
|
24
|
+
urgency: "normal" | "high";
|
|
25
|
+
/** The count the app icon shows after this push: the person's notices not handled. */
|
|
26
|
+
badge: number;
|
|
27
|
+
title?: string;
|
|
28
|
+
body?: string;
|
|
29
|
+
/** The transports that may carry the row, in order; absent = all the sender has, in order. */
|
|
30
|
+
via?: readonly string[];
|
|
31
|
+
createdAt: Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type NewOutboxRow = Omit<OutboxRow, "id" | "createdAt">;
|
|
35
|
+
|
|
36
|
+
/** What a transport said of one delivery. */
|
|
37
|
+
export type DeliveryResult = "sent" | "gone" | "failed" | "skipped";
|
|
38
|
+
|
|
39
|
+
interface Row {
|
|
40
|
+
id: string | number;
|
|
41
|
+
kind: OutboxKind;
|
|
42
|
+
tag: string;
|
|
43
|
+
principal_id: string;
|
|
44
|
+
open: string;
|
|
45
|
+
urgency: "normal" | "high";
|
|
46
|
+
badge: number;
|
|
47
|
+
title: string | null;
|
|
48
|
+
body: string | null;
|
|
49
|
+
via: string[] | null;
|
|
50
|
+
created_at: Date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rowOf = (row: Row): OutboxRow => ({
|
|
54
|
+
id: Number(row.id),
|
|
55
|
+
kind: row.kind,
|
|
56
|
+
tag: row.tag,
|
|
57
|
+
principalId: row.principal_id,
|
|
58
|
+
open: row.open,
|
|
59
|
+
urgency: row.urgency,
|
|
60
|
+
badge: row.badge,
|
|
61
|
+
...(row.title === null ? {} : { title: row.title }),
|
|
62
|
+
...(row.body === null ? {} : { body: row.body }),
|
|
63
|
+
...(row.via === null ? {} : { via: row.via }),
|
|
64
|
+
createdAt: row.created_at,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** A claimed row that no sender finished is claimed again after this long. */
|
|
68
|
+
const CLAIM_LEASE_SECONDS = 60;
|
|
69
|
+
/** Finished rows, deliveries and receipts are kept this long, in days. */
|
|
70
|
+
export const OUTBOX_KEEP_DAYS = 30;
|
|
71
|
+
/** A device that returned a receipt this recently counts as working. */
|
|
72
|
+
export const RECEIPT_WINDOW_DAYS = 30;
|
|
73
|
+
|
|
74
|
+
/** A receipt the service worker posted: the notification arrived, or was opened. */
|
|
75
|
+
export type ReceiptEvent = "received" | "opened";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The queue between the app and the process that signs and sends pushes (`web_push_outbox`), what
|
|
79
|
+
* the sender did with each row (`web_push_deliveries`), and the receipts the service worker posts
|
|
80
|
+
* back (`web_push_receipts`). The app and an external sender both use it: the sender needs only to
|
|
81
|
+
* read and update the outbox and insert deliveries.
|
|
82
|
+
*/
|
|
83
|
+
export class PushOutbox {
|
|
84
|
+
readonly #sql: SQL;
|
|
85
|
+
|
|
86
|
+
constructor(sql: SQL) {
|
|
87
|
+
this.#sql = sql;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static readonly migration: Migration = {
|
|
91
|
+
name: "web-push-outbox",
|
|
92
|
+
up: async (sql) => {
|
|
93
|
+
await sql`
|
|
94
|
+
CREATE TABLE IF NOT EXISTS web_push_outbox (
|
|
95
|
+
id bigserial PRIMARY KEY,
|
|
96
|
+
kind text NOT NULL,
|
|
97
|
+
tag text NOT NULL,
|
|
98
|
+
principal_id text NOT NULL,
|
|
99
|
+
open text NOT NULL,
|
|
100
|
+
urgency text NOT NULL,
|
|
101
|
+
badge integer NOT NULL DEFAULT 0,
|
|
102
|
+
title text,
|
|
103
|
+
body text,
|
|
104
|
+
via text[],
|
|
105
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
106
|
+
claimed_at timestamptz,
|
|
107
|
+
done_at timestamptz
|
|
108
|
+
)`;
|
|
109
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_outbox_open ON web_push_outbox (id) WHERE done_at IS NULL`;
|
|
110
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_outbox_done ON web_push_outbox (done_at)`;
|
|
111
|
+
await sql`
|
|
112
|
+
CREATE TABLE IF NOT EXISTS web_push_deliveries (
|
|
113
|
+
outbox_id bigint NOT NULL,
|
|
114
|
+
transport text NOT NULL,
|
|
115
|
+
endpoint_hash text,
|
|
116
|
+
result text NOT NULL,
|
|
117
|
+
at timestamptz NOT NULL DEFAULT now()
|
|
118
|
+
)`;
|
|
119
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_deliveries_outbox ON web_push_deliveries (outbox_id)`;
|
|
120
|
+
await sql`
|
|
121
|
+
CREATE TABLE IF NOT EXISTS web_push_receipts (
|
|
122
|
+
principal_id text NOT NULL,
|
|
123
|
+
tag text NOT NULL,
|
|
124
|
+
event text NOT NULL,
|
|
125
|
+
at timestamptz NOT NULL DEFAULT now()
|
|
126
|
+
)`;
|
|
127
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_receipts_principal ON web_push_receipts (principal_id, at DESC)`;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Queues a push; returns its id. */
|
|
132
|
+
async enqueue(row: NewOutboxRow): Promise<number> {
|
|
133
|
+
const [made]: { id: string | number }[] = await this.#sql`
|
|
134
|
+
INSERT INTO web_push_outbox (kind, tag, principal_id, open, urgency, badge, title, body, via)
|
|
135
|
+
VALUES (${row.kind}, ${row.tag}, ${row.principalId}, ${row.open}, ${row.urgency},
|
|
136
|
+
${row.badge}, ${row.title ?? null}, ${row.body ?? null},
|
|
137
|
+
${row.via ? this.#sql.array([...row.via], "TEXT") : null})
|
|
138
|
+
RETURNING id`;
|
|
139
|
+
if (!made) throw new Error("the push outbox did not return an id");
|
|
140
|
+
return Number(made.id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Takes up to `limit` rows no sender holds, oldest first; a row held longer than the lease is taken again. */
|
|
144
|
+
async claim(limit: number): Promise<OutboxRow[]> {
|
|
145
|
+
const rows: Row[] = await this.#sql`
|
|
146
|
+
UPDATE web_push_outbox SET claimed_at = now()
|
|
147
|
+
WHERE id IN (
|
|
148
|
+
SELECT id FROM web_push_outbox
|
|
149
|
+
WHERE done_at IS NULL
|
|
150
|
+
AND (claimed_at IS NULL OR claimed_at < now() - make_interval(secs => ${CLAIM_LEASE_SECONDS}))
|
|
151
|
+
ORDER BY id LIMIT ${limit}
|
|
152
|
+
FOR UPDATE SKIP LOCKED)
|
|
153
|
+
RETURNING *`;
|
|
154
|
+
return rows.map(rowOf).sort((a, b) => a.id - b.id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Records what the transports said of a row and finishes it. */
|
|
158
|
+
async finish(
|
|
159
|
+
id: number,
|
|
160
|
+
deliveries: readonly { transport: string; result: DeliveryResult }[],
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
await this.#sql.begin(async (tx) => {
|
|
163
|
+
for (const delivery of deliveries)
|
|
164
|
+
await tx`
|
|
165
|
+
INSERT INTO web_push_deliveries (outbox_id, transport, result)
|
|
166
|
+
VALUES (${id}, ${delivery.transport}, ${delivery.result})`;
|
|
167
|
+
await tx`UPDATE web_push_outbox SET done_at = now() WHERE id = ${id}`;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether the person has a queued push of this kind and tag, which makes a receipt for it theirs. */
|
|
172
|
+
async has(
|
|
173
|
+
principalId: string,
|
|
174
|
+
kind: OutboxKind,
|
|
175
|
+
tag: string,
|
|
176
|
+
): Promise<boolean> {
|
|
177
|
+
const [found]: { n: number }[] = await this.#sql`
|
|
178
|
+
SELECT count(*)::int AS n FROM web_push_outbox
|
|
179
|
+
WHERE principal_id = ${principalId} AND kind = ${kind} AND tag = ${tag}`;
|
|
180
|
+
return (found?.n ?? 0) > 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async receipt(
|
|
184
|
+
principalId: string,
|
|
185
|
+
tag: string,
|
|
186
|
+
event: ReceiptEvent,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
await this.#sql`
|
|
189
|
+
INSERT INTO web_push_receipts (principal_id, tag, event)
|
|
190
|
+
VALUES (${principalId}, ${tag}, ${event})`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** When a device of the person last posted a receipt, within the window; undefined for none. */
|
|
194
|
+
async lastReceiptAt(
|
|
195
|
+
principalId: string,
|
|
196
|
+
withinDays = RECEIPT_WINDOW_DAYS,
|
|
197
|
+
): Promise<Date | undefined> {
|
|
198
|
+
const [found]: { at: Date | null }[] = await this.#sql`
|
|
199
|
+
SELECT max(at) AS at FROM web_push_receipts
|
|
200
|
+
WHERE principal_id = ${principalId}
|
|
201
|
+
AND at > now() - make_interval(days => ${withinDays})`;
|
|
202
|
+
return found?.at ?? undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Deletes finished rows, their deliveries and old receipts; returns how many rows went. */
|
|
206
|
+
async sweep(): Promise<number> {
|
|
207
|
+
const gone: { id: string | number }[] = await this.#sql`
|
|
208
|
+
DELETE FROM web_push_outbox
|
|
209
|
+
WHERE done_at < now() - make_interval(days => ${OUTBOX_KEEP_DAYS}) RETURNING id`;
|
|
210
|
+
await this.#sql`
|
|
211
|
+
DELETE FROM web_push_deliveries d WHERE NOT EXISTS (
|
|
212
|
+
SELECT 1 FROM web_push_outbox o WHERE o.id = d.outbox_id)`;
|
|
213
|
+
await this.#sql`
|
|
214
|
+
DELETE FROM web_push_receipts
|
|
215
|
+
WHERE at < now() - make_interval(days => ${OUTBOX_KEEP_DAYS})`;
|
|
216
|
+
return gone.length;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import { minutesOfDay, QuietClock, type QuietWindow } from "./quiet-hours.ts";
|
|
4
|
+
|
|
5
|
+
/** A person's own quiet hours: no push for a normal notice in the window, on their time zone's clock. */
|
|
6
|
+
export interface PushPrefs {
|
|
7
|
+
/** `HH:MM`, where the window begins. */
|
|
8
|
+
quietStart: string;
|
|
9
|
+
/** `HH:MM`, where it ends; before `start` when it crosses midnight. */
|
|
10
|
+
quietEnd: string;
|
|
11
|
+
/** The IANA zone the times are read in. */
|
|
12
|
+
timeZone: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Row {
|
|
16
|
+
quiet_start: string;
|
|
17
|
+
quiet_end: string;
|
|
18
|
+
time_zone: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Why a preference was refused, in words the page shows. */
|
|
22
|
+
export class PrefsRefused extends Error {}
|
|
23
|
+
|
|
24
|
+
/** Checks the three fields; throws `PrefsRefused` for a time that is not `HH:MM`, equal times or an unknown zone. */
|
|
25
|
+
export function checkPrefs(value: unknown): PushPrefs {
|
|
26
|
+
const given = value as Partial<PushPrefs> | null;
|
|
27
|
+
if (
|
|
28
|
+
typeof given !== "object" ||
|
|
29
|
+
given === null ||
|
|
30
|
+
typeof given.quietStart !== "string" ||
|
|
31
|
+
typeof given.quietEnd !== "string" ||
|
|
32
|
+
typeof given.timeZone !== "string"
|
|
33
|
+
)
|
|
34
|
+
throw new PrefsRefused(
|
|
35
|
+
"Quiet hours need quietStart, quietEnd and timeZone.",
|
|
36
|
+
);
|
|
37
|
+
if (
|
|
38
|
+
minutesOfDay(given.quietStart) === undefined ||
|
|
39
|
+
minutesOfDay(given.quietEnd) === undefined
|
|
40
|
+
)
|
|
41
|
+
throw new PrefsRefused("Quiet hours are times like 22:00 and 07:30.");
|
|
42
|
+
if (given.quietStart === given.quietEnd)
|
|
43
|
+
throw new PrefsRefused("The two times of the quiet hours must differ.");
|
|
44
|
+
try {
|
|
45
|
+
new QuietClock(
|
|
46
|
+
{ start: given.quietStart, end: given.quietEnd },
|
|
47
|
+
given.timeZone,
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new PrefsRefused("That time zone is not known.");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
quietStart: given.quietStart,
|
|
54
|
+
quietEnd: given.quietEnd,
|
|
55
|
+
timeZone: given.timeZone,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The people's quiet hours, in `web_push_prefs`. */
|
|
60
|
+
export class PushPrefsStore {
|
|
61
|
+
readonly #sql: SQL;
|
|
62
|
+
|
|
63
|
+
constructor(sql: SQL) {
|
|
64
|
+
this.#sql = sql;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static readonly migration: Migration = {
|
|
68
|
+
name: "web-push-prefs",
|
|
69
|
+
up: async (sql) => {
|
|
70
|
+
await sql`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS web_push_prefs (
|
|
72
|
+
principal_id text PRIMARY KEY,
|
|
73
|
+
quiet_start text NOT NULL,
|
|
74
|
+
quiet_end text NOT NULL,
|
|
75
|
+
time_zone text NOT NULL
|
|
76
|
+
)`;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
async get(principalId: string): Promise<PushPrefs | undefined> {
|
|
81
|
+
const [row]: Row[] = await this.#sql`
|
|
82
|
+
SELECT quiet_start, quiet_end, time_zone FROM web_push_prefs
|
|
83
|
+
WHERE principal_id = ${principalId}`;
|
|
84
|
+
return row
|
|
85
|
+
? {
|
|
86
|
+
quietStart: row.quiet_start,
|
|
87
|
+
quietEnd: row.quiet_end,
|
|
88
|
+
timeZone: row.time_zone,
|
|
89
|
+
}
|
|
90
|
+
: undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stores the person's quiet hours, or removes them with `undefined`. */
|
|
94
|
+
async set(principalId: string, prefs: PushPrefs | undefined): Promise<void> {
|
|
95
|
+
if (!prefs) {
|
|
96
|
+
await this
|
|
97
|
+
.#sql`DELETE FROM web_push_prefs WHERE principal_id = ${principalId}`;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await this.#sql`
|
|
101
|
+
INSERT INTO web_push_prefs (principal_id, quiet_start, quiet_end, time_zone)
|
|
102
|
+
VALUES (${principalId}, ${prefs.quietStart}, ${prefs.quietEnd}, ${prefs.timeZone})
|
|
103
|
+
ON CONFLICT (principal_id) DO UPDATE SET
|
|
104
|
+
quiet_start = EXCLUDED.quiet_start, quiet_end = EXCLUDED.quiet_end,
|
|
105
|
+
time_zone = EXCLUDED.time_zone`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether the moment is inside the person's quiet hours; false when they set none. */
|
|
109
|
+
async quietAt(principalId: string, at: number): Promise<boolean> {
|
|
110
|
+
const prefs = await this.get(principalId);
|
|
111
|
+
if (!prefs) return false;
|
|
112
|
+
const window: QuietWindow = {
|
|
113
|
+
start: prefs.quietStart,
|
|
114
|
+
end: prefs.quietEnd,
|
|
115
|
+
};
|
|
116
|
+
return new QuietClock(window, prefs.timeZone).quietAt(at);
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/push/sender.ts
CHANGED
|
@@ -18,7 +18,11 @@ export interface PushPayload {
|
|
|
18
18
|
url?: string;
|
|
19
19
|
/** Notifications with one tag replace each other on the device. */
|
|
20
20
|
tag: string;
|
|
21
|
-
kind: "card" | "cap" | "reply" | "notice";
|
|
21
|
+
kind: "card" | "cap" | "reply" | "notice" | "test" | "clear";
|
|
22
|
+
/** The count the app icon shows after this push; absent = leave it. */
|
|
23
|
+
badge?: number;
|
|
24
|
+
/** `high` asks the device to keep the notification until it is dealt with. */
|
|
25
|
+
urgency?: "normal" | "high";
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/** The part of a stored subscription the sender needs. */
|
|
@@ -83,7 +87,8 @@ export class PushSender {
|
|
|
83
87
|
"content-encoding": "aes128gcm",
|
|
84
88
|
"content-type": "application/octet-stream",
|
|
85
89
|
ttl: String(TTL_SECONDS),
|
|
86
|
-
urgency:
|
|
90
|
+
urgency:
|
|
91
|
+
payload.urgency ?? (payload.kind === "reply" ? "normal" : "high"),
|
|
87
92
|
},
|
|
88
93
|
body,
|
|
89
94
|
// A push service never redirects; a redirect would walk past the allowlist.
|
package/src/push/triggers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IdentityService, Logger } from "pi-roundtable";
|
|
2
|
+
import type { NewOutboxRow, PushOutbox } from "./outbox.ts";
|
|
2
3
|
import type { PushPayload, PushSender, SendResult } from "./sender.ts";
|
|
3
4
|
import type { PushSubscriptions, Subscription } from "./subscriptions.ts";
|
|
4
5
|
|
|
@@ -7,12 +8,26 @@ export const PREVIEW_CHARS = 120;
|
|
|
7
8
|
/** Replies in one room closer together than this share a notification. */
|
|
8
9
|
const REPLY_QUIET_MS = 15_000;
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Where the push goes when the app does not sign it: the outbox an external sender empties. The
|
|
13
|
+
* app then holds no VAPID key and a row carries a title and a preview only with `content: "full"`.
|
|
14
|
+
*/
|
|
15
|
+
export interface OutboxRoute {
|
|
16
|
+
outbox: Pick<PushOutbox, "enqueue">;
|
|
17
|
+
content: "full" | "none";
|
|
18
|
+
/** The count the app icon shows for the person: their notices not handled. */
|
|
19
|
+
badge(principalId: string): Promise<number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export interface PushServiceOptions {
|
|
11
23
|
subscriptions: Pick<
|
|
12
24
|
PushSubscriptions,
|
|
13
25
|
"all" | "delivered" | "forget" | "failed"
|
|
14
26
|
>;
|
|
15
|
-
sender
|
|
27
|
+
/** Signs and sends in this process; absent when `route` queues the pushes for an external sender. */
|
|
28
|
+
sender?: Pick<PushSender, "send">;
|
|
29
|
+
/** Queue the pushes instead of signing them here; absent = sign and send in this process. */
|
|
30
|
+
route?: OutboxRoute;
|
|
16
31
|
identity: Pick<IdentityService, "tierOf">;
|
|
17
32
|
/** Whether a page of this person is in view now; their devices are not pushed while one is. */
|
|
18
33
|
visible(principalId: string): boolean;
|
|
@@ -33,6 +48,8 @@ export interface PushServiceOptions {
|
|
|
33
48
|
* card is. Default never.
|
|
34
49
|
*/
|
|
35
50
|
muted?: (room: string) => Promise<boolean>;
|
|
51
|
+
/** Whether a room is a host's: those reach phones only through notices, never through a card or reply push. */
|
|
52
|
+
hostRoom?: (room: string) => boolean;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
/** What a trigger says; the service shapes it into a notification. */
|
|
@@ -72,6 +89,7 @@ export class PushService {
|
|
|
72
89
|
|
|
73
90
|
/** A card opened in a room: an approval or a question waits for an owner. */
|
|
74
91
|
card(room: string, title: string, longRunningCap: boolean): Promise<void> {
|
|
92
|
+
if (this.#options.hostRoom?.(room)) return Promise.resolve();
|
|
75
93
|
return this.#push({
|
|
76
94
|
kind: longRunningCap ? "cap" : "card",
|
|
77
95
|
title: longRunningCap ? "Long-running cap reached" : "Card waiting",
|
|
@@ -83,6 +101,7 @@ export class PushService {
|
|
|
83
101
|
|
|
84
102
|
/** An agent posted in a room, such as a finished job's report or an answer nobody was watching for. */
|
|
85
103
|
async reply(room: string, author: string, text: string): Promise<void> {
|
|
104
|
+
if (this.#options.hostRoom?.(room)) return;
|
|
86
105
|
const now = this.#now();
|
|
87
106
|
if (this.#options.quiet?.(now)) return;
|
|
88
107
|
if (await this.#muted(room)) return;
|
|
@@ -142,12 +161,15 @@ export class PushService {
|
|
|
142
161
|
...(preview && event.text ? { body: previewOf(event.text) } : {}),
|
|
143
162
|
};
|
|
144
163
|
const targets = await this.#targets(event.principalId);
|
|
164
|
+
if (this.#options.route) {
|
|
165
|
+
await this.#queue(this.#options.route, event, payload, targets);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const { sender } = this.#options;
|
|
169
|
+
if (!sender) return;
|
|
145
170
|
await Promise.all(
|
|
146
171
|
targets.map(async (target) =>
|
|
147
|
-
this.#settle(
|
|
148
|
-
target,
|
|
149
|
-
await this.#options.sender.send(target, payload),
|
|
150
|
-
),
|
|
172
|
+
this.#settle(target, await sender.send(target, payload)),
|
|
151
173
|
),
|
|
152
174
|
);
|
|
153
175
|
} catch (error) {
|
|
@@ -155,6 +177,35 @@ export class PushService {
|
|
|
155
177
|
}
|
|
156
178
|
}
|
|
157
179
|
|
|
180
|
+
/** One outbox row for each person the push reaches; the sender finds their devices. */
|
|
181
|
+
async #queue(
|
|
182
|
+
route: OutboxRoute,
|
|
183
|
+
event: Event,
|
|
184
|
+
payload: PushPayload,
|
|
185
|
+
targets: readonly Subscription[],
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const people = [...new Set(targets.map((target) => target.principalId))];
|
|
188
|
+
await Promise.all(
|
|
189
|
+
people.map(async (principalId) => {
|
|
190
|
+
const row: NewOutboxRow = {
|
|
191
|
+
kind: payload.kind === "cap" ? "card" : payload.kind,
|
|
192
|
+
tag: payload.tag,
|
|
193
|
+
principalId,
|
|
194
|
+
open: payload.url ?? "",
|
|
195
|
+
urgency: payload.kind === "reply" ? "normal" : "high",
|
|
196
|
+
badge: await route.badge(principalId),
|
|
197
|
+
...(route.content === "full"
|
|
198
|
+
? {
|
|
199
|
+
title: event.title,
|
|
200
|
+
...(payload.body ? { body: payload.body } : {}),
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
};
|
|
204
|
+
await route.outbox.enqueue(row);
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
158
209
|
async #targets(principalId?: string): Promise<Subscription[]> {
|
|
159
210
|
const owners = new Map<string, boolean>();
|
|
160
211
|
const isOwner = async (id: string): Promise<boolean> => {
|
package/src/push/vapid.ts
CHANGED
|
@@ -71,6 +71,25 @@ async function importStored(stored: Stored, path: string): Promise<Vapid> {
|
|
|
71
71
|
return { publicKey: stored.publicKey, privateKey };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The VAPID keys in `path`, which must exist: for a sender process that holds the key the app
|
|
76
|
+
* never sees. A missing or unreadable file stops it, since a new key would not match the one the
|
|
77
|
+
* devices subscribed against.
|
|
78
|
+
*/
|
|
79
|
+
export async function readVapid(path: string): Promise<Vapid> {
|
|
80
|
+
let raw: string;
|
|
81
|
+
try {
|
|
82
|
+
raw = readFileSync(path, "utf8");
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
85
|
+
throw new Error(
|
|
86
|
+
`${path} does not exist; the sender needs the VAPID key file the devices subscribed against`,
|
|
87
|
+
);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return importStored(parse(raw, path), path);
|
|
91
|
+
}
|
|
92
|
+
|
|
74
93
|
/**
|
|
75
94
|
* The VAPID keys in `path`, made on first use with WebCrypto and kept readable by this user
|
|
76
95
|
* alone. Subscriptions are bound to the public key, so the file must outlive a restart; a file
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Logger } from "pi-roundtable";
|
|
3
|
+
import type { PushTransport } from "./drain.ts";
|
|
4
|
+
import { EndpointPolicy } from "./endpoint.ts";
|
|
5
|
+
import { type PushPayload, PushSender } from "./sender.ts";
|
|
6
|
+
import { PushSubscriptions } from "./subscriptions.ts";
|
|
7
|
+
import { readVapid, type Vapid } from "./vapid.ts";
|
|
8
|
+
|
|
9
|
+
/** The name of the Web Push transport, which a notice's `via` may name. */
|
|
10
|
+
export const WEB_PUSH = "web-push";
|
|
11
|
+
|
|
12
|
+
export interface WebPushTransportOptions {
|
|
13
|
+
/** The key the devices subscribed against. Read when the first push is sent; it must exist. */
|
|
14
|
+
vapidFile?: string;
|
|
15
|
+
/** The key itself, when the caller already holds it (the app's own sender). */
|
|
16
|
+
vapid?: Vapid;
|
|
17
|
+
/** The VAPID `sub`: a `mailto:` or `https:` contact for the push services. */
|
|
18
|
+
subject: string;
|
|
19
|
+
/** Push services besides the browsers' own, as `push.endpointHosts` writes them. */
|
|
20
|
+
endpointHosts?: readonly string[];
|
|
21
|
+
database: SQL;
|
|
22
|
+
/** The HTTP client; default `fetch`. */
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
logger?: Logger;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A logger that says nothing, for a sender run without one. */
|
|
28
|
+
const silent: Logger = {
|
|
29
|
+
debug: () => undefined,
|
|
30
|
+
info: () => undefined,
|
|
31
|
+
warn: () => undefined,
|
|
32
|
+
error: () => undefined,
|
|
33
|
+
fatal: () => undefined,
|
|
34
|
+
child: () => silent,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** What a push says on a device, from the row and the words the sender gave it. */
|
|
38
|
+
export function payloadOf(
|
|
39
|
+
row: Parameters<PushTransport["send"]>[0],
|
|
40
|
+
rendered: Parameters<PushTransport["send"]>[1],
|
|
41
|
+
): PushPayload {
|
|
42
|
+
return {
|
|
43
|
+
kind: row.kind,
|
|
44
|
+
title: rendered.title,
|
|
45
|
+
...(rendered.body ? { body: rendered.body } : {}),
|
|
46
|
+
tag: row.tag,
|
|
47
|
+
url: row.open,
|
|
48
|
+
badge: row.badge,
|
|
49
|
+
urgency: row.urgency,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Web Push to all the devices a person subscribed. It reads their subscriptions, posts a sealed
|
|
55
|
+
* payload to each under the VAPID key, forgets a device the push service says is gone, and counts
|
|
56
|
+
* a failure against one that keeps failing. `sent` when at least one device took it; `gone` when
|
|
57
|
+
* every device was gone; `skipped` when the person has no device.
|
|
58
|
+
*/
|
|
59
|
+
export function webPushTransport(
|
|
60
|
+
options: WebPushTransportOptions,
|
|
61
|
+
): PushTransport {
|
|
62
|
+
const subscriptions = new PushSubscriptions(options.database);
|
|
63
|
+
const policy = new EndpointPolicy(options.endpointHosts);
|
|
64
|
+
const logger = options.logger ?? silent;
|
|
65
|
+
let sender: Promise<PushSender> | undefined;
|
|
66
|
+
const senderOf = (): Promise<PushSender> => {
|
|
67
|
+
sender ??= (async () => {
|
|
68
|
+
const vapid =
|
|
69
|
+
options.vapid ??
|
|
70
|
+
(options.vapidFile ? await readVapid(options.vapidFile) : undefined);
|
|
71
|
+
if (!vapid) throw new Error("webPushTransport needs vapidFile or vapid");
|
|
72
|
+
return new PushSender({
|
|
73
|
+
vapid,
|
|
74
|
+
subject: options.subject,
|
|
75
|
+
policy,
|
|
76
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
77
|
+
logger,
|
|
78
|
+
});
|
|
79
|
+
})();
|
|
80
|
+
return sender;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
name: WEB_PUSH,
|
|
84
|
+
async send(row, rendered, signal) {
|
|
85
|
+
const devices = await subscriptions.of(row.principalId);
|
|
86
|
+
if (devices.length === 0) return "skipped";
|
|
87
|
+
const push = await senderOf();
|
|
88
|
+
const payload = payloadOf(row, rendered);
|
|
89
|
+
let sent = 0;
|
|
90
|
+
let gone = 0;
|
|
91
|
+
for (const device of devices) {
|
|
92
|
+
if (signal.aborted) break;
|
|
93
|
+
const result = await push.send(device, payload);
|
|
94
|
+
if (result === "sent") {
|
|
95
|
+
sent += 1;
|
|
96
|
+
await subscriptions.delivered(device.endpoint);
|
|
97
|
+
} else if (result === "gone") {
|
|
98
|
+
gone += 1;
|
|
99
|
+
await subscriptions.forget(device.endpoint);
|
|
100
|
+
} else await subscriptions.failed(device.endpoint);
|
|
101
|
+
}
|
|
102
|
+
if (sent > 0) return "sent";
|
|
103
|
+
return gone === devices.length ? "gone" : "failed";
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The entry `pi-roundtable-web/push-sender`: the process that signs and sends the web app's pushes
|
|
2
|
+
// when the app holds no VAPID key (`push.sender: { external }`). The app queues each push as a row
|
|
3
|
+
// in `web_push_outbox`; this empties it through transports (Web Push here, a host's own beside it).
|
|
4
|
+
|
|
5
|
+
export type { PushTransport } from "../push/drain.ts";
|
|
6
|
+
export type { DeliveryResult, OutboxKind, OutboxRow } from "../push/outbox.ts";
|
|
7
|
+
export { WEB_PUSH, webPushTransport } from "../push/web-push-transport.ts";
|
|
8
|
+
export type { PushSenderOptions } from "./run.ts";
|
|
9
|
+
export { runPushSender } from "./run.ts";
|
|
10
|
+
export {
|
|
11
|
+
SESSION_COOKIE,
|
|
12
|
+
webSessionClaims,
|
|
13
|
+
webSessionPrincipal,
|
|
14
|
+
} from "./session.ts";
|