taskmarket-feed 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @taskmarket/feed
2
+
3
+ RSS feeds and webhook subscriptions for [TaskMarket](https://market.daydreams.systems) activity.
4
+
5
+ ## Features
6
+
7
+ - **RSS feeds** — subscribe in any RSS reader
8
+ - `GET /rss/tasks/open` — live open bounties
9
+ - `GET /rss/tasks/completed` — recently completed tasks
10
+ - `GET /rss/agents/:id` — tasks for a specific agent
11
+ - **Webhooks** — receive events via HTTP POST with HMAC-SHA256 signatures
12
+ - `POST /subscribe` — register a webhook URL
13
+ - `DELETE /subscribe/:id` — unsubscribe
14
+ - `GET /subscriptions` — list active subscriptions
15
+ - **Polling** — checks TaskMarket API every 60s for new/completed tasks
16
+ - **SQLite** — subscriptions persisted with `bun:sqlite`
17
+ - **Retry logic** — 3 attempts with backoff on delivery failure
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ npm install -g @taskmarket/feed
23
+ taskmarket-feed
24
+ # Listening on port 3000
25
+ ```
26
+
27
+ Or as a library:
28
+
29
+ ```ts
30
+ import { createServer, startPoller } from '@taskmarket/feed';
31
+
32
+ startPoller();
33
+ const server = createServer();
34
+ console.log(`Listening on port ${server.port}`);
35
+ ```
36
+
37
+ ## Webhook Payload
38
+
39
+ ```json
40
+ {
41
+ "event": "task.new",
42
+ "task": { "id": "0x...", "description": "...", "reward": "5000000", ... },
43
+ "timestamp": "2026-03-03T12:00:00.000Z"
44
+ }
45
+ ```
46
+
47
+ Verify the signature:
48
+
49
+ ```ts
50
+ import { createHmac } from 'crypto';
51
+
52
+ function verify(secret: string, body: string, header: string): boolean {
53
+ const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
54
+ return header === expected;
55
+ }
56
+ ```
57
+
58
+ ## Subscription Filters
59
+
60
+ ```json
61
+ {
62
+ "url": "https://your-server.com/webhook",
63
+ "secret": "your-hmac-secret",
64
+ "filters": {
65
+ "events": ["task.new"],
66
+ "tags": ["python"],
67
+ "minReward": 5
68
+ }
69
+ }
70
+ ```
71
+
72
+ ## Environment Variables
73
+
74
+ | Variable | Default | Description |
75
+ |-------------------|---------------------|--------------------------------|
76
+ | `PORT` | `3000` | Server port |
77
+ | `DB_PATH` | `subscriptions.db` | SQLite database path |
78
+ | `POLL_INTERVAL_MS`| `60000` | TaskMarket poll interval (ms) |
79
+
80
+ ## Deploy on Railway
81
+
82
+ [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/taskmarket-feed)
83
+
84
+ ```bash
85
+ railway login
86
+ railway init
87
+ railway up
88
+ ```
89
+
90
+ ## License
91
+
92
+ MIT
package/bun.lock ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "feed",
7
+ "devDependencies": {
8
+ "@types/bun": "latest",
9
+ },
10
+ "peerDependencies": {
11
+ "typescript": "^5",
12
+ },
13
+ },
14
+ },
15
+ "packages": {
16
+ "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
17
+
18
+ "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
19
+
20
+ "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
21
+
22
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
23
+
24
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
25
+ }
26
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ console.log("Hello via Bun!");
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "taskmarket-feed",
3
+ "version": "1.0.0",
4
+ "description": "RSS feeds and webhook subscriptions for TaskMarket activity",
5
+ "module": "src/index.ts",
6
+ "main": "src/index.ts",
7
+ "type": "module",
8
+ "bin": {
9
+ "taskmarket-feed": "./src/server.ts"
10
+ },
11
+ "scripts": {
12
+ "start": "bun run src/server.ts",
13
+ "test": "bun test"
14
+ },
15
+ "keywords": [
16
+ "taskmarket",
17
+ "rss",
18
+ "webhooks",
19
+ "feed"
20
+ ],
21
+ "license": "MIT",
22
+ "devDependencies": {
23
+ "@types/bun": "latest"
24
+ },
25
+ "peerDependencies": {
26
+ "typescript": "^5"
27
+ }
28
+ }
package/railway.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://railway.app/railway.schema.json",
3
+ "build": {
4
+ "builder": "NIXPACKS"
5
+ },
6
+ "deploy": {
7
+ "startCommand": "bun run src/server.ts",
8
+ "healthcheckPath": "/health",
9
+ "healthcheckTimeout": 300,
10
+ "restartPolicyType": "ON_FAILURE",
11
+ "restartPolicyMaxRetries": 10
12
+ }
13
+ }
package/src/db.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import type { Subscription, SubscriptionFilters } from './types';
3
+ import { randomUUID } from 'crypto';
4
+
5
+ let _db: Database | null = null;
6
+
7
+ export function getDb(path = process.env.DB_PATH || 'subscriptions.db'): Database {
8
+ if (!_db) {
9
+ _db = new Database(path);
10
+ _db.run(`
11
+ CREATE TABLE IF NOT EXISTS subscriptions (
12
+ id TEXT PRIMARY KEY,
13
+ url TEXT NOT NULL,
14
+ secret TEXT NOT NULL,
15
+ filters TEXT NOT NULL DEFAULT '{}',
16
+ created_at TEXT NOT NULL,
17
+ fail_count INTEGER NOT NULL DEFAULT 0,
18
+ last_delivery TEXT
19
+ )
20
+ `);
21
+ }
22
+ return _db;
23
+ }
24
+
25
+ export function resetDb() { _db = null; }
26
+
27
+ export function createSubscription(
28
+ url: string,
29
+ secret: string,
30
+ filters: SubscriptionFilters = {}
31
+ ): Subscription {
32
+ const db = getDb();
33
+ const sub: Subscription = {
34
+ id: randomUUID(),
35
+ url,
36
+ secret,
37
+ filters,
38
+ createdAt: new Date().toISOString(),
39
+ failCount: 0,
40
+ lastDelivery: null,
41
+ };
42
+ db.run(
43
+ `INSERT INTO subscriptions (id, url, secret, filters, created_at, fail_count, last_delivery)
44
+ VALUES (?, ?, ?, ?, ?, 0, NULL)`,
45
+ [sub.id, sub.url, sub.secret, JSON.stringify(sub.filters), sub.createdAt]
46
+ );
47
+ return sub;
48
+ }
49
+
50
+ export function getSubscription(id: string): Subscription | null {
51
+ const db = getDb();
52
+ const row = db.query('SELECT * FROM subscriptions WHERE id = ?').get(id) as any;
53
+ return row ? rowToSub(row) : null;
54
+ }
55
+
56
+ export function getAllSubscriptions(): Subscription[] {
57
+ const db = getDb();
58
+ const rows = db.query('SELECT * FROM subscriptions').all() as any[];
59
+ return rows.map(rowToSub);
60
+ }
61
+
62
+ export function deleteSubscription(id: string): boolean {
63
+ const db = getDb();
64
+ const r = db.run('DELETE FROM subscriptions WHERE id = ?', [id]);
65
+ return r.changes > 0;
66
+ }
67
+
68
+ export function recordDelivery(id: string, success: boolean): void {
69
+ const db = getDb();
70
+ if (success) {
71
+ db.run(
72
+ 'UPDATE subscriptions SET fail_count = 0, last_delivery = ? WHERE id = ?',
73
+ [new Date().toISOString(), id]
74
+ );
75
+ } else {
76
+ db.run(
77
+ 'UPDATE subscriptions SET fail_count = fail_count + 1 WHERE id = ?',
78
+ [id]
79
+ );
80
+ }
81
+ }
82
+
83
+ function rowToSub(row: any): Subscription {
84
+ return {
85
+ id: row.id,
86
+ url: row.url,
87
+ secret: row.secret,
88
+ filters: JSON.parse(row.filters || '{}'),
89
+ createdAt: row.created_at,
90
+ failCount: row.fail_count,
91
+ lastDelivery: row.last_delivery,
92
+ };
93
+ }
@@ -0,0 +1,308 @@
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import { buildFeed } from './rss';
3
+ import { signPayload, matchesFilter, matchesEvent } from './webhooks';
4
+ import {
5
+ getDb, resetDb,
6
+ createSubscription, getSubscription,
7
+ getAllSubscriptions, deleteSubscription, recordDelivery,
8
+ } from './db';
9
+ import { createServer } from './server';
10
+ import { resetPoller } from './poller';
11
+ import type { Task, SubscriptionFilters } from './types';
12
+
13
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
14
+
15
+ function makeTask(overrides: Partial<Task> = {}): Task {
16
+ return {
17
+ id: '0xabc123',
18
+ requester: '0xDeAdBeEf',
19
+ description: 'Write a Python script to scrape data and output CSV',
20
+ reward: '5000000', // $5 USDC
21
+ status: 'open',
22
+ mode: 'bounty',
23
+ tags: ['python', 'data'],
24
+ createdAt: '2026-03-01T10:00:00.000Z',
25
+ expiryTime: '2026-03-07T10:00:00.000Z',
26
+ submissionCount: 0,
27
+ worker: null,
28
+ workerAgentId: null,
29
+ requesterAgentId: '12345',
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ // ── 1. RSS: valid XML structure ───────────────────────────────────────────────
35
+
36
+ describe('RSS Feed', () => {
37
+ it('produces valid XML declaration', () => {
38
+ const xml = buildFeed('Test', 'Desc', 'http://example.com', []);
39
+ expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>');
40
+ });
41
+
42
+ it('includes rss version 2.0', () => {
43
+ const xml = buildFeed('Test', 'Desc', 'http://example.com', []);
44
+ expect(xml).toContain('version="2.0"');
45
+ });
46
+
47
+ it('renders channel title and description', () => {
48
+ const xml = buildFeed('My Title', 'My Desc', 'http://example.com', []);
49
+ expect(xml).toContain('<title>My Title</title>');
50
+ expect(xml).toContain('<description>My Desc</description>');
51
+ });
52
+
53
+ it('renders one item per task', () => {
54
+ const xml = buildFeed('T', 'D', 'http://example.com', [makeTask(), makeTask({ id: '0xdef456' })]);
55
+ expect(xml.match(/<item>/g)?.length).toBe(2);
56
+ });
57
+
58
+ it('escapes HTML special chars in description', () => {
59
+ const task = makeTask({ description: '<script>alert("xss")</script>' });
60
+ const xml = buildFeed('T', 'D', 'http://example.com', [task]);
61
+ expect(xml).not.toContain('<script>');
62
+ expect(xml).toContain('&lt;script&gt;');
63
+ });
64
+
65
+ it('includes task id as guid', () => {
66
+ const task = makeTask({ id: '0xdeadbeef' });
67
+ const xml = buildFeed('T', 'D', 'http://example.com', [task]);
68
+ expect(xml).toContain('<guid isPermaLink="false">0xdeadbeef</guid>');
69
+ });
70
+
71
+ it('includes reward in item title', () => {
72
+ const task = makeTask({ reward: '10000000' }); // $10
73
+ const xml = buildFeed('T', 'D', 'http://example.com', [task]);
74
+ expect(xml).toContain('$10.00 USDC');
75
+ });
76
+
77
+ it('returns empty channel with no tasks', () => {
78
+ const xml = buildFeed('T', 'D', 'http://example.com', []);
79
+ expect(xml).toContain('<channel>');
80
+ expect(xml).not.toContain('<item>');
81
+ });
82
+ });
83
+
84
+ // ── 2. HMAC generation ────────────────────────────────────────────────────────
85
+
86
+ describe('HMAC Signing', () => {
87
+ it('produces sha256= prefix', () => {
88
+ const sig = signPayload('mysecret', '{"event":"task.new"}');
89
+ expect(sig).toStartWith('sha256=');
90
+ });
91
+
92
+ it('produces consistent signature for same input', () => {
93
+ const body = '{"event":"task.new","task":{}}';
94
+ expect(signPayload('secret', body)).toBe(signPayload('secret', body));
95
+ });
96
+
97
+ it('produces different signature for different secrets', () => {
98
+ const body = '{"event":"task.new"}';
99
+ expect(signPayload('secret1', body)).not.toBe(signPayload('secret2', body));
100
+ });
101
+
102
+ it('produces different signature for different bodies', () => {
103
+ const sig1 = signPayload('secret', '{"event":"task.new"}');
104
+ const sig2 = signPayload('secret', '{"event":"task.completed"}');
105
+ expect(sig1).not.toBe(sig2);
106
+ });
107
+
108
+ it('produces 71-char string (sha256= + 64 hex)', () => {
109
+ const sig = signPayload('secret', 'body');
110
+ expect(sig.length).toBe(71);
111
+ });
112
+ });
113
+
114
+ // ── 3. Filter evaluation ──────────────────────────────────────────────────────
115
+
116
+ describe('Subscription Filters', () => {
117
+ it('passes task with no filters set', () => {
118
+ expect(matchesFilter(makeTask(), {})).toBe(true);
119
+ });
120
+
121
+ it('passes task matching required tag', () => {
122
+ expect(matchesFilter(makeTask({ tags: ['python'] }), { tags: ['python'] })).toBe(true);
123
+ });
124
+
125
+ it('fails task missing required tag', () => {
126
+ expect(matchesFilter(makeTask({ tags: ['js'] }), { tags: ['python'] })).toBe(false);
127
+ });
128
+
129
+ it('passes task above minReward', () => {
130
+ expect(matchesFilter(makeTask({ reward: '5000000' }), { minReward: 4 })).toBe(true);
131
+ });
132
+
133
+ it('fails task below minReward', () => {
134
+ expect(matchesFilter(makeTask({ reward: '1000000' }), { minReward: 5 })).toBe(false);
135
+ });
136
+
137
+ it('passes exact minReward boundary', () => {
138
+ expect(matchesFilter(makeTask({ reward: '5000000' }), { minReward: 5 })).toBe(true);
139
+ });
140
+
141
+ it('matchesEvent passes when no event filter', () => {
142
+ expect(matchesEvent('task.new', {})).toBe(true);
143
+ });
144
+
145
+ it('matchesEvent passes matching event', () => {
146
+ expect(matchesEvent('task.new', { events: ['task.new', 'task.completed'] })).toBe(true);
147
+ });
148
+
149
+ it('matchesEvent fails non-matching event', () => {
150
+ expect(matchesEvent('task.accepted', { events: ['task.new'] })).toBe(false);
151
+ });
152
+ });
153
+
154
+ // ── 4. Database operations ────────────────────────────────────────────────────
155
+
156
+ describe('Database', () => {
157
+ beforeEach(() => {
158
+ resetDb();
159
+ process.env.DB_PATH = ':memory:';
160
+ });
161
+ afterEach(() => resetDb());
162
+
163
+ it('creates a subscription', () => {
164
+ const sub = createSubscription('https://example.com/hook', 'secret', {});
165
+ expect(sub.id).toBeTruthy();
166
+ expect(sub.url).toBe('https://example.com/hook');
167
+ expect(sub.failCount).toBe(0);
168
+ });
169
+
170
+ it('retrieves created subscription by id', () => {
171
+ const sub = createSubscription('https://example.com/hook', 'sec', {});
172
+ const fetched = getSubscription(sub.id);
173
+ expect(fetched?.id).toBe(sub.id);
174
+ });
175
+
176
+ it('returns null for unknown subscription', () => {
177
+ expect(getSubscription('nonexistent')).toBeNull();
178
+ });
179
+
180
+ it('lists all subscriptions', () => {
181
+ createSubscription('https://a.com', 'sec1', {});
182
+ createSubscription('https://b.com', 'sec2', {});
183
+ expect(getAllSubscriptions().length).toBe(2);
184
+ });
185
+
186
+ it('deletes a subscription', () => {
187
+ const sub = createSubscription('https://example.com', 'sec', {});
188
+ expect(deleteSubscription(sub.id)).toBe(true);
189
+ expect(getSubscription(sub.id)).toBeNull();
190
+ });
191
+
192
+ it('returns false deleting unknown subscription', () => {
193
+ expect(deleteSubscription('nonexistent')).toBe(false);
194
+ });
195
+
196
+ it('increments fail_count on failed delivery', () => {
197
+ const sub = createSubscription('https://example.com', 'sec', {});
198
+ recordDelivery(sub.id, false);
199
+ expect(getSubscription(sub.id)?.failCount).toBe(1);
200
+ });
201
+
202
+ it('resets fail_count on successful delivery', () => {
203
+ const sub = createSubscription('https://example.com', 'sec', {});
204
+ recordDelivery(sub.id, false);
205
+ recordDelivery(sub.id, false);
206
+ recordDelivery(sub.id, true);
207
+ expect(getSubscription(sub.id)?.failCount).toBe(0);
208
+ });
209
+
210
+ it('stores and retrieves filters correctly', () => {
211
+ const filters: SubscriptionFilters = { events: ['task.new'], minReward: 10 };
212
+ const sub = createSubscription('https://example.com', 'sec', filters);
213
+ const fetched = getSubscription(sub.id);
214
+ expect(fetched?.filters).toEqual(filters);
215
+ });
216
+ });
217
+
218
+ // ── 5. HTTP Server endpoints ──────────────────────────────────────────────────
219
+
220
+ describe('HTTP Server', () => {
221
+ let server: ReturnType<typeof createServer>;
222
+
223
+ beforeEach(() => {
224
+ resetDb();
225
+ resetPoller();
226
+ process.env.DB_PATH = ':memory:';
227
+ process.env.PORT = '0'; // random port
228
+ server = createServer();
229
+ });
230
+ afterEach(() => { server.stop(true); resetDb(); });
231
+
232
+ async function req(path: string, opts?: RequestInit) {
233
+ return fetch(`http://localhost:${server.port}${path}`, opts);
234
+ }
235
+
236
+ it('GET /health returns ok', async () => {
237
+ const res = await req('/health');
238
+ const data = await res.json() as any;
239
+ expect(res.status).toBe(200);
240
+ expect(data.ok).toBe(true);
241
+ });
242
+
243
+ it('POST /subscribe creates subscription', async () => {
244
+ const res = await req('/subscribe', {
245
+ method: 'POST',
246
+ headers: { 'Content-Type': 'application/json' },
247
+ body: JSON.stringify({ url: 'https://example.com/hook', secret: 'sec' }),
248
+ });
249
+ expect(res.status).toBe(201);
250
+ const data = await res.json() as any;
251
+ expect(data.ok).toBe(true);
252
+ expect(data.id).toBeTruthy();
253
+ });
254
+
255
+ it('POST /subscribe rejects missing url', async () => {
256
+ const res = await req('/subscribe', {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ secret: 'sec' }),
260
+ });
261
+ expect(res.status).toBe(400);
262
+ });
263
+
264
+ it('POST /subscribe rejects invalid url', async () => {
265
+ const res = await req('/subscribe', {
266
+ method: 'POST',
267
+ headers: { 'Content-Type': 'application/json' },
268
+ body: JSON.stringify({ url: 'not-a-url', secret: 'sec' }),
269
+ });
270
+ expect(res.status).toBe(400);
271
+ });
272
+
273
+ it('GET /subscriptions returns list', async () => {
274
+ await req('/subscribe', {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ url: 'https://example.com/hook', secret: 'sec' }),
278
+ });
279
+ const res = await req('/subscriptions');
280
+ const data = await res.json() as any;
281
+ expect(data.subscriptions.length).toBe(1);
282
+ expect(data.subscriptions[0].url).toBe('https://example.com/hook');
283
+ expect(data.subscriptions[0].secret).toBeUndefined(); // never expose secret
284
+ });
285
+
286
+ it('DELETE /subscribe/:id removes subscription', async () => {
287
+ const create = await req('/subscribe', {
288
+ method: 'POST',
289
+ headers: { 'Content-Type': 'application/json' },
290
+ body: JSON.stringify({ url: 'https://example.com/hook', secret: 'sec' }),
291
+ });
292
+ const { id } = await create.json() as any;
293
+ const del = await req(`/subscribe/${id}`, { method: 'DELETE' });
294
+ expect(del.status).toBe(200);
295
+ const list = await (await req('/subscriptions')).json() as any;
296
+ expect(list.subscriptions.length).toBe(0);
297
+ });
298
+
299
+ it('DELETE /subscribe/:id returns 404 for unknown id', async () => {
300
+ const res = await req('/subscribe/nonexistent', { method: 'DELETE' });
301
+ expect(res.status).toBe(404);
302
+ });
303
+
304
+ it('unknown routes return 404', async () => {
305
+ const res = await req('/notaroute');
306
+ expect(res.status).toBe(404);
307
+ });
308
+ });
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { buildFeed, fetchTasks, fetchAgentTasks } from './rss';
2
+ export { signPayload, dispatch, matchesFilter, matchesEvent } from './webhooks';
3
+ export { createSubscription, getAllSubscriptions, deleteSubscription, recordDelivery, getDb } from './db';
4
+ export { startPoller, stopPoller, poll, resetPoller } from './poller';
5
+ export { createServer } from './server';
6
+ export type { Task, Subscription, SubscriptionFilters, WebhookPayload, EventType } from './types';
package/src/poller.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { dispatch } from './webhooks';
2
+ import { fetchTasks } from './rss';
3
+ import type { Task } from './types';
4
+
5
+ const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL_MS || '60000', 10);
6
+
7
+ let knownOpen = new Map<string, Task>();
8
+ let knownCompleted = new Set<string>();
9
+ let pollTimer: Timer | null = null;
10
+
11
+ export async function poll(): Promise<void> {
12
+ try {
13
+ const [open, completed] = await Promise.all([
14
+ fetchTasks('open'),
15
+ fetchTasks('completed'),
16
+ ]);
17
+
18
+ // Detect new open tasks
19
+ for (const task of open) {
20
+ if (!knownOpen.has(task.id)) {
21
+ knownOpen.set(task.id, task);
22
+ await dispatch('task.new', task);
23
+ }
24
+ }
25
+
26
+ // Detect newly completed tasks
27
+ for (const task of completed) {
28
+ if (!knownCompleted.has(task.id)) {
29
+ knownCompleted.add(task.id);
30
+ await dispatch('task.completed', task);
31
+ }
32
+ }
33
+
34
+ // Detect tasks that moved from open → accepted
35
+ for (const [id, task] of knownOpen) {
36
+ const stillOpen = open.find(t => t.id === id);
37
+ if (!stillOpen && task.status !== 'completed') {
38
+ knownOpen.delete(id);
39
+ // Re-fetch to get current status
40
+ try {
41
+ const res = await fetch(`https://api-market.daydreams.systems/api/tasks/${id}`);
42
+ const data = await res.json() as any;
43
+ const updated: Task = data.data || data;
44
+ if (updated.status === 'accepted') {
45
+ await dispatch('task.accepted', updated);
46
+ }
47
+ } catch {}
48
+ }
49
+ }
50
+ } catch (e) {
51
+ console.error('[poller] error:', e);
52
+ }
53
+ }
54
+
55
+ export function startPoller(): void {
56
+ poll(); // immediate first run
57
+ pollTimer = setInterval(poll, POLL_INTERVAL);
58
+ console.log(`[poller] polling every ${POLL_INTERVAL / 1000}s`);
59
+ }
60
+
61
+ export function stopPoller(): void {
62
+ if (pollTimer) {
63
+ clearInterval(pollTimer);
64
+ pollTimer = null;
65
+ }
66
+ }
67
+
68
+ export function resetPoller(): void {
69
+ knownOpen = new Map();
70
+ knownCompleted = new Set();
71
+ }
package/src/rss.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { Task } from './types';
2
+
3
+ const API_BASE = 'https://api-market.daydreams.systems';
4
+
5
+ function esc(s: string): string {
6
+ return s
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;')
11
+ .replace(/'/g, '&apos;');
12
+ }
13
+
14
+ function rewardUsdc(task: Task): string {
15
+ return (parseInt(task.reward, 10) / 1_000_000).toFixed(2);
16
+ }
17
+
18
+ function taskToItem(task: Task): string {
19
+ const link = `https://market.daydreams.systems/tasks/${task.id}`;
20
+ const title = esc(`[${task.mode}] $${rewardUsdc(task)} USDC — ${task.description.slice(0, 80)}`);
21
+ const desc = esc(task.description);
22
+ const pubDate = new Date(task.createdAt).toUTCString();
23
+ return ` <item>
24
+ <title>${title}</title>
25
+ <link>${link}</link>
26
+ <guid isPermaLink="false">${task.id}</guid>
27
+ <pubDate>${pubDate}</pubDate>
28
+ <description>${desc}</description>
29
+ <category>${esc(task.mode)}</category>
30
+ </item>`;
31
+ }
32
+
33
+ export function buildFeed(
34
+ title: string,
35
+ description: string,
36
+ link: string,
37
+ tasks: Task[]
38
+ ): string {
39
+ const items = tasks.map(taskToItem).join('\n');
40
+ const updated = new Date().toUTCString();
41
+ return `<?xml version="1.0" encoding="UTF-8"?>
42
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
43
+ <channel>
44
+ <title>${esc(title)}</title>
45
+ <description>${esc(description)}</description>
46
+ <link>${link}</link>
47
+ <atom:link href="${link}" rel="self" type="application/rss+xml"/>
48
+ <lastBuildDate>${updated}</lastBuildDate>
49
+ <generator>@taskmarket/feed</generator>
50
+ ${items}
51
+ </channel>
52
+ </rss>`;
53
+ }
54
+
55
+ export async function fetchTasks(status?: string, agentId?: string): Promise<Task[]> {
56
+ try {
57
+ const params = new URLSearchParams({ limit: '50' });
58
+ if (status) params.set('status', status);
59
+ if (agentId) params.set('agentId', agentId);
60
+ const res = await fetch(`${API_BASE}/api/tasks?${params}`);
61
+ const data = await res.json() as any;
62
+ return (data.tasks || data.data?.tasks || []) as Task[];
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+
68
+ export async function fetchAgentTasks(agentId: string): Promise<Task[]> {
69
+ // Fetch tasks created by or assigned to this agent
70
+ const [asRequester, asWorker] = await Promise.all([
71
+ fetchTasks(undefined, agentId),
72
+ fetchTasks(undefined, undefined),
73
+ ]);
74
+ const byWorker = asWorker.filter(t => t.workerAgentId === agentId || t.requesterAgentId === agentId);
75
+ const seen = new Set<string>();
76
+ return [...asRequester, ...byWorker].filter(t => {
77
+ if (seen.has(t.id)) return false;
78
+ seen.add(t.id);
79
+ return true;
80
+ });
81
+ }
package/src/server.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { createSubscription, getAllSubscriptions, deleteSubscription } from './db';
2
+ import { buildFeed, fetchTasks, fetchAgentTasks } from './rss';
3
+ import { startPoller } from './poller';
4
+
5
+ const PORT = parseInt(process.env.PORT || '3000', 10);
6
+
7
+ export function createServer() {
8
+ return Bun.serve({
9
+ port: PORT,
10
+ async fetch(req: Request): Promise<Response> {
11
+ const url = new URL(req.url);
12
+ const { pathname } = url;
13
+ const method = req.method.toUpperCase();
14
+
15
+ // ── RSS feeds ──────────────────────────────────────────────────────────
16
+
17
+ if (method === 'GET' && pathname === '/rss/tasks/open') {
18
+ const tasks = await fetchTasks('open');
19
+ const xml = buildFeed(
20
+ 'TaskMarket — Open Tasks',
21
+ 'Live feed of open bounties on TaskMarket',
22
+ `${req.headers.get('origin') || ''}${pathname}`,
23
+ tasks
24
+ );
25
+ return new Response(xml, {
26
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }
27
+ });
28
+ }
29
+
30
+ if (method === 'GET' && pathname === '/rss/tasks/completed') {
31
+ const tasks = await fetchTasks('completed');
32
+ const xml = buildFeed(
33
+ 'TaskMarket — Completed Tasks',
34
+ 'Recently completed tasks on TaskMarket',
35
+ `${req.headers.get('origin') || ''}${pathname}`,
36
+ tasks
37
+ );
38
+ return new Response(xml, {
39
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }
40
+ });
41
+ }
42
+
43
+ if (method === 'GET' && pathname.startsWith('/rss/agents/')) {
44
+ const agentId = pathname.split('/rss/agents/')[1];
45
+ if (!agentId) return json({ error: 'missing agentId' }, 400);
46
+ const tasks = await fetchAgentTasks(agentId);
47
+ const xml = buildFeed(
48
+ `TaskMarket — Agent ${agentId}`,
49
+ `Tasks for agent ${agentId}`,
50
+ `${req.headers.get('origin') || ''}${pathname}`,
51
+ tasks
52
+ );
53
+ return new Response(xml, {
54
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }
55
+ });
56
+ }
57
+
58
+ // ── Webhook subscriptions ──────────────────────────────────────────────
59
+
60
+ if (method === 'POST' && pathname === '/subscribe') {
61
+ let body: any;
62
+ try { body = await req.json(); } catch { return json({ error: 'invalid JSON' }, 400); }
63
+ const { url: hookUrl, secret, filters } = body;
64
+ if (!hookUrl || !secret) return json({ error: 'url and secret are required' }, 400);
65
+ try { new URL(hookUrl); } catch { return json({ error: 'invalid url' }, 400); }
66
+ const sub = createSubscription(hookUrl, secret, filters || {});
67
+ return json({ ok: true, id: sub.id }, 201);
68
+ }
69
+
70
+ if (method === 'GET' && pathname === '/subscriptions') {
71
+ const subs = getAllSubscriptions().map(s => ({
72
+ id: s.id,
73
+ url: s.url,
74
+ filters: s.filters,
75
+ createdAt: s.createdAt,
76
+ failCount: s.failCount,
77
+ lastDelivery: s.lastDelivery,
78
+ // never expose secret
79
+ }));
80
+ return json({ ok: true, subscriptions: subs });
81
+ }
82
+
83
+ if (method === 'DELETE' && pathname.startsWith('/subscribe/')) {
84
+ const id = pathname.split('/subscribe/')[1];
85
+ if (!id) return json({ error: 'missing id' }, 400);
86
+ const removed = deleteSubscription(id);
87
+ if (!removed) return json({ error: 'not found' }, 404);
88
+ return json({ ok: true });
89
+ }
90
+
91
+ // ── Health ─────────────────────────────────────────────────────────────
92
+
93
+ if (method === 'GET' && pathname === '/health') {
94
+ return json({ ok: true, service: '@taskmarket/feed' });
95
+ }
96
+
97
+ return json({ error: 'not found' }, 404);
98
+ },
99
+ });
100
+ }
101
+
102
+ function json(data: unknown, status = 200): Response {
103
+ return new Response(JSON.stringify(data), {
104
+ status,
105
+ headers: { 'Content-Type': 'application/json' }
106
+ });
107
+ }
108
+
109
+ // ── Entry point ───────────────────────────────────────────────────────────────
110
+ if (import.meta.main) {
111
+ startPoller();
112
+ const server = createServer();
113
+ console.log(`[@taskmarket/feed] listening on port ${server.port}`);
114
+ }
package/src/types.ts ADDED
@@ -0,0 +1,39 @@
1
+ export interface Task {
2
+ id: string;
3
+ requester: string;
4
+ description: string;
5
+ reward: string;
6
+ status: string;
7
+ mode: string;
8
+ tags: string[];
9
+ createdAt: string;
10
+ expiryTime: string;
11
+ submissionCount: number;
12
+ worker: string | null;
13
+ workerAgentId: string | null;
14
+ requesterAgentId: string | null;
15
+ }
16
+
17
+ export interface Subscription {
18
+ id: string;
19
+ url: string;
20
+ secret: string;
21
+ filters: SubscriptionFilters;
22
+ createdAt: string;
23
+ failCount: number;
24
+ lastDelivery: string | null;
25
+ }
26
+
27
+ export interface SubscriptionFilters {
28
+ events?: string[]; // e.g. ['task.new', 'task.completed']
29
+ tags?: string[]; // only tasks matching these tags
30
+ minReward?: number; // minimum reward in USDC (full units)
31
+ }
32
+
33
+ export interface WebhookPayload {
34
+ event: string;
35
+ task: Task;
36
+ timestamp: string;
37
+ }
38
+
39
+ export type EventType = 'task.new' | 'task.completed' | 'task.accepted';
@@ -0,0 +1,81 @@
1
+ import { createHmac } from 'crypto';
2
+ import { getAllSubscriptions, recordDelivery } from './db';
3
+ import type { Subscription, WebhookPayload, EventType, Task, SubscriptionFilters } from './types';
4
+
5
+ const MAX_RETRIES = 3;
6
+ const RETRY_DELAYS = [1000, 5000, 15000]; // ms
7
+ const MAX_FAIL_COUNT = 10; // disable after 10 consecutive failures
8
+
9
+ export function signPayload(secret: string, body: string): string {
10
+ return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
11
+ }
12
+
13
+ export function matchesFilter(task: Task, filters: SubscriptionFilters): boolean {
14
+ if (filters.tags && filters.tags.length > 0) {
15
+ const hasTag = filters.tags.some(t => task.tags.includes(t));
16
+ if (!hasTag) return false;
17
+ }
18
+ if (filters.minReward !== undefined) {
19
+ const rewardUsdc = parseInt(task.reward, 10) / 1_000_000;
20
+ if (rewardUsdc < filters.minReward) return false;
21
+ }
22
+ return true;
23
+ }
24
+
25
+ export function matchesEvent(event: string, filters: SubscriptionFilters): boolean {
26
+ if (!filters.events || filters.events.length === 0) return true;
27
+ return filters.events.includes(event);
28
+ }
29
+
30
+ async function deliverOnce(url: string, body: string, sig: string): Promise<boolean> {
31
+ try {
32
+ const res = await fetch(url, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ 'X-TaskMarket-Signature': sig,
37
+ },
38
+ body,
39
+ signal: AbortSignal.timeout(10_000),
40
+ });
41
+ return res.ok;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function deliverWithRetry(
48
+ sub: Subscription,
49
+ body: string,
50
+ sig: string
51
+ ): Promise<boolean> {
52
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
53
+ if (attempt > 0) {
54
+ await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt - 1]));
55
+ }
56
+ const ok = await deliverOnce(sub.url, body, sig);
57
+ if (ok) return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ export async function dispatch(event: EventType, task: Task): Promise<void> {
63
+ const subs = getAllSubscriptions();
64
+ const payload: WebhookPayload = { event, task, timestamp: new Date().toISOString() };
65
+ const body = JSON.stringify(payload);
66
+
67
+ await Promise.allSettled(subs.map(async (sub) => {
68
+ // Skip subscriptions with too many failures
69
+ if (sub.failCount >= MAX_FAIL_COUNT) return;
70
+
71
+ // Filter by event type
72
+ if (!matchesEvent(event, sub.filters)) return;
73
+
74
+ // Filter by task properties
75
+ if (!matchesFilter(task, sub.filters)) return;
76
+
77
+ const sig = signPayload(sub.secret, body);
78
+ const ok = await deliverWithRetry(sub, body, sig);
79
+ recordDelivery(sub.id, ok);
80
+ }));
81
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }