@alteran/astro 0.1.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 +558 -0
- package/index.d.ts +12 -0
- package/index.js +129 -0
- package/package.json +75 -0
- package/src/_worker.ts +44 -0
- package/src/app.ts +10 -0
- package/src/db/client.ts +7 -0
- package/src/db/dal.ts +97 -0
- package/src/db/repo.ts +135 -0
- package/src/db/schema.ts +89 -0
- package/src/db/seed.ts +14 -0
- package/src/env.d.ts +4 -0
- package/src/handlers/debug.ts +34 -0
- package/src/handlers/health.ts +6 -0
- package/src/handlers/ready.ts +14 -0
- package/src/handlers/root.ts +5 -0
- package/src/handlers/wellknown.ts +7 -0
- package/src/handlers/xrpc.repo.core.ts +57 -0
- package/src/handlers/xrpc.server.createSession.ts +25 -0
- package/src/handlers/xrpc.server.refreshSession.ts +43 -0
- package/src/lib/auth.ts +20 -0
- package/src/lib/blockstore-gc.ts +197 -0
- package/src/lib/cache.ts +236 -0
- package/src/lib/car-reader.ts +157 -0
- package/src/lib/commit-log-pruning.ts +76 -0
- package/src/lib/commit.ts +162 -0
- package/src/lib/config.ts +208 -0
- package/src/lib/errors.ts +142 -0
- package/src/lib/firehose/frames.ts +229 -0
- package/src/lib/firehose/parse.ts +82 -0
- package/src/lib/firehose/validation.ts +9 -0
- package/src/lib/handle.ts +90 -0
- package/src/lib/jwt.ts +150 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/metrics.ts +194 -0
- package/src/lib/mst/blockstore.ts +105 -0
- package/src/lib/mst/index.ts +3 -0
- package/src/lib/mst/mst.ts +643 -0
- package/src/lib/mst/util.ts +86 -0
- package/src/lib/ratelimit.ts +34 -0
- package/src/lib/sequencer.ts +10 -0
- package/src/lib/streaming-car.ts +137 -0
- package/src/lib/token-cleanup.ts +38 -0
- package/src/lib/tracing.ts +136 -0
- package/src/lib/util.ts +55 -0
- package/src/middleware.ts +102 -0
- package/src/pages/.well-known/atproto-did.ts +7 -0
- package/src/pages/.well-known/did.json.ts +76 -0
- package/src/pages/debug/blob/[...key].ts +27 -0
- package/src/pages/debug/db/bootstrap.ts +23 -0
- package/src/pages/debug/db/commits.ts +20 -0
- package/src/pages/debug/gc/blobs.ts +16 -0
- package/src/pages/debug/record.ts +33 -0
- package/src/pages/health.ts +68 -0
- package/src/pages/index.astro +57 -0
- package/src/pages/index.ts +2 -0
- package/src/pages/ready.ts +16 -0
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +38 -0
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +45 -0
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +73 -0
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +51 -0
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +25 -0
- package/src/pages/xrpc/com.atproto.repo.listRecords.ts +57 -0
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +53 -0
- package/src/pages/xrpc/com.atproto.server.createSession.ts +92 -0
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +25 -0
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +17 -0
- package/src/pages/xrpc/com.atproto.server.getSession.ts +46 -0
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +67 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.json.ts +16 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.ts +56 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +43 -0
- package/src/pages/xrpc/com.atproto.sync.getHead.ts +11 -0
- package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +42 -0
- package/src/pages/xrpc/com.atproto.sync.getRecord.ts +63 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.range.ts +34 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.ts +17 -0
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +53 -0
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +31 -0
- package/src/services/car.ts +249 -0
- package/src/services/r2-blob-store.ts +87 -0
- package/src/services/repo-manager.ts +339 -0
- package/src/shims/astro-internal-handler.d.ts +4 -0
- package/src/worker/sequencer.ts +563 -0
- package/types/env.d.ts +48 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
|
|
3
|
+
function bearer(req: Request): string | null {
|
|
4
|
+
const h = req.headers.get('authorization') || '';
|
|
5
|
+
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
6
|
+
return m ? m[1] : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function verifyJwt(_ctx: APIContext, token: string): Promise<any | null> {
|
|
10
|
+
try { return { payload: JSON.parse(atob(token)) }; } catch { return null; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function signJwt(_ctx: APIContext, payload: any, _kind: 'access'|'refresh'): Promise<string> {
|
|
14
|
+
const base = { ...payload, exp: Math.floor(Date.now()/1000)+3600 };
|
|
15
|
+
return btoa(JSON.stringify(base));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function POST(ctx: APIContext) {
|
|
19
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
20
|
+
const token = bearer(ctx.request);
|
|
21
|
+
if (!token) return Response.json({ error: 'AuthRequired' }, { status: 401 });
|
|
22
|
+
const ver = await verifyJwt(ctx, token);
|
|
23
|
+
if (!ver || ver.payload.t !== 'refresh') return Response.json({ error: 'InvalidToken' }, { status: 401 });
|
|
24
|
+
|
|
25
|
+
const jtiOld = String(ver.payload.jti || '');
|
|
26
|
+
if (jtiOld && env.DB) {
|
|
27
|
+
await env.DB.exec('CREATE TABLE IF NOT EXISTS token_revocation (refresh_jti TEXT PRIMARY KEY, exp INTEGER NOT NULL);');
|
|
28
|
+
const row: any = await env.DB.prepare('SELECT refresh_jti FROM token_revocation WHERE refresh_jti=?').bind(jtiOld).first();
|
|
29
|
+
if (row?.refresh_jti) return Response.json({ error: 'InvalidToken' }, { status: 401 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const did = String(ver.payload.sub || (env.PDS_DID ?? 'did:example:single-user'));
|
|
33
|
+
const handle = String(ver.payload.handle || env.PDS_HANDLE || 'user.example');
|
|
34
|
+
const jtiNew = crypto.randomUUID();
|
|
35
|
+
const accessJwt = await signJwt(ctx, { sub: did, handle, t: 'access' }, 'access');
|
|
36
|
+
const refreshJwt = await signJwt(ctx, { sub: did, handle, t: 'refresh', jti: jtiNew }, 'refresh');
|
|
37
|
+
if (jtiOld && ver.payload.exp && env.DB) {
|
|
38
|
+
await env.DB.exec('CREATE TABLE IF NOT EXISTS token_revocation (refresh_jti TEXT PRIMARY KEY, exp INTEGER NOT NULL);');
|
|
39
|
+
await env.DB.prepare('INSERT OR REPLACE INTO token_revocation (refresh_jti, exp) VALUES (?,?)').bind(jtiOld, Number(ver.payload.exp)).run();
|
|
40
|
+
}
|
|
41
|
+
return Response.json({ did, handle, accessJwt, refreshJwt });
|
|
42
|
+
}
|
|
43
|
+
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { verifyJwt } from './jwt';
|
|
3
|
+
|
|
4
|
+
export async function isAuthorized(request: Request, env: any): Promise<boolean> {
|
|
5
|
+
const auth = request.headers.get('authorization');
|
|
6
|
+
if (!auth || !auth.startsWith('Bearer ')) return false;
|
|
7
|
+
const token = auth.slice(7);
|
|
8
|
+
// Prefer JWT
|
|
9
|
+
const ver = await verifyJwt(env, token).catch(() => null);
|
|
10
|
+
if (ver && ver.valid && ver.payload.t === 'access') return true;
|
|
11
|
+
// Back-compat local escape hatch if explicitly enabled
|
|
12
|
+
const allowDev = (env as any).PDS_ALLOW_DEV_TOKEN === '1';
|
|
13
|
+
if (allowDev && token === 'dev-access-token') return true;
|
|
14
|
+
if (allowDev && env.USER_PASSWORD && token === env.USER_PASSWORD) return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function unauthorized() {
|
|
19
|
+
return new Response(JSON.stringify({ error: 'AuthRequired' }), { status: 401 });
|
|
20
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { blockstore, commit_log } from '../db/schema';
|
|
4
|
+
import { desc, notInArray, eq } from 'drizzle-orm';
|
|
5
|
+
import { logger } from './logger';
|
|
6
|
+
import { CID } from 'multiformats/cid';
|
|
7
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Collect CIDs referenced by recent commits
|
|
11
|
+
* This traverses the MST structure to find all blocks that are still in use
|
|
12
|
+
*/
|
|
13
|
+
async function collectReferencedCids(env: Env, keepCommits: number = 10000): Promise<Set<string>> {
|
|
14
|
+
const db = drizzle(env.DB);
|
|
15
|
+
const referenced = new Set<string>();
|
|
16
|
+
|
|
17
|
+
// Get recent commits
|
|
18
|
+
const commits = await db
|
|
19
|
+
.select()
|
|
20
|
+
.from(commit_log)
|
|
21
|
+
.orderBy(desc(commit_log.seq))
|
|
22
|
+
.limit(keepCommits)
|
|
23
|
+
.all();
|
|
24
|
+
|
|
25
|
+
logger.debug('blockstore_gc', { message: 'Collecting referenced CIDs', commits: commits.length });
|
|
26
|
+
|
|
27
|
+
// For each commit, parse the commit data and collect referenced CIDs
|
|
28
|
+
for (const commit of commits) {
|
|
29
|
+
try {
|
|
30
|
+
const commitData = JSON.parse(commit.data);
|
|
31
|
+
|
|
32
|
+
// Add the commit CID itself
|
|
33
|
+
referenced.add(commit.cid);
|
|
34
|
+
|
|
35
|
+
// Add the data CID (MST root)
|
|
36
|
+
if (commitData.data) {
|
|
37
|
+
referenced.add(commitData.data);
|
|
38
|
+
|
|
39
|
+
// Traverse the MST to collect all node CIDs
|
|
40
|
+
await traverseMst(env, commitData.data, referenced);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Add prev commit CID if present
|
|
44
|
+
if (commitData.prev) {
|
|
45
|
+
referenced.add(commitData.prev);
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger.warn('blockstore_gc', {
|
|
49
|
+
message: 'Failed to parse commit data',
|
|
50
|
+
cid: commit.cid,
|
|
51
|
+
error: String(error)
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return referenced;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Recursively traverse MST nodes to collect all CIDs
|
|
61
|
+
*/
|
|
62
|
+
async function traverseMst(env: Env, rootCid: string, referenced: Set<string>): Promise<void> {
|
|
63
|
+
const db = drizzle(env.DB);
|
|
64
|
+
const visited = new Set<string>();
|
|
65
|
+
const queue = [rootCid];
|
|
66
|
+
|
|
67
|
+
while (queue.length > 0) {
|
|
68
|
+
const cidStr = queue.shift()!;
|
|
69
|
+
|
|
70
|
+
if (visited.has(cidStr)) continue;
|
|
71
|
+
visited.add(cidStr);
|
|
72
|
+
referenced.add(cidStr);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Load the block
|
|
76
|
+
const block = await db
|
|
77
|
+
.select()
|
|
78
|
+
.from(blockstore)
|
|
79
|
+
.where(eq(blockstore.cid, cidStr))
|
|
80
|
+
.get();
|
|
81
|
+
|
|
82
|
+
if (!block || !block.bytes) continue;
|
|
83
|
+
|
|
84
|
+
// Decode the CBOR data (workers-safe base64)
|
|
85
|
+
const bin = atob(block.bytes);
|
|
86
|
+
const bytes = new Uint8Array(bin.length);
|
|
87
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
88
|
+
const data = dagCbor.decode(bytes) as any;
|
|
89
|
+
|
|
90
|
+
// If this is an MST node, collect child CIDs
|
|
91
|
+
if (data.l) {
|
|
92
|
+
// Left subtree
|
|
93
|
+
const leftCid = CID.decode(data.l);
|
|
94
|
+
queue.push(leftCid.toString());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (data.e) {
|
|
98
|
+
// Entries with subtrees
|
|
99
|
+
for (const entry of data.e) {
|
|
100
|
+
if (entry.t) {
|
|
101
|
+
const treeCid = CID.decode(entry.t);
|
|
102
|
+
queue.push(treeCid.toString());
|
|
103
|
+
}
|
|
104
|
+
if (entry.v) {
|
|
105
|
+
// Record value CID
|
|
106
|
+
const valueCid = CID.decode(entry.v);
|
|
107
|
+
referenced.add(valueCid.toString());
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.warn('blockstore_gc', {
|
|
113
|
+
message: 'Failed to traverse MST node',
|
|
114
|
+
cid: cidStr,
|
|
115
|
+
error: String(error)
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove orphaned blocks from the blockstore
|
|
123
|
+
* Keeps blocks referenced by recent commits (default: last 10000 commits)
|
|
124
|
+
*
|
|
125
|
+
* @param env - Worker environment
|
|
126
|
+
* @param keepCommits - Number of recent commits to preserve blocks for (default: 10000)
|
|
127
|
+
* @returns Number of blocks removed
|
|
128
|
+
*/
|
|
129
|
+
export async function pruneOrphanedBlocks(env: Env, keepCommits: number = 10000): Promise<number> {
|
|
130
|
+
const db = drizzle(env.DB);
|
|
131
|
+
|
|
132
|
+
// Collect all CIDs referenced by recent commits
|
|
133
|
+
const referenced = await collectReferencedCids(env, keepCommits);
|
|
134
|
+
|
|
135
|
+
logger.info('blockstore_gc', {
|
|
136
|
+
message: 'Collected referenced CIDs',
|
|
137
|
+
count: referenced.size
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Get all blocks
|
|
141
|
+
const allBlocks = await db.select({ cid: blockstore.cid }).from(blockstore).all();
|
|
142
|
+
|
|
143
|
+
// Find orphaned blocks
|
|
144
|
+
const orphaned = allBlocks
|
|
145
|
+
.map(b => b.cid)
|
|
146
|
+
.filter(cid => cid && !referenced.has(cid));
|
|
147
|
+
|
|
148
|
+
if (orphaned.length === 0) {
|
|
149
|
+
logger.debug('blockstore_gc', { message: 'No orphaned blocks to remove' });
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Delete orphaned blocks in batches
|
|
154
|
+
const batchSize = 100;
|
|
155
|
+
let removed = 0;
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < orphaned.length; i += batchSize) {
|
|
158
|
+
const batch = orphaned.slice(i, i + batchSize);
|
|
159
|
+
const result = await db
|
|
160
|
+
.delete(blockstore)
|
|
161
|
+
.where(notInArray(blockstore.cid, Array.from(referenced)))
|
|
162
|
+
.run();
|
|
163
|
+
|
|
164
|
+
removed += result.meta.changes || 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
logger.info('blockstore_gc', {
|
|
168
|
+
message: 'Pruned orphaned blocks',
|
|
169
|
+
removed,
|
|
170
|
+
kept: referenced.size
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return removed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get blockstore statistics
|
|
178
|
+
*/
|
|
179
|
+
export async function getBlockstoreStats(env: Env): Promise<{
|
|
180
|
+
total: number;
|
|
181
|
+
totalSize: number;
|
|
182
|
+
}> {
|
|
183
|
+
const db = drizzle(env.DB);
|
|
184
|
+
const blocks = await db.select().from(blockstore).all();
|
|
185
|
+
|
|
186
|
+
const totalSize = blocks.reduce((sum, block) => {
|
|
187
|
+
if (!block.bytes) return sum;
|
|
188
|
+
// Approximate decoded size by counting base64 length * 3/4
|
|
189
|
+
const len = Math.floor((block.bytes.length * 3) / 4);
|
|
190
|
+
return sum + len;
|
|
191
|
+
}, 0);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
total: blocks.length,
|
|
195
|
+
totalSize,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/lib/cache.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Types via tsconfig.app.json
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Edge caching utilities for Cloudflare Workers
|
|
5
|
+
*
|
|
6
|
+
* Implements caching strategies for:
|
|
7
|
+
* - DID documents (/.well-known/did.json)
|
|
8
|
+
* - Well-known files (/.well-known/atproto-did)
|
|
9
|
+
* - Frequently-accessed records
|
|
10
|
+
* - Static assets
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface CacheOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Cache TTL in seconds
|
|
16
|
+
*/
|
|
17
|
+
ttl: number;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cache key prefix
|
|
21
|
+
*/
|
|
22
|
+
prefix?: string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether to use stale-while-revalidate
|
|
26
|
+
*/
|
|
27
|
+
swr?: boolean;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stale-while-revalidate duration in seconds
|
|
31
|
+
*/
|
|
32
|
+
swrTtl?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default cache configurations for different content types
|
|
37
|
+
*/
|
|
38
|
+
export const CACHE_CONFIGS = {
|
|
39
|
+
// DID documents rarely change
|
|
40
|
+
DID_DOCUMENT: {
|
|
41
|
+
ttl: 3600, // 1 hour
|
|
42
|
+
swr: true,
|
|
43
|
+
swrTtl: 86400, // 24 hours
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Well-known files are static
|
|
47
|
+
WELL_KNOWN: {
|
|
48
|
+
ttl: 3600, // 1 hour
|
|
49
|
+
swr: true,
|
|
50
|
+
swrTtl: 86400, // 24 hours
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Records can be cached briefly
|
|
54
|
+
RECORD: {
|
|
55
|
+
ttl: 60, // 1 minute
|
|
56
|
+
swr: true,
|
|
57
|
+
swrTtl: 300, // 5 minutes
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// Repo snapshots can be cached
|
|
61
|
+
REPO_SNAPSHOT: {
|
|
62
|
+
ttl: 300, // 5 minutes
|
|
63
|
+
swr: true,
|
|
64
|
+
swrTtl: 3600, // 1 hour
|
|
65
|
+
},
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate cache headers for a response
|
|
70
|
+
*/
|
|
71
|
+
export function getCacheHeaders(options: CacheOptions): Record<string, string> {
|
|
72
|
+
const headers: Record<string, string> = {};
|
|
73
|
+
|
|
74
|
+
if (options.swr && options.swrTtl) {
|
|
75
|
+
// Use stale-while-revalidate
|
|
76
|
+
headers['Cache-Control'] = `public, max-age=${options.ttl}, stale-while-revalidate=${options.swrTtl}`;
|
|
77
|
+
} else {
|
|
78
|
+
// Simple max-age
|
|
79
|
+
headers['Cache-Control'] = `public, max-age=${options.ttl}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Add ETag for conditional requests
|
|
83
|
+
headers['Vary'] = 'Accept, Accept-Encoding';
|
|
84
|
+
|
|
85
|
+
return headers;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a cache key from request
|
|
90
|
+
*/
|
|
91
|
+
export function getCacheKey(request: Request, prefix?: string): string {
|
|
92
|
+
const url = new URL(request.url);
|
|
93
|
+
const key = `${url.pathname}${url.search}`;
|
|
94
|
+
return prefix ? `${prefix}:${key}` : key;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get cached response from Cache API
|
|
99
|
+
*/
|
|
100
|
+
export async function getCachedResponse(
|
|
101
|
+
request: Request,
|
|
102
|
+
options?: { prefix?: string }
|
|
103
|
+
): Promise<Response | null> {
|
|
104
|
+
try {
|
|
105
|
+
const cache = (caches as any).default as Cache;
|
|
106
|
+
const cacheKey = getCacheKey(request, options?.prefix);
|
|
107
|
+
const cacheUrl = new URL(cacheKey, request.url);
|
|
108
|
+
const cacheRequest = new Request(cacheUrl, request);
|
|
109
|
+
|
|
110
|
+
return (await cache.match(cacheRequest)) ?? null;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error('Cache read error:', error);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Put response in cache
|
|
119
|
+
*/
|
|
120
|
+
export async function putCachedResponse(
|
|
121
|
+
request: Request,
|
|
122
|
+
response: Response,
|
|
123
|
+
options: CacheOptions
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
try {
|
|
126
|
+
const cache = (caches as any).default as Cache;
|
|
127
|
+
const cacheKey = getCacheKey(request, options.prefix);
|
|
128
|
+
const cacheUrl = new URL(cacheKey, request.url);
|
|
129
|
+
const cacheRequest = new Request(cacheUrl, request);
|
|
130
|
+
|
|
131
|
+
// Clone response and add cache headers
|
|
132
|
+
const headers = new Headers(response.headers);
|
|
133
|
+
const cacheHeaders = getCacheHeaders(options);
|
|
134
|
+
for (const [key, value] of Object.entries(cacheHeaders)) {
|
|
135
|
+
headers.set(key, value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const cachedResponse = new Response(response.body, {
|
|
139
|
+
status: response.status,
|
|
140
|
+
statusText: response.statusText,
|
|
141
|
+
headers,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await cache.put(cacheRequest, cachedResponse);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error('Cache write error:', error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Invalidate cache entry
|
|
152
|
+
*/
|
|
153
|
+
export async function invalidateCache(
|
|
154
|
+
request: Request,
|
|
155
|
+
options?: { prefix?: string }
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
const cache = (caches as any).default as Cache;
|
|
159
|
+
const cacheKey = getCacheKey(request, options?.prefix);
|
|
160
|
+
const cacheUrl = new URL(cacheKey, request.url);
|
|
161
|
+
const cacheRequest = new Request(cacheUrl, request);
|
|
162
|
+
|
|
163
|
+
return await cache.delete(cacheRequest);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error('Cache invalidation error:', error);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Middleware to handle caching for GET requests
|
|
172
|
+
*/
|
|
173
|
+
export async function withCache(
|
|
174
|
+
request: Request,
|
|
175
|
+
handler: () => Promise<Response>,
|
|
176
|
+
options: CacheOptions
|
|
177
|
+
): Promise<Response> {
|
|
178
|
+
// Only cache GET requests
|
|
179
|
+
if (request.method !== 'GET') {
|
|
180
|
+
return handler();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check cache first
|
|
184
|
+
const cached = await getCachedResponse(request, { prefix: options.prefix });
|
|
185
|
+
if (cached) {
|
|
186
|
+
return cached;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Generate response
|
|
190
|
+
const response = await handler();
|
|
191
|
+
|
|
192
|
+
// Only cache successful responses
|
|
193
|
+
if (response.ok) {
|
|
194
|
+
// Don't await - cache in background
|
|
195
|
+
putCachedResponse(request, response.clone(), options);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return response;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Generate ETag from content
|
|
203
|
+
*/
|
|
204
|
+
export async function generateETag(content: string | Uint8Array): Promise<string> {
|
|
205
|
+
const data = typeof content === 'string'
|
|
206
|
+
? new TextEncoder().encode(content)
|
|
207
|
+
: content;
|
|
208
|
+
|
|
209
|
+
// Digest accepts a BufferSource, so pass the Uint8Array view directly
|
|
210
|
+
const hash = await crypto.subtle.digest('SHA-256', new Uint8Array(data));
|
|
211
|
+
const hashArray = Array.from(new Uint8Array(hash));
|
|
212
|
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
213
|
+
|
|
214
|
+
return `"${hashHex.substring(0, 16)}"`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check if request has matching ETag
|
|
219
|
+
*/
|
|
220
|
+
export function checkETag(request: Request, etag: string): boolean {
|
|
221
|
+
const ifNoneMatch = request.headers.get('If-None-Match');
|
|
222
|
+
return ifNoneMatch === etag;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Create 304 Not Modified response
|
|
227
|
+
*/
|
|
228
|
+
export function notModifiedResponse(etag: string): Response {
|
|
229
|
+
return new Response(null, {
|
|
230
|
+
status: 304,
|
|
231
|
+
headers: {
|
|
232
|
+
'ETag': etag,
|
|
233
|
+
'Cache-Control': 'public, max-age=3600',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CAR (Content Addressable aRchive) Reader
|
|
3
|
+
* Implements CAR v1 spec: https://ipld.io/specs/transport/car/carv1/
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CID } from 'multiformats/cid';
|
|
7
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
8
|
+
|
|
9
|
+
export interface CarHeader {
|
|
10
|
+
version: 1;
|
|
11
|
+
roots: CID[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CarBlock {
|
|
15
|
+
cid: CID;
|
|
16
|
+
bytes: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read varint from buffer
|
|
21
|
+
* Returns [value, bytesRead]
|
|
22
|
+
*/
|
|
23
|
+
function readVarint(bytes: Uint8Array, offset: number): [number, number] {
|
|
24
|
+
let value = 0;
|
|
25
|
+
let shift = 0;
|
|
26
|
+
let bytesRead = 0;
|
|
27
|
+
|
|
28
|
+
while (offset + bytesRead < bytes.length) {
|
|
29
|
+
const byte = bytes[offset + bytesRead];
|
|
30
|
+
bytesRead++;
|
|
31
|
+
|
|
32
|
+
value |= (byte & 0x7f) << shift;
|
|
33
|
+
|
|
34
|
+
if ((byte & 0x80) === 0) {
|
|
35
|
+
return [value, bytesRead];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
shift += 7;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error('Invalid varint: unexpected end of buffer');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse CAR header from bytes
|
|
46
|
+
*/
|
|
47
|
+
export function parseCarHeader(bytes: Uint8Array): { header: CarHeader; offset: number } {
|
|
48
|
+
// Read header length
|
|
49
|
+
const [headerLength, headerLengthBytes] = readVarint(bytes, 0);
|
|
50
|
+
|
|
51
|
+
// Extract header bytes
|
|
52
|
+
const headerStart = headerLengthBytes;
|
|
53
|
+
const headerEnd = headerStart + headerLength;
|
|
54
|
+
const headerBytes = bytes.slice(headerStart, headerEnd);
|
|
55
|
+
|
|
56
|
+
// Decode header
|
|
57
|
+
const decoded = dagCbor.decode(headerBytes) as any;
|
|
58
|
+
|
|
59
|
+
if (decoded.version !== 1) {
|
|
60
|
+
throw new Error(`Unsupported CAR version: ${decoded.version}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Roots are already CID objects from dag-cbor decode
|
|
64
|
+
const roots = (decoded.roots || []).map((r: any) => {
|
|
65
|
+
if (r instanceof Uint8Array) {
|
|
66
|
+
return CID.decode(r);
|
|
67
|
+
}
|
|
68
|
+
return r; // Already a CID
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
header: { version: 1, roots },
|
|
73
|
+
offset: headerEnd,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a single block from CAR bytes
|
|
79
|
+
* Returns the block and the new offset, or null if no more blocks
|
|
80
|
+
*/
|
|
81
|
+
export function parseCarBlock(bytes: Uint8Array, offset: number): { block: CarBlock; offset: number } | null {
|
|
82
|
+
if (offset >= bytes.length) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Read block length
|
|
87
|
+
const [blockLength, blockLengthBytes] = readVarint(bytes, offset);
|
|
88
|
+
offset += blockLengthBytes;
|
|
89
|
+
|
|
90
|
+
if (offset + blockLength > bytes.length) {
|
|
91
|
+
throw new Error('Invalid CAR: block extends beyond buffer');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Extract block bytes
|
|
95
|
+
const blockBytes = bytes.slice(offset, offset + blockLength);
|
|
96
|
+
offset += blockLength;
|
|
97
|
+
|
|
98
|
+
// Parse CID (first part of block) using decodeFirst to get remainder
|
|
99
|
+
const [cid, remainder] = CID.decodeFirst(blockBytes);
|
|
100
|
+
|
|
101
|
+
// Extract data (rest of block is the remainder)
|
|
102
|
+
const data = remainder;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
block: { cid, bytes: data },
|
|
106
|
+
offset,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse entire CAR file
|
|
112
|
+
*/
|
|
113
|
+
export function parseCarFile(bytes: Uint8Array): { header: CarHeader; blocks: CarBlock[] } {
|
|
114
|
+
const { header, offset: initialOffset } = parseCarHeader(bytes);
|
|
115
|
+
const blocks: CarBlock[] = [];
|
|
116
|
+
|
|
117
|
+
let offset = initialOffset;
|
|
118
|
+
while (offset < bytes.length) {
|
|
119
|
+
const result = parseCarBlock(bytes, offset);
|
|
120
|
+
if (!result) break;
|
|
121
|
+
|
|
122
|
+
blocks.push(result.block);
|
|
123
|
+
offset = result.offset;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { header, blocks };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate that block CID matches content
|
|
131
|
+
*/
|
|
132
|
+
export async function validateBlock(block: CarBlock): Promise<boolean> {
|
|
133
|
+
try {
|
|
134
|
+
const decoded = dagCbor.decode(block.bytes);
|
|
135
|
+
const reencoded = dagCbor.encode(decoded);
|
|
136
|
+
|
|
137
|
+
// Verify bytes match
|
|
138
|
+
if (reencoded.length !== block.bytes.length) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < reencoded.length; i++) {
|
|
143
|
+
if (reencoded[i] !== block.bytes[i]) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Verify CID matches
|
|
149
|
+
const { sha256 } = await import('multiformats/hashes/sha2');
|
|
150
|
+
const hash = await sha256.digest(block.bytes);
|
|
151
|
+
const expectedCid = CID.createV1(dagCbor.code, hash);
|
|
152
|
+
|
|
153
|
+
return block.cid.equals(expectedCid);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|