@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/xrpc.js CHANGED
@@ -6,12 +6,41 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ /**
10
+ * XRPC method handler system for serving AT Protocol endpoints.
11
+ *
12
+ * Place handler modules in the `xrpc/` directory, nested by NSID segments
13
+ * (e.g. `xrpc/app/bsky/feed/getAuthorFeed.ts` → `app.bsky.feed.getAuthorFeed`).
14
+ * Each module default-exports a `{ handler }` function that receives an
15
+ * {@link XrpcContext} with database access, query params, pagination, and
16
+ * viewer auth.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * // xrpc/xyz/statusphere/getStatuses.ts
21
+ * import { defineXrpc } from '../../hatk.generated.ts'
22
+ *
23
+ * export default defineXrpc('xyz.statusphere.getStatuses', async (ctx) => {
24
+ * const rows = await ctx.db.query('SELECT * FROM statusphere_status LIMIT ?', [ctx.limit])
25
+ * return { statuses: rows }
26
+ * })
27
+ * ```
28
+ */
29
+ import { guardedQuerySQL, unfilteredQuerySQL } from "./spaces/guard.js";
9
30
  import { resolve, relative } from 'node:path';
10
31
  import { readdirSync, statSync } from 'node:fs';
11
- import { log } from "./logger.js";
12
- import { querySQL, runSQL, packCursor, unpackCursor, isTakendownDid, filterTakendownDids, searchRecords, findUriByFields, lookupByFieldBatch, countByFieldBatch, queryLabelsForUris, } from "./db.js";
13
- import { resolveRecords } from "./hydrate.js";
14
- import { getLexicon } from "./schema.js";
32
+ import { log, emit, timer } from "./logger.js";
33
+ import { runSQL, packCursor, unpackCursor, isTakendownDid, filterTakendownDids, searchRecords, findUriByFields, } from "./database/db.js";
34
+ import { resolveRecords, buildBaseContext } from "./hydrate.js";
35
+ import { getLexicon } from "./database/schema.js";
36
+ import { createHmac } from 'node:crypto';
37
+ import { pdsCreateRecord, pdsPutRecord, pdsDeleteRecord, pdsApplyWrites, pdsXrpc } from "./pds-proxy.js";
38
+ let _oauthConfig = null;
39
+ /** Set the OAuth config used for record write helpers. Called once during boot. */
40
+ export function configureOAuth(config) {
41
+ _oauthConfig = config;
42
+ }
43
+ /** Thrown from XRPC handlers to return a 400 response with an error message. */
15
44
  export class InvalidRequestError extends Error {
16
45
  status = 400;
17
46
  errorName;
@@ -20,6 +49,7 @@ export class InvalidRequestError extends Error {
20
49
  this.errorName = errorName;
21
50
  }
22
51
  }
52
+ /** Thrown from XRPC handlers to return a 404 response. */
23
53
  export class NotFoundError extends InvalidRequestError {
24
54
  status = 404;
25
55
  constructor(message = 'Not found') {
@@ -27,21 +57,124 @@ export class NotFoundError extends InvalidRequestError {
27
57
  }
28
58
  }
29
59
  let _relayUrl = '';
60
+ let _cdn = null;
61
+ /** Set the relay URL used for blob URL generation. Called once during boot. */
62
+ /** True when the relay is a loopback URL — a dev PDS rather than the network. */
63
+ export function isLocalRelay() {
64
+ try {
65
+ const host = new URL(_relayUrl).hostname;
66
+ return (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1' || host.endsWith('.localhost'));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
30
72
  export function configureRelay(relay) {
31
73
  _relayUrl = relay;
32
74
  }
75
+ /** Set the CDN config for imgproxy URL signing. Called once during boot. */
76
+ export function configureCdn(cdn) {
77
+ if (cdn) {
78
+ _cdn = {
79
+ url: cdn.url.replace(/\/$/, ''),
80
+ key: Buffer.from(cdn.key, 'hex'),
81
+ salt: Buffer.from(cdn.salt, 'hex'),
82
+ };
83
+ }
84
+ else {
85
+ _cdn = null;
86
+ }
87
+ }
88
+ /** Sign an imgproxy path with HMAC-SHA256 (URL-safe base64). */
89
+ function signPath(path) {
90
+ const hmac = createHmac('sha256', _cdn.key);
91
+ hmac.update(_cdn.salt);
92
+ hmac.update(path);
93
+ const sig = hmac.digest('base64url');
94
+ return `/${sig}${path}`;
95
+ }
96
+ /**
97
+ * Generate a CDN URL for a blob ref. Uses the PDS directly in local dev,
98
+ * a configured imgproxy CDN if available, or the Bluesky CDN as fallback.
99
+ */
33
100
  export function blobUrl(did, ref, preset = 'avatar') {
34
101
  if (!ref)
35
102
  return undefined;
36
103
  const p = typeof ref === 'string' ? JSON.parse(ref) : ref;
37
104
  if (!p?.ref?.$link)
38
105
  return undefined;
39
- if (_relayUrl.includes('localhost:2583')) {
40
- return `http://localhost:2583/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${p.ref.$link}`;
106
+ // A local network has no image CDN. Serve blobs through the dev proxy
107
+ // (see /blob/ in server.ts), which fetches from each repo's own PDS — on a
108
+ // multi-PDS dev stack the repos are not all behind the relay being tailed.
109
+ if (isLocalRelay()) {
110
+ return `/blob/${did}/${p.ref.$link}`;
111
+ }
112
+ if (_cdn) {
113
+ const path = `/${preset}/plain/${did}/${p.ref.$link}`;
114
+ return `${_cdn.url}${signPath(path)}`;
41
115
  }
42
116
  return `https://cdn.bsky.app/img/${preset}/plain/${did}/${p.ref.$link}@jpeg`;
43
117
  }
118
+ /** Build a full XrpcContext from request parameters. Reuses buildBaseContext for shared fields. */
119
+ export function buildXrpcContext(params, cursor, limit, viewer, input) {
120
+ const base = buildBaseContext(viewer);
121
+ return {
122
+ ...base,
123
+ db: { query: guardedQuerySQL, run: runSQL, unfiltered: unfilteredQuerySQL },
124
+ params,
125
+ input: input || {},
126
+ cursor,
127
+ limit,
128
+ packCursor,
129
+ unpackCursor,
130
+ isTakendown: isTakendownDid,
131
+ filterTakendownDids,
132
+ search: searchRecords,
133
+ resolve: resolveRecords,
134
+ exists: async (collection, filters) => {
135
+ const conditions = Object.entries(filters).map(([field, value]) => ({ field, value }));
136
+ const uri = await findUriByFields(collection, conditions);
137
+ return uri !== null;
138
+ },
139
+ createRecord: async (collection, record, opts) => {
140
+ if (!_oauthConfig)
141
+ throw new Error('No OAuth config — cannot write to PDS');
142
+ if (!viewer)
143
+ throw new Error('Authentication required to write records');
144
+ return pdsCreateRecord(_oauthConfig, viewer, { collection, record, rkey: opts?.rkey });
145
+ },
146
+ putRecord: async (collection, rkey, record) => {
147
+ if (!_oauthConfig)
148
+ throw new Error('No OAuth config — cannot write to PDS');
149
+ if (!viewer)
150
+ throw new Error('Authentication required to write records');
151
+ return pdsPutRecord(_oauthConfig, viewer, { collection, rkey, record });
152
+ },
153
+ deleteRecord: async (collection, rkey) => {
154
+ if (!_oauthConfig)
155
+ throw new Error('No OAuth config — cannot write to PDS');
156
+ if (!viewer)
157
+ throw new Error('Authentication required to write records');
158
+ await pdsDeleteRecord(_oauthConfig, viewer, { collection, rkey });
159
+ },
160
+ applyWrites: async (writes) => {
161
+ if (!_oauthConfig)
162
+ throw new Error('No OAuth config — cannot write to PDS');
163
+ if (!viewer)
164
+ throw new Error('Authentication required to write records');
165
+ return pdsApplyWrites(_oauthConfig, viewer, { writes });
166
+ },
167
+ pds: async (nsid, options) => {
168
+ if (!_oauthConfig)
169
+ throw new Error('No OAuth config — cannot call the PDS');
170
+ if (!viewer)
171
+ throw new Error('Authentication required to call the PDS');
172
+ return pdsXrpc(_oauthConfig, viewer, nsid, options);
173
+ },
174
+ };
175
+ }
44
176
  const handlers = new Map();
177
+ /** Recursively collect .ts/.js files in a directory, skipping files prefixed with `_`. */
45
178
  function walkDir(dir) {
46
179
  const results = [];
47
180
  try {
@@ -58,6 +191,11 @@ function walkDir(dir) {
58
191
  catch { }
59
192
  return results.sort();
60
193
  }
194
+ /**
195
+ * Discover and load XRPC handler modules from the `xrpc/` directory.
196
+ * Directory nesting maps to NSID segments. Parameters are validated and
197
+ * coerced against the matching lexicon definition.
198
+ */
61
199
  export async function initXrpc(xrpcDir) {
62
200
  const files = walkDir(xrpcDir);
63
201
  if (files.length === 0)
@@ -65,7 +203,7 @@ export async function initXrpc(xrpcDir) {
65
203
  for (const scriptPath of files) {
66
204
  const rel = relative(xrpcDir, scriptPath).replace(/\.(ts|js)$/, '');
67
205
  const name = rel.replace(/[\\/]/g, '.');
68
- const mod = await import(__rewriteRelativeImportExtension(scriptPath));
206
+ const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`));
69
207
  const handler = mod.default;
70
208
  // Extract param schema from lexicon for validation and defaults
71
209
  const lexicon = getLexicon(name);
@@ -75,65 +213,127 @@ export async function initXrpc(xrpcDir) {
75
213
  handlers.set(name, {
76
214
  name,
77
215
  execute: async (params, cursor, limit, viewer, input) => {
78
- // Apply defaults and coerce types from lexicon
79
- for (const [key, def] of Object.entries(paramProperties)) {
80
- if (params[key] == null && def.default != null) {
81
- params[key] = String(def.default);
82
- }
83
- if (params[key] != null && def.type === 'integer') {
84
- params[key] = Number(params[key]);
85
- }
86
- }
87
- for (const param of requiredParams) {
88
- if (!params[param]) {
89
- throw new InvalidRequestError(`Missing required parameter: ${param}`, 'InvalidRequest');
90
- }
91
- }
92
- const ctx = {
93
- db: { query: querySQL, run: runSQL },
94
- params,
95
- input: input || {},
96
- cursor,
97
- limit,
98
- viewer,
99
- packCursor,
100
- unpackCursor,
101
- isTakendown: isTakendownDid,
102
- filterTakendownDids,
103
- search: searchRecords,
104
- resolve: resolveRecords,
105
- lookup: async (collection, field, values) => {
106
- if (values.length === 0)
107
- return new Map();
108
- const unique = [...new Set(values.filter(Boolean))];
109
- return lookupByFieldBatch(collection, field, unique);
110
- },
111
- count: async (collection, field, values) => {
112
- if (values.length === 0)
113
- return new Map();
114
- const unique = [...new Set(values.filter(Boolean))];
115
- return countByFieldBatch(collection, field, unique);
116
- },
117
- exists: async (collection, filters) => {
118
- const conditions = Object.entries(filters).map(([field, value]) => ({ field, value }));
119
- const uri = await findUriByFields(collection, conditions);
120
- return uri !== null;
121
- },
122
- labels: queryLabelsForUris,
123
- blobUrl,
124
- };
216
+ coerceParams(params, paramProperties, requiredParams);
217
+ const ctx = buildXrpcContext(params, cursor, limit, viewer, input);
125
218
  return handler.handler(ctx);
126
219
  },
127
220
  });
128
221
  log(`[xrpc] discovered: ${name}`);
129
222
  }
130
223
  }
224
+ /** Register a single XRPC handler from a scanned server/ module. */
225
+ export function registerXrpcHandler(nsid, handlerModule) {
226
+ const lexicon = getLexicon(nsid);
227
+ const paramsDef = lexicon?.defs?.main?.parameters;
228
+ const requiredParams = paramsDef?.required || [];
229
+ const paramProperties = paramsDef?.properties || {};
230
+ handlers.set(nsid, {
231
+ name: nsid,
232
+ execute: async (params, cursor, limit, viewer, input) => {
233
+ coerceParams(params, paramProperties, requiredParams);
234
+ const ctx = buildXrpcContext(params, cursor, limit, viewer, input);
235
+ return handlerModule.handler(ctx);
236
+ },
237
+ });
238
+ }
239
+ /** Collect a query string into params, keeping every value of a repeated key. */
240
+ export function paramsFromSearch(search) {
241
+ const params = {};
242
+ for (const [key, value] of search) {
243
+ const prior = params[key];
244
+ if (prior === undefined)
245
+ params[key] = value;
246
+ else if (Array.isArray(prior))
247
+ prior.push(value);
248
+ else
249
+ params[key] = [prior, value];
250
+ }
251
+ return params;
252
+ }
253
+ function coerceScalar(value, def) {
254
+ // Only integers, as before: a boolean still reaches the handler as the
255
+ // string it was sent as, and handlers written against that keep working.
256
+ return def?.type === 'integer' ? Number(value) : value;
257
+ }
258
+ /**
259
+ * Apply the lexicon's parameter schema before a handler runs: defaults,
260
+ * integer coercion, and arrays. A query string cannot say whether
261
+ * `dids=x` is one string or a one-element list, so the schema decides: a
262
+ * parameter declared `array` always reaches the handler as one, or a handler
263
+ * that spreads it would spread the characters of a DID instead.
264
+ */
265
+ export function coerceParams(params, properties, required) {
266
+ for (const [key, def] of Object.entries(properties)) {
267
+ if (params[key] == null && def.default != null) {
268
+ params[key] = def.type === 'array' ? [...def.default] : def.type === 'string' ? String(def.default) : def.default;
269
+ }
270
+ if (params[key] == null)
271
+ continue;
272
+ if (def.type === 'array') {
273
+ const list = Array.isArray(params[key]) ? params[key] : [params[key]];
274
+ params[key] = list.map((v) => coerceScalar(v, def.items));
275
+ }
276
+ else if (Array.isArray(params[key])) {
277
+ // A scalar given twice: the last one wins, as a form submission would.
278
+ params[key] = coerceScalar(params[key][params[key].length - 1], def);
279
+ }
280
+ else {
281
+ params[key] = coerceScalar(params[key], def);
282
+ }
283
+ }
284
+ for (const param of required) {
285
+ const v = params[param];
286
+ if (v == null || v === '' || (Array.isArray(v) && v.length === 0)) {
287
+ throw new InvalidRequestError(`Missing required parameter: ${param}`, 'InvalidRequest');
288
+ }
289
+ }
290
+ }
291
+ /** Execute a registered XRPC handler by name. Returns null if no handler matches. */
131
292
  export async function executeXrpc(name, params, cursor, limit, viewer, input) {
132
293
  const handler = handlers.get(name);
133
294
  if (!handler)
134
295
  return null;
135
- return handler.execute(params, cursor, limit, viewer || null, input);
296
+ const elapsed = timer();
297
+ try {
298
+ const result = await handler.execute(params, cursor, limit, viewer || null, input);
299
+ emit('xrpc', name, { duration_ms: elapsed(), params, cursor, limit, viewer: viewer?.did });
300
+ return result;
301
+ }
302
+ catch (err) {
303
+ emit('xrpc', name, { duration_ms: elapsed(), params, cursor, limit, viewer: viewer?.did, error: err.message });
304
+ throw err;
305
+ }
306
+ }
307
+ /** Call a registered XRPC handler directly (no HTTP). For use in SSR renderers. */
308
+ export async function callXrpc(nsid, params = {}, input) {
309
+ const viewer = globalThis.__hatk_viewer ?? null;
310
+ // In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis.
311
+ // The runner's module instance has all registered handlers; this (Node's) instance may not.
312
+ if (handlers.size === 0 && globalThis.__hatk_callXrpc) {
313
+ return globalThis.__hatk_callXrpc(nsid, params, input);
314
+ }
315
+ const stringParams = {};
316
+ for (const [k, v] of Object.entries(params)) {
317
+ if (v == null)
318
+ continue;
319
+ // A list stays a list, as the query string would have carried it.
320
+ stringParams[k] = Array.isArray(v) ? v.map(String) : String(v);
321
+ }
322
+ const limit = params.limit ? Number(params.limit) : 20;
323
+ const cursor = params.cursor ?? undefined;
324
+ const result = await executeXrpc(nsid, stringParams, cursor, limit, viewer, input);
325
+ if (result === null)
326
+ throw new Error(`No XRPC handler registered for ${nsid}`);
327
+ return result;
328
+ }
329
+ /**
330
+ * Register a core XRPC handler directly (no XrpcContext wrapping).
331
+ * Used for built-in dev.hatk.* handlers that manage their own dependencies.
332
+ */
333
+ export function registerCoreXrpcHandler(nsid, fn) {
334
+ handlers.set(nsid, { name: nsid, execute: fn });
136
335
  }
336
+ /** Return all registered XRPC method names. */
137
337
  export function listXrpc() {
138
338
  return Array.from(handlers.keys());
139
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.9",
3
+ "version": "0.0.1-alpha.90",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"
@@ -20,27 +20,39 @@
20
20
  "./xrpc-client": "./dist/xrpc-client.js",
21
21
  "./views": "./dist/views.js",
22
22
  "./seed": "./dist/seed.js",
23
+ "./hooks": "./dist/hooks.js",
23
24
  "./setup": "./dist/setup.js",
24
25
  "./test": "./dist/test.js",
25
- "./test/browser": "./dist/test-browser.js",
26
- "./vite-plugin": "./dist/vite-plugin.js"
26
+ "./config": "./dist/config.js",
27
+ "./vite-plugin": "./dist/vite-plugin.js",
28
+ "./renderer": "./dist/renderer.js",
29
+ "./spaces": "./dist/spaces/index.js"
27
30
  },
28
31
  "scripts": {
29
- "build": "tsc -p tsconfig.build.json",
30
- "prepublishOnly": "npm run build"
32
+ "clean": "rm -rf dist",
33
+ "build": "npm run clean && tsc -p tsconfig.build.json && cp -r src/templates dist/templates && cp -r src/lexicons dist/lexicons",
34
+ "prepublishOnly": "npm run build",
35
+ "test": "vitest run --coverage",
36
+ "test:watch": "vitest"
31
37
  },
32
38
  "dependencies": {
33
- "@bigmoves/lexicon": "^0.2.1",
39
+ "@bigmoves/lexicon": "^0.2.4",
34
40
  "@duckdb/node-api": "^1.4.4-r.1",
35
41
  "@hatk/oauth-client": "*",
42
+ "@noble/curves": "^2.4.0",
36
43
  "@resvg/resvg-js": "^2.6.2",
37
- "satori": "^0.19.2",
44
+ "better-sqlite3": "^12.6.2",
45
+ "satori": "^0.28.1",
38
46
  "vitest": "^4",
39
47
  "yaml": "^2.7.0"
40
48
  },
41
49
  "devDependencies": {
42
- "@playwright/test": "^1.58.2",
50
+ "@types/better-sqlite3": "^7.6.13",
43
51
  "@types/react": "^19.2.14",
44
- "vite": "^6"
52
+ "@vitest/coverage-v8": "^4.1.11",
53
+ "vite": "^8.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "vite": "^8.0.0"
45
57
  }
46
58
  }