GET /xrpc/app.bsky.actor.searchActorsTypeahead typeahead.waow.tech
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial commit: typeahead service

cloudflare worker + zig ingester for ATProto actor search.
includes rate limiting, admin auth, bluesky backfill, and smoke tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz ff26dfda

+2759
+5
.gitignore
··· 1 + node_modules/ 2 + .wrangler/ 3 + .zig-cache/ 4 + zig-out/ 5 + zig-cache/
+32
ingester/Dockerfile
··· 1 + # build stage 2 + FROM debian:bookworm-slim AS builder 3 + 4 + RUN apt-get update && apt-get install -y --no-install-recommends \ 5 + ca-certificates \ 6 + curl \ 7 + xz-utils \ 8 + && rm -rf /var/lib/apt/lists/* 9 + 10 + # install zig 0.15.2 11 + RUN curl -L https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz | tar -xJ -C /usr/local \ 12 + && ln -s /usr/local/zig-x86_64-linux-0.15.2/zig /usr/local/bin/zig 13 + 14 + WORKDIR /app 15 + COPY build.zig build.zig.zon ./ 16 + COPY src ./src 17 + 18 + RUN zig build -Doptimize=ReleaseSafe 19 + 20 + # runtime stage 21 + FROM debian:bookworm-slim 22 + 23 + RUN apt-get update && apt-get install -y --no-install-recommends \ 24 + ca-certificates \ 25 + && rm -rf /var/lib/apt/lists/* \ 26 + # prefer IPv4 over IPv6 for outbound connections 27 + && echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf 28 + 29 + WORKDIR /app 30 + COPY --from=builder /app/zig-out/bin/typeahead-ingester . 31 + 32 + CMD ["./typeahead-ingester"]
+34
ingester/build.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn build(b: *std.Build) void { 4 + const target = b.standardTargetOptions(.{}); 5 + const optimize = b.standardOptimizeOption(.{}); 6 + 7 + const zat = b.dependency("zat", .{ 8 + .target = target, 9 + .optimize = optimize, 10 + }); 11 + 12 + const exe = b.addExecutable(.{ 13 + .name = "typeahead-ingester", 14 + .root_module = b.createModule(.{ 15 + .root_source_file = b.path("src/main.zig"), 16 + .target = target, 17 + .optimize = optimize, 18 + .imports = &.{ 19 + .{ .name = "zat", .module = zat.module("zat") }, 20 + }, 21 + }), 22 + }); 23 + 24 + b.installArtifact(exe); 25 + 26 + const run_cmd = b.addRunArtifact(exe); 27 + run_cmd.step.dependOn(b.getInstallStep()); 28 + if (b.args) |args| { 29 + run_cmd.addArgs(args); 30 + } 31 + 32 + const run_step = b.step("run", "Run the ingester"); 33 + run_step.dependOn(&run_cmd.step); 34 + }
+17
ingester/build.zig.zon
··· 1 + .{ 2 + .name = .typeahead_ingester, 3 + .version = "0.0.1", 4 + .fingerprint = 0xc3959a06c9697d7c, 5 + .minimum_zig_version = "0.15.0", 6 + .dependencies = .{ 7 + .zat = .{ 8 + .url = "https://tangled.sh/zat.dev/zat/archive/v0.2.15", 9 + .hash = "zat-0.2.15-5PuC7tjwBAAR5tL2Nc5LfSUaUORA6vONr1IdgaU4vAvo", 10 + }, 11 + }, 12 + .paths = .{ 13 + "build.zig", 14 + "build.zig.zon", 15 + "src", 16 + }, 17 + }
+22
ingester/fly.toml
··· 1 + # fly.toml app configuration file generated for typeahead-ingester on 2026-03-16T22:03:25-05:00 2 + # 3 + # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 + # 5 + 6 + app = 'typeahead-ingester' 7 + primary_region = 'ewr' 8 + 9 + [build] 10 + dockerfile = 'Dockerfile' 11 + 12 + [env] 13 + TYPEAHEAD_URL = 'https://typeahead.nate-8fe.workers.dev' 14 + 15 + [processes] 16 + app = './typeahead-ingester' 17 + 18 + [[vm]] 19 + memory = '256mb' 20 + cpu_kind = 'shared' 21 + cpus = 1 22 + memory_mb = 256
+346
ingester/src/main.zig
··· 1 + const std = @import("std"); 2 + const mem = std.mem; 3 + const json = std.json; 4 + const http = std.http; 5 + const Allocator = mem.Allocator; 6 + const zat = @import("zat"); 7 + 8 + const log = std.log.scoped(.ingester); 9 + 10 + const MAX_BATCH: usize = 500; 11 + const SLINGSHOT_URL = "https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier="; 12 + 13 + const Config = struct { 14 + worker_url: []const u8, 15 + secret: []const u8, 16 + }; 17 + 18 + fn getConfig() Config { 19 + return .{ 20 + .worker_url = std.posix.getenv("TYPEAHEAD_URL") orelse 21 + @panic("TYPEAHEAD_URL not set"), 22 + .secret = std.posix.getenv("TYPEAHEAD_SECRET") orelse 23 + @panic("TYPEAHEAD_SECRET not set"), 24 + }; 25 + } 26 + 27 + /// an actor event to POST to the worker 28 + const ActorEvent = struct { 29 + did: []const u8, 30 + handle: ?[]const u8 = null, 31 + display_name: ?[]const u8 = null, 32 + avatar_cid: ?[]const u8 = null, 33 + }; 34 + 35 + const IngestHandler = struct { 36 + allocator: Allocator, 37 + config: Config, 38 + buffer: std.ArrayList(ActorEvent), 39 + delete_buffer: std.ArrayList([]const u8), 40 + /// arena owns all string data in buffer/delete_buffer 41 + arena: std.heap.ArenaAllocator, 42 + last_cursor: i64 = 0, 43 + total_ingested: u64 = 0, 44 + total_deleted: u64 = 0, 45 + last_flush: i64 = 0, 46 + 47 + fn init(allocator: Allocator, config: Config) IngestHandler { 48 + return .{ 49 + .allocator = allocator, 50 + .config = config, 51 + .buffer = .{}, 52 + .delete_buffer = .{}, 53 + .arena = std.heap.ArenaAllocator.init(allocator), 54 + .last_flush = std.time.timestamp(), 55 + }; 56 + } 57 + 58 + fn deinit(self: *IngestHandler) void { 59 + self.arena.deinit(); 60 + self.buffer.deinit(self.allocator); 61 + self.delete_buffer.deinit(self.allocator); 62 + } 63 + 64 + fn dupe(self: *IngestHandler, s: []const u8) ?[]const u8 { 65 + return self.arena.allocator().dupe(u8, s) catch null; 66 + } 67 + 68 + pub fn onEvent(self: *IngestHandler, event: zat.JetstreamEvent) void { 69 + switch (event) { 70 + .commit => |c| self.handleCommit(c), 71 + .identity => |id| self.handleIdentity(id), 72 + .account => |acct| self.handleAccount(acct), 73 + } 74 + 75 + self.last_cursor = event.timeUs(); 76 + 77 + const now = std.time.timestamp(); 78 + const should_flush = self.buffer.items.len >= MAX_BATCH or 79 + self.delete_buffer.items.len >= MAX_BATCH or 80 + (now - self.last_flush >= 5 and (self.buffer.items.len > 0 or self.delete_buffer.items.len > 0)); 81 + 82 + if (should_flush) { 83 + self.flush(); 84 + } 85 + } 86 + 87 + pub fn onError(_: *IngestHandler, err: anyerror) void { 88 + log.err("jetstream: {s}", .{@errorName(err)}); 89 + } 90 + 91 + pub fn onConnect(_: *IngestHandler, host: []const u8) void { 92 + log.info("connected to {s}", .{host}); 93 + } 94 + 95 + fn handleCommit(self: *IngestHandler, c: zat.jetstream.CommitEvent) void { 96 + if (!mem.eql(u8, c.collection, "app.bsky.actor.profile")) return; 97 + if (c.operation != .create and c.operation != .update) return; 98 + 99 + const record = c.record orelse return; 100 + const aa = self.arena.allocator(); 101 + 102 + const did = self.dupe(c.did) orelse return; 103 + var event = ActorEvent{ .did = did }; 104 + 105 + if (zat.json.getString(record, "displayName")) |name| { 106 + event.display_name = self.dupe(name); 107 + } 108 + 109 + if (zat.json.getPath(record, "avatar")) |avatar| { 110 + if (zat.json.getString(avatar, "ref.$link")) |cid| { 111 + event.avatar_cid = self.dupe(cid); 112 + } 113 + } 114 + 115 + // resolve handle via slingshot 116 + event.handle = resolveHandle(aa, did); 117 + 118 + if (event.handle != null or event.display_name != null or event.avatar_cid != null) { 119 + self.buffer.append(self.allocator, event) catch return; 120 + } 121 + } 122 + 123 + fn handleIdentity(self: *IngestHandler, id: zat.jetstream.IdentityEvent) void { 124 + const handle = id.handle orelse return; 125 + self.buffer.append(self.allocator, .{ 126 + .did = self.dupe(id.did) orelse return, 127 + .handle = self.dupe(handle), 128 + }) catch return; 129 + } 130 + 131 + fn handleAccount(self: *IngestHandler, acct: zat.jetstream.AccountEvent) void { 132 + if (!acct.active) { 133 + self.delete_buffer.append(self.allocator, self.dupe(acct.did) orelse return) catch return; 134 + } 135 + } 136 + 137 + fn flush(self: *IngestHandler) void { 138 + self.last_flush = std.time.timestamp(); 139 + 140 + if (self.buffer.items.len > 0) { 141 + const count = self.buffer.items.len; 142 + const ok = postBatch( 143 + self.allocator, 144 + self.config, 145 + self.buffer.items, 146 + self.last_cursor, 147 + ); 148 + if (ok) { 149 + self.total_ingested += count; 150 + log.info("+{d} actors (total: {d}) cursor={d}", .{ 151 + count, self.total_ingested, self.last_cursor, 152 + }); 153 + } else { 154 + log.err("ingest batch failed ({d} events)", .{count}); 155 + } 156 + self.buffer.clearRetainingCapacity(); 157 + } 158 + 159 + if (self.delete_buffer.items.len > 0) { 160 + const count = self.delete_buffer.items.len; 161 + const ok = deleteActors( 162 + self.allocator, 163 + self.config, 164 + self.delete_buffer.items, 165 + ); 166 + if (ok) { 167 + self.total_deleted += count; 168 + log.info("-{d} moderated (total: {d})", .{ 169 + count, self.total_deleted, 170 + }); 171 + } else { 172 + log.err("delete batch failed ({d} dids)", .{count}); 173 + } 174 + self.delete_buffer.clearRetainingCapacity(); 175 + } 176 + 177 + // free all duped strings at once 178 + _ = self.arena.reset(.retain_capacity); 179 + } 180 + }; 181 + 182 + /// resolve DID → handle via slingshot (microcosm community service) 183 + fn resolveHandle(allocator: Allocator, did: []const u8) ?[]const u8 { 184 + var client = http.Client{ .allocator = allocator }; 185 + defer client.deinit(); 186 + 187 + var url_buf: [256]u8 = undefined; 188 + const url = std.fmt.bufPrint(&url_buf, SLINGSHOT_URL ++ "{s}", .{did}) catch return null; 189 + 190 + var aw: std.Io.Writer.Allocating = .init(allocator); 191 + defer aw.deinit(); 192 + 193 + const result = client.fetch(.{ 194 + .location = .{ .url = url }, 195 + .method = .GET, 196 + .response_writer = &aw.writer, 197 + }) catch return null; 198 + 199 + if (result.status != .ok) return null; 200 + 201 + var resp = aw.toArrayList(); 202 + defer resp.deinit(allocator); 203 + 204 + const parsed = json.parseFromSlice(json.Value, allocator, resp.items, .{}) catch return null; 205 + defer parsed.deinit(); 206 + 207 + const handle = zat.json.getString(parsed.value, "handle") orelse return null; 208 + return allocator.dupe(u8, handle) catch null; 209 + } 210 + 211 + fn writeJsonEscaped(w: anytype, s: []const u8) !void { 212 + for (s) |c| { 213 + switch (c) { 214 + '"' => try w.writeAll("\\\""), 215 + '\\' => try w.writeAll("\\\\"), 216 + '\n' => try w.writeAll("\\n"), 217 + '\r' => try w.writeAll("\\r"), 218 + '\t' => try w.writeAll("\\t"), 219 + else => { 220 + if (c < 0x20) { 221 + try w.print("\\u{x:0>4}", .{c}); 222 + } else { 223 + try w.writeByte(c); 224 + } 225 + }, 226 + } 227 + } 228 + } 229 + 230 + fn postBatch(allocator: Allocator, config: Config, events: []const ActorEvent, cursor: i64) bool { 231 + var client = http.Client{ .allocator = allocator }; 232 + defer client.deinit(); 233 + 234 + var body: std.ArrayList(u8) = .{}; 235 + defer body.deinit(allocator); 236 + 237 + var w = body.writer(allocator); 238 + w.writeAll("{\"events\":[") catch return false; 239 + 240 + for (events, 0..) |event, i| { 241 + if (i > 0) w.writeByte(',') catch return false; 242 + w.writeAll("{\"did\":\"") catch return false; 243 + w.writeAll(event.did) catch return false; 244 + w.writeByte('"') catch return false; 245 + if (event.handle) |h| { 246 + w.writeAll(",\"handle\":\"") catch return false; 247 + writeJsonEscaped(&w, h) catch return false; 248 + w.writeByte('"') catch return false; 249 + } 250 + if (event.display_name) |n| { 251 + w.writeAll(",\"display_name\":\"") catch return false; 252 + writeJsonEscaped(&w, n) catch return false; 253 + w.writeByte('"') catch return false; 254 + } 255 + if (event.avatar_cid) |c| { 256 + w.writeAll(",\"avatar_cid\":\"") catch return false; 257 + w.writeAll(c) catch return false; 258 + w.writeByte('"') catch return false; 259 + } 260 + w.writeByte('}') catch return false; 261 + } 262 + 263 + w.print("],\"cursor\":{d}}}", .{cursor}) catch return false; 264 + 265 + var url_buf: [512]u8 = undefined; 266 + const url = std.fmt.bufPrint(&url_buf, "{s}/admin/ingest", .{config.worker_url}) catch return false; 267 + 268 + var auth_buf: [256]u8 = undefined; 269 + const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{config.secret}) catch return false; 270 + 271 + var aw: std.Io.Writer.Allocating = .init(allocator); 272 + defer aw.deinit(); 273 + 274 + const result = client.fetch(.{ 275 + .location = .{ .url = url }, 276 + .method = .POST, 277 + .headers = .{ 278 + .content_type = .{ .override = "application/json" }, 279 + .authorization = .{ .override = auth }, 280 + }, 281 + .payload = body.items, 282 + .response_writer = &aw.writer, 283 + }) catch return false; 284 + 285 + if (result.status != .ok) { 286 + const resp = aw.toArrayList(); 287 + log.err("ingest HTTP {d}: {s}", .{ @intFromEnum(result.status), resp.items }); 288 + } 289 + 290 + return result.status == .ok; 291 + } 292 + 293 + fn deleteActors(allocator: Allocator, config: Config, dids: []const []const u8) bool { 294 + var client = http.Client{ .allocator = allocator }; 295 + defer client.deinit(); 296 + 297 + var body: std.ArrayList(u8) = .{}; 298 + defer body.deinit(allocator); 299 + 300 + var w = body.writer(allocator); 301 + w.writeAll("{\"dids\":[") catch return false; 302 + for (dids, 0..) |did, i| { 303 + if (i > 0) w.writeByte(',') catch return false; 304 + w.writeByte('"') catch return false; 305 + w.writeAll(did) catch return false; 306 + w.writeByte('"') catch return false; 307 + } 308 + w.writeAll("]}") catch return false; 309 + 310 + var url_buf: [512]u8 = undefined; 311 + const url = std.fmt.bufPrint(&url_buf, "{s}/admin/delete", .{config.worker_url}) catch return false; 312 + 313 + var auth_buf: [256]u8 = undefined; 314 + const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{config.secret}) catch return false; 315 + 316 + const result = client.fetch(.{ 317 + .location = .{ .url = url }, 318 + .method = .POST, 319 + .headers = .{ 320 + .content_type = .{ .override = "application/json" }, 321 + .authorization = .{ .override = auth }, 322 + }, 323 + .payload = body.items, 324 + }) catch return false; 325 + 326 + return result.status == .ok; 327 + } 328 + 329 + pub fn main() !void { 330 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 331 + defer _ = gpa.deinit(); 332 + const allocator = gpa.allocator(); 333 + 334 + const config = getConfig(); 335 + log.info("typeahead ingester → {s}", .{config.worker_url}); 336 + 337 + var handler = IngestHandler.init(allocator, config); 338 + defer handler.deinit(); 339 + 340 + var client = zat.JetstreamClient.init(allocator, .{ 341 + .wanted_collections = &.{"app.bsky.actor.profile"}, 342 + }); 343 + defer client.deinit(); 344 + 345 + client.subscribe(&handler); 346 + }
+1502
package-lock.json
··· 1 + { 2 + "name": "typeahead", 3 + "lockfileVersion": 3, 4 + "requires": true, 5 + "packages": { 6 + "": { 7 + "name": "typeahead", 8 + "devDependencies": { 9 + "wrangler": "^4" 10 + } 11 + }, 12 + "node_modules/@cloudflare/kv-asset-handler": { 13 + "version": "0.4.2", 14 + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", 15 + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", 16 + "dev": true, 17 + "license": "MIT OR Apache-2.0", 18 + "engines": { 19 + "node": ">=18.0.0" 20 + } 21 + }, 22 + "node_modules/@cloudflare/unenv-preset": { 23 + "version": "2.15.0", 24 + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.15.0.tgz", 25 + "integrity": "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==", 26 + "dev": true, 27 + "license": "MIT OR Apache-2.0", 28 + "peerDependencies": { 29 + "unenv": "2.0.0-rc.24", 30 + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" 31 + }, 32 + "peerDependenciesMeta": { 33 + "workerd": { 34 + "optional": true 35 + } 36 + } 37 + }, 38 + "node_modules/@cloudflare/workerd-darwin-64": { 39 + "version": "1.20260312.1", 40 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260312.1.tgz", 41 + "integrity": "sha512-HUAtDWaqUduS6yasV6+NgsK7qBpP1qGU49ow/Wb117IHjYp+PZPUGReDYocpB4GOMRoQlvdd4L487iFxzdARpw==", 42 + "cpu": [ 43 + "x64" 44 + ], 45 + "dev": true, 46 + "license": "Apache-2.0", 47 + "optional": true, 48 + "os": [ 49 + "darwin" 50 + ], 51 + "engines": { 52 + "node": ">=16" 53 + } 54 + }, 55 + "node_modules/@cloudflare/workerd-darwin-arm64": { 56 + "version": "1.20260312.1", 57 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260312.1.tgz", 58 + "integrity": "sha512-DOn7TPTHSxJYfi4m4NYga/j32wOTqvJf/pY4Txz5SDKWIZHSTXFyGz2K4B+thoPWLop/KZxGoyTv7db0mk/qyw==", 59 + "cpu": [ 60 + "arm64" 61 + ], 62 + "dev": true, 63 + "license": "Apache-2.0", 64 + "optional": true, 65 + "os": [ 66 + "darwin" 67 + ], 68 + "engines": { 69 + "node": ">=16" 70 + } 71 + }, 72 + "node_modules/@cloudflare/workerd-linux-64": { 73 + "version": "1.20260312.1", 74 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260312.1.tgz", 75 + "integrity": "sha512-TdkIh3WzPXYHuvz7phAtFEEvAxvFd30tHrm4gsgpw0R0F5b8PtoM3hfL2uY7EcBBWVYUBtkY2ahDYFfufnXw/g==", 76 + "cpu": [ 77 + "x64" 78 + ], 79 + "dev": true, 80 + "license": "Apache-2.0", 81 + "optional": true, 82 + "os": [ 83 + "linux" 84 + ], 85 + "engines": { 86 + "node": ">=16" 87 + } 88 + }, 89 + "node_modules/@cloudflare/workerd-linux-arm64": { 90 + "version": "1.20260312.1", 91 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260312.1.tgz", 92 + "integrity": "sha512-kNauZhL569Iy94t844OMwa1zP6zKFiL3xiJ4tGLS+TFTEfZ3pZsRH6lWWOtkXkjTyCmBEOog0HSEKjIV4oAffw==", 93 + "cpu": [ 94 + "arm64" 95 + ], 96 + "dev": true, 97 + "license": "Apache-2.0", 98 + "optional": true, 99 + "os": [ 100 + "linux" 101 + ], 102 + "engines": { 103 + "node": ">=16" 104 + } 105 + }, 106 + "node_modules/@cloudflare/workerd-windows-64": { 107 + "version": "1.20260312.1", 108 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260312.1.tgz", 109 + "integrity": "sha512-5dBrlSK+nMsZy5bYQpj8t9iiQNvCRlkm9GGvswJa9vVU/1BNO4BhJMlqOLWT24EmFyApZ+kaBiPJMV8847NDTg==", 110 + "cpu": [ 111 + "x64" 112 + ], 113 + "dev": true, 114 + "license": "Apache-2.0", 115 + "optional": true, 116 + "os": [ 117 + "win32" 118 + ], 119 + "engines": { 120 + "node": ">=16" 121 + } 122 + }, 123 + "node_modules/@cspotcode/source-map-support": { 124 + "version": "0.8.1", 125 + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", 126 + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", 127 + "dev": true, 128 + "license": "MIT", 129 + "dependencies": { 130 + "@jridgewell/trace-mapping": "0.3.9" 131 + }, 132 + "engines": { 133 + "node": ">=12" 134 + } 135 + }, 136 + "node_modules/@emnapi/runtime": { 137 + "version": "1.9.0", 138 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", 139 + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", 140 + "dev": true, 141 + "license": "MIT", 142 + "optional": true, 143 + "dependencies": { 144 + "tslib": "^2.4.0" 145 + } 146 + }, 147 + "node_modules/@esbuild/aix-ppc64": { 148 + "version": "0.27.3", 149 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", 150 + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", 151 + "cpu": [ 152 + "ppc64" 153 + ], 154 + "dev": true, 155 + "license": "MIT", 156 + "optional": true, 157 + "os": [ 158 + "aix" 159 + ], 160 + "engines": { 161 + "node": ">=18" 162 + } 163 + }, 164 + "node_modules/@esbuild/android-arm": { 165 + "version": "0.27.3", 166 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", 167 + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", 168 + "cpu": [ 169 + "arm" 170 + ], 171 + "dev": true, 172 + "license": "MIT", 173 + "optional": true, 174 + "os": [ 175 + "android" 176 + ], 177 + "engines": { 178 + "node": ">=18" 179 + } 180 + }, 181 + "node_modules/@esbuild/android-arm64": { 182 + "version": "0.27.3", 183 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", 184 + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", 185 + "cpu": [ 186 + "arm64" 187 + ], 188 + "dev": true, 189 + "license": "MIT", 190 + "optional": true, 191 + "os": [ 192 + "android" 193 + ], 194 + "engines": { 195 + "node": ">=18" 196 + } 197 + }, 198 + "node_modules/@esbuild/android-x64": { 199 + "version": "0.27.3", 200 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", 201 + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", 202 + "cpu": [ 203 + "x64" 204 + ], 205 + "dev": true, 206 + "license": "MIT", 207 + "optional": true, 208 + "os": [ 209 + "android" 210 + ], 211 + "engines": { 212 + "node": ">=18" 213 + } 214 + }, 215 + "node_modules/@esbuild/darwin-arm64": { 216 + "version": "0.27.3", 217 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", 218 + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", 219 + "cpu": [ 220 + "arm64" 221 + ], 222 + "dev": true, 223 + "license": "MIT", 224 + "optional": true, 225 + "os": [ 226 + "darwin" 227 + ], 228 + "engines": { 229 + "node": ">=18" 230 + } 231 + }, 232 + "node_modules/@esbuild/darwin-x64": { 233 + "version": "0.27.3", 234 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", 235 + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", 236 + "cpu": [ 237 + "x64" 238 + ], 239 + "dev": true, 240 + "license": "MIT", 241 + "optional": true, 242 + "os": [ 243 + "darwin" 244 + ], 245 + "engines": { 246 + "node": ">=18" 247 + } 248 + }, 249 + "node_modules/@esbuild/freebsd-arm64": { 250 + "version": "0.27.3", 251 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", 252 + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", 253 + "cpu": [ 254 + "arm64" 255 + ], 256 + "dev": true, 257 + "license": "MIT", 258 + "optional": true, 259 + "os": [ 260 + "freebsd" 261 + ], 262 + "engines": { 263 + "node": ">=18" 264 + } 265 + }, 266 + "node_modules/@esbuild/freebsd-x64": { 267 + "version": "0.27.3", 268 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", 269 + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", 270 + "cpu": [ 271 + "x64" 272 + ], 273 + "dev": true, 274 + "license": "MIT", 275 + "optional": true, 276 + "os": [ 277 + "freebsd" 278 + ], 279 + "engines": { 280 + "node": ">=18" 281 + } 282 + }, 283 + "node_modules/@esbuild/linux-arm": { 284 + "version": "0.27.3", 285 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", 286 + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", 287 + "cpu": [ 288 + "arm" 289 + ], 290 + "dev": true, 291 + "license": "MIT", 292 + "optional": true, 293 + "os": [ 294 + "linux" 295 + ], 296 + "engines": { 297 + "node": ">=18" 298 + } 299 + }, 300 + "node_modules/@esbuild/linux-arm64": { 301 + "version": "0.27.3", 302 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", 303 + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", 304 + "cpu": [ 305 + "arm64" 306 + ], 307 + "dev": true, 308 + "license": "MIT", 309 + "optional": true, 310 + "os": [ 311 + "linux" 312 + ], 313 + "engines": { 314 + "node": ">=18" 315 + } 316 + }, 317 + "node_modules/@esbuild/linux-ia32": { 318 + "version": "0.27.3", 319 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", 320 + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", 321 + "cpu": [ 322 + "ia32" 323 + ], 324 + "dev": true, 325 + "license": "MIT", 326 + "optional": true, 327 + "os": [ 328 + "linux" 329 + ], 330 + "engines": { 331 + "node": ">=18" 332 + } 333 + }, 334 + "node_modules/@esbuild/linux-loong64": { 335 + "version": "0.27.3", 336 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", 337 + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", 338 + "cpu": [ 339 + "loong64" 340 + ], 341 + "dev": true, 342 + "license": "MIT", 343 + "optional": true, 344 + "os": [ 345 + "linux" 346 + ], 347 + "engines": { 348 + "node": ">=18" 349 + } 350 + }, 351 + "node_modules/@esbuild/linux-mips64el": { 352 + "version": "0.27.3", 353 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", 354 + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", 355 + "cpu": [ 356 + "mips64el" 357 + ], 358 + "dev": true, 359 + "license": "MIT", 360 + "optional": true, 361 + "os": [ 362 + "linux" 363 + ], 364 + "engines": { 365 + "node": ">=18" 366 + } 367 + }, 368 + "node_modules/@esbuild/linux-ppc64": { 369 + "version": "0.27.3", 370 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", 371 + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", 372 + "cpu": [ 373 + "ppc64" 374 + ], 375 + "dev": true, 376 + "license": "MIT", 377 + "optional": true, 378 + "os": [ 379 + "linux" 380 + ], 381 + "engines": { 382 + "node": ">=18" 383 + } 384 + }, 385 + "node_modules/@esbuild/linux-riscv64": { 386 + "version": "0.27.3", 387 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", 388 + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", 389 + "cpu": [ 390 + "riscv64" 391 + ], 392 + "dev": true, 393 + "license": "MIT", 394 + "optional": true, 395 + "os": [ 396 + "linux" 397 + ], 398 + "engines": { 399 + "node": ">=18" 400 + } 401 + }, 402 + "node_modules/@esbuild/linux-s390x": { 403 + "version": "0.27.3", 404 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", 405 + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", 406 + "cpu": [ 407 + "s390x" 408 + ], 409 + "dev": true, 410 + "license": "MIT", 411 + "optional": true, 412 + "os": [ 413 + "linux" 414 + ], 415 + "engines": { 416 + "node": ">=18" 417 + } 418 + }, 419 + "node_modules/@esbuild/linux-x64": { 420 + "version": "0.27.3", 421 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", 422 + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", 423 + "cpu": [ 424 + "x64" 425 + ], 426 + "dev": true, 427 + "license": "MIT", 428 + "optional": true, 429 + "os": [ 430 + "linux" 431 + ], 432 + "engines": { 433 + "node": ">=18" 434 + } 435 + }, 436 + "node_modules/@esbuild/netbsd-arm64": { 437 + "version": "0.27.3", 438 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", 439 + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", 440 + "cpu": [ 441 + "arm64" 442 + ], 443 + "dev": true, 444 + "license": "MIT", 445 + "optional": true, 446 + "os": [ 447 + "netbsd" 448 + ], 449 + "engines": { 450 + "node": ">=18" 451 + } 452 + }, 453 + "node_modules/@esbuild/netbsd-x64": { 454 + "version": "0.27.3", 455 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", 456 + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", 457 + "cpu": [ 458 + "x64" 459 + ], 460 + "dev": true, 461 + "license": "MIT", 462 + "optional": true, 463 + "os": [ 464 + "netbsd" 465 + ], 466 + "engines": { 467 + "node": ">=18" 468 + } 469 + }, 470 + "node_modules/@esbuild/openbsd-arm64": { 471 + "version": "0.27.3", 472 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", 473 + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", 474 + "cpu": [ 475 + "arm64" 476 + ], 477 + "dev": true, 478 + "license": "MIT", 479 + "optional": true, 480 + "os": [ 481 + "openbsd" 482 + ], 483 + "engines": { 484 + "node": ">=18" 485 + } 486 + }, 487 + "node_modules/@esbuild/openbsd-x64": { 488 + "version": "0.27.3", 489 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", 490 + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", 491 + "cpu": [ 492 + "x64" 493 + ], 494 + "dev": true, 495 + "license": "MIT", 496 + "optional": true, 497 + "os": [ 498 + "openbsd" 499 + ], 500 + "engines": { 501 + "node": ">=18" 502 + } 503 + }, 504 + "node_modules/@esbuild/openharmony-arm64": { 505 + "version": "0.27.3", 506 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", 507 + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", 508 + "cpu": [ 509 + "arm64" 510 + ], 511 + "dev": true, 512 + "license": "MIT", 513 + "optional": true, 514 + "os": [ 515 + "openharmony" 516 + ], 517 + "engines": { 518 + "node": ">=18" 519 + } 520 + }, 521 + "node_modules/@esbuild/sunos-x64": { 522 + "version": "0.27.3", 523 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", 524 + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", 525 + "cpu": [ 526 + "x64" 527 + ], 528 + "dev": true, 529 + "license": "MIT", 530 + "optional": true, 531 + "os": [ 532 + "sunos" 533 + ], 534 + "engines": { 535 + "node": ">=18" 536 + } 537 + }, 538 + "node_modules/@esbuild/win32-arm64": { 539 + "version": "0.27.3", 540 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", 541 + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", 542 + "cpu": [ 543 + "arm64" 544 + ], 545 + "dev": true, 546 + "license": "MIT", 547 + "optional": true, 548 + "os": [ 549 + "win32" 550 + ], 551 + "engines": { 552 + "node": ">=18" 553 + } 554 + }, 555 + "node_modules/@esbuild/win32-ia32": { 556 + "version": "0.27.3", 557 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", 558 + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", 559 + "cpu": [ 560 + "ia32" 561 + ], 562 + "dev": true, 563 + "license": "MIT", 564 + "optional": true, 565 + "os": [ 566 + "win32" 567 + ], 568 + "engines": { 569 + "node": ">=18" 570 + } 571 + }, 572 + "node_modules/@esbuild/win32-x64": { 573 + "version": "0.27.3", 574 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", 575 + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", 576 + "cpu": [ 577 + "x64" 578 + ], 579 + "dev": true, 580 + "license": "MIT", 581 + "optional": true, 582 + "os": [ 583 + "win32" 584 + ], 585 + "engines": { 586 + "node": ">=18" 587 + } 588 + }, 589 + "node_modules/@img/colour": { 590 + "version": "1.1.0", 591 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", 592 + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", 593 + "dev": true, 594 + "license": "MIT", 595 + "engines": { 596 + "node": ">=18" 597 + } 598 + }, 599 + "node_modules/@img/sharp-darwin-arm64": { 600 + "version": "0.34.5", 601 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", 602 + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", 603 + "cpu": [ 604 + "arm64" 605 + ], 606 + "dev": true, 607 + "license": "Apache-2.0", 608 + "optional": true, 609 + "os": [ 610 + "darwin" 611 + ], 612 + "engines": { 613 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 614 + }, 615 + "funding": { 616 + "url": "https://opencollective.com/libvips" 617 + }, 618 + "optionalDependencies": { 619 + "@img/sharp-libvips-darwin-arm64": "1.2.4" 620 + } 621 + }, 622 + "node_modules/@img/sharp-darwin-x64": { 623 + "version": "0.34.5", 624 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", 625 + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", 626 + "cpu": [ 627 + "x64" 628 + ], 629 + "dev": true, 630 + "license": "Apache-2.0", 631 + "optional": true, 632 + "os": [ 633 + "darwin" 634 + ], 635 + "engines": { 636 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 637 + }, 638 + "funding": { 639 + "url": "https://opencollective.com/libvips" 640 + }, 641 + "optionalDependencies": { 642 + "@img/sharp-libvips-darwin-x64": "1.2.4" 643 + } 644 + }, 645 + "node_modules/@img/sharp-libvips-darwin-arm64": { 646 + "version": "1.2.4", 647 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", 648 + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", 649 + "cpu": [ 650 + "arm64" 651 + ], 652 + "dev": true, 653 + "license": "LGPL-3.0-or-later", 654 + "optional": true, 655 + "os": [ 656 + "darwin" 657 + ], 658 + "funding": { 659 + "url": "https://opencollective.com/libvips" 660 + } 661 + }, 662 + "node_modules/@img/sharp-libvips-darwin-x64": { 663 + "version": "1.2.4", 664 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", 665 + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", 666 + "cpu": [ 667 + "x64" 668 + ], 669 + "dev": true, 670 + "license": "LGPL-3.0-or-later", 671 + "optional": true, 672 + "os": [ 673 + "darwin" 674 + ], 675 + "funding": { 676 + "url": "https://opencollective.com/libvips" 677 + } 678 + }, 679 + "node_modules/@img/sharp-libvips-linux-arm": { 680 + "version": "1.2.4", 681 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", 682 + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", 683 + "cpu": [ 684 + "arm" 685 + ], 686 + "dev": true, 687 + "license": "LGPL-3.0-or-later", 688 + "optional": true, 689 + "os": [ 690 + "linux" 691 + ], 692 + "funding": { 693 + "url": "https://opencollective.com/libvips" 694 + } 695 + }, 696 + "node_modules/@img/sharp-libvips-linux-arm64": { 697 + "version": "1.2.4", 698 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", 699 + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", 700 + "cpu": [ 701 + "arm64" 702 + ], 703 + "dev": true, 704 + "license": "LGPL-3.0-or-later", 705 + "optional": true, 706 + "os": [ 707 + "linux" 708 + ], 709 + "funding": { 710 + "url": "https://opencollective.com/libvips" 711 + } 712 + }, 713 + "node_modules/@img/sharp-libvips-linux-ppc64": { 714 + "version": "1.2.4", 715 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", 716 + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", 717 + "cpu": [ 718 + "ppc64" 719 + ], 720 + "dev": true, 721 + "license": "LGPL-3.0-or-later", 722 + "optional": true, 723 + "os": [ 724 + "linux" 725 + ], 726 + "funding": { 727 + "url": "https://opencollective.com/libvips" 728 + } 729 + }, 730 + "node_modules/@img/sharp-libvips-linux-riscv64": { 731 + "version": "1.2.4", 732 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", 733 + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", 734 + "cpu": [ 735 + "riscv64" 736 + ], 737 + "dev": true, 738 + "license": "LGPL-3.0-or-later", 739 + "optional": true, 740 + "os": [ 741 + "linux" 742 + ], 743 + "funding": { 744 + "url": "https://opencollective.com/libvips" 745 + } 746 + }, 747 + "node_modules/@img/sharp-libvips-linux-s390x": { 748 + "version": "1.2.4", 749 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", 750 + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", 751 + "cpu": [ 752 + "s390x" 753 + ], 754 + "dev": true, 755 + "license": "LGPL-3.0-or-later", 756 + "optional": true, 757 + "os": [ 758 + "linux" 759 + ], 760 + "funding": { 761 + "url": "https://opencollective.com/libvips" 762 + } 763 + }, 764 + "node_modules/@img/sharp-libvips-linux-x64": { 765 + "version": "1.2.4", 766 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", 767 + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", 768 + "cpu": [ 769 + "x64" 770 + ], 771 + "dev": true, 772 + "license": "LGPL-3.0-or-later", 773 + "optional": true, 774 + "os": [ 775 + "linux" 776 + ], 777 + "funding": { 778 + "url": "https://opencollective.com/libvips" 779 + } 780 + }, 781 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 782 + "version": "1.2.4", 783 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", 784 + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", 785 + "cpu": [ 786 + "arm64" 787 + ], 788 + "dev": true, 789 + "license": "LGPL-3.0-or-later", 790 + "optional": true, 791 + "os": [ 792 + "linux" 793 + ], 794 + "funding": { 795 + "url": "https://opencollective.com/libvips" 796 + } 797 + }, 798 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 799 + "version": "1.2.4", 800 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", 801 + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", 802 + "cpu": [ 803 + "x64" 804 + ], 805 + "dev": true, 806 + "license": "LGPL-3.0-or-later", 807 + "optional": true, 808 + "os": [ 809 + "linux" 810 + ], 811 + "funding": { 812 + "url": "https://opencollective.com/libvips" 813 + } 814 + }, 815 + "node_modules/@img/sharp-linux-arm": { 816 + "version": "0.34.5", 817 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", 818 + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", 819 + "cpu": [ 820 + "arm" 821 + ], 822 + "dev": true, 823 + "license": "Apache-2.0", 824 + "optional": true, 825 + "os": [ 826 + "linux" 827 + ], 828 + "engines": { 829 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 830 + }, 831 + "funding": { 832 + "url": "https://opencollective.com/libvips" 833 + }, 834 + "optionalDependencies": { 835 + "@img/sharp-libvips-linux-arm": "1.2.4" 836 + } 837 + }, 838 + "node_modules/@img/sharp-linux-arm64": { 839 + "version": "0.34.5", 840 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", 841 + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", 842 + "cpu": [ 843 + "arm64" 844 + ], 845 + "dev": true, 846 + "license": "Apache-2.0", 847 + "optional": true, 848 + "os": [ 849 + "linux" 850 + ], 851 + "engines": { 852 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 853 + }, 854 + "funding": { 855 + "url": "https://opencollective.com/libvips" 856 + }, 857 + "optionalDependencies": { 858 + "@img/sharp-libvips-linux-arm64": "1.2.4" 859 + } 860 + }, 861 + "node_modules/@img/sharp-linux-ppc64": { 862 + "version": "0.34.5", 863 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", 864 + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", 865 + "cpu": [ 866 + "ppc64" 867 + ], 868 + "dev": true, 869 + "license": "Apache-2.0", 870 + "optional": true, 871 + "os": [ 872 + "linux" 873 + ], 874 + "engines": { 875 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 876 + }, 877 + "funding": { 878 + "url": "https://opencollective.com/libvips" 879 + }, 880 + "optionalDependencies": { 881 + "@img/sharp-libvips-linux-ppc64": "1.2.4" 882 + } 883 + }, 884 + "node_modules/@img/sharp-linux-riscv64": { 885 + "version": "0.34.5", 886 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", 887 + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", 888 + "cpu": [ 889 + "riscv64" 890 + ], 891 + "dev": true, 892 + "license": "Apache-2.0", 893 + "optional": true, 894 + "os": [ 895 + "linux" 896 + ], 897 + "engines": { 898 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 899 + }, 900 + "funding": { 901 + "url": "https://opencollective.com/libvips" 902 + }, 903 + "optionalDependencies": { 904 + "@img/sharp-libvips-linux-riscv64": "1.2.4" 905 + } 906 + }, 907 + "node_modules/@img/sharp-linux-s390x": { 908 + "version": "0.34.5", 909 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", 910 + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", 911 + "cpu": [ 912 + "s390x" 913 + ], 914 + "dev": true, 915 + "license": "Apache-2.0", 916 + "optional": true, 917 + "os": [ 918 + "linux" 919 + ], 920 + "engines": { 921 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 922 + }, 923 + "funding": { 924 + "url": "https://opencollective.com/libvips" 925 + }, 926 + "optionalDependencies": { 927 + "@img/sharp-libvips-linux-s390x": "1.2.4" 928 + } 929 + }, 930 + "node_modules/@img/sharp-linux-x64": { 931 + "version": "0.34.5", 932 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", 933 + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", 934 + "cpu": [ 935 + "x64" 936 + ], 937 + "dev": true, 938 + "license": "Apache-2.0", 939 + "optional": true, 940 + "os": [ 941 + "linux" 942 + ], 943 + "engines": { 944 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 945 + }, 946 + "funding": { 947 + "url": "https://opencollective.com/libvips" 948 + }, 949 + "optionalDependencies": { 950 + "@img/sharp-libvips-linux-x64": "1.2.4" 951 + } 952 + }, 953 + "node_modules/@img/sharp-linuxmusl-arm64": { 954 + "version": "0.34.5", 955 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", 956 + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", 957 + "cpu": [ 958 + "arm64" 959 + ], 960 + "dev": true, 961 + "license": "Apache-2.0", 962 + "optional": true, 963 + "os": [ 964 + "linux" 965 + ], 966 + "engines": { 967 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 968 + }, 969 + "funding": { 970 + "url": "https://opencollective.com/libvips" 971 + }, 972 + "optionalDependencies": { 973 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" 974 + } 975 + }, 976 + "node_modules/@img/sharp-linuxmusl-x64": { 977 + "version": "0.34.5", 978 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", 979 + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", 980 + "cpu": [ 981 + "x64" 982 + ], 983 + "dev": true, 984 + "license": "Apache-2.0", 985 + "optional": true, 986 + "os": [ 987 + "linux" 988 + ], 989 + "engines": { 990 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 991 + }, 992 + "funding": { 993 + "url": "https://opencollective.com/libvips" 994 + }, 995 + "optionalDependencies": { 996 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" 997 + } 998 + }, 999 + "node_modules/@img/sharp-wasm32": { 1000 + "version": "0.34.5", 1001 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", 1002 + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", 1003 + "cpu": [ 1004 + "wasm32" 1005 + ], 1006 + "dev": true, 1007 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 1008 + "optional": true, 1009 + "dependencies": { 1010 + "@emnapi/runtime": "^1.7.0" 1011 + }, 1012 + "engines": { 1013 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1014 + }, 1015 + "funding": { 1016 + "url": "https://opencollective.com/libvips" 1017 + } 1018 + }, 1019 + "node_modules/@img/sharp-win32-arm64": { 1020 + "version": "0.34.5", 1021 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", 1022 + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", 1023 + "cpu": [ 1024 + "arm64" 1025 + ], 1026 + "dev": true, 1027 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1028 + "optional": true, 1029 + "os": [ 1030 + "win32" 1031 + ], 1032 + "engines": { 1033 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1034 + }, 1035 + "funding": { 1036 + "url": "https://opencollective.com/libvips" 1037 + } 1038 + }, 1039 + "node_modules/@img/sharp-win32-ia32": { 1040 + "version": "0.34.5", 1041 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", 1042 + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", 1043 + "cpu": [ 1044 + "ia32" 1045 + ], 1046 + "dev": true, 1047 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1048 + "optional": true, 1049 + "os": [ 1050 + "win32" 1051 + ], 1052 + "engines": { 1053 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1054 + }, 1055 + "funding": { 1056 + "url": "https://opencollective.com/libvips" 1057 + } 1058 + }, 1059 + "node_modules/@img/sharp-win32-x64": { 1060 + "version": "0.34.5", 1061 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", 1062 + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", 1063 + "cpu": [ 1064 + "x64" 1065 + ], 1066 + "dev": true, 1067 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1068 + "optional": true, 1069 + "os": [ 1070 + "win32" 1071 + ], 1072 + "engines": { 1073 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1074 + }, 1075 + "funding": { 1076 + "url": "https://opencollective.com/libvips" 1077 + } 1078 + }, 1079 + "node_modules/@jridgewell/resolve-uri": { 1080 + "version": "3.1.2", 1081 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 1082 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 1083 + "dev": true, 1084 + "license": "MIT", 1085 + "engines": { 1086 + "node": ">=6.0.0" 1087 + } 1088 + }, 1089 + "node_modules/@jridgewell/sourcemap-codec": { 1090 + "version": "1.5.5", 1091 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 1092 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 1093 + "dev": true, 1094 + "license": "MIT" 1095 + }, 1096 + "node_modules/@jridgewell/trace-mapping": { 1097 + "version": "0.3.9", 1098 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", 1099 + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 1100 + "dev": true, 1101 + "license": "MIT", 1102 + "dependencies": { 1103 + "@jridgewell/resolve-uri": "^3.0.3", 1104 + "@jridgewell/sourcemap-codec": "^1.4.10" 1105 + } 1106 + }, 1107 + "node_modules/@poppinss/colors": { 1108 + "version": "4.1.6", 1109 + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", 1110 + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", 1111 + "dev": true, 1112 + "license": "MIT", 1113 + "dependencies": { 1114 + "kleur": "^4.1.5" 1115 + } 1116 + }, 1117 + "node_modules/@poppinss/dumper": { 1118 + "version": "0.6.5", 1119 + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", 1120 + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", 1121 + "dev": true, 1122 + "license": "MIT", 1123 + "dependencies": { 1124 + "@poppinss/colors": "^4.1.5", 1125 + "@sindresorhus/is": "^7.0.2", 1126 + "supports-color": "^10.0.0" 1127 + } 1128 + }, 1129 + "node_modules/@poppinss/exception": { 1130 + "version": "1.2.3", 1131 + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", 1132 + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", 1133 + "dev": true, 1134 + "license": "MIT" 1135 + }, 1136 + "node_modules/@sindresorhus/is": { 1137 + "version": "7.2.0", 1138 + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", 1139 + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", 1140 + "dev": true, 1141 + "license": "MIT", 1142 + "engines": { 1143 + "node": ">=18" 1144 + }, 1145 + "funding": { 1146 + "url": "https://github.com/sindresorhus/is?sponsor=1" 1147 + } 1148 + }, 1149 + "node_modules/@speed-highlight/core": { 1150 + "version": "1.2.14", 1151 + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", 1152 + "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", 1153 + "dev": true, 1154 + "license": "CC0-1.0" 1155 + }, 1156 + "node_modules/blake3-wasm": { 1157 + "version": "2.1.5", 1158 + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", 1159 + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", 1160 + "dev": true, 1161 + "license": "MIT" 1162 + }, 1163 + "node_modules/cookie": { 1164 + "version": "1.1.1", 1165 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", 1166 + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", 1167 + "dev": true, 1168 + "license": "MIT", 1169 + "engines": { 1170 + "node": ">=18" 1171 + }, 1172 + "funding": { 1173 + "type": "opencollective", 1174 + "url": "https://opencollective.com/express" 1175 + } 1176 + }, 1177 + "node_modules/detect-libc": { 1178 + "version": "2.1.2", 1179 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 1180 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 1181 + "dev": true, 1182 + "license": "Apache-2.0", 1183 + "engines": { 1184 + "node": ">=8" 1185 + } 1186 + }, 1187 + "node_modules/error-stack-parser-es": { 1188 + "version": "1.0.5", 1189 + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", 1190 + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", 1191 + "dev": true, 1192 + "license": "MIT", 1193 + "funding": { 1194 + "url": "https://github.com/sponsors/antfu" 1195 + } 1196 + }, 1197 + "node_modules/esbuild": { 1198 + "version": "0.27.3", 1199 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", 1200 + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", 1201 + "dev": true, 1202 + "hasInstallScript": true, 1203 + "license": "MIT", 1204 + "bin": { 1205 + "esbuild": "bin/esbuild" 1206 + }, 1207 + "engines": { 1208 + "node": ">=18" 1209 + }, 1210 + "optionalDependencies": { 1211 + "@esbuild/aix-ppc64": "0.27.3", 1212 + "@esbuild/android-arm": "0.27.3", 1213 + "@esbuild/android-arm64": "0.27.3", 1214 + "@esbuild/android-x64": "0.27.3", 1215 + "@esbuild/darwin-arm64": "0.27.3", 1216 + "@esbuild/darwin-x64": "0.27.3", 1217 + "@esbuild/freebsd-arm64": "0.27.3", 1218 + "@esbuild/freebsd-x64": "0.27.3", 1219 + "@esbuild/linux-arm": "0.27.3", 1220 + "@esbuild/linux-arm64": "0.27.3", 1221 + "@esbuild/linux-ia32": "0.27.3", 1222 + "@esbuild/linux-loong64": "0.27.3", 1223 + "@esbuild/linux-mips64el": "0.27.3", 1224 + "@esbuild/linux-ppc64": "0.27.3", 1225 + "@esbuild/linux-riscv64": "0.27.3", 1226 + "@esbuild/linux-s390x": "0.27.3", 1227 + "@esbuild/linux-x64": "0.27.3", 1228 + "@esbuild/netbsd-arm64": "0.27.3", 1229 + "@esbuild/netbsd-x64": "0.27.3", 1230 + "@esbuild/openbsd-arm64": "0.27.3", 1231 + "@esbuild/openbsd-x64": "0.27.3", 1232 + "@esbuild/openharmony-arm64": "0.27.3", 1233 + "@esbuild/sunos-x64": "0.27.3", 1234 + "@esbuild/win32-arm64": "0.27.3", 1235 + "@esbuild/win32-ia32": "0.27.3", 1236 + "@esbuild/win32-x64": "0.27.3" 1237 + } 1238 + }, 1239 + "node_modules/fsevents": { 1240 + "version": "2.3.3", 1241 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1242 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1243 + "dev": true, 1244 + "hasInstallScript": true, 1245 + "license": "MIT", 1246 + "optional": true, 1247 + "os": [ 1248 + "darwin" 1249 + ], 1250 + "engines": { 1251 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1252 + } 1253 + }, 1254 + "node_modules/kleur": { 1255 + "version": "4.1.5", 1256 + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 1257 + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", 1258 + "dev": true, 1259 + "license": "MIT", 1260 + "engines": { 1261 + "node": ">=6" 1262 + } 1263 + }, 1264 + "node_modules/miniflare": { 1265 + "version": "4.20260312.1", 1266 + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260312.1.tgz", 1267 + "integrity": "sha512-YSWxec9ssisqkQgaCgcIQxZlB41E9hMiq1nxUgxXHRrE9NsfyC6ptSt8yfgBobsKIseAVKLTB/iEDpMumBv8oA==", 1268 + "dev": true, 1269 + "license": "MIT", 1270 + "dependencies": { 1271 + "@cspotcode/source-map-support": "0.8.1", 1272 + "sharp": "^0.34.5", 1273 + "undici": "7.18.2", 1274 + "workerd": "1.20260312.1", 1275 + "ws": "8.18.0", 1276 + "youch": "4.1.0-beta.10" 1277 + }, 1278 + "bin": { 1279 + "miniflare": "bootstrap.js" 1280 + }, 1281 + "engines": { 1282 + "node": ">=18.0.0" 1283 + } 1284 + }, 1285 + "node_modules/path-to-regexp": { 1286 + "version": "6.3.0", 1287 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", 1288 + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 1289 + "dev": true, 1290 + "license": "MIT" 1291 + }, 1292 + "node_modules/pathe": { 1293 + "version": "2.0.3", 1294 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", 1295 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 1296 + "dev": true, 1297 + "license": "MIT" 1298 + }, 1299 + "node_modules/semver": { 1300 + "version": "7.7.4", 1301 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 1302 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 1303 + "dev": true, 1304 + "license": "ISC", 1305 + "bin": { 1306 + "semver": "bin/semver.js" 1307 + }, 1308 + "engines": { 1309 + "node": ">=10" 1310 + } 1311 + }, 1312 + "node_modules/sharp": { 1313 + "version": "0.34.5", 1314 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", 1315 + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", 1316 + "dev": true, 1317 + "hasInstallScript": true, 1318 + "license": "Apache-2.0", 1319 + "dependencies": { 1320 + "@img/colour": "^1.0.0", 1321 + "detect-libc": "^2.1.2", 1322 + "semver": "^7.7.3" 1323 + }, 1324 + "engines": { 1325 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1326 + }, 1327 + "funding": { 1328 + "url": "https://opencollective.com/libvips" 1329 + }, 1330 + "optionalDependencies": { 1331 + "@img/sharp-darwin-arm64": "0.34.5", 1332 + "@img/sharp-darwin-x64": "0.34.5", 1333 + "@img/sharp-libvips-darwin-arm64": "1.2.4", 1334 + "@img/sharp-libvips-darwin-x64": "1.2.4", 1335 + "@img/sharp-libvips-linux-arm": "1.2.4", 1336 + "@img/sharp-libvips-linux-arm64": "1.2.4", 1337 + "@img/sharp-libvips-linux-ppc64": "1.2.4", 1338 + "@img/sharp-libvips-linux-riscv64": "1.2.4", 1339 + "@img/sharp-libvips-linux-s390x": "1.2.4", 1340 + "@img/sharp-libvips-linux-x64": "1.2.4", 1341 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", 1342 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", 1343 + "@img/sharp-linux-arm": "0.34.5", 1344 + "@img/sharp-linux-arm64": "0.34.5", 1345 + "@img/sharp-linux-ppc64": "0.34.5", 1346 + "@img/sharp-linux-riscv64": "0.34.5", 1347 + "@img/sharp-linux-s390x": "0.34.5", 1348 + "@img/sharp-linux-x64": "0.34.5", 1349 + "@img/sharp-linuxmusl-arm64": "0.34.5", 1350 + "@img/sharp-linuxmusl-x64": "0.34.5", 1351 + "@img/sharp-wasm32": "0.34.5", 1352 + "@img/sharp-win32-arm64": "0.34.5", 1353 + "@img/sharp-win32-ia32": "0.34.5", 1354 + "@img/sharp-win32-x64": "0.34.5" 1355 + } 1356 + }, 1357 + "node_modules/supports-color": { 1358 + "version": "10.2.2", 1359 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", 1360 + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", 1361 + "dev": true, 1362 + "license": "MIT", 1363 + "engines": { 1364 + "node": ">=18" 1365 + }, 1366 + "funding": { 1367 + "url": "https://github.com/chalk/supports-color?sponsor=1" 1368 + } 1369 + }, 1370 + "node_modules/tslib": { 1371 + "version": "2.8.1", 1372 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 1373 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 1374 + "dev": true, 1375 + "license": "0BSD", 1376 + "optional": true 1377 + }, 1378 + "node_modules/undici": { 1379 + "version": "7.18.2", 1380 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", 1381 + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", 1382 + "dev": true, 1383 + "license": "MIT", 1384 + "engines": { 1385 + "node": ">=20.18.1" 1386 + } 1387 + }, 1388 + "node_modules/unenv": { 1389 + "version": "2.0.0-rc.24", 1390 + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", 1391 + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", 1392 + "dev": true, 1393 + "license": "MIT", 1394 + "dependencies": { 1395 + "pathe": "^2.0.3" 1396 + } 1397 + }, 1398 + "node_modules/workerd": { 1399 + "version": "1.20260312.1", 1400 + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260312.1.tgz", 1401 + "integrity": "sha512-nNpPkw9jaqo79B+iBCOiksx+N62xC+ETIfyzofUEdY3cSOHJg6oNnVSHm7vHevzVblfV76c8Gr0cXHEapYMBEg==", 1402 + "dev": true, 1403 + "hasInstallScript": true, 1404 + "license": "Apache-2.0", 1405 + "bin": { 1406 + "workerd": "bin/workerd" 1407 + }, 1408 + "engines": { 1409 + "node": ">=16" 1410 + }, 1411 + "optionalDependencies": { 1412 + "@cloudflare/workerd-darwin-64": "1.20260312.1", 1413 + "@cloudflare/workerd-darwin-arm64": "1.20260312.1", 1414 + "@cloudflare/workerd-linux-64": "1.20260312.1", 1415 + "@cloudflare/workerd-linux-arm64": "1.20260312.1", 1416 + "@cloudflare/workerd-windows-64": "1.20260312.1" 1417 + } 1418 + }, 1419 + "node_modules/wrangler": { 1420 + "version": "4.74.0", 1421 + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.74.0.tgz", 1422 + "integrity": "sha512-3qprbhgdUyqYGHZ+Y1k0gsyHLMOlLrKL/HU0LDqLlCkbsKPprUA0/ThE4IZsxD84xAAXY6pv5JUuxS2+OnMa3A==", 1423 + "dev": true, 1424 + "license": "MIT OR Apache-2.0", 1425 + "dependencies": { 1426 + "@cloudflare/kv-asset-handler": "0.4.2", 1427 + "@cloudflare/unenv-preset": "2.15.0", 1428 + "blake3-wasm": "2.1.5", 1429 + "esbuild": "0.27.3", 1430 + "miniflare": "4.20260312.1", 1431 + "path-to-regexp": "6.3.0", 1432 + "unenv": "2.0.0-rc.24", 1433 + "workerd": "1.20260312.1" 1434 + }, 1435 + "bin": { 1436 + "wrangler": "bin/wrangler.js", 1437 + "wrangler2": "bin/wrangler.js" 1438 + }, 1439 + "engines": { 1440 + "node": ">=20.0.0" 1441 + }, 1442 + "optionalDependencies": { 1443 + "fsevents": "~2.3.2" 1444 + }, 1445 + "peerDependencies": { 1446 + "@cloudflare/workers-types": "^4.20260312.1" 1447 + }, 1448 + "peerDependenciesMeta": { 1449 + "@cloudflare/workers-types": { 1450 + "optional": true 1451 + } 1452 + } 1453 + }, 1454 + "node_modules/ws": { 1455 + "version": "8.18.0", 1456 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", 1457 + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", 1458 + "dev": true, 1459 + "license": "MIT", 1460 + "engines": { 1461 + "node": ">=10.0.0" 1462 + }, 1463 + "peerDependencies": { 1464 + "bufferutil": "^4.0.1", 1465 + "utf-8-validate": ">=5.0.2" 1466 + }, 1467 + "peerDependenciesMeta": { 1468 + "bufferutil": { 1469 + "optional": true 1470 + }, 1471 + "utf-8-validate": { 1472 + "optional": true 1473 + } 1474 + } 1475 + }, 1476 + "node_modules/youch": { 1477 + "version": "4.1.0-beta.10", 1478 + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", 1479 + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", 1480 + "dev": true, 1481 + "license": "MIT", 1482 + "dependencies": { 1483 + "@poppinss/colors": "^4.1.5", 1484 + "@poppinss/dumper": "^0.6.4", 1485 + "@speed-highlight/core": "^1.2.7", 1486 + "cookie": "^1.0.2", 1487 + "youch-core": "^0.3.3" 1488 + } 1489 + }, 1490 + "node_modules/youch-core": { 1491 + "version": "0.3.3", 1492 + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", 1493 + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", 1494 + "dev": true, 1495 + "license": "MIT", 1496 + "dependencies": { 1497 + "@poppinss/exception": "^1.2.2", 1498 + "error-stack-parser-es": "^1.0.5" 1499 + } 1500 + } 1501 + } 1502 + }
+11
package.json
··· 1 + { 2 + "name": "typeahead", 3 + "private": true, 4 + "scripts": { 5 + "dev": "wrangler dev", 6 + "deploy": "wrangler deploy" 7 + }, 8 + "devDependencies": { 9 + "wrangler": "^4" 10 + } 11 + }
+33
schema.sql
··· 1 + CREATE TABLE IF NOT EXISTS actors ( 2 + did TEXT PRIMARY KEY, 3 + handle TEXT NOT NULL, 4 + display_name TEXT DEFAULT '', 5 + avatar_cid TEXT DEFAULT '', 6 + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) 7 + ); 8 + 9 + CREATE INDEX IF NOT EXISTS idx_actors_handle ON actors(handle COLLATE NOCASE); 10 + 11 + CREATE VIRTUAL TABLE IF NOT EXISTS actors_fts USING fts5( 12 + handle, display_name, 13 + content='actors', content_rowid='rowid', 14 + tokenize='unicode61 remove_diacritics 2' 15 + ); 16 + 17 + -- keep FTS5 in sync via triggers 18 + CREATE TRIGGER IF NOT EXISTS actors_ai AFTER INSERT ON actors BEGIN 19 + INSERT INTO actors_fts(rowid, handle, display_name) 20 + VALUES (new.rowid, new.handle, new.display_name); 21 + END; 22 + 23 + CREATE TRIGGER IF NOT EXISTS actors_ad AFTER DELETE ON actors BEGIN 24 + INSERT INTO actors_fts(actors_fts, rowid, handle, display_name) 25 + VALUES ('delete', old.rowid, old.handle, old.display_name); 26 + END; 27 + 28 + CREATE TRIGGER IF NOT EXISTS actors_au AFTER UPDATE ON actors BEGIN 29 + INSERT INTO actors_fts(actors_fts, rowid, handle, display_name) 30 + VALUES ('delete', old.rowid, old.handle, old.display_name); 31 + INSERT INTO actors_fts(rowid, handle, display_name) 32 + VALUES (new.rowid, new.handle, new.display_name); 33 + END;
+204
scripts/smoke.py
··· 1 + #!/usr/bin/env -S PYTHONUNBUFFERED=1 uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = [] 5 + # /// 6 + """ 7 + smoke tests for typeahead service. 8 + 9 + verifies response shape, search behavior, CORS, and optional 10 + comparison against public.api.bsky.app. 11 + 12 + usage: 13 + ./scripts/smoke.py --url https://typeahead.waow.tech 14 + ./scripts/smoke.py --url http://localhost:8787 15 + ./scripts/smoke.py --url https://typeahead.waow.tech --compare 16 + """ 17 + 18 + import argparse 19 + import json 20 + import sys 21 + import urllib.request 22 + import urllib.error 23 + 24 + PASS = "\033[32mpass\033[0m" 25 + FAIL = "\033[31mFAIL\033[0m" 26 + SKIP = "\033[33mskip\033[0m" 27 + 28 + failures = 0 29 + 30 + BSKY_PUBLIC = "https://public.api.bsky.app" 31 + XRPC_PATH = "/xrpc/app.bsky.actor.searchActorsTypeahead" 32 + 33 + 34 + def check(name: str, ok: bool, detail: str = ""): 35 + global failures 36 + tag = PASS if ok else FAIL 37 + msg = f" [{tag}] {name}" 38 + if detail: 39 + msg += f" ({detail})" 40 + print(msg) 41 + if not ok: 42 + failures += 1 43 + return ok 44 + 45 + 46 + def fetch(url: str, timeout: int = 15) -> tuple[dict | None, dict]: 47 + """fetch JSON + response headers. returns (body, headers).""" 48 + try: 49 + req = urllib.request.Request(url, headers={"User-Agent": "typeahead-smoke/1.0"}) 50 + with urllib.request.urlopen(req, timeout=timeout) as resp: 51 + headers = {k.lower(): v for k, v in resp.headers.items()} 52 + return json.loads(resp.read()), headers 53 + except urllib.error.HTTPError as e: 54 + return {"_http_error": e.code}, {} 55 + except Exception as e: 56 + return {"_error": str(e)}, {} 57 + 58 + 59 + def test_response_shape(base_url: str): 60 + print("\n--- response shape ---") 61 + data, _ = fetch(f"{base_url}{XRPC_PATH}?q=test&limit=3") 62 + check("returns valid JSON", data is not None and "_error" not in data) 63 + if not data or "_error" in data or "_http_error" in data: 64 + return 65 + 66 + check("has actors array", isinstance(data.get("actors"), list)) 67 + actors = data.get("actors", []) 68 + if actors: 69 + a = actors[0] 70 + check("actor has did", "did" in a) 71 + check("actor has handle", "handle" in a) 72 + check("did starts with did:", a.get("did", "").startswith("did:")) 73 + 74 + 75 + def test_known_handle(base_url: str): 76 + print("\n--- known handle ---") 77 + data, _ = fetch(f"{base_url}{XRPC_PATH}?q=zzstoatzz&limit=10") 78 + if not data or "_error" in data or "_http_error" in data: 79 + check("fetch succeeded", False) 80 + return 81 + 82 + actors = data.get("actors", []) 83 + handles = [a.get("handle", "") for a in actors] 84 + check("zzstoatzz.io in results", "zzstoatzz.io" in handles, f"got {handles[:5]}") 85 + 86 + 87 + def test_prefix_match(base_url: str): 88 + print("\n--- prefix match ---") 89 + data, _ = fetch(f"{base_url}{XRPC_PATH}?q=zzst&limit=10") 90 + if not data or "_error" in data or "_http_error" in data: 91 + check("fetch succeeded", False) 92 + return 93 + 94 + actors = data.get("actors", []) 95 + handles = [a.get("handle", "") for a in actors] 96 + check("prefix 'zzst' finds zzstoatzz.io", "zzstoatzz.io" in handles, f"got {handles[:5]}") 97 + 98 + 99 + def test_cors(base_url: str): 100 + print("\n--- CORS headers ---") 101 + _, headers = fetch(f"{base_url}{XRPC_PATH}?q=test&limit=1") 102 + origin = headers.get("access-control-allow-origin", "") 103 + check("Access-Control-Allow-Origin: *", origin == "*", f"got '{origin}'") 104 + 105 + 106 + def test_deprecated_param(base_url: str): 107 + print("\n--- deprecated param ---") 108 + data_q, _ = fetch(f"{base_url}{XRPC_PATH}?q=nate&limit=5") 109 + data_term, _ = fetch(f"{base_url}{XRPC_PATH}?term=nate&limit=5") 110 + 111 + if not data_q or "_error" in data_q or not data_term or "_error" in data_term: 112 + check("both params work", False) 113 + return 114 + 115 + actors_q = {a.get("did") for a in data_q.get("actors", [])} 116 + actors_term = {a.get("did") for a in data_term.get("actors", [])} 117 + check("?term= returns same as ?q=", actors_q == actors_term, f"q={len(actors_q)}, term={len(actors_term)}") 118 + 119 + 120 + def test_limit_bounds(base_url: str): 121 + print("\n--- limit bounds ---") 122 + data, _ = fetch(f"{base_url}{XRPC_PATH}?q=a&limit=3") 123 + if not data or "_error" in data or "_http_error" in data: 124 + check("fetch succeeded", False) 125 + return 126 + 127 + actors = data.get("actors", []) 128 + check("limit=3 returns ≤3", len(actors) <= 3, f"got {len(actors)}") 129 + 130 + 131 + def test_empty_query(base_url: str): 132 + print("\n--- empty query ---") 133 + data, _ = fetch(f"{base_url}{XRPC_PATH}?q=") 134 + if not data or "_error" in data or "_http_error" in data: 135 + check("fetch succeeded", False) 136 + return 137 + 138 + actors = data.get("actors", []) 139 + check("empty query returns empty actors", len(actors) == 0, f"got {len(actors)}") 140 + 141 + 142 + def test_comparison(base_url: str, queries: list[str]): 143 + print("\n--- comparison vs public.api.bsky.app ---") 144 + 145 + for q in queries: 146 + sys.stdout.write(f" comparing '{q}'...") 147 + sys.stdout.flush() 148 + 149 + ours, _ = fetch(f"{base_url}{XRPC_PATH}?q={q}&limit=10") 150 + theirs, _ = fetch(f"{BSKY_PUBLIC}{XRPC_PATH}?q={q}&limit=10") 151 + 152 + if not ours or "_error" in ours or not theirs or "_error" in theirs: 153 + sys.stdout.write(f"\r [{FAIL}] '{q}': fetch failed\n") 154 + continue 155 + 156 + our_handles = {a.get("handle") for a in ours.get("actors", [])} 157 + their_handles = {a.get("handle") for a in theirs.get("actors", [])} 158 + 159 + overlap = our_handles & their_handles 160 + pct = (len(overlap) / len(their_handles) * 100) if their_handles else 0 161 + sys.stdout.write( 162 + f"\r [{PASS}] '{q}': {len(overlap)}/{len(their_handles)} overlap ({pct:.0f}%)" 163 + f" — ours={len(our_handles)}, theirs={len(their_handles)}\n" 164 + ) 165 + 166 + 167 + def main(): 168 + parser = argparse.ArgumentParser(description="typeahead smoke tests") 169 + parser.add_argument("--url", required=True, help="typeahead service URL") 170 + parser.add_argument("--compare", action="store_true", help="compare results vs public.api.bsky.app") 171 + parser.add_argument( 172 + "--queries", 173 + nargs="+", 174 + default=["nate", "zzstoatzz", "paul", "dan", "sky"], 175 + help="queries for comparison test", 176 + ) 177 + args = parser.parse_args() 178 + 179 + print(f"typeahead: {args.url}") 180 + 181 + test_response_shape(args.url) 182 + test_known_handle(args.url) 183 + test_prefix_match(args.url) 184 + test_cors(args.url) 185 + test_deprecated_param(args.url) 186 + test_limit_bounds(args.url) 187 + test_empty_query(args.url) 188 + 189 + if args.compare: 190 + test_comparison(args.url, args.queries) 191 + else: 192 + print(f"\n--- comparison ---") 193 + print(f" [{SKIP}] skipped (use --compare)") 194 + 195 + print() 196 + if failures == 0: 197 + print("all checks passed.") 198 + else: 199 + print(f"{failures} check(s) failed.") 200 + return 1 if failures else 0 201 + 202 + 203 + if __name__ == "__main__": 204 + sys.exit(main())
+518
src/index.ts
··· 1 + interface Env { 2 + DB: D1Database; 3 + KV: KVNamespace; 4 + ADMIN_SECRET: string; 5 + RATE_LIMITER: RateLimit; 6 + RATE_LIMITER_STRICT: RateLimit; 7 + } 8 + 9 + const CORS_HEADERS = { 10 + "Access-Control-Allow-Origin": "*", 11 + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 12 + "Access-Control-Allow-Headers": "Content-Type, Authorization", 13 + }; 14 + 15 + const SLINGSHOT_URL = 16 + "https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc"; 17 + 18 + function clientIP(request: Request): string { 19 + return request.headers.get("CF-Connecting-IP") || "unknown"; 20 + } 21 + 22 + function json(data: unknown, status = 200): Response { 23 + return Response.json(data, { status, headers: CORS_HEADERS }); 24 + } 25 + 26 + /** strip anything that could break FTS5 syntax */ 27 + function sanitize(q: string): string { 28 + return q.replace(/[^\w\s.-]/g, "").trim(); 29 + } 30 + 31 + interface ActorRow { 32 + did: string; 33 + handle: string; 34 + display_name: string; 35 + avatar_url: string; 36 + } 37 + 38 + interface IngestEvent { 39 + did: string; 40 + handle?: string; 41 + display_name?: string; 42 + avatar_cid?: string; 43 + } 44 + 45 + interface SlingshotResponse { 46 + did: string; 47 + handle: string; 48 + pds: string; 49 + } 50 + 51 + const BSKY_TYPEAHEAD_URL = 52 + "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead"; 53 + 54 + // --- backfill: remove this block once at parity with Bluesky --- 55 + 56 + async function backfillFromBsky( 57 + term: string, 58 + limit: number, 59 + env: Env 60 + ): Promise<void> { 61 + try { 62 + const res = await fetch( 63 + `${BSKY_TYPEAHEAD_URL}?q=${encodeURIComponent(term)}&limit=${limit}` 64 + ); 65 + if (!res.ok) return; // 429 or other error — just bail 66 + 67 + const data: any = await res.json(); 68 + const actors: any[] = (data.actors || []).filter((a: any) => a.did); 69 + if (actors.length === 0) return; 70 + 71 + // upsert all — fills in missing actors AND enriches existing ones 72 + // (e.g. actors ingested via Jetstream that lack avatar/displayName) 73 + const stmts = actors.map((a) => 74 + env.DB.prepare( 75 + `INSERT INTO actors (did, handle, display_name, avatar_url, updated_at) 76 + VALUES (?1, ?2, ?3, ?4, unixepoch()) 77 + ON CONFLICT(did) DO UPDATE SET 78 + handle = COALESCE(?2, actors.handle), 79 + display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 80 + avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 81 + updated_at = unixepoch()` 82 + ).bind( 83 + a.did, 84 + a.handle || null, 85 + a.displayName || null, 86 + a.avatar || null 87 + ) 88 + ); 89 + 90 + await env.DB.batch(stmts); 91 + console.log(JSON.stringify({ event: "backfill", term, upserted: actors.length })); 92 + } catch { 93 + // best-effort — don't let backfill errors affect anything 94 + } 95 + } 96 + 97 + // --- end backfill --- 98 + 99 + async function handleSearch( 100 + request: Request, 101 + env: Env, 102 + ctx: ExecutionContext 103 + ): Promise<Response> { 104 + const url = new URL(request.url); 105 + const q = url.searchParams.get("q") || url.searchParams.get("term") || ""; 106 + const limitParam = parseInt(url.searchParams.get("limit") || "10", 10); 107 + const limit = Math.max(1, Math.min(limitParam || 10, 100)); 108 + 109 + const term = sanitize(q); 110 + if (!term) { 111 + return json({ actors: [] }); 112 + } 113 + 114 + // cache API — edge cache for hot queries 115 + const cacheKey = new Request( 116 + `https://typeahead-cache/${encodeURIComponent(term)}:${limit}`, 117 + request 118 + ); 119 + const cache = caches.default; 120 + const cached = await cache.match(cacheKey); 121 + if (cached) { 122 + return cached; 123 + } 124 + 125 + const ftsQuery = `"${term}"*`; 126 + const { results } = await env.DB.prepare( 127 + `SELECT a.did, a.handle, a.display_name, a.avatar_url 128 + FROM actors_fts 129 + JOIN actors a ON a.rowid = actors_fts.rowid 130 + WHERE actors_fts MATCH ?1 131 + ORDER BY rank 132 + LIMIT ?2` 133 + ) 134 + .bind(ftsQuery, limit) 135 + .all<ActorRow>(); 136 + 137 + const actors = (results || []).map((r) => ({ 138 + did: r.did, 139 + handle: r.handle, 140 + ...(r.display_name ? { displayName: r.display_name } : {}), 141 + ...(r.avatar_url ? { avatar: r.avatar_url } : {}), 142 + })); 143 + 144 + // --- backfill: remove this block once at parity with Bluesky --- 145 + const hasGaps = actors.length < limit || actors.some((a) => !a.avatar); 146 + if (hasGaps) { 147 + ctx.waitUntil(backfillFromBsky(term, limit, env)); 148 + } 149 + // --- end backfill --- 150 + 151 + const response = json({ actors }); 152 + 153 + // cache for 60 seconds 154 + const cacheable = new Response(response.body, response); 155 + cacheable.headers.set("Cache-Control", "public, max-age=60"); 156 + await cache.put(cacheKey, cacheable.clone()); 157 + 158 + return cacheable; 159 + } 160 + 161 + async function handleIngest( 162 + request: Request, 163 + env: Env 164 + ): Promise<Response> { 165 + const auth = request.headers.get("Authorization"); 166 + if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 167 + const ip = clientIP(request); 168 + await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 169 + console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/ingest", ip })); 170 + return json({ error: "unauthorized" }, 401); 171 + } 172 + 173 + let body: { events: IngestEvent[]; cursor?: number }; 174 + try { 175 + body = await request.json(); 176 + } catch { 177 + return json({ error: "invalid json" }, 400); 178 + } 179 + 180 + const { events, cursor } = body; 181 + if (!Array.isArray(events) || events.length === 0) { 182 + return json({ error: "events must be a non-empty array" }, 400); 183 + } 184 + if (events.length > 10_000) { 185 + return json({ error: "batch too large (max 10000)" }, 400); 186 + } 187 + 188 + // batch upsert — use COALESCE to preserve existing fields on partial updates 189 + const stmts = events.map((e) => { 190 + const avatarUrl = e.avatar_cid 191 + ? `https://cdn.bsky.app/img/avatar/plain/${e.did}/${e.avatar_cid}@jpeg` 192 + : null; 193 + return env.DB.prepare( 194 + `INSERT INTO actors (did, handle, display_name, avatar_url, updated_at) 195 + VALUES (?1, ?2, ?3, ?4, unixepoch()) 196 + ON CONFLICT(did) DO UPDATE SET 197 + handle = COALESCE(?2, actors.handle), 198 + display_name = COALESCE(?3, actors.display_name), 199 + avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 200 + updated_at = unixepoch()` 201 + ).bind( 202 + e.did, 203 + e.handle || null, 204 + e.display_name || null, 205 + avatarUrl 206 + ); 207 + }); 208 + 209 + await env.DB.batch(stmts); 210 + 211 + if (cursor !== undefined) { 212 + await env.KV.put("jetstream_cursor", String(cursor)); 213 + } 214 + 215 + return json({ ok: true, ingested: events.length }); 216 + } 217 + 218 + async function handleDelete( 219 + request: Request, 220 + env: Env 221 + ): Promise<Response> { 222 + const auth = request.headers.get("Authorization"); 223 + if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 224 + const ip = clientIP(request); 225 + await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 226 + console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/delete", ip })); 227 + return json({ error: "unauthorized" }, 401); 228 + } 229 + 230 + let body: { dids: string[] }; 231 + try { 232 + body = await request.json(); 233 + } catch { 234 + return json({ error: "invalid json" }, 400); 235 + } 236 + 237 + const { dids } = body; 238 + if (!Array.isArray(dids) || dids.length === 0) { 239 + return json({ error: "dids must be a non-empty array" }, 400); 240 + } 241 + if (dids.length > 10_000) { 242 + return json({ error: "batch too large (max 10000)" }, 400); 243 + } 244 + 245 + const stmts = dids.map((did) => 246 + env.DB.prepare("DELETE FROM actors WHERE did = ?1").bind(did) 247 + ); 248 + await env.DB.batch(stmts); 249 + 250 + return json({ ok: true, deleted: dids.length }); 251 + } 252 + 253 + async function handleCursor( 254 + request: Request, 255 + env: Env 256 + ): Promise<Response> { 257 + const auth = request.headers.get("Authorization"); 258 + if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 259 + const ip = clientIP(request); 260 + await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 261 + console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/cursor", ip })); 262 + return json({ error: "unauthorized" }, 401); 263 + } 264 + 265 + const cursor = await env.KV.get("jetstream_cursor"); 266 + return json({ cursor: cursor ? Number(cursor) : null }); 267 + } 268 + 269 + /** resolve a handle or DID via slingshot, then upsert into D1 */ 270 + async function handleRequestIndexing( 271 + request: Request, 272 + env: Env 273 + ): Promise<Response> { 274 + const url = new URL(request.url); 275 + const identifier = 276 + url.searchParams.get("handle") || 277 + url.searchParams.get("did") || 278 + ""; 279 + 280 + if (!identifier) { 281 + return html(indexPage("enter a handle or DID to request indexing.")); 282 + } 283 + 284 + // resolve via slingshot 285 + const res = await fetch( 286 + `${SLINGSHOT_URL}?identifier=${encodeURIComponent(identifier)}` 287 + ); 288 + if (!res.ok) { 289 + return html(indexPage(`could not resolve "${identifier}". check that it's a valid handle or DID.`)); 290 + } 291 + 292 + const identity: SlingshotResponse = await res.json(); 293 + 294 + // fetch profile from public API for display name + avatar 295 + let displayName = ""; 296 + let avatarUrl = ""; 297 + try { 298 + const profileRes = await fetch( 299 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identity.did)}` 300 + ); 301 + if (profileRes.ok) { 302 + const profile: any = await profileRes.json(); 303 + displayName = profile.displayName || ""; 304 + avatarUrl = profile.avatar || ""; 305 + } 306 + } catch { 307 + // profile enrichment is best-effort 308 + } 309 + 310 + await env.DB.prepare( 311 + `INSERT INTO actors (did, handle, display_name, avatar_url, updated_at) 312 + VALUES (?1, ?2, ?3, ?4, unixepoch()) 313 + ON CONFLICT(did) DO UPDATE SET 314 + handle = ?2, 315 + display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 316 + avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 317 + updated_at = unixepoch()` 318 + ) 319 + .bind(identity.did, identity.handle, displayName, avatarUrl) 320 + .run(); 321 + 322 + return html( 323 + indexPage(`indexed <strong>@${identity.handle}</strong> (${identity.did})`) 324 + ); 325 + } 326 + 327 + function indexPage(message?: string): string { 328 + return `<!doctype html> 329 + <html> 330 + <head> 331 + <meta charset="utf-8"> 332 + <meta name="viewport" content="width=device-width, initial-scale=1"> 333 + <title>typeahead</title> 334 + <style> 335 + * { margin: 0; padding: 0; box-sizing: border-box; } 336 + body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 337 + display: flex; justify-content: center; align-items: center; min-height: 100vh; } 338 + .container { max-width: 460px; width: 100%; padding: 2rem; } 339 + .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 340 + h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 341 + h1 strong { color: #e0e0e0; } 342 + .experimental { font-size: 0.55em; color: #664; cursor: default; position: relative; 343 + vertical-align: super; line-height: 1; margin-left: 0.3em; } 344 + .experimental:hover::after { 345 + content: "this is experimental and may break or disappear — don't depend on it for anything critical"; 346 + position: absolute; left: 0; top: 1.4em; width: 220px; padding: 0.5rem; 347 + background: #1a1a1a; border: 1px solid #333; border-radius: 4px; 348 + font-size: 1.4em; color: #999; line-height: 1.4; z-index: 20; white-space: normal; 349 + } 350 + .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 351 + .subtitle a { color: #555; text-decoration: none; } 352 + .subtitle a:hover { color: #888; } 353 + section { margin-bottom: 2rem; } 354 + label { display: block; font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; } 355 + .search-wrap { position: relative; } 356 + input { width: 100%; padding: 0.6rem 0.8rem; background: #1a1a1a; border: 1px solid #333; 357 + border-radius: 6px; color: #e0e0e0; font-size: 0.9rem; outline: none; } 358 + input:focus { border-color: #555; } 359 + input::placeholder { color: #555; } 360 + .results { position: absolute; top: 100%; left: 0; right: 0; margin-top: 4px; 361 + background: #141414; border: 1px solid #2a2a2a; border-radius: 6px; 362 + max-height: 260px; overflow-y: auto; z-index: 10; display: none; } 363 + .results.show { display: block; } 364 + .result { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; 365 + cursor: default; font-size: 0.85rem; } 366 + .result:hover { background: #1a1a1a; } 367 + .result img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } 368 + .result .placeholder { width: 28px; height: 28px; border-radius: 50%; background: #222; flex-shrink: 0; } 369 + .result .info { min-width: 0; } 370 + .result .name { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 371 + .result .handle { color: #555; font-size: 0.75rem; } 372 + .empty { padding: 0.6rem 0.8rem; color: #444; font-size: 0.8rem; } 373 + .index-form { display: flex; gap: 0.5rem; } 374 + .index-form input { flex: 1; } 375 + button { padding: 0.6rem 1rem; background: #2a2a2a; border: 1px solid #333; 376 + border-radius: 6px; color: #e0e0e0; font-size: 0.9rem; cursor: pointer; } 377 + button:hover { background: #333; } 378 + .msg { margin-top: 1rem; padding: 0.8rem; background: #1a1a1a; border-radius: 6px; 379 + font-size: 0.85rem; line-height: 1.4; } 380 + .api { margin-bottom: 1.5rem; background: #111; border: 1px solid #222; border-radius: 6px; 381 + padding: 0.5rem 0.7rem; display: flex; align-items: center; gap: 0.5rem; 382 + overflow-x: auto; white-space: nowrap; } 383 + .api .method { font-size: 0.65rem; font-weight: 600; color: #4a9; background: #4a92; 384 + padding: 0.15rem 0.4rem; border-radius: 3px; flex-shrink: 0; 385 + font-family: ui-monospace, monospace; letter-spacing: 0.03em; } 386 + .api .path { font-size: 0.7rem; color: #666; font-family: ui-monospace, monospace; } 387 + footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 388 + color: #444; display: flex; justify-content: center; gap: 0.4rem; } 389 + footer a { color: #555; text-decoration: none; } 390 + footer a:hover { color: #888; } 391 + </style> 392 + </head> 393 + <body> 394 + <div class="container"> 395 + <div class="header"> 396 + <h1><strong>typeahead</strong><sup class="experimental">*experimental</sup></h1> 397 + </div> 398 + <p class="subtitle">community actor search for <a href="https://atproto.com" target="_blank" rel="noopener">atproto</a></p> 399 + 400 + <section> 401 + <label>try it</label> 402 + <div class="search-wrap"> 403 + <input id="q" placeholder="search handles..." autocomplete="off" autofocus> 404 + <div class="results" id="results"></div> 405 + </div> 406 + </section> 407 + 408 + <section> 409 + <label>request indexing</label> 410 + <form class="index-form" method="get" action="/request-indexing"> 411 + <input name="handle" placeholder="handle or DID" autocomplete="off"> 412 + <button type="submit">index</button> 413 + </form> 414 + ${message ? `<div class="msg">${message}</div>` : ""} 415 + </section> 416 + 417 + <div class="api"> 418 + <span class="method">GET</span> 419 + <span class="path">/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10</span> 420 + </div> 421 + 422 + <footer> 423 + by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener">@zzstoatzz.io</a> 424 + </footer> 425 + </div> 426 + <script> 427 + const q = document.getElementById('q'); 428 + const results = document.getElementById('results'); 429 + let timer = null; 430 + q.addEventListener('input', () => { 431 + clearTimeout(timer); 432 + const v = q.value.trim(); 433 + if (v.length < 2) { results.classList.remove('show'); return; } 434 + timer = setTimeout(async () => { 435 + try { 436 + const r = await fetch('/xrpc/app.bsky.actor.searchActorsTypeahead?q=' + encodeURIComponent(v) + '&limit=8'); 437 + const data = await r.json(); 438 + const actors = data.actors || []; 439 + if (actors.length === 0) { 440 + results.innerHTML = '<div class="empty">no results</div>'; 441 + } else { 442 + results.innerHTML = actors.map(a => 443 + '<div class="result">' + 444 + (a.avatar ? '<img src="' + a.avatar + '" alt="">' : '<div class="placeholder"></div>') + 445 + '<div class="info"><div class="name">' + esc(a.displayName || a.handle) + '</div>' + 446 + '<div class="handle">@' + esc(a.handle) + '</div></div></div>' 447 + ).join(''); 448 + } 449 + results.classList.add('show'); 450 + } catch(e) {} 451 + }, 200); 452 + }); 453 + document.addEventListener('click', e => { if (!e.target.closest('.search-wrap')) results.classList.remove('show'); }); 454 + q.addEventListener('focus', () => { if (results.innerHTML) results.classList.add('show'); }); 455 + function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } 456 + </script> 457 + </body> 458 + </html>`; 459 + } 460 + 461 + function html(body: string, status = 200): Response { 462 + return new Response(body, { 463 + status, 464 + headers: { "Content-Type": "text/html; charset=utf-8", ...CORS_HEADERS }, 465 + }); 466 + } 467 + 468 + export default { 469 + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 470 + if (request.method === "OPTIONS") { 471 + return new Response(null, { status: 204, headers: CORS_HEADERS }); 472 + } 473 + 474 + const { pathname } = new URL(request.url); 475 + 476 + if (pathname === "/" && request.method === "GET") { 477 + return html(indexPage()); 478 + } 479 + 480 + if (pathname === "/request-indexing" && request.method === "GET") { 481 + const ip = clientIP(request); 482 + // stricter limit — this endpoint makes outbound fetches 483 + const { success } = await env.RATE_LIMITER_STRICT.limit({ key: ip }); 484 + if (!success) { 485 + console.log(JSON.stringify({ event: "rate_limited", endpoint: "/request-indexing", ip })); 486 + return json({ error: "rate limited" }, 429); 487 + } 488 + return handleRequestIndexing(request, env); 489 + } 490 + 491 + if ( 492 + pathname === "/xrpc/app.bsky.actor.searchActorsTypeahead" && 493 + request.method === "GET" 494 + ) { 495 + const ip = clientIP(request); 496 + const { success } = await env.RATE_LIMITER.limit({ key: ip }); 497 + if (!success) { 498 + console.log(JSON.stringify({ event: "rate_limited", endpoint: "/search", ip })); 499 + return json({ error: "rate limited" }, 429); 500 + } 501 + return handleSearch(request, env, ctx); 502 + } 503 + 504 + if (pathname === "/admin/ingest" && request.method === "POST") { 505 + return handleIngest(request, env); 506 + } 507 + 508 + if (pathname === "/admin/delete" && request.method === "POST") { 509 + return handleDelete(request, env); 510 + } 511 + 512 + if (pathname === "/admin/cursor" && request.method === "GET") { 513 + return handleCursor(request, env); 514 + } 515 + 516 + return json({ error: "not found" }, 404); 517 + }, 518 + };
+35
wrangler.jsonc
··· 1 + { 2 + "name": "typeahead", 3 + "main": "src/index.ts", 4 + "compatibility_date": "2024-12-01", 5 + "compatibility_flags": ["nodejs_compat"], 6 + "d1_databases": [ 7 + { 8 + "binding": "DB", 9 + "database_name": "typeahead-db", 10 + "database_id": "7e289d5d-dc50-46d1-8084-49aeec2679e5" 11 + } 12 + ], 13 + "kv_namespaces": [ 14 + { 15 + "binding": "KV", 16 + "id": "a2582ddac2f841cf8ba7117dd851c770" 17 + } 18 + ], 19 + "unsafe": { 20 + "bindings": [ 21 + { 22 + "type": "ratelimit", 23 + "name": "RATE_LIMITER", 24 + "namespace_id": "0", 25 + "simple": { "limit": 60, "period": 60 } 26 + }, 27 + { 28 + "type": "ratelimit", 29 + "name": "RATE_LIMITER_STRICT", 30 + "namespace_id": "0", 31 + "simple": { "limit": 10, "period": 60 } 32 + } 33 + ] 34 + } 35 + }