+4
backend/fly.toml
+4
backend/fly.toml
-97
backend/src/backfill.zig
-97
backend/src/backfill.zig
···
1
-
const std = @import("std");
2
-
const mem = std.mem;
3
-
const json = std.json;
4
-
const Allocator = mem.Allocator;
5
-
const db = @import("db.zig");
6
-
const jetstream = @import("jetstream.zig");
7
-
8
-
pub fn run(allocator: Allocator) void {
9
-
std.debug.print("starting backfill from ufos-api.microcosm.blue...\n", .{});
10
-
11
-
backfillCollection(allocator, "tech.waow.poll");
12
-
backfillCollection(allocator, "tech.waow.vote");
13
-
14
-
std.debug.print("backfill complete\n", .{});
15
-
}
16
-
17
-
fn backfillCollection(allocator: Allocator, collection: []const u8) void {
18
-
var url_buf: [256]u8 = undefined;
19
-
const url = std.fmt.bufPrint(&url_buf, "https://ufos-api.microcosm.blue/records?collection={s}", .{collection}) catch return;
20
-
21
-
std.debug.print("backfill: fetching {s} from ufos\n", .{collection});
22
-
23
-
// make https request
24
-
var client = std.http.Client{ .allocator = allocator };
25
-
defer client.deinit();
26
-
27
-
const uri = std.Uri.parse(url) catch return;
28
-
29
-
var req = client.request(.GET, uri, .{
30
-
.headers = .{ .accept_encoding = .{ .override = "identity" } },
31
-
}) catch |err| {
32
-
std.debug.print("backfill: http request error: {}\n", .{err});
33
-
return;
34
-
};
35
-
defer req.deinit();
36
-
37
-
req.sendBodiless() catch |err| {
38
-
std.debug.print("backfill: http send error: {}\n", .{err});
39
-
return;
40
-
};
41
-
42
-
var redirect_buf: [8192]u8 = undefined;
43
-
var response = req.receiveHead(&redirect_buf) catch |err| {
44
-
std.debug.print("backfill: http receive error: {}\n", .{err});
45
-
return;
46
-
};
47
-
48
-
if (response.head.status != .ok) {
49
-
std.debug.print("backfill: http status {}\n", .{response.head.status});
50
-
return;
51
-
}
52
-
53
-
// read response body
54
-
var reader = response.reader(&.{});
55
-
const body = reader.allocRemaining(allocator, std.Io.Limit.limited(65536)) catch |err| {
56
-
std.debug.print("backfill: http read error: {}\n", .{err});
57
-
return;
58
-
};
59
-
defer allocator.free(body);
60
-
61
-
// parse json response - UFOs returns array of {did, collection, rkey, record}
62
-
const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| {
63
-
std.debug.print("backfill: json parse error: {}\n", .{err});
64
-
return;
65
-
};
66
-
defer parsed.deinit();
67
-
68
-
if (parsed.value != .array) return;
69
-
70
-
var count: usize = 0;
71
-
for (parsed.value.array.items) |item| {
72
-
if (item != .object) continue;
73
-
74
-
const did = item.object.get("did") orelse continue;
75
-
if (did != .string) continue;
76
-
77
-
const rkey = item.object.get("rkey") orelse continue;
78
-
if (rkey != .string) continue;
79
-
80
-
const record = item.object.get("record") orelse continue;
81
-
if (record != .object) continue;
82
-
83
-
// construct record uri
84
-
const record_uri = std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.string, collection, rkey.string }) catch continue;
85
-
defer allocator.free(record_uri);
86
-
87
-
if (mem.eql(u8, collection, "tech.waow.poll")) {
88
-
jetstream.processPoll(allocator, record_uri, did.string, rkey.string, record.object) catch continue;
89
-
count += 1;
90
-
} else if (mem.eql(u8, collection, "tech.waow.vote")) {
91
-
jetstream.processVote(record_uri, did.string, record.object) catch continue;
92
-
count += 1;
93
-
}
94
-
}
95
-
96
-
std.debug.print("backfill: indexed {d} {s} records\n", .{ count, collection });
97
-
}
+12
-36
backend/src/db.zig
+12
-36
backend/src/db.zig
···
70
70
return err;
71
71
};
72
72
73
-
conn.execNoArgs(
74
-
\\CREATE TABLE IF NOT EXISTS cursor (
75
-
\\ id INTEGER PRIMARY KEY CHECK (id = 1),
76
-
\\ time_us INTEGER NOT NULL
77
-
\\)
78
-
) catch |err| {
79
-
std.debug.print("failed to create cursor table: {}\n", .{err});
80
-
return err;
81
-
};
82
-
83
73
std.debug.print("database schema initialized\n", .{});
84
74
}
85
75
86
-
pub fn getCursor() ?i64 {
87
-
mutex.lock();
88
-
defer mutex.unlock();
89
-
90
-
const row = conn.row("SELECT time_us FROM cursor WHERE id = 1", .{}) catch return null;
91
-
if (row == null) return null;
92
-
defer row.?.deinit();
93
-
return row.?.int(0);
94
-
}
95
-
96
-
pub fn saveCursor(time_us: i64) void {
97
-
mutex.lock();
98
-
defer mutex.unlock();
99
-
100
-
conn.exec("INSERT OR REPLACE INTO cursor (id, time_us) VALUES (1, ?)", .{time_us}) catch |err| {
101
-
std.debug.print("failed to save cursor: {}\n", .{err});
102
-
};
103
-
}
104
-
105
76
pub fn insertPoll(uri: []const u8, did: []const u8, rkey: []const u8, text_json: []const u8, options_json: []const u8, created_at: []const u8) !void {
106
77
mutex.lock();
107
78
defer mutex.unlock();
···
119
90
mutex.lock();
120
91
defer mutex.unlock();
121
92
122
-
// delete any existing vote by this user on this poll, then insert new one
123
-
// this enforces one vote per user per poll
124
-
conn.exec("DELETE FROM votes WHERE subject = ? AND voter = ?", .{ subject, voter }) catch {};
125
-
93
+
// upsert: update if exists and new vote is newer, otherwise insert
94
+
// this handles out-of-order events from tap
126
95
conn.exec(
127
-
"INSERT INTO votes (uri, subject, option, voter, created_at) VALUES (?, ?, ?, ?, ?)",
128
-
.{ uri, subject, option, voter, created_at },
129
-
) catch |err| {
96
+
\\INSERT INTO votes (uri, subject, option, voter, created_at)
97
+
\\VALUES (?, ?, ?, ?, ?)
98
+
\\ON CONFLICT(subject, voter) DO UPDATE SET
99
+
\\ uri = excluded.uri,
100
+
\\ option = excluded.option,
101
+
\\ created_at = excluded.created_at
102
+
\\WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL
103
+
, .{ uri, subject, option, voter, created_at }) catch |err| {
130
104
std.debug.print("db insert vote error: {}\n", .{err});
131
105
return err;
132
106
};
···
149
123
mutex.lock();
150
124
defer mutex.unlock();
151
125
126
+
// only delete if the URI matches - if a newer vote replaced this one,
127
+
// the URI won't match and we should not delete
152
128
conn.exec("DELETE FROM votes WHERE uri = ?", .{uri}) catch |err| {
153
129
std.debug.print("db delete vote error: {}\n", .{err});
154
130
};
+51
-41
backend/src/jetstream.zig
backend/src/tap.zig
+51
-41
backend/src/jetstream.zig
backend/src/tap.zig
···
9
9
const POLL_COLLECTION = "tech.waow.poll";
10
10
const VOTE_COLLECTION = "tech.waow.vote";
11
11
12
+
// tap url from env or default to fly.io internal network
13
+
fn getTapHost() []const u8 {
14
+
return std.posix.getenv("TAP_HOST") orelse "pollz-tap.fly.dev";
15
+
}
16
+
17
+
fn getTapPort() u16 {
18
+
const port_str = std.posix.getenv("TAP_PORT") orelse "443";
19
+
return std.fmt.parseInt(u16, port_str, 10) catch 443;
20
+
}
21
+
22
+
fn useTls() bool {
23
+
const port = getTapPort();
24
+
return port == 443;
25
+
}
26
+
12
27
pub fn consumer(allocator: Allocator) void {
28
+
// exponential backoff: 1s -> 2s -> 4s -> ... -> 60s cap
29
+
var backoff: u64 = 1;
30
+
const max_backoff: u64 = 60;
31
+
13
32
while (true) {
14
33
connect(allocator) catch |err| {
15
-
std.debug.print("jetstream error: {}, reconnecting in 3s...\n", .{err});
34
+
std.debug.print("tap error: {}, reconnecting in {}s...\n", .{ err, backoff });
16
35
};
17
-
posix.nanosleep(3, 0);
36
+
posix.nanosleep(backoff, 0);
37
+
backoff = @min(backoff * 2, max_backoff);
18
38
}
19
39
}
20
40
···
25
45
pub fn serverMessage(self: *Handler, data: []const u8) !void {
26
46
self.msg_count += 1;
27
47
if (self.msg_count % 100 == 1) {
28
-
std.debug.print("jetstream: received {} messages\n", .{self.msg_count});
48
+
std.debug.print("tap: received {} messages\n", .{self.msg_count});
29
49
}
30
50
processMessage(self.allocator, data) catch |err| {
31
51
std.debug.print("message processing error: {}\n", .{err});
···
33
53
}
34
54
35
55
pub fn close(_: *Handler) void {
36
-
std.debug.print("jetstream connection closed\n", .{});
56
+
std.debug.print("tap connection closed\n", .{});
37
57
}
38
58
};
39
59
40
60
fn connect(allocator: Allocator) !void {
41
-
const host = "jetstream1.us-east.bsky.network";
61
+
const host = getTapHost();
62
+
const port = getTapPort();
63
+
const tls = useTls();
42
64
43
-
var path_buf: [512]u8 = undefined;
65
+
const path = "/channel";
44
66
45
-
// only use saved cursor if we have one (for resuming after disconnect)
46
-
// otherwise start from NOW - UFOs handles backfill, Jetstream is for live events only
47
-
const path = if (db.getCursor()) |cursor|
48
-
std.fmt.bufPrint(&path_buf, "/subscribe?wantedCollections={s}&wantedCollections={s}&cursor={d}", .{ POLL_COLLECTION, VOTE_COLLECTION, cursor }) catch "/subscribe"
49
-
else
50
-
std.fmt.bufPrint(&path_buf, "/subscribe?wantedCollections={s}&wantedCollections={s}", .{ POLL_COLLECTION, VOTE_COLLECTION }) catch "/subscribe";
51
-
52
-
std.debug.print("connecting to wss://{s}{s}\n", .{ host, path });
67
+
std.debug.print("connecting to {s}://{s}:{d}{s}\n", .{ if (tls) "wss" else "ws", host, port, path });
53
68
54
69
var client = websocket.Client.init(allocator, .{
55
70
.host = host,
56
-
.port = 443,
57
-
.tls = true,
71
+
.port = port,
72
+
.tls = tls,
58
73
}) catch |err| {
59
74
std.debug.print("websocket client init failed: {}\n", .{err});
60
75
return err;
61
76
};
62
77
defer client.deinit();
63
78
64
-
std.debug.print("tcp+tls connected, starting handshake...\n", .{});
79
+
std.debug.print("tcp connected, starting handshake...\n", .{});
65
80
66
-
// add Host header which is required for websocket handshake
67
-
var host_header_buf: [128]u8 = undefined;
68
-
const host_header = std.fmt.bufPrint(&host_header_buf, "Host: {s}\r\n", .{host}) catch "Host: jetstream1.us-east.bsky.network\r\n";
81
+
var host_header_buf: [256]u8 = undefined;
82
+
const host_header = std.fmt.bufPrint(&host_header_buf, "Host: {s}\r\n", .{host}) catch "Host: pollz-tap.fly.dev\r\n";
69
83
70
84
client.handshake(path, .{ .headers = host_header }) catch |err| {
71
85
std.debug.print("websocket handshake failed: {}\n", .{err});
72
86
return err;
73
87
};
74
88
75
-
std.debug.print("jetstream connected!\n", .{});
89
+
std.debug.print("tap connected!\n", .{});
76
90
77
91
var handler = Handler{ .allocator = allocator };
78
92
client.readLoop(&handler) catch |err| {
···
82
96
}
83
97
84
98
fn processMessage(allocator: Allocator, payload: []const u8) !void {
85
-
// parse jetstream event
99
+
// parse tap event
86
100
const parsed = json.parseFromSlice(json.Value, allocator, payload, .{}) catch return;
87
101
defer parsed.deinit();
88
102
89
103
const root = parsed.value.object;
90
104
91
-
// save cursor from event timestamp
92
-
if (root.get("time_us")) |time_us_val| {
93
-
if (time_us_val == .integer) {
94
-
db.saveCursor(time_us_val.integer);
95
-
}
96
-
}
105
+
// tap format: { "id": 123, "type": "record", "record": { ... } }
106
+
const msg_type = root.get("type") orelse return;
107
+
if (msg_type != .string) return;
97
108
98
-
const kind = root.get("kind") orelse return;
99
-
if (kind != .string) return;
109
+
if (!mem.eql(u8, msg_type.string, "record")) return;
100
110
101
-
if (!mem.eql(u8, kind.string, "commit")) return;
111
+
const record_wrapper = root.get("record") orelse return;
112
+
if (record_wrapper != .object) return;
102
113
103
-
const commit = root.get("commit") orelse return;
104
-
if (commit != .object) return;
114
+
const rec = record_wrapper.object;
105
115
106
-
const collection = commit.object.get("collection") orelse return;
116
+
const collection = rec.get("collection") orelse return;
107
117
if (collection != .string) return;
108
118
109
-
const operation = commit.object.get("operation") orelse return;
110
-
if (operation != .string) return;
119
+
const action = rec.get("action") orelse return;
120
+
if (action != .string) return;
111
121
112
-
const did = root.get("did") orelse return;
122
+
const did = rec.get("did") orelse return;
113
123
if (did != .string) return;
114
124
115
-
const rkey = commit.object.get("rkey") orelse return;
125
+
const rkey = rec.get("rkey") orelse return;
116
126
if (rkey != .string) return;
117
127
118
128
const uri_str = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.string, collection.string, rkey.string });
119
129
defer allocator.free(uri_str);
120
130
121
-
if (mem.eql(u8, operation.string, "create")) {
122
-
const record = commit.object.get("record") orelse return;
131
+
if (mem.eql(u8, action.string, "create") or mem.eql(u8, action.string, "update")) {
132
+
const record = rec.get("record") orelse return;
123
133
if (record != .object) return;
124
134
125
135
if (mem.eql(u8, collection.string, POLL_COLLECTION)) {
···
131
141
std.debug.print("vote processing error: {}\n", .{err});
132
142
};
133
143
}
134
-
} else if (mem.eql(u8, operation.string, "delete")) {
144
+
} else if (mem.eql(u8, action.string, "delete")) {
135
145
if (mem.eql(u8, collection.string, POLL_COLLECTION)) {
136
146
db.deletePoll(uri_str);
137
147
std.debug.print("deleted poll: {s}\n", .{uri_str});
+5
-7
backend/src/main.zig
+5
-7
backend/src/main.zig
···
3
3
const Thread = std.Thread;
4
4
const db = @import("db.zig");
5
5
const http_server = @import("http.zig");
6
-
const jetstream = @import("jetstream.zig");
7
-
const backfill = @import("backfill.zig");
6
+
const tap = @import("tap.zig");
8
7
9
8
pub fn main() !void {
10
9
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
···
16
15
try db.init(db_path);
17
16
defer db.close();
18
17
19
-
// backfill existing records from known repos at startup
20
-
backfill.run(allocator);
18
+
// tap handles backfill automatically - no need to call backfill.run()
21
19
22
-
// start jetstream consumer in background
23
-
const jetstream_thread = try Thread.spawn(.{}, jetstream.consumer, .{allocator});
24
-
defer jetstream_thread.join();
20
+
// start tap consumer in background
21
+
const tap_thread = try Thread.spawn(.{}, tap.consumer, .{allocator});
22
+
defer tap_thread.join();
25
23
26
24
// start http server (bind to 0.0.0.0 for containerized deployments)
27
25
const address = try net.Address.parseIp("0.0.0.0", 3000);
-137
backend/zignotes.md
-137
backend/zignotes.md
···
1
-
# zig 0.15 notes
2
-
3
-
## breaking changes from 0.14
4
-
5
-
### `json.stringify` → `json.fmt`
6
-
```zig
7
-
// old: json.stringify(value, .{}, writer);
8
-
// new: use json.fmt formatter
9
-
try buffer.print(allocator, "{f}", .{json.fmt(value, .{})});
10
-
```
11
-
12
-
### `std.builtin.Mode` → `std.builtin.OptimizeMode`
13
-
the optimization mode enum was renamed
14
-
15
-
### `std.time.sleep` removed
16
-
use `std.posix.nanosleep(seconds, nanoseconds)` instead
17
-
18
-
### `std.ArrayList` is now unmanaged by default
19
-
the old `ArrayList` with embedded allocator is now `array_list.AlignedManaged`.
20
-
the new `std.ArrayList(T)` returns an unmanaged struct that:
21
-
- doesn't store the allocator internally
22
-
- requires passing allocator to methods like `appendSlice(alloc, slice)`
23
-
- initializes with `.{}` or `.empty` instead of `.init(allocator)`
24
-
- `deinit(alloc)` takes allocator as argument
25
-
26
-
```zig
27
-
// old 0.14 style:
28
-
var list = std.ArrayList(u8).init(allocator);
29
-
try list.appendSlice("hello");
30
-
list.deinit();
31
-
32
-
// new 0.15 style:
33
-
var list: std.ArrayList(u8) = .{};
34
-
try list.appendSlice(allocator, "hello");
35
-
list.deinit(allocator);
36
-
```
37
-
38
-
### build.zig changes
39
-
- `root_source_file` → `root_module` with `b.createModule(...)`
40
-
- `strip` field removed from `ExecutableOptions`
41
-
- fingerprint field required in `build.zig.zon`
42
-
43
-
### `std.http.Server` API
44
-
requires `*std.Io.Reader` and `*std.Io.Writer`, not raw `net.Stream`
45
-
46
-
## net.Stream → http.Server
47
-
48
-
```zig
49
-
var read_buffer: [8192]u8 = undefined;
50
-
var write_buffer: [8192]u8 = undefined;
51
-
52
-
var reader = conn.stream.reader(&read_buffer);
53
-
var writer = conn.stream.writer(&write_buffer);
54
-
55
-
// reader has .interface() method that returns *Io.Reader
56
-
// writer has .interface field that is Io.Writer
57
-
var server = http.Server.init(reader.interface(), &writer.interface);
58
-
```
59
-
60
-
### http.Server.Request.respond
61
-
the `respond` method is on `Request`, not `Server`:
62
-
```zig
63
-
try request.respond(body, .{
64
-
.status = .ok,
65
-
.extra_headers = &.{
66
-
.{ .name = "content-type", .value = "application/json" },
67
-
},
68
-
});
69
-
```
70
-
71
-
### `std.Uri.percentDecode` → `std.Uri.percentDecodeInPlace`
72
-
there's no allocating `percentDecode` anymore. use in-place decoding:
73
-
```zig
74
-
// copy to mutable buffer first, then decode in place
75
-
const uri_buf = try alloc.dupe(u8, uri_encoded);
76
-
const uri = std.Uri.percentDecodeInPlace(uri_buf);
77
-
```
78
-
79
-
### `std.http.Client` for outgoing requests
80
-
```zig
81
-
var client = std.http.Client{ .allocator = allocator };
82
-
defer client.deinit();
83
-
84
-
const uri = std.Uri.parse("https://example.com/api") catch return;
85
-
86
-
// use .headers to control accept-encoding (default is gzip/deflate)
87
-
var req = client.request(.GET, uri, .{
88
-
.headers = .{ .accept_encoding = .{ .override = "identity" } },
89
-
}) catch return;
90
-
defer req.deinit();
91
-
92
-
req.sendBodiless() catch return;
93
-
94
-
var redirect_buf: [8192]u8 = undefined;
95
-
var response = req.receiveHead(&redirect_buf) catch return;
96
-
97
-
if (response.head.status != .ok) return;
98
-
99
-
// read response body - use allocRemaining with Limit
100
-
var reader = response.reader(&.{});
101
-
const body = reader.allocRemaining(allocator, std.Io.Limit.limited(65536)) catch return;
102
-
defer allocator.free(body);
103
-
```
104
-
105
-
## external libraries
106
-
107
-
### websocket.zig (karlseguin/websocket.zig)
108
-
use for websocket client/server. add to `build.zig.zon`:
109
-
```zig
110
-
.dependencies = .{
111
-
.websocket = .{
112
-
.url = "https://github.com/karlseguin/websocket.zig/archive/refs/heads/master.tar.gz",
113
-
.hash = "websocket-0.1.0-ZPISdRNzAwAGszh62EpRtoQxu8wb1MSMVI6Ow0o2dmyJ",
114
-
},
115
-
},
116
-
```
117
-
118
-
client usage:
119
-
```zig
120
-
const websocket = @import("websocket");
121
-
122
-
var client = try websocket.Client.init(allocator, .{
123
-
.host = "example.com",
124
-
.port = 443,
125
-
.tls = true,
126
-
});
127
-
defer client.deinit();
128
-
129
-
// Host header is NOT automatically added - must provide it
130
-
client.handshake("/path", .{ .headers = "Host: example.com\r\n" }) catch |err| {
131
-
// handle error
132
-
};
133
-
134
-
// handler must have serverMessage(self, data) function
135
-
var handler = MyHandler{};
136
-
try client.readLoop(&handler);
137
-
```
+126
docs/architecture.md
+126
docs/architecture.md
···
1
+
# pollz architecture
2
+
3
+
## overview
4
+
5
+
pollz is a polling app built on atproto. users create polls and vote using their bluesky accounts.
6
+
7
+
```
8
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
9
+
│ frontend │────▶│ backend │◀────│ tap │
10
+
│ (vite/ts) │ │ (zig) │ │ (go) │
11
+
│ cloudflare │ │ fly.io │ │ fly.io │
12
+
└─────────────┘ └─────────────┘ └─────────────┘
13
+
│ │ │
14
+
│ ▼ │
15
+
│ ┌─────────────┐ │
16
+
│ │ sqlite │ │
17
+
│ │ (fly vol) │ │
18
+
│ └─────────────┘ │
19
+
│ │
20
+
▼ ▼
21
+
┌─────────────┐ ┌─────────────┐
22
+
│ user PDS │ │ firehose │
23
+
│ (bsky.social) │ (relay) │
24
+
└─────────────┘ └─────────────┘
25
+
```
26
+
27
+
## components
28
+
29
+
### frontend (src/)
30
+
- vanilla typescript with vite
31
+
- oauth via @atcute/oauth-browser-client
32
+
- writes polls/votes directly to user's PDS
33
+
- fetches poll data from backend API
34
+
35
+
### backend (backend/)
36
+
- zig http server
37
+
- sqlite for persistence
38
+
- consumes events from tap via websocket
39
+
- serves REST API for frontend
40
+
41
+
### tap (tap/)
42
+
- bluesky's official atproto sync utility
43
+
- handles firehose connection, backfill, cursor management
44
+
- filters for `tech.waow.poll` and `tech.waow.vote` collections
45
+
- delivers events to backend via websocket
46
+
47
+
## data flow
48
+
49
+
### creating a poll
50
+
1. user logs in via oauth
51
+
2. frontend calls `com.atproto.repo.createRecord` on user's PDS
52
+
3. PDS broadcasts to relay/firehose
53
+
4. tap receives event, forwards to backend
54
+
5. backend inserts poll into sqlite
55
+
56
+
### voting
57
+
1. frontend checks if user has existing vote on this poll
58
+
2. if exists: `com.atproto.repo.putRecord` (update)
59
+
3. if not: `com.atproto.repo.createRecord` (create)
60
+
4. tap receives event, forwards to backend
61
+
5. backend upserts vote (one vote per user per poll)
62
+
63
+
### reading polls
64
+
1. frontend fetches `/api/polls` from backend
65
+
2. backend queries sqlite, returns polls with vote counts
66
+
3. frontend renders poll list
67
+
68
+
## lexicons
69
+
70
+
### tech.waow.poll
71
+
```json
72
+
{
73
+
"$type": "tech.waow.poll",
74
+
"text": "what's the best language?",
75
+
"options": ["rust", "zig", "go"],
76
+
"createdAt": "2024-01-01T00:00:00.000Z"
77
+
}
78
+
```
79
+
80
+
### tech.waow.vote
81
+
```json
82
+
{
83
+
"$type": "tech.waow.vote",
84
+
"subject": "at://did:plc:.../tech.waow.poll/...",
85
+
"option": 0,
86
+
"createdAt": "2024-01-01T00:00:00.000Z"
87
+
}
88
+
```
89
+
90
+
## key lessons learned
91
+
92
+
### vote updates, not delete+create
93
+
when changing a vote, use `putRecord` to update the existing record rather than deleting and creating. this avoids race conditions where tap receives events out of order (create then delete) causing the vote to disappear.
94
+
95
+
### tap event ordering
96
+
tap delivers events in the order they're received from the firehose, but the firehose itself can deliver events out of order. the backend must handle this gracefully:
97
+
- `insertVote` uses upsert with timestamp comparison
98
+
- only updates if the incoming vote is newer than existing
99
+
100
+
### one vote per user per poll
101
+
enforced at multiple levels:
102
+
- frontend: checks for existing vote before creating
103
+
- backend: `UNIQUE(subject, voter)` constraint
104
+
- backend: upsert logic in `insertVote`
105
+
106
+
## deployment
107
+
108
+
### fly.io apps
109
+
- `pollz-backend` - zig backend with sqlite volume
110
+
- `pollz-tap` - tap instance with sqlite volume
111
+
112
+
### cloudflare pages
113
+
- frontend static files
114
+
- oauth client metadata at `/oauth-client-metadata.json`
115
+
116
+
### environment variables
117
+
118
+
backend:
119
+
- `TAP_HOST` - tap hostname (default: pollz-tap.internal)
120
+
- `TAP_PORT` - tap port (default: 2480)
121
+
- `DATA_PATH` - sqlite db path (default: /data/pollz.db)
122
+
123
+
tap:
124
+
- `TAP_DATABASE_URL` - sqlite path
125
+
- `TAP_COLLECTION_FILTERS` - collections to track
126
+
- `TAP_SIGNAL_COLLECTION` - collection for auto-discovery
+122
docs/tap.md
+122
docs/tap.md
···
1
+
# tap integration
2
+
3
+
tap is bluesky's official atproto sync utility. pollz uses it to receive real-time events from the firehose.
4
+
5
+
## what tap provides
6
+
7
+
- firehose connection with automatic reconnection
8
+
- signature verification of repo structure and identity
9
+
- automatic backfill when adding new repos
10
+
- filtered output by collection
11
+
- ordering guarantees - backfill completes before live events
12
+
- cursor management - persists automatically, resumes on restart
13
+
14
+
## pollz tap configuration
15
+
16
+
```toml
17
+
# tap/fly.toml
18
+
[env]
19
+
TAP_COLLECTION_FILTERS = "tech.waow.poll,tech.waow.vote"
20
+
TAP_SIGNAL_COLLECTION = "tech.waow.poll"
21
+
TAP_DATABASE_URL = "sqlite:///data/tap.db"
22
+
TAP_DISABLE_ACKS = "true"
23
+
```
24
+
25
+
`TAP_SIGNAL_COLLECTION` makes tap automatically discover and track all repos that have ever created a poll.
26
+
27
+
## event format
28
+
29
+
tap delivers events via websocket at `/channel`:
30
+
31
+
```json
32
+
{
33
+
"id": 12345,
34
+
"type": "record",
35
+
"record": {
36
+
"live": true,
37
+
"did": "did:plc:abc123",
38
+
"collection": "tech.waow.poll",
39
+
"rkey": "3kb3fge5lm32x",
40
+
"action": "create",
41
+
"record": {
42
+
"text": "what's your favorite color?",
43
+
"options": ["red", "blue", "green"],
44
+
"$type": "tech.waow.poll",
45
+
"createdAt": "2024-10-07T12:00:00.000Z"
46
+
}
47
+
}
48
+
}
49
+
```
50
+
51
+
### action types
52
+
- `create` - new record created
53
+
- `update` - existing record updated (same rkey)
54
+
- `delete` - record deleted
55
+
56
+
## backend tap consumer
57
+
58
+
the backend connects to tap via websocket and processes events:
59
+
60
+
```zig
61
+
// tap.zig
62
+
if (mem.eql(u8, action.string, "create") or mem.eql(u8, action.string, "update")) {
63
+
// process poll or vote
64
+
} else if (mem.eql(u8, action.string, "delete")) {
65
+
// delete poll or vote
66
+
}
67
+
```
68
+
69
+
## handling out-of-order events
70
+
71
+
tap delivers events in firehose order, but the firehose itself can deliver events out of order. example:
72
+
73
+
1. user deletes old vote, creates new vote
74
+
2. firehose delivers: create (new), delete (old)
75
+
3. if backend processes delete after create, the new vote disappears
76
+
77
+
### solution: use putRecord instead of delete+create
78
+
79
+
when changing a vote, the frontend uses `putRecord` to update the existing record:
80
+
81
+
```typescript
82
+
// api.ts
83
+
if (existingRkey) {
84
+
// update existing vote - single "update" event
85
+
await rpc.post("com.atproto.repo.putRecord", { ... });
86
+
} else {
87
+
// create new vote
88
+
await rpc.post("com.atproto.repo.createRecord", { ... });
89
+
}
90
+
```
91
+
92
+
this results in a single "update" event instead of separate "delete" and "create" events, eliminating the race condition.
93
+
94
+
### backend upsert logic
95
+
96
+
as additional protection, `insertVote` uses upsert with timestamp comparison:
97
+
98
+
```sql
99
+
INSERT INTO votes (uri, subject, option, voter, created_at)
100
+
VALUES (?, ?, ?, ?, ?)
101
+
ON CONFLICT(subject, voter) DO UPDATE SET
102
+
uri = excluded.uri,
103
+
option = excluded.option,
104
+
created_at = excluded.created_at
105
+
WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL
106
+
```
107
+
108
+
this ensures that if out-of-order events do occur, older events don't overwrite newer ones.
109
+
110
+
## deployment
111
+
112
+
tap runs as a separate fly.io app (`pollz-tap`) and communicates with the backend over fly's internal network:
113
+
114
+
```
115
+
pollz-tap.internal:2480 → pollz-backend
116
+
```
117
+
118
+
## further reading
119
+
120
+
- [tap README](https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md)
121
+
- [indigo repo](https://github.com/bluesky-social/indigo)
122
+
- [bailey's tap guide](https://marvins-guide.leaflet.pub/3m7ttuppfzc23)
+126
docs/zig.md
+126
docs/zig.md
···
1
+
# zig 0.15 notes
2
+
3
+
reference for zig 0.15 patterns used in the backend.
4
+
5
+
## breaking changes from 0.14
6
+
7
+
### json.stringify → json.fmt
8
+
```zig
9
+
// old: json.stringify(value, .{}, writer);
10
+
// new: use json.fmt formatter
11
+
try buffer.print(allocator, "{f}", .{json.fmt(value, .{})});
12
+
```
13
+
14
+
### std.ArrayList is unmanaged by default
15
+
```zig
16
+
// old 0.14 style:
17
+
var list = std.ArrayList(u8).init(allocator);
18
+
try list.appendSlice("hello");
19
+
list.deinit();
20
+
21
+
// new 0.15 style:
22
+
var list: std.ArrayList(u8) = .{};
23
+
try list.appendSlice(allocator, "hello");
24
+
list.deinit(allocator);
25
+
```
26
+
27
+
### std.time.sleep removed
28
+
```zig
29
+
// use posix.nanosleep instead
30
+
std.posix.nanosleep(seconds, nanoseconds);
31
+
```
32
+
33
+
### std.Uri.percentDecode → percentDecodeInPlace
34
+
```zig
35
+
// copy to mutable buffer first, then decode in place
36
+
const uri_buf = try alloc.dupe(u8, uri_encoded);
37
+
const uri = std.Uri.percentDecodeInPlace(uri_buf);
38
+
```
39
+
40
+
## http server patterns
41
+
42
+
### net.Stream → http.Server
43
+
```zig
44
+
var read_buffer: [8192]u8 = undefined;
45
+
var write_buffer: [8192]u8 = undefined;
46
+
47
+
var reader = conn.stream.reader(&read_buffer);
48
+
var writer = conn.stream.writer(&write_buffer);
49
+
50
+
var server = http.Server.init(reader.interface(), &writer.interface);
51
+
```
52
+
53
+
### responding to requests
54
+
```zig
55
+
try request.respond(body, .{
56
+
.status = .ok,
57
+
.extra_headers = &.{
58
+
.{ .name = "content-type", .value = "application/json" },
59
+
},
60
+
});
61
+
```
62
+
63
+
## websocket client (karlseguin/websocket.zig)
64
+
65
+
```zig
66
+
const websocket = @import("websocket");
67
+
68
+
var client = try websocket.Client.init(allocator, .{
69
+
.host = "example.com",
70
+
.port = 443,
71
+
.tls = true,
72
+
});
73
+
defer client.deinit();
74
+
75
+
// Host header must be provided manually
76
+
client.handshake("/path", .{ .headers = "Host: example.com\r\n" }) catch |err| {
77
+
// handle error
78
+
};
79
+
80
+
// handler must have serverMessage(self, data) function
81
+
var handler = MyHandler{};
82
+
try client.readLoop(&handler);
83
+
```
84
+
85
+
## sqlite patterns (zqlite)
86
+
87
+
### prepared statements with bind
88
+
```zig
89
+
var stmt = conn.prepare("SELECT * FROM votes WHERE uri = ?") catch return;
90
+
defer stmt.deinit();
91
+
92
+
const row = stmt.bind(.{uri}).step() catch return;
93
+
if (row) |r| {
94
+
const subject = r[0].?.text;
95
+
// ...
96
+
}
97
+
```
98
+
99
+
### upsert with ON CONFLICT
100
+
```zig
101
+
conn.exec(
102
+
\\INSERT INTO votes (uri, subject, option, voter, created_at)
103
+
\\VALUES (?, ?, ?, ?, ?)
104
+
\\ON CONFLICT(subject, voter) DO UPDATE SET
105
+
\\ uri = excluded.uri,
106
+
\\ option = excluded.option,
107
+
\\ created_at = excluded.created_at
108
+
\\WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL
109
+
, .{ uri, subject, option, voter, created_at }) catch |err| {
110
+
// handle error
111
+
};
112
+
```
113
+
114
+
## build.zig.zon
115
+
116
+
```zig
117
+
.{
118
+
.name = .pollz,
119
+
.version = "0.0.0",
120
+
.fingerprint = 0x..., // required in 0.15
121
+
.dependencies = .{
122
+
.zqlite = .{ ... },
123
+
.websocket = .{ ... },
124
+
},
125
+
}
126
+
```
+6
-1
justfile
+6
-1
justfile
···
1
1
# pollz
2
2
mod backend
3
+
mod tap
3
4
4
5
# show available commands
5
6
default:
···
18
19
deploy-backend:
19
20
just backend::deploy
20
21
22
+
# deploy tap to fly.io
23
+
deploy-tap:
24
+
just tap::deploy
25
+
21
26
# deploy everything
22
-
deploy: deploy-backend deploy-frontend
27
+
deploy: deploy-tap deploy-backend deploy-frontend
+312
src/lib/api.ts
+312
src/lib/api.ts
···
1
+
import { Client, simpleFetchHandler } from "@atcute/client";
2
+
import {
3
+
CompositeDidDocumentResolver,
4
+
CompositeHandleResolver,
5
+
DohJsonHandleResolver,
6
+
PlcDidDocumentResolver,
7
+
AtprotoWebDidDocumentResolver,
8
+
WellKnownHandleResolver,
9
+
} from "@atcute/identity-resolver";
10
+
import {
11
+
configureOAuth,
12
+
createAuthorizationUrl,
13
+
defaultIdentityResolver,
14
+
finalizeAuthorization,
15
+
getSession,
16
+
OAuthUserAgent,
17
+
deleteStoredSession,
18
+
} from "@atcute/oauth-browser-client";
19
+
20
+
export const POLL = "tech.waow.poll";
21
+
export const VOTE = "tech.waow.vote";
22
+
23
+
export const didDocumentResolver = new CompositeDidDocumentResolver({
24
+
methods: {
25
+
plc: new PlcDidDocumentResolver(),
26
+
web: new AtprotoWebDidDocumentResolver(),
27
+
},
28
+
});
29
+
30
+
export const handleResolver = new CompositeHandleResolver({
31
+
strategy: "dns-first",
32
+
methods: {
33
+
dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }),
34
+
http: new WellKnownHandleResolver(),
35
+
},
36
+
});
37
+
38
+
const BASE_URL = import.meta.env.VITE_BASE_URL || "https://pollz.waow.tech";
39
+
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "https://pollz-backend.fly.dev";
40
+
41
+
configureOAuth({
42
+
metadata: {
43
+
client_id: `${BASE_URL}/oauth-client-metadata.json`,
44
+
redirect_uri: `${BASE_URL}/`,
45
+
},
46
+
identityResolver: defaultIdentityResolver({
47
+
handleResolver,
48
+
didDocumentResolver,
49
+
}),
50
+
});
51
+
52
+
// state
53
+
export let agent: OAuthUserAgent | null = null;
54
+
export let currentDid: string | null = null;
55
+
56
+
export const setAgent = (a: OAuthUserAgent | null) => { agent = a; };
57
+
export const setCurrentDid = (did: string | null) => { currentDid = did; };
58
+
59
+
export type Poll = {
60
+
uri: string;
61
+
repo: string;
62
+
rkey: string;
63
+
text: string;
64
+
options: string[];
65
+
createdAt: string;
66
+
votes: Map<string, number>;
67
+
voteCount?: number;
68
+
};
69
+
70
+
export const polls = new Map<string, Poll>();
71
+
72
+
// oauth
73
+
export const login = async (handle: string): Promise<void> => {
74
+
const url = await createAuthorizationUrl({
75
+
scope: `atproto repo:${POLL} repo:${VOTE}`,
76
+
target: { type: "account", identifier: handle },
77
+
});
78
+
location.assign(url);
79
+
};
80
+
81
+
export const logout = async (): Promise<void> => {
82
+
if (currentDid) {
83
+
await deleteStoredSession(currentDid as `did:${string}:${string}`);
84
+
localStorage.removeItem("lastDid");
85
+
}
86
+
agent = null;
87
+
currentDid = null;
88
+
};
89
+
90
+
export const handleCallback = async (): Promise<boolean> => {
91
+
const params = new URLSearchParams(location.hash.slice(1));
92
+
if (!params.has("state")) return false;
93
+
94
+
history.replaceState(null, "", "/");
95
+
const { session } = await finalizeAuthorization(params);
96
+
agent = new OAuthUserAgent(session);
97
+
currentDid = session.info.sub;
98
+
localStorage.setItem("lastDid", currentDid);
99
+
return true;
100
+
};
101
+
102
+
export const restoreSession = async (): Promise<void> => {
103
+
const lastDid = localStorage.getItem("lastDid");
104
+
if (!lastDid) return;
105
+
106
+
try {
107
+
const session = await getSession(lastDid as `did:${string}:${string}`);
108
+
agent = new OAuthUserAgent(session);
109
+
currentDid = session.info.sub;
110
+
} catch {
111
+
localStorage.removeItem("lastDid");
112
+
}
113
+
};
114
+
115
+
// backend api
116
+
export const fetchPolls = async (): Promise<void> => {
117
+
const res = await fetch(`${BACKEND_URL}/api/polls`);
118
+
if (!res.ok) throw new Error("failed to fetch polls");
119
+
120
+
const backendPolls = await res.json() as Array<{
121
+
uri: string;
122
+
repo: string;
123
+
rkey: string;
124
+
text: string;
125
+
options: string[];
126
+
createdAt: string;
127
+
voteCount: number;
128
+
}>;
129
+
130
+
for (const p of backendPolls) {
131
+
const existing = polls.get(p.uri);
132
+
if (existing) {
133
+
existing.voteCount = p.voteCount;
134
+
} else {
135
+
polls.set(p.uri, {
136
+
uri: p.uri,
137
+
repo: p.repo,
138
+
rkey: p.rkey,
139
+
text: p.text,
140
+
options: p.options,
141
+
createdAt: p.createdAt,
142
+
votes: new Map(),
143
+
voteCount: p.voteCount,
144
+
});
145
+
}
146
+
}
147
+
};
148
+
149
+
export const fetchPoll = async (uri: string) => {
150
+
const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(uri)}`);
151
+
if (!res.ok) return null;
152
+
return res.json() as Promise<{
153
+
uri: string;
154
+
repo: string;
155
+
rkey: string;
156
+
text: string;
157
+
options: Array<{ text: string; count: number }>;
158
+
createdAt: string;
159
+
}>;
160
+
};
161
+
162
+
export const fetchVoters = async (pollUri: string) => {
163
+
const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}/votes`);
164
+
if (!res.ok) return [];
165
+
return res.json() as Promise<Array<{ voter: string; option: number; uri: string; createdAt?: string }>>;
166
+
};
167
+
168
+
// user votes
169
+
export const loadUserVotes = async (): Promise<void> => {
170
+
if (!agent || !currentDid) return;
171
+
172
+
try {
173
+
const rpc = new Client({ handler: agent });
174
+
const res = await rpc.get("com.atproto.repo.listRecords", {
175
+
params: { repo: currentDid, collection: VOTE, limit: 100 },
176
+
});
177
+
178
+
if (res.ok) {
179
+
for (const record of res.data.records) {
180
+
const val = record.value as { subject?: string; option?: number };
181
+
if (val.subject && typeof val.option === "number") {
182
+
const poll = polls.get(val.subject);
183
+
if (poll) {
184
+
poll.votes.set(record.uri, val.option);
185
+
}
186
+
}
187
+
}
188
+
}
189
+
} catch (e) {
190
+
console.error("failed to load user votes:", e);
191
+
}
192
+
};
193
+
194
+
// create poll
195
+
export const createPoll = async (text: string, options: string[]): Promise<string | null> => {
196
+
if (!agent || !currentDid) return null;
197
+
198
+
const rpc = new Client({ handler: agent });
199
+
const res = await rpc.post("com.atproto.repo.createRecord", {
200
+
input: {
201
+
repo: currentDid,
202
+
collection: POLL,
203
+
record: { $type: POLL, text, options, createdAt: new Date().toISOString() },
204
+
},
205
+
});
206
+
207
+
if (!res.ok) throw new Error(res.data.error || "failed to create poll");
208
+
209
+
const rkey = res.data.uri.split("/").pop()!;
210
+
polls.set(res.data.uri, {
211
+
uri: res.data.uri,
212
+
repo: currentDid,
213
+
rkey,
214
+
text,
215
+
options,
216
+
createdAt: new Date().toISOString(),
217
+
votes: new Map(),
218
+
});
219
+
220
+
return res.data.uri;
221
+
};
222
+
223
+
// vote - creates or updates vote record on user's PDS
224
+
export const vote = async (pollUri: string, option: number): Promise<void> => {
225
+
if (!agent || !currentDid) throw new Error("not logged in");
226
+
227
+
const rpc = new Client({ handler: agent });
228
+
229
+
// check if we already have a vote on this poll
230
+
const existing = await rpc.get("com.atproto.repo.listRecords", {
231
+
params: { repo: currentDid, collection: VOTE, limit: 100 },
232
+
});
233
+
234
+
let existingRkey: string | null = null;
235
+
if (existing.ok) {
236
+
for (const record of existing.data.records) {
237
+
const val = record.value as { subject?: string };
238
+
if (val.subject === pollUri) {
239
+
existingRkey = record.uri.split("/").pop()!;
240
+
break;
241
+
}
242
+
}
243
+
}
244
+
245
+
if (existingRkey) {
246
+
// update existing vote
247
+
const res = await rpc.post("com.atproto.repo.putRecord", {
248
+
input: {
249
+
repo: currentDid,
250
+
collection: VOTE,
251
+
rkey: existingRkey,
252
+
record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() },
253
+
},
254
+
});
255
+
if (!res.ok) throw new Error(res.data.error || res.data.message || "vote update failed");
256
+
} else {
257
+
// create new vote
258
+
const res = await rpc.post("com.atproto.repo.createRecord", {
259
+
input: {
260
+
repo: currentDid,
261
+
collection: VOTE,
262
+
record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() },
263
+
},
264
+
});
265
+
if (!res.ok) throw new Error(res.data.error || res.data.message || "vote failed");
266
+
}
267
+
};
268
+
269
+
// resolve handle from DID
270
+
const handleCache = new Map<string, string>();
271
+
272
+
export const resolveHandle = async (did: string): Promise<string> => {
273
+
if (handleCache.has(did)) return handleCache.get(did)!;
274
+
try {
275
+
const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
276
+
if (res.ok) {
277
+
const data = await res.json();
278
+
if (data.handle) {
279
+
handleCache.set(did, data.handle);
280
+
return data.handle;
281
+
}
282
+
}
283
+
} catch {}
284
+
return did;
285
+
};
286
+
287
+
// fetch poll directly from PDS (fallback)
288
+
export const fetchPollFromPDS = async (repo: string, rkey: string) => {
289
+
const didDoc = await didDocumentResolver.resolve(repo as `did:${string}:${string}`);
290
+
const pds = didDoc?.service?.find((s: { id: string }) => s.id === "#atproto_pds") as { serviceEndpoint?: string } | undefined;
291
+
const pdsUrl = pds?.serviceEndpoint || "https://bsky.social";
292
+
293
+
const pdsClient = new Client({
294
+
handler: simpleFetchHandler({ service: pdsUrl }),
295
+
});
296
+
297
+
const res = await pdsClient.get("com.atproto.repo.getRecord", {
298
+
params: { repo, collection: POLL, rkey },
299
+
});
300
+
301
+
if (!res.ok) return null;
302
+
303
+
const rec = res.data.value as { text: string; options: string[]; createdAt: string };
304
+
return {
305
+
uri: res.data.uri,
306
+
repo,
307
+
rkey,
308
+
text: rec.text,
309
+
options: rec.options,
310
+
createdAt: rec.createdAt,
311
+
};
312
+
};
+16
src/lib/utils.ts
+16
src/lib/utils.ts
···
1
+
// html escaping
2
+
export const esc = (s: string) =>
3
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
4
+
5
+
// relative time
6
+
export const ago = (date: string) => {
7
+
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
8
+
if (seconds < 60) return "just now";
9
+
const minutes = Math.floor(seconds / 60);
10
+
if (minutes < 60) return `${minutes}m ago`;
11
+
const hours = Math.floor(minutes / 60);
12
+
if (hours < 24) return `${hours}h ago`;
13
+
const days = Math.floor(hours / 24);
14
+
if (days < 30) return `${days}d ago`;
15
+
return new Date(date).toLocaleDateString();
16
+
};
+159
-362
src/main.ts
+159
-362
src/main.ts
···
1
-
import { Client, simpleFetchHandler } from "@atcute/client";
2
1
import {
3
-
CompositeDidDocumentResolver,
4
-
CompositeHandleResolver,
5
-
DohJsonHandleResolver,
6
-
PlcDidDocumentResolver,
7
-
AtprotoWebDidDocumentResolver,
8
-
WellKnownHandleResolver,
9
-
} from "@atcute/identity-resolver";
10
-
import {
11
-
configureOAuth,
12
-
createAuthorizationUrl,
13
-
defaultIdentityResolver,
14
-
finalizeAuthorization,
15
-
getSession,
16
-
OAuthUserAgent,
17
-
deleteStoredSession,
18
-
} from "@atcute/oauth-browser-client";
19
-
20
-
const POLL = "tech.waow.poll";
21
-
const VOTE = "tech.waow.vote";
22
-
23
-
const didDocumentResolver = new CompositeDidDocumentResolver({
24
-
methods: {
25
-
plc: new PlcDidDocumentResolver(),
26
-
web: new AtprotoWebDidDocumentResolver(),
27
-
},
28
-
});
29
-
30
-
const handleResolver = new CompositeHandleResolver({
31
-
strategy: "dns-first",
32
-
methods: {
33
-
dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }),
34
-
http: new WellKnownHandleResolver(),
35
-
},
36
-
});
37
-
38
-
const BASE_URL = import.meta.env.VITE_BASE_URL || "https://pollz.waow.tech";
39
-
40
-
configureOAuth({
41
-
metadata: {
42
-
client_id: `${BASE_URL}/oauth-client-metadata.json`,
43
-
redirect_uri: `${BASE_URL}/`,
44
-
},
45
-
identityResolver: defaultIdentityResolver({
46
-
handleResolver,
47
-
didDocumentResolver,
48
-
}),
49
-
});
2
+
POLL,
3
+
VOTE,
4
+
agent,
5
+
currentDid,
6
+
setAgent,
7
+
setCurrentDid,
8
+
polls,
9
+
login,
10
+
logout,
11
+
handleCallback,
12
+
restoreSession,
13
+
fetchPolls,
14
+
fetchPoll,
15
+
fetchVoters,
16
+
loadUserVotes,
17
+
createPoll,
18
+
vote,
19
+
resolveHandle,
20
+
fetchPollFromPDS,
21
+
type Poll,
22
+
} from "./lib/api";
23
+
import { esc, ago } from "./lib/utils";
50
24
51
25
const app = document.getElementById("app")!;
52
26
const nav = document.getElementById("nav")!;
53
27
const status = document.getElementById("status")!;
54
28
55
-
let agent: OAuthUserAgent | null = null;
56
-
let currentDid: string | null = null;
57
-
let jetstream: WebSocket | null = null;
58
-
59
29
const setStatus = (msg: string) => (status.textContent = msg);
60
30
61
-
type Poll = {
62
-
uri: string;
63
-
repo: string;
64
-
rkey: string;
65
-
text: string;
66
-
options: string[];
67
-
createdAt: string;
68
-
votes: Map<string, number>;
69
-
voteCount?: number; // from backend, used when votes map is empty
70
-
};
31
+
// track if a vote is in progress to prevent double-clicks
32
+
let votingInProgress = false;
71
33
72
-
const polls = new Map<string, Poll>();
34
+
// jetstream - replay last 24h on connect, then live updates
35
+
let jetstream: WebSocket | null = null;
73
36
74
-
// jetstream - replay last 24h on connect, then live updates
75
37
const connectJetstream = () => {
76
38
if (jetstream?.readyState === WebSocket.OPEN) return;
77
39
78
-
// cursor is microseconds since epoch - go back 24 hours
79
40
const cursor = (Date.now() - 24 * 60 * 60 * 1000) * 1000;
80
41
const url = `wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=${POLL}&wantedCollections=${VOTE}&cursor=${cursor}`;
81
42
jetstream = new WebSocket(url);
···
113
74
render();
114
75
}
115
76
} else if (commit.operation === "delete") {
116
-
// find and remove vote from its poll
117
77
for (const poll of polls.values()) {
118
78
if (poll.votes.has(uri)) {
119
79
poll.votes.delete(uri);
···
136
96
const match = path.match(/^\/poll\/([^/]+)\/([^/]+)$/);
137
97
138
98
if (match) {
139
-
renderPoll(match[1], match[2]);
99
+
renderPollPage(match[1], match[2]);
140
100
} else if (path === "/new") {
141
101
renderCreate();
102
+
} else if (path === "/mine") {
103
+
renderHome(true);
142
104
} else {
143
-
renderHome();
105
+
renderHome(false);
144
106
}
145
107
};
146
108
147
109
const renderNav = () => {
148
110
if (agent) {
149
-
nav.innerHTML = `<a href="/">my polls</a> · <a href="/new">new</a> · <a href="#" id="logout">logout</a>`;
111
+
nav.innerHTML = `<a href="/">all</a> · <a href="/mine">mine</a> · <a href="/new">new</a> · <a href="#" id="logout">logout</a>`;
150
112
document.getElementById("logout")!.onclick = async (e) => {
151
113
e.preventDefault();
152
-
if (currentDid) {
153
-
await deleteStoredSession(currentDid as `did:${string}:${string}`);
154
-
localStorage.removeItem("lastDid");
155
-
}
156
-
agent = null;
157
-
currentDid = null;
114
+
await logout();
115
+
setAgent(null);
116
+
setCurrentDid(null);
158
117
render();
159
118
};
160
119
} else {
···
164
123
if (!handle) return;
165
124
setStatus("redirecting...");
166
125
try {
167
-
const url = await createAuthorizationUrl({
168
-
scope: `atproto repo:${POLL} repo:${VOTE}`,
169
-
target: { type: "account", identifier: handle },
170
-
});
171
-
location.assign(url);
126
+
await login(handle);
172
127
} catch (e) {
173
128
setStatus(`error: ${e}`);
174
129
}
···
176
131
}
177
132
};
178
133
179
-
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "https://pollz-backend.fly.dev";
180
-
181
-
// fetch user's existing votes from their PDS
182
-
const loadUserVotes = async () => {
183
-
if (!agent || !currentDid) return;
184
-
185
-
try {
186
-
const rpc = new Client({ handler: agent });
187
-
const res = await rpc.get("com.atproto.repo.listRecords", {
188
-
params: { repo: currentDid, collection: VOTE, limit: 100 },
189
-
});
190
-
191
-
if (res.ok) {
192
-
for (const record of res.data.records) {
193
-
const val = record.value as { subject?: string; option?: number };
194
-
if (val.subject && typeof val.option === "number") {
195
-
const poll = polls.get(val.subject);
196
-
if (poll) {
197
-
poll.votes.set(record.uri, val.option);
198
-
}
199
-
}
200
-
}
201
-
}
202
-
} catch (e) {
203
-
console.error("failed to load user votes:", e);
204
-
}
205
-
};
206
-
207
-
const renderHome = async () => {
134
+
const renderHome = async (mineOnly: boolean = false) => {
208
135
app.innerHTML = "<p>loading polls...</p>";
209
136
210
137
try {
211
-
// fetch all polls from backend
212
-
const res = await fetch(`${BACKEND_URL}/api/polls`);
213
-
if (!res.ok) throw new Error("failed to fetch polls");
138
+
await fetchPolls();
139
+
await loadUserVotes();
214
140
215
-
const backendPolls = await res.json() as Array<{
216
-
uri: string;
217
-
repo: string;
218
-
rkey: string;
219
-
text: string;
220
-
options: string[];
221
-
createdAt: string;
222
-
voteCount: number;
223
-
}>;
224
-
225
-
// merge into local state
226
-
for (const p of backendPolls) {
227
-
const existing = polls.get(p.uri);
228
-
if (existing) {
229
-
// update vote count from backend
230
-
existing.voteCount = p.voteCount;
231
-
} else {
232
-
polls.set(p.uri, {
233
-
uri: p.uri,
234
-
repo: p.repo,
235
-
rkey: p.rkey,
236
-
text: p.text,
237
-
options: p.options,
238
-
createdAt: p.createdAt,
239
-
votes: new Map(),
240
-
voteCount: p.voteCount,
241
-
});
242
-
}
141
+
let filteredPolls = Array.from(polls.values());
142
+
if (mineOnly && currentDid) {
143
+
filteredPolls = filteredPolls.filter((p) => p.repo === currentDid);
243
144
}
244
-
245
-
// load user's votes now that polls are in memory
246
-
await loadUserVotes();
247
-
248
-
const allPolls = Array.from(polls.values())
249
-
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
145
+
filteredPolls.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
250
146
251
147
const newLink = agent ? `<p><a href="/new">+ new poll</a></p>` : `<p>login to create polls</p>`;
148
+
const heading = mineOnly ? `<p><strong>my polls</strong></p>` : "";
252
149
253
-
if (allPolls.length === 0) {
254
-
app.innerHTML = newLink + "<p>no polls yet</p>";
150
+
if (filteredPolls.length === 0) {
151
+
const msg = mineOnly ? "you haven't created any polls yet" : "no polls yet";
152
+
app.innerHTML = newLink + heading + `<p>${msg}</p>`;
255
153
} else {
256
-
app.innerHTML = newLink + allPolls.map(renderPollCard).join("");
154
+
app.innerHTML = newLink + heading + filteredPolls.map(renderPollCard).join("");
257
155
attachVoteHandlers();
258
156
}
259
157
} catch (e) {
···
263
161
};
264
162
265
163
const renderPollCard = (p: Poll) => {
266
-
// always use backend voteCount for total
267
164
const total = p.voteCount ?? 0;
165
+
const disabled = votingInProgress ? " disabled" : "";
268
166
269
167
const opts = p.options
270
-
.map((opt, i) => {
271
-
return `
272
-
<div class="option" data-vote="${i}" data-poll="${p.uri}">
273
-
<span class="option-text">${esc(opt)}</span>
274
-
</div>
275
-
`;
276
-
})
168
+
.map((opt, i) => `
169
+
<div class="option${disabled}" data-vote="${i}" data-poll="${p.uri}">
170
+
<span class="option-text">${esc(opt)}</span>
171
+
</div>
172
+
`)
277
173
.join("");
278
174
279
175
return `
···
285
181
`;
286
182
};
287
183
288
-
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
289
-
290
-
const ago = (date: string) => {
291
-
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
292
-
if (seconds < 60) return "just now";
293
-
const minutes = Math.floor(seconds / 60);
294
-
if (minutes < 60) return `${minutes}m ago`;
295
-
const hours = Math.floor(minutes / 60);
296
-
if (hours < 24) return `${hours}h ago`;
297
-
const days = Math.floor(hours / 24);
298
-
if (days < 30) return `${days}d ago`;
299
-
return new Date(date).toLocaleDateString();
300
-
};
301
-
302
-
const attachVoteHandlers = () => {
303
-
app.querySelectorAll("[data-vote]").forEach((el) => {
304
-
el.addEventListener("click", async (e) => {
305
-
e.preventDefault();
306
-
const t = e.currentTarget as HTMLElement;
307
-
await vote(t.dataset.poll!, parseInt(t.dataset.vote!, 10));
308
-
});
309
-
});
310
-
311
-
// attach hover handlers for vote counts
312
-
app.querySelectorAll(".vote-count").forEach((el) => {
313
-
el.addEventListener("mouseenter", showVotersTooltip);
314
-
el.addEventListener("mouseleave", hideVotersTooltip);
315
-
});
316
-
};
317
-
318
-
type Vote = { voter: string; option: number; uri: string; createdAt?: string; handle?: string };
319
-
const votersCache = new Map<string, Vote[]>();
320
-
const handleCache = new Map<string, string>();
184
+
// voters tooltip
185
+
type VoteInfo = { voter: string; option: number; uri: string; createdAt?: string; handle?: string };
186
+
const votersCache = new Map<string, VoteInfo[]>();
321
187
let activeTooltip: HTMLElement | null = null;
322
188
let tooltipTimeout: ReturnType<typeof setTimeout> | null = null;
323
189
324
-
// resolve DID to handle
325
-
const resolveHandle = async (did: string): Promise<string> => {
326
-
if (handleCache.has(did)) return handleCache.get(did)!;
327
-
try {
328
-
const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
329
-
if (res.ok) {
330
-
const data = await res.json();
331
-
if (data.handle) {
332
-
handleCache.set(did, data.handle);
333
-
return data.handle;
334
-
}
335
-
}
336
-
} catch {}
337
-
return did; // fallback to DID
338
-
};
339
-
340
190
const showVotersTooltip = async (e: Event) => {
341
191
const el = e.target as HTMLElement;
342
192
const pollUri = el.dataset.pollUri;
343
193
if (!pollUri) return;
344
194
345
-
// clear any pending hide
346
195
if (tooltipTimeout) {
347
196
clearTimeout(tooltipTimeout);
348
197
tooltipTimeout = null;
349
198
}
350
199
351
-
// fetch voters if not cached
352
200
if (!votersCache.has(pollUri)) {
353
201
try {
354
-
const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}/votes`);
355
-
if (res.ok) {
356
-
votersCache.set(pollUri, await res.json());
357
-
}
202
+
const voters = await fetchVoters(pollUri);
203
+
votersCache.set(pollUri, voters);
358
204
} catch (err) {
359
205
console.error("failed to fetch voters:", err);
360
206
return;
···
364
210
const voters = votersCache.get(pollUri);
365
211
if (!voters || voters.length === 0) return;
366
212
367
-
// resolve handles for all voters
368
213
await Promise.all(voters.map(async (v) => {
369
214
if (!v.handle) {
370
215
v.handle = await resolveHandle(v.voter);
371
216
}
372
217
}));
373
218
374
-
// get poll options for display
375
219
const poll = polls.get(pollUri);
376
220
const options = poll?.options || [];
377
221
378
-
// remove existing tooltip if any
379
222
if (activeTooltip) activeTooltip.remove();
380
223
381
-
// create tooltip
382
224
const tooltip = document.createElement("div");
383
225
tooltip.className = "voters-tooltip";
384
226
tooltip.innerHTML = voters
···
391
233
})
392
234
.join("");
393
235
394
-
// keep tooltip visible when hovering over it
395
236
tooltip.addEventListener("mouseenter", () => {
396
237
if (tooltipTimeout) {
397
238
clearTimeout(tooltipTimeout);
···
400
241
});
401
242
tooltip.addEventListener("mouseleave", hideVotersTooltip);
402
243
403
-
// position tooltip
404
244
const rect = el.getBoundingClientRect();
405
245
tooltip.style.position = "fixed";
406
246
tooltip.style.left = `${rect.left}px`;
···
411
251
};
412
252
413
253
const hideVotersTooltip = () => {
414
-
// delay hiding so user can move to tooltip
415
254
tooltipTimeout = setTimeout(() => {
416
255
if (activeTooltip) {
417
256
activeTooltip.remove();
···
420
259
}, 150);
421
260
};
422
261
262
+
const attachVoteHandlers = () => {
263
+
app.querySelectorAll("[data-vote]").forEach((el) => {
264
+
el.addEventListener("click", async (e) => {
265
+
e.preventDefault();
266
+
const t = e.currentTarget as HTMLElement;
267
+
await handleVote(t.dataset.poll!, parseInt(t.dataset.vote!, 10));
268
+
});
269
+
});
270
+
271
+
app.querySelectorAll(".vote-count").forEach((el) => {
272
+
el.addEventListener("mouseenter", showVotersTooltip);
273
+
el.addEventListener("mouseleave", hideVotersTooltip);
274
+
});
275
+
};
276
+
277
+
const handleVote = async (pollUri: string, option: number) => {
278
+
console.log("[handleVote] called", { pollUri, option, votingInProgress, agent: !!agent, currentDid });
279
+
280
+
if (!agent || !currentDid) {
281
+
setStatus("login to vote");
282
+
console.log("[handleVote] not logged in, returning");
283
+
return;
284
+
}
285
+
286
+
if (votingInProgress) {
287
+
console.log("[handleVote] vote already in progress, returning");
288
+
return;
289
+
}
290
+
291
+
votingInProgress = true;
292
+
setStatus("voting...");
293
+
console.log("[handleVote] set votingInProgress=true, calling vote()");
294
+
295
+
// disable all vote options visually
296
+
app.querySelectorAll("[data-vote]").forEach((el) => {
297
+
el.classList.add("disabled");
298
+
});
299
+
300
+
try {
301
+
await vote(pollUri, option);
302
+
console.log("[handleVote] vote() completed successfully");
303
+
setStatus("confirming...");
304
+
305
+
// poll backend until vote is confirmed (tap needs time to process)
306
+
const maxWait = 10000;
307
+
const pollInterval = 500;
308
+
const start = Date.now();
309
+
310
+
while (Date.now() - start < maxWait) {
311
+
const voters = await fetchVoters(pollUri);
312
+
const myVote = voters.find(v => v.voter === currentDid);
313
+
console.log("[handleVote] polling backend", { myVote, elapsed: Date.now() - start });
314
+
if (myVote && myVote.option === option) {
315
+
console.log("[handleVote] vote confirmed in backend");
316
+
break;
317
+
}
318
+
await new Promise(r => setTimeout(r, pollInterval));
319
+
}
320
+
321
+
setStatus("");
322
+
console.log("[handleVote] calling render()");
323
+
render();
324
+
} catch (e) {
325
+
console.error("[handleVote] error:", e);
326
+
setStatus(`error: ${e}`);
327
+
setTimeout(() => {
328
+
setStatus("");
329
+
render();
330
+
}, 2000);
331
+
} finally {
332
+
votingInProgress = false;
333
+
console.log("[handleVote] finally, votingInProgress=false");
334
+
}
335
+
};
336
+
423
337
const attachShareHandler = () => {
424
338
const btn = app.querySelector(".share-btn") as HTMLButtonElement;
425
339
if (!btn) return;
···
435
349
btn.classList.remove("copied");
436
350
}, 2000);
437
351
} catch {
438
-
// fallback for older browsers
439
352
const input = document.createElement("input");
440
353
input.value = url;
441
354
document.body.appendChild(input);
···
452
365
});
453
366
};
454
367
455
-
const renderPoll = async (repo: string, rkey: string) => {
368
+
const renderPollPage = async (repo: string, rkey: string) => {
456
369
const uri = `at://${repo}/${POLL}/${rkey}`;
457
370
app.innerHTML = "<p>loading...</p>";
458
371
459
372
try {
460
-
// fetch poll with vote counts from backend
461
-
const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(uri)}`);
373
+
const data = await fetchPoll(uri);
462
374
463
-
if (res.ok) {
464
-
const data = await res.json() as {
465
-
uri: string;
466
-
repo: string;
467
-
rkey: string;
468
-
text: string;
469
-
options: Array<{ text: string; count: number }>;
470
-
createdAt: string;
471
-
};
472
-
473
-
// render poll with vote counts from backend
375
+
if (data) {
474
376
const total = data.options.reduce((sum, o) => sum + o.count, 0);
377
+
const disabled = votingInProgress ? " disabled" : "";
378
+
475
379
const opts = data.options
476
380
.map((opt, i) => {
477
381
const pct = total > 0 ? Math.round((opt.count / total) * 100) : 0;
478
382
return `
479
-
<div class="option" data-vote="${i}" data-poll="${uri}">
480
-
<div class="option-bar" style="width: ${pct}%"></div>
481
-
<span class="option-text">${esc(opt.text)}</span>
482
-
<span class="option-count">${opt.count} (${pct}%)</span>
483
-
</div>`;
383
+
<div class="option${disabled}" data-vote="${i}" data-poll="${uri}">
384
+
<div class="option-bar" style="width: ${pct}%"></div>
385
+
<span class="option-text">${esc(opt.text)}</span>
386
+
<span class="option-count">${opt.count} (${pct}%)</span>
387
+
</div>`;
484
388
})
485
389
.join("");
486
390
···
501
405
}
502
406
503
407
// fallback to direct PDS fetch if backend doesn't have it
504
-
const didDoc = await didDocumentResolver.resolve(repo as `did:${string}:${string}`);
505
-
const pds = didDoc?.service?.find((s: { id: string }) => s.id === "#atproto_pds") as { serviceEndpoint?: string } | undefined;
506
-
const pdsUrl = pds?.serviceEndpoint || "https://bsky.social";
507
-
508
-
const pdsClient = new Client({
509
-
handler: simpleFetchHandler({ service: pdsUrl }),
510
-
});
511
-
512
-
const pdsRes = await pdsClient.get("com.atproto.repo.getRecord", {
513
-
params: { repo, collection: POLL, rkey },
514
-
});
515
-
if (!pdsRes.ok) {
408
+
const pdsData = await fetchPollFromPDS(repo, rkey);
409
+
if (!pdsData) {
516
410
app.innerHTML = "<p>not found</p>";
517
411
return;
518
412
}
519
-
const rec = pdsRes.data.value as { text: string; options: string[]; createdAt: string };
520
-
const poll = { uri: pdsRes.data.uri, repo, rkey, text: rec.text, options: rec.options, createdAt: rec.createdAt, votes: new Map() };
413
+
414
+
const poll: Poll = { ...pdsData, votes: new Map() };
521
415
polls.set(uri, poll);
522
416
523
417
app.innerHTML = `<p><a href="/">← back</a></p>${renderPollCard(poll)}`;
···
540
434
<button id="create">create</button>
541
435
</div>
542
436
`;
543
-
document.getElementById("create")!.onclick = create;
437
+
document.getElementById("create")!.onclick = handleCreate;
544
438
};
545
439
546
-
const create = async () => {
440
+
const handleCreate = async () => {
547
441
if (!agent || !currentDid) return;
548
442
549
443
const text = (document.getElementById("question") as HTMLInputElement).value.trim();
···
558
452
}
559
453
560
454
setStatus("creating...");
561
-
const rpc = new Client({ handler: agent });
562
-
const res = await rpc.post("com.atproto.repo.createRecord", {
563
-
input: {
564
-
repo: currentDid,
565
-
collection: POLL,
566
-
record: { $type: POLL, text, options, createdAt: new Date().toISOString() },
567
-
},
568
-
});
569
-
570
-
if (!res.ok) {
571
-
setStatus(`error: ${res.data.error}`);
572
-
return;
573
-
}
574
-
575
-
const rkey = res.data.uri.split("/").pop()!;
576
-
polls.set(res.data.uri, {
577
-
uri: res.data.uri,
578
-
repo: currentDid,
579
-
rkey,
580
-
text,
581
-
options,
582
-
createdAt: new Date().toISOString(),
583
-
votes: new Map(),
584
-
});
585
-
586
-
setStatus("");
587
-
history.pushState(null, "", "/");
588
-
render();
589
-
};
590
-
591
-
const vote = async (pollUri: string, option: number) => {
592
-
if (!agent || !currentDid) {
593
-
setStatus("login to vote");
594
-
return;
595
-
}
596
-
597
-
setStatus("voting...");
598
-
const rpc = new Client({ handler: agent });
599
-
600
-
// first, find and delete any existing votes on this poll
601
455
try {
602
-
const existing = await rpc.get("com.atproto.repo.listRecords", {
603
-
params: { repo: currentDid, collection: VOTE, limit: 100 },
604
-
});
605
-
if (existing.ok) {
606
-
for (const record of existing.data.records) {
607
-
const val = record.value as { subject?: string };
608
-
if (val.subject === pollUri) {
609
-
const rkey = record.uri.split("/").pop()!;
610
-
await rpc.post("com.atproto.repo.deleteRecord", {
611
-
input: { repo: currentDid, collection: VOTE, rkey },
612
-
});
613
-
}
614
-
}
615
-
}
456
+
await createPoll(text, options);
457
+
setStatus("");
458
+
history.pushState(null, "", "/");
459
+
render();
616
460
} catch (e) {
617
-
console.error("error checking existing votes:", e);
461
+
setStatus(`error: ${e}`);
618
462
}
619
-
620
-
const res = await rpc.post("com.atproto.repo.createRecord", {
621
-
input: {
622
-
repo: currentDid,
623
-
collection: VOTE,
624
-
record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() },
625
-
},
626
-
});
627
-
628
-
if (!res.ok) {
629
-
console.error("vote error:", res.status, res.data);
630
-
setStatus(`error: ${res.data.error || res.data.message || "unknown"}`);
631
-
setTimeout(() => setStatus(""), 3000);
632
-
return;
633
-
}
634
-
635
-
// update local state
636
-
const poll = polls.get(pollUri);
637
-
if (poll) {
638
-
// remove any existing vote from this user
639
-
for (const [uri, _] of poll.votes) {
640
-
if (uri.startsWith(`at://${currentDid}/${VOTE}/`)) {
641
-
poll.votes.delete(uri);
642
-
}
643
-
}
644
-
poll.votes.set(res.data.uri, option);
645
-
}
646
-
647
-
setStatus("");
648
-
render();
649
463
};
650
464
651
-
// oauth
652
-
const handleCallback = async () => {
465
+
// oauth callback handler
466
+
const handleOAuthCallback = async () => {
653
467
const params = new URLSearchParams(location.hash.slice(1));
654
468
if (!params.has("state")) return false;
655
469
656
-
history.replaceState(null, "", "/");
657
470
setStatus("logging in...");
658
471
659
472
try {
660
-
const { session } = await finalizeAuthorization(params);
661
-
agent = new OAuthUserAgent(session);
662
-
currentDid = session.info.sub;
663
-
localStorage.setItem("lastDid", currentDid);
473
+
const success = await handleCallback();
664
474
setStatus("");
665
-
return true;
475
+
return success;
666
476
} catch (e) {
667
477
setStatus(`login failed: ${e}`);
668
478
return false;
669
479
}
670
480
};
671
481
672
-
const restoreSession = async () => {
673
-
const lastDid = localStorage.getItem("lastDid");
674
-
if (!lastDid) return;
675
-
676
-
try {
677
-
const session = await getSession(lastDid as `did:${string}:${string}`);
678
-
agent = new OAuthUserAgent(session);
679
-
currentDid = session.info.sub;
680
-
} catch {
681
-
localStorage.removeItem("lastDid");
682
-
}
683
-
};
684
-
685
482
// routing
686
483
window.addEventListener("popstate", render);
687
484
document.addEventListener("click", (e) => {
···
695
492
696
493
// init
697
494
(async () => {
698
-
await handleCallback();
495
+
await handleOAuthCallback();
699
496
await restoreSession();
700
497
connectJetstream();
701
498
render();
+30
tap/fly.toml
+30
tap/fly.toml
···
1
+
app = 'pollz-tap'
2
+
primary_region = 'iad'
3
+
4
+
[build]
5
+
image = 'ghcr.io/bluesky-social/indigo/tap:latest'
6
+
7
+
[env]
8
+
TAP_DATABASE_URL = 'sqlite:///data/tap.db'
9
+
TAP_BIND = ':2480'
10
+
TAP_SIGNAL_COLLECTION = 'tech.waow.poll'
11
+
TAP_COLLECTION_FILTERS = 'tech.waow.poll,tech.waow.vote'
12
+
TAP_DISABLE_ACKS = 'true'
13
+
TAP_LOG_LEVEL = 'info'
14
+
TAP_CURSOR_SAVE_INTERVAL = '5s'
15
+
16
+
[http_service]
17
+
internal_port = 2480
18
+
force_https = false
19
+
auto_stop_machines = 'off'
20
+
auto_start_machines = true
21
+
min_machines_running = 1
22
+
23
+
[[vm]]
24
+
memory = '512mb'
25
+
cpu_kind = 'shared'
26
+
cpus = 1
27
+
28
+
[mounts]
29
+
source = 'tap_data'
30
+
destination = '/data'
+17
tap/justfile
+17
tap/justfile
···
1
+
# tap instance for pollz
2
+
3
+
# deploy tap to fly.io
4
+
deploy:
5
+
fly deploy --app pollz-tap
6
+
7
+
# check tap status
8
+
status:
9
+
fly status --app pollz-tap
10
+
11
+
# view tap logs
12
+
logs:
13
+
fly logs --app pollz-tap
14
+
15
+
# ssh into tap instance
16
+
ssh:
17
+
fly ssh console --app pollz-tap