@hatk/hatk 0.0.1-alpha.9 → 0.0.1-alpha.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +29 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +137 -51
  7. package/dist/car.d.ts +20 -0
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +108 -0
  10. package/dist/cli.js +247 -1043
  11. package/dist/config.d.ts +145 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +61 -8
  14. package/dist/database/adapter-factory.d.ts +6 -0
  15. package/dist/database/adapter-factory.d.ts.map +1 -0
  16. package/dist/database/adapter-factory.js +20 -0
  17. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  18. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  19. package/dist/database/adapters/duckdb-search.js +27 -0
  20. package/dist/database/adapters/duckdb.d.ts +25 -0
  21. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  22. package/dist/database/adapters/duckdb.js +161 -0
  23. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +76 -0
  26. package/dist/database/adapters/sqlite.d.ts +18 -0
  27. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  28. package/dist/database/adapters/sqlite.js +88 -0
  29. package/dist/{db.d.ts → database/db.d.ts} +68 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/database/db.js +1775 -0
  32. package/dist/database/dialect.d.ts +45 -0
  33. package/dist/database/dialect.d.ts.map +1 -0
  34. package/dist/database/dialect.js +72 -0
  35. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  36. package/dist/database/fts.d.ts.map +1 -0
  37. package/dist/{fts.js → database/fts.js} +122 -34
  38. package/dist/database/index.d.ts +7 -0
  39. package/dist/database/index.d.ts.map +1 -0
  40. package/dist/database/index.js +6 -0
  41. package/dist/database/ports.d.ts +50 -0
  42. package/dist/database/ports.d.ts.map +1 -0
  43. package/dist/database/ports.js +1 -0
  44. package/dist/{schema.d.ts → database/schema.d.ts} +16 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +126 -44
  47. package/dist/dev-entry.d.ts +8 -0
  48. package/dist/dev-entry.d.ts.map +1 -0
  49. package/dist/dev-entry.js +127 -0
  50. package/dist/feeds.d.ts +26 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +72 -16
  53. package/dist/hooks.d.ts +102 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +186 -0
  56. package/dist/hydrate.d.ts +49 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +21 -20
  59. package/dist/indexer.d.ts +156 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +539 -94
  62. package/dist/jetstream.d.ts +118 -0
  63. package/dist/jetstream.d.ts.map +1 -0
  64. package/dist/jetstream.js +225 -0
  65. package/dist/labels.d.ts +36 -0
  66. package/dist/labels.d.ts.map +1 -1
  67. package/dist/labels.js +71 -6
  68. package/dist/lex-types.d.ts +2 -0
  69. package/dist/lex-types.d.ts.map +1 -1
  70. package/dist/lexicon-resolve.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.js +27 -112
  72. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  73. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  74. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  75. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  76. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  77. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  78. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  79. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  80. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  81. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  82. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  83. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  84. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  85. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  86. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  87. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  88. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  89. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  90. package/dist/logger.d.ts +29 -0
  91. package/dist/logger.d.ts.map +1 -1
  92. package/dist/logger.js +29 -0
  93. package/dist/main.js +198 -75
  94. package/dist/mst.d.ts +15 -0
  95. package/dist/mst.d.ts.map +1 -1
  96. package/dist/mst.js +13 -0
  97. package/dist/oauth/db.d.ts +16 -1
  98. package/dist/oauth/db.d.ts.map +1 -1
  99. package/dist/oauth/db.js +74 -19
  100. package/dist/oauth/discovery.d.ts +10 -0
  101. package/dist/oauth/discovery.d.ts.map +1 -1
  102. package/dist/oauth/discovery.js +54 -2
  103. package/dist/oauth/server.d.ts +93 -1
  104. package/dist/oauth/server.d.ts.map +1 -1
  105. package/dist/oauth/server.js +416 -39
  106. package/dist/oauth/service-describe.d.ts +42 -0
  107. package/dist/oauth/service-describe.d.ts.map +1 -0
  108. package/dist/oauth/service-describe.js +108 -0
  109. package/dist/oauth/session.d.ts +32 -0
  110. package/dist/oauth/session.d.ts.map +1 -0
  111. package/dist/oauth/session.js +131 -0
  112. package/dist/opengraph.d.ts +10 -0
  113. package/dist/opengraph.d.ts.map +1 -1
  114. package/dist/opengraph.js +94 -42
  115. package/dist/pds-proxy.d.ts +82 -0
  116. package/dist/pds-proxy.d.ts.map +1 -0
  117. package/dist/pds-proxy.js +356 -0
  118. package/dist/private-collections.d.ts +13 -0
  119. package/dist/private-collections.d.ts.map +1 -0
  120. package/dist/private-collections.js +27 -0
  121. package/dist/push.d.ts +54 -0
  122. package/dist/push.d.ts.map +1 -0
  123. package/dist/push.js +367 -0
  124. package/dist/renderer.d.ts +27 -0
  125. package/dist/renderer.d.ts.map +1 -0
  126. package/dist/renderer.js +46 -0
  127. package/dist/resolve-hatk.d.ts +6 -0
  128. package/dist/resolve-hatk.d.ts.map +1 -0
  129. package/dist/resolve-hatk.js +20 -0
  130. package/dist/response.d.ts +16 -0
  131. package/dist/response.d.ts.map +1 -0
  132. package/dist/response.js +69 -0
  133. package/dist/scanner.d.ts +21 -0
  134. package/dist/scanner.d.ts.map +1 -0
  135. package/dist/scanner.js +88 -0
  136. package/dist/seed.d.ts +19 -0
  137. package/dist/seed.d.ts.map +1 -1
  138. package/dist/seed.js +43 -4
  139. package/dist/server-init.d.ts +8 -0
  140. package/dist/server-init.d.ts.map +1 -0
  141. package/dist/server-init.js +62 -0
  142. package/dist/server.d.ts +34 -3
  143. package/dist/server.d.ts.map +1 -1
  144. package/dist/server.js +1021 -649
  145. package/dist/setup.d.ts +28 -1
  146. package/dist/setup.d.ts.map +1 -1
  147. package/dist/setup.js +50 -3
  148. package/dist/spaces/blob.d.ts +51 -0
  149. package/dist/spaces/blob.d.ts.map +1 -0
  150. package/dist/spaces/blob.js +111 -0
  151. package/dist/spaces/credential.d.ts +62 -0
  152. package/dist/spaces/credential.d.ts.map +1 -0
  153. package/dist/spaces/credential.js +184 -0
  154. package/dist/spaces/engine.d.ts +97 -0
  155. package/dist/spaces/engine.d.ts.map +1 -0
  156. package/dist/spaces/engine.js +559 -0
  157. package/dist/spaces/guard.d.ts +41 -0
  158. package/dist/spaces/guard.d.ts.map +1 -0
  159. package/dist/spaces/guard.js +79 -0
  160. package/dist/spaces/identity.d.ts +37 -0
  161. package/dist/spaces/identity.d.ts.map +1 -0
  162. package/dist/spaces/identity.js +88 -0
  163. package/dist/spaces/index.d.ts +55 -0
  164. package/dist/spaces/index.d.ts.map +1 -0
  165. package/dist/spaces/index.js +133 -0
  166. package/dist/spaces/notify.d.ts +51 -0
  167. package/dist/spaces/notify.d.ts.map +1 -0
  168. package/dist/spaces/notify.js +121 -0
  169. package/dist/spaces/records.d.ts +37 -0
  170. package/dist/spaces/records.d.ts.map +1 -0
  171. package/dist/spaces/records.js +63 -0
  172. package/dist/spaces/store.d.ts +51 -0
  173. package/dist/spaces/store.d.ts.map +1 -0
  174. package/dist/spaces/store.js +99 -0
  175. package/dist/spaces/uri.d.ts +60 -0
  176. package/dist/spaces/uri.d.ts.map +1 -0
  177. package/dist/spaces/uri.js +97 -0
  178. package/dist/spaces/verify.d.ts +39 -0
  179. package/dist/spaces/verify.d.ts.map +1 -0
  180. package/dist/spaces/verify.js +87 -0
  181. package/dist/spaces/viewer.d.ts +51 -0
  182. package/dist/spaces/viewer.d.ts.map +1 -0
  183. package/dist/spaces/viewer.js +167 -0
  184. package/dist/spaces/visibility.d.ts +57 -0
  185. package/dist/spaces/visibility.d.ts.map +1 -0
  186. package/dist/spaces/visibility.js +81 -0
  187. package/dist/templates/feed.tpl +18 -0
  188. package/dist/templates/hook.tpl +5 -0
  189. package/dist/templates/label.tpl +15 -0
  190. package/dist/templates/og.tpl +17 -0
  191. package/dist/templates/seed.tpl +11 -0
  192. package/dist/templates/setup.tpl +5 -0
  193. package/dist/templates/test-feed.tpl +19 -0
  194. package/dist/templates/test-xrpc.tpl +19 -0
  195. package/dist/templates/xrpc.tpl +53 -0
  196. package/dist/test.d.ts +3 -1
  197. package/dist/test.d.ts.map +1 -1
  198. package/dist/test.js +60 -36
  199. package/dist/views.js +1 -1
  200. package/dist/vite-plugin.d.ts +1 -1
  201. package/dist/vite-plugin.d.ts.map +1 -1
  202. package/dist/vite-plugin.js +254 -66
  203. package/dist/xrpc.d.ts +101 -12
  204. package/dist/xrpc.d.ts.map +1 -1
  205. package/dist/xrpc.js +255 -55
  206. package/package.json +21 -9
  207. package/public/admin-auth.js +271 -306
  208. package/public/admin.html +177 -61
  209. package/dist/db.d.ts.map +0 -1
  210. package/dist/db.js +0 -1361
  211. package/dist/fts.d.ts.map +0 -1
  212. package/dist/oauth/hooks.d.ts +0 -10
  213. package/dist/oauth/hooks.d.ts.map +0 -1
  214. package/dist/oauth/hooks.js +0 -40
  215. package/dist/schema.d.ts.map +0 -1
  216. package/dist/test-browser.d.ts +0 -14
  217. package/dist/test-browser.d.ts.map +0 -1
  218. package/dist/test-browser.js +0 -26
package/dist/push.js ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Push notification delivery via APNs HTTP/2 and FCM HTTP v1.
3
+ *
4
+ * Provides `push.send()` for use in on-commit hook context. Looks up device
5
+ * tokens, builds a payload per platform, and sends over the transport that
6
+ * platform's tokens belong to. Self-cleans tokens the vendor reports as dead —
7
+ * Apple's 410, Google's UNREGISTERED. Fire-and-forget — failures are logged via
8
+ * `emit()` but never throw.
9
+ *
10
+ * Either transport can be configured alone. A deployment with only an APNs key
11
+ * still sends to iOS, and Android tokens simply sit unused until an FCM service
12
+ * account is supplied.
13
+ */
14
+ import { connect } from 'node:http2';
15
+ import { readFileSync } from 'node:fs';
16
+ import { createSign } from 'node:crypto';
17
+ import { resolve } from 'node:path';
18
+ import { emit } from "./logger.js";
19
+ import { runSQL, querySQL } from "./database/db.js";
20
+ let pushConfig = null;
21
+ let apnsKey = null;
22
+ let cachedJwt = null;
23
+ let http2Session = null;
24
+ let fcmCredentials = null;
25
+ let cachedFcmToken = null;
26
+ /** Where a service account trades its signed JWT for a bearer token. */
27
+ const GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token';
28
+ /** The only scope FCM sending needs. */
29
+ const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
30
+ /**
31
+ * Initialize push with config. Must be called before send().
32
+ *
33
+ * Each transport is loaded on its own: a missing or malformed credential
34
+ * disables that platform and leaves the other one sending, rather than taking
35
+ * push down entirely.
36
+ */
37
+ export function initPush(config, configDir) {
38
+ pushConfig = config;
39
+ apnsKey = null;
40
+ fcmCredentials = null;
41
+ cachedJwt = null;
42
+ cachedFcmToken = null;
43
+ if (config.apns) {
44
+ const keyPath = resolve(configDir, config.apns.keyFile);
45
+ try {
46
+ apnsKey = readFileSync(keyPath, 'utf8');
47
+ }
48
+ catch {
49
+ emit('push', 'init_error', { transport: 'apns', error: `APNs key file not found: ${keyPath}` });
50
+ }
51
+ }
52
+ if (config.fcm) {
53
+ const keyPath = resolve(configDir, config.fcm.keyFile);
54
+ try {
55
+ const account = JSON.parse(readFileSync(keyPath, 'utf8'));
56
+ const projectId = config.fcm.projectId ?? account.project_id;
57
+ if (!account.client_email || !account.private_key || !projectId) {
58
+ throw new Error('needs client_email, private_key and a project id');
59
+ }
60
+ fcmCredentials = {
61
+ clientEmail: account.client_email,
62
+ privateKey: account.private_key,
63
+ projectId,
64
+ tokenUri: account.token_uri ?? GOOGLE_TOKEN_URI,
65
+ };
66
+ }
67
+ catch (err) {
68
+ emit('push', 'init_error', {
69
+ transport: 'fcm',
70
+ error: `FCM service account unusable (${keyPath}): ${err.message}`,
71
+ });
72
+ }
73
+ }
74
+ if (!apnsKey && !fcmCredentials)
75
+ pushConfig = null;
76
+ }
77
+ /** Check if push is configured and at least one transport is usable. */
78
+ export function isPushEnabled() {
79
+ return pushConfig !== null && (apnsKey !== null || fcmCredentials !== null);
80
+ }
81
+ /** Which platforms this process can actually deliver to, for startup logging. */
82
+ export function enabledPushTransports() {
83
+ const transports = [];
84
+ if (apnsKey)
85
+ transports.push('apns');
86
+ if (fcmCredentials)
87
+ transports.push('fcm');
88
+ return transports;
89
+ }
90
+ /** Build the push interface injected into hook contexts. */
91
+ export function buildPushInterface() {
92
+ return { send };
93
+ }
94
+ /** Create a JWT for APNs authentication (cached for 50 minutes). */
95
+ function getApnsJwt() {
96
+ if (cachedJwt && Date.now() < cachedJwt.expires)
97
+ return cachedJwt.token;
98
+ const apns = pushConfig?.apns;
99
+ if (!apns || !apnsKey)
100
+ throw new Error('APNs not initialized');
101
+ const header = Buffer.from(JSON.stringify({
102
+ alg: 'ES256',
103
+ kid: apns.keyId,
104
+ })).toString('base64url');
105
+ const now = Math.floor(Date.now() / 1000);
106
+ const claims = Buffer.from(JSON.stringify({
107
+ iss: apns.teamId,
108
+ iat: now,
109
+ })).toString('base64url');
110
+ const signer = createSign('SHA256');
111
+ signer.update(`${header}.${claims}`);
112
+ const signature = signer.sign(apnsKey, 'base64url');
113
+ const token = `${header}.${claims}.${signature}`;
114
+ cachedJwt = { token, expires: Date.now() + 50 * 60 * 1000 };
115
+ return token;
116
+ }
117
+ /** Get or create an HTTP/2 connection to APNs. */
118
+ function getHttp2Session() {
119
+ if (http2Session && !http2Session.closed && !http2Session.destroyed) {
120
+ return http2Session;
121
+ }
122
+ const host = pushConfig?.apns?.production !== false ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com';
123
+ emit('push', 'connecting', { host });
124
+ http2Session = connect(host, {
125
+ peerMaxConcurrentStreams: 100,
126
+ });
127
+ http2Session.on('connect', () => {
128
+ emit('push', 'connected', { host });
129
+ });
130
+ http2Session.on('error', (err) => {
131
+ emit('push', 'connection_error', { host, error: err.message });
132
+ http2Session = null;
133
+ });
134
+ http2Session.on('close', () => {
135
+ http2Session = null;
136
+ });
137
+ return http2Session;
138
+ }
139
+ /** Send a push notification to all devices registered for a DID. */
140
+ async function send(payload) {
141
+ if (!isPushEnabled())
142
+ return;
143
+ const tokens = (await querySQL(`SELECT token, platform FROM _push_tokens WHERE did = $1`, [payload.did]));
144
+ if (tokens.length === 0)
145
+ return;
146
+ // A device is reachable over exactly one transport, named by the platform it
147
+ // registered under. An unrecognized platform is left alone rather than
148
+ // guessed at — the row may belong to a client this build doesn't serve yet.
149
+ const apnsTokens = tokens.filter((t) => t.platform === 'apns');
150
+ const fcmTokens = tokens.filter((t) => t.platform === 'fcm');
151
+ if (apnsKey && apnsTokens.length > 0) {
152
+ const jwt = getApnsJwt();
153
+ const aps = {
154
+ alert: { title: payload.title, body: payload.body },
155
+ sound: 'default',
156
+ };
157
+ if (payload.badge !== undefined) {
158
+ aps.badge = payload.badge;
159
+ }
160
+ const apnsPayload = JSON.stringify({
161
+ aps,
162
+ ...(payload.data || {}),
163
+ });
164
+ for (const { token } of apnsTokens) {
165
+ sendToApns(token, apnsPayload, jwt, payload).catch(() => { });
166
+ }
167
+ }
168
+ if (fcmCredentials && fcmTokens.length > 0) {
169
+ // One access token serves every device in this fan-out; it's minted once
170
+ // and cached for the hour Google grants it.
171
+ let accessToken;
172
+ try {
173
+ accessToken = await getFcmAccessToken();
174
+ }
175
+ catch (err) {
176
+ emit('push', 'send_error', { did: payload.did, transport: 'fcm', error: err.message });
177
+ return;
178
+ }
179
+ for (const { token } of fcmTokens) {
180
+ sendToFcm(token, accessToken, payload).catch(() => { });
181
+ }
182
+ }
183
+ }
184
+ /** Send a single APNs push and handle the response. */
185
+ async function sendToApns(token, payload, jwt, original) {
186
+ const apns = pushConfig?.apns;
187
+ if (!apns)
188
+ return;
189
+ const session = getHttp2Session();
190
+ const headers = {
191
+ ':method': 'POST',
192
+ ':path': `/3/device/${token}`,
193
+ authorization: `bearer ${jwt}`,
194
+ 'apns-topic': apns.bundleId,
195
+ 'apns-push-type': 'alert',
196
+ };
197
+ if (original.collapseId) {
198
+ headers['apns-collapse-id'] = original.collapseId;
199
+ }
200
+ return new Promise((resolve) => {
201
+ const req = session.request(headers);
202
+ let settled = false;
203
+ const done = () => {
204
+ if (settled)
205
+ return;
206
+ settled = true;
207
+ resolve();
208
+ };
209
+ req.setTimeout(15_000, () => {
210
+ req.close();
211
+ emit('push', 'send_error', { did: original.did, error: 'APNs request timed out' });
212
+ done();
213
+ });
214
+ let status = 0;
215
+ let body = '';
216
+ req.on('response', (headers) => {
217
+ status = headers[':status'];
218
+ });
219
+ req.on('data', (chunk) => {
220
+ body += chunk.toString();
221
+ });
222
+ req.on('end', async () => {
223
+ if (settled)
224
+ return;
225
+ if (status === 200) {
226
+ emit('push', 'sent', { did: original.did, token: token.slice(0, 8) + '...' });
227
+ }
228
+ else if (status === 410) {
229
+ // Token is no longer valid — remove it
230
+ await removeToken(token).catch(() => { });
231
+ emit('push', 'token_removed', { did: original.did, reason: 'expired' });
232
+ }
233
+ else {
234
+ emit('push', 'send_error', {
235
+ did: original.did,
236
+ status,
237
+ body: body.slice(0, 200),
238
+ });
239
+ }
240
+ done();
241
+ });
242
+ req.on('error', (err) => {
243
+ if (settled)
244
+ return;
245
+ emit('push', 'send_error', { did: original.did, error: err.message });
246
+ done();
247
+ });
248
+ req.write(payload);
249
+ req.end();
250
+ });
251
+ }
252
+ /**
253
+ * Mint an OAuth2 access token for the FCM HTTP v1 API.
254
+ *
255
+ * The service account signs a JWT asserting the messaging scope, and Google
256
+ * trades it for a bearer token good for an hour. Cached with a minute of
257
+ * headroom so one can't expire between minting and the send that uses it.
258
+ */
259
+ async function getFcmAccessToken() {
260
+ if (cachedFcmToken && Date.now() < cachedFcmToken.expires)
261
+ return cachedFcmToken.token;
262
+ if (!fcmCredentials)
263
+ throw new Error('FCM not initialized');
264
+ const now = Math.floor(Date.now() / 1000);
265
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
266
+ const claims = Buffer.from(JSON.stringify({
267
+ iss: fcmCredentials.clientEmail,
268
+ scope: FCM_SCOPE,
269
+ aud: fcmCredentials.tokenUri,
270
+ iat: now,
271
+ exp: now + 3600,
272
+ })).toString('base64url');
273
+ const signer = createSign('RSA-SHA256');
274
+ signer.update(`${header}.${claims}`);
275
+ const assertion = `${header}.${claims}.${signer.sign(fcmCredentials.privateKey, 'base64url')}`;
276
+ const res = await fetch(fcmCredentials.tokenUri, {
277
+ method: 'POST',
278
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
279
+ body: new URLSearchParams({
280
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
281
+ assertion,
282
+ }),
283
+ signal: AbortSignal.timeout(15_000),
284
+ });
285
+ if (!res.ok) {
286
+ const body = await res.text().catch(() => '');
287
+ throw new Error(`FCM token exchange failed: ${res.status} ${body.slice(0, 200)}`);
288
+ }
289
+ const granted = (await res.json());
290
+ if (!granted.access_token)
291
+ throw new Error('FCM token exchange returned no access_token');
292
+ cachedFcmToken = {
293
+ token: granted.access_token,
294
+ expires: Date.now() + ((granted.expires_in ?? 3600) - 60) * 1000,
295
+ };
296
+ return cachedFcmToken.token;
297
+ }
298
+ /** Send a single FCM push and handle the response. */
299
+ async function sendToFcm(token, accessToken, original) {
300
+ // Data-only, deliberately: a `notification` block has the Android SDK draw
301
+ // the alert itself while the app is backgrounded, which skips the client's
302
+ // handler and with it the tap destination it derives from `type` and `uri`.
303
+ // FCM only carries strings in `data`, so everything is sent as one.
304
+ const data = { title: original.title, body: original.body };
305
+ for (const [key, value] of Object.entries(original.data ?? {})) {
306
+ data[key] = String(value);
307
+ }
308
+ const message = {
309
+ token,
310
+ data,
311
+ android: {
312
+ // Data-only messages are "normal" priority by default, which lets Doze
313
+ // hold one until the device next wakes. These are alerts somebody is
314
+ // waiting on, so they go high, the way APNs alert pushes do.
315
+ priority: 'HIGH',
316
+ ...(original.collapseId ? { collapse_key: original.collapseId } : {}),
317
+ },
318
+ // `badge` has no FCM equivalent — an Android launcher badges from the
319
+ // notification the client posts, not from the payload.
320
+ };
321
+ const res = await fetch(`https://fcm.googleapis.com/v1/projects/${fcmCredentials.projectId}/messages:send`, {
322
+ method: 'POST',
323
+ headers: {
324
+ authorization: `Bearer ${accessToken}`,
325
+ 'content-type': 'application/json',
326
+ },
327
+ body: JSON.stringify({ message }),
328
+ signal: AbortSignal.timeout(15_000),
329
+ });
330
+ if (res.ok) {
331
+ emit('push', 'sent', { did: original.did, transport: 'fcm', token: token.slice(0, 8) + '...' });
332
+ return;
333
+ }
334
+ const body = await res.text().catch(() => '');
335
+ // Google's word for a token that no longer belongs to an install — the same
336
+ // fact Apple reports as a 410.
337
+ if (res.status === 404 || body.includes('UNREGISTERED')) {
338
+ await removeToken(token).catch(() => { });
339
+ emit('push', 'token_removed', { did: original.did, transport: 'fcm', reason: 'unregistered' });
340
+ return;
341
+ }
342
+ // The bearer token was refused: drop it so the next send mints a fresh one
343
+ // rather than replaying a credential Google has already rejected.
344
+ if (res.status === 401 || res.status === 403) {
345
+ cachedFcmToken = null;
346
+ }
347
+ emit('push', 'send_error', {
348
+ did: original.did,
349
+ transport: 'fcm',
350
+ status: res.status,
351
+ body: body.slice(0, 200),
352
+ });
353
+ }
354
+ /** Register a push token for a DID. Upserts on conflict. */
355
+ export async function registerToken(did, token, platform) {
356
+ await runSQL(`INSERT INTO _push_tokens (did, token, platform, created_at)
357
+ VALUES ($1, $2, $3, $4)
358
+ ON CONFLICT (did, token) DO UPDATE SET platform = excluded.platform`, [did, token, platform, new Date().toISOString()]);
359
+ }
360
+ /** Remove a push token. */
361
+ export async function removeToken(token) {
362
+ await runSQL(`DELETE FROM _push_tokens WHERE token = $1`, [token]);
363
+ }
364
+ /** Unregister a specific token for a DID. */
365
+ export async function unregisterToken(did, token) {
366
+ await runSQL(`DELETE FROM _push_tokens WHERE did = $1 AND token = $2`, [did, token]);
367
+ }
@@ -0,0 +1,27 @@
1
+ export interface SSRManifest {
2
+ getPreloadTags(url: string): string;
3
+ }
4
+ export interface RenderResult {
5
+ html: string;
6
+ head?: string;
7
+ }
8
+ export type RendererHandler = (request: Request, manifest: SSRManifest) => Promise<RenderResult>;
9
+ export declare function defineRenderer(handler: RendererHandler): {
10
+ __type: "renderer";
11
+ handler: RendererHandler;
12
+ };
13
+ export declare function registerRenderer(handler: RendererHandler): void;
14
+ export declare function setSSRManifest(manifest: SSRManifest): void;
15
+ export declare function getRenderer(): RendererHandler | null;
16
+ export declare function getSSRManifest(): SSRManifest | null;
17
+ /**
18
+ * Render an HTML page by calling the user's renderer and assembling the result
19
+ * into the index.html template.
20
+ *
21
+ * @param template - The index.html content (with <!--ssr-outlet--> placeholder)
22
+ * @param request - The incoming Request
23
+ * @param ogMeta - Optional OG meta tags to inject
24
+ * @returns Assembled HTML string, or null if no renderer is registered
25
+ */
26
+ export declare function renderPage(template: string, request: Request, ogMeta?: string | null): Promise<string | null>;
27
+ //# sourceMappingURL=renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,WAAW;IAC1B,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC,CAAA;AAKhG,wBAAgB,cAAc,CAAC,OAAO,EAAE,eAAe;;;EAEtD;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,CAG/D;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAE1D;AAED,wBAAgB,WAAW,IAAI,eAAe,GAAG,IAAI,CAEpD;AAED,wBAAgB,cAAc,IAAI,WAAW,GAAG,IAAI,CAEnD;AAED;;;;;;;;GAQG;AACH,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAsBnH"}
@@ -0,0 +1,46 @@
1
+ import { log } from "./logger.js";
2
+ let renderer = null;
3
+ let ssrManifest = null;
4
+ export function defineRenderer(handler) {
5
+ return { __type: 'renderer', handler };
6
+ }
7
+ export function registerRenderer(handler) {
8
+ renderer = handler;
9
+ log('[renderer] SSR renderer registered');
10
+ }
11
+ export function setSSRManifest(manifest) {
12
+ ssrManifest = manifest;
13
+ }
14
+ export function getRenderer() {
15
+ return renderer;
16
+ }
17
+ export function getSSRManifest() {
18
+ return ssrManifest;
19
+ }
20
+ /**
21
+ * Render an HTML page by calling the user's renderer and assembling the result
22
+ * into the index.html template.
23
+ *
24
+ * @param template - The index.html content (with <!--ssr-outlet--> placeholder)
25
+ * @param request - The incoming Request
26
+ * @param ogMeta - Optional OG meta tags to inject
27
+ * @returns Assembled HTML string, or null if no renderer is registered
28
+ */
29
+ export async function renderPage(template, request, ogMeta) {
30
+ if (!renderer)
31
+ return null;
32
+ const manifest = ssrManifest || { getPreloadTags: () => '' };
33
+ const result = await renderer(request, manifest);
34
+ let html = template;
35
+ // Inject SSR head tags (preloads, styles)
36
+ if (result.head) {
37
+ html = html.replace('</head>', `${result.head}\n</head>`);
38
+ }
39
+ // Inject OG meta tags
40
+ if (ogMeta) {
41
+ html = html.replace('</head>', `${ogMeta}\n</head>`);
42
+ }
43
+ // Inject rendered HTML into the outlet
44
+ html = html.replace('<!--ssr-outlet-->', result.html);
45
+ return html;
46
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Register a Node.js module resolve hook so dynamic import() of server files
3
+ * can resolve the $hatk alias to the generated entry points.
4
+ */
5
+ export declare function registerHatkResolveHook(): void;
6
+ //# sourceMappingURL=resolve-hatk.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-hatk.d.ts","sourceRoot":"","sources":["../src/resolve-hatk.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAU9C"}
@@ -0,0 +1,20 @@
1
+ import { resolve } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { registerHooks } from 'node:module';
4
+ /**
5
+ * Register a Node.js module resolve hook so dynamic import() of server files
6
+ * can resolve the $hatk alias to the generated entry points.
7
+ */
8
+ export function registerHatkResolveHook() {
9
+ const hatkUrl = pathToFileURL(resolve('hatk.generated.ts')).href;
10
+ const hatkClientUrl = pathToFileURL(resolve('hatk.generated.client.ts')).href;
11
+ registerHooks({
12
+ resolve(specifier, context, nextResolve) {
13
+ if (specifier === '$hatk/client')
14
+ return { url: hatkClientUrl, shortCircuit: true };
15
+ if (specifier === '$hatk')
16
+ return { url: hatkUrl, shortCircuit: true };
17
+ return nextResolve(specifier, context);
18
+ },
19
+ });
20
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Create a JSON Response with optional gzip compression.
3
+ * Mirrors the old jsonResponse/sendJson behavior.
4
+ */
5
+ export declare function json(data: unknown, status?: number, acceptEncoding?: string | null): Response;
6
+ /** Create a JSON error Response. */
7
+ export declare function jsonError(status: number, message: string, acceptEncoding?: string | null): Response;
8
+ /** CORS preflight Response. */
9
+ export declare function cors(): Response;
10
+ /** Add CORS headers to an existing Response. */
11
+ export declare function withCors(response: Response): Response;
12
+ /** Create a static file Response with correct MIME type. */
13
+ export declare function file(content: Buffer | Uint8Array, contentType: string, cacheControl?: string): Response;
14
+ /** 404 Not Found. */
15
+ export declare function notFound(): Response;
16
+ //# sourceMappingURL=response.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../src/response.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,wBAAgB,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,SAAM,EAAE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,QAAQ,CAuB1F;AAED,oCAAoC;AACpC,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,QAAQ,CAEnG;AAED,+BAA+B;AAC/B,wBAAgB,IAAI,IAAI,QAAQ,CAS/B;AAED,gDAAgD;AAChD,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAUrD;AAED,4DAA4D;AAC5D,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,QAAQ,CAQvG;AAED,qBAAqB;AACrB,wBAAgB,QAAQ,IAAI,QAAQ,CAEnC"}
@@ -0,0 +1,69 @@
1
+ import { gzipSync } from 'node:zlib';
2
+ import { normalizeValue } from "./database/db.js";
3
+ /**
4
+ * Create a JSON Response with optional gzip compression.
5
+ * Mirrors the old jsonResponse/sendJson behavior.
6
+ */
7
+ export function json(data, status = 200, acceptEncoding) {
8
+ const body = Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v)));
9
+ if (body.length > 1024 && acceptEncoding && /\bgzip\b/.test(acceptEncoding)) {
10
+ const compressed = gzipSync(body);
11
+ return new Response(compressed, {
12
+ status,
13
+ headers: {
14
+ 'Content-Type': 'application/json',
15
+ 'Content-Encoding': 'gzip',
16
+ Vary: 'Accept-Encoding',
17
+ ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
18
+ },
19
+ });
20
+ }
21
+ return new Response(body, {
22
+ status,
23
+ headers: {
24
+ 'Content-Type': 'application/json',
25
+ ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
26
+ },
27
+ });
28
+ }
29
+ /** Create a JSON error Response. */
30
+ export function jsonError(status, message, acceptEncoding) {
31
+ return json({ error: message }, status, acceptEncoding);
32
+ }
33
+ /** CORS preflight Response. */
34
+ export function cors() {
35
+ return new Response(null, {
36
+ status: 200,
37
+ headers: {
38
+ 'Access-Control-Allow-Origin': '*',
39
+ 'Access-Control-Allow-Headers': '*',
40
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
41
+ },
42
+ });
43
+ }
44
+ /** Add CORS headers to an existing Response. */
45
+ export function withCors(response) {
46
+ const headers = new Headers(response.headers);
47
+ headers.set('Access-Control-Allow-Origin', '*');
48
+ headers.set('Access-Control-Allow-Headers', '*');
49
+ headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
50
+ return new Response(response.body, {
51
+ status: response.status,
52
+ statusText: response.statusText,
53
+ headers,
54
+ });
55
+ }
56
+ /** Create a static file Response with correct MIME type. */
57
+ export function file(content, contentType, cacheControl) {
58
+ return new Response(Buffer.from(content), {
59
+ status: 200,
60
+ headers: {
61
+ 'Content-Type': contentType,
62
+ ...(cacheControl ? { 'Cache-Control': cacheControl } : {}),
63
+ },
64
+ });
65
+ }
66
+ /** 404 Not Found. */
67
+ export function notFound() {
68
+ return new Response('Not Found', { status: 404 });
69
+ }
@@ -0,0 +1,21 @@
1
+ export interface ScannedModule {
2
+ path: string;
3
+ name: string;
4
+ mod: any;
5
+ }
6
+ export interface ScanResult {
7
+ feeds: ScannedModule[];
8
+ queries: ScannedModule[];
9
+ procedures: ScannedModule[];
10
+ hooks: ScannedModule[];
11
+ setup: ScannedModule[];
12
+ labels: ScannedModule[];
13
+ og: ScannedModule[];
14
+ renderer: ScannedModule | null;
15
+ }
16
+ /**
17
+ * Scan a directory for hatk server modules.
18
+ * Each file's default export is inspected for a `__type` tag.
19
+ */
20
+ export declare function scanServerDir(serverDir: string): Promise<ScanResult>;
21
+ //# sourceMappingURL=scanner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scanner.d.ts","sourceRoot":"","sources":["../src/scanner.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,GAAG,CAAA;CACT;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,OAAO,EAAE,aAAa,EAAE,CAAA;IACxB,UAAU,EAAE,aAAa,EAAE,CAAA;IAC3B,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,MAAM,EAAE,aAAa,EAAE,CAAA;IACvB,EAAE,EAAE,aAAa,EAAE,CAAA;IACnB,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;CAC/B;AAmBD;;;GAGG;AACH,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA2D1E"}
@@ -0,0 +1,88 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { resolve, relative } from 'node:path';
10
+ import { readdirSync, statSync, existsSync } from 'node:fs';
11
+ import { log } from "./logger.js";
12
+ /** Recursively collect .ts/.js files, skipping _ prefixed and dot files */
13
+ function walkDir(dir) {
14
+ const results = [];
15
+ try {
16
+ for (const entry of readdirSync(dir)) {
17
+ if (entry.startsWith('_') || entry.startsWith('.'))
18
+ continue;
19
+ const full = resolve(dir, entry);
20
+ if (statSync(full).isDirectory()) {
21
+ results.push(...walkDir(full));
22
+ }
23
+ else if (entry.endsWith('.ts') || entry.endsWith('.js')) {
24
+ results.push(full);
25
+ }
26
+ }
27
+ }
28
+ catch { }
29
+ return results.sort();
30
+ }
31
+ /**
32
+ * Scan a directory for hatk server modules.
33
+ * Each file's default export is inspected for a `__type` tag.
34
+ */
35
+ export async function scanServerDir(serverDir) {
36
+ const result = {
37
+ feeds: [],
38
+ queries: [],
39
+ procedures: [],
40
+ hooks: [],
41
+ setup: [],
42
+ labels: [],
43
+ og: [],
44
+ renderer: null,
45
+ };
46
+ if (!existsSync(serverDir))
47
+ return result;
48
+ const files = walkDir(serverDir);
49
+ for (const filePath of files) {
50
+ const name = relative(serverDir, filePath).replace(/\.(ts|js)$/, '');
51
+ const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${filePath}?t=${Date.now()}`));
52
+ const exported = mod.default;
53
+ if (!exported) {
54
+ log(`[scanner] ${name}: no default export, skipping`);
55
+ continue;
56
+ }
57
+ const entry = { path: filePath, name, mod: exported };
58
+ switch (exported.__type) {
59
+ case 'feed':
60
+ result.feeds.push(entry);
61
+ break;
62
+ case 'query':
63
+ result.queries.push(entry);
64
+ break;
65
+ case 'procedure':
66
+ result.procedures.push(entry);
67
+ break;
68
+ case 'hook':
69
+ result.hooks.push(entry);
70
+ break;
71
+ case 'setup':
72
+ result.setup.push(entry);
73
+ break;
74
+ case 'labels':
75
+ result.labels.push(entry);
76
+ break;
77
+ case 'og':
78
+ result.og.push(entry);
79
+ break;
80
+ case 'renderer':
81
+ result.renderer = entry;
82
+ break;
83
+ default:
84
+ log(`[scanner] ${name}: no recognized __type tag, skipping`);
85
+ }
86
+ }
87
+ return result;
88
+ }