tangled
alpha
login
or
join now
jakelazaroff.com
/
athost
6
fork
atom
serve a static website from your pds
6
fork
atom
overview
issues
pulls
pipelines
Upload tarball
Jake Lazaroff
4 months ago
da901462
4694da7d
+114
-97
5 changed files
expand all
collapse all
unified
split
proxy.js
src
lib
atproto.ts
routes
~
+layout.svelte
+layout.ts
sites
[name]
+page.svelte
+102
-35
proxy.js
···
1
import { createServer } from "node:http";
0
2
import { parseArgs } from "node:util";
3
4
const { values } = parseArgs({
···
27
const doc = await resolveDid(did);
28
const pds = doc.service[0].serviceEndpoint;
29
0
0
0
30
let record = await getRecord(pds, did, collection, rkey);
0
31
let updating = false;
32
33
async function updateRecord() {
···
35
try {
36
updating = true;
37
record = await getRecord(pds, did, collection, rkey);
0
0
0
0
38
} finally {
39
updating = false;
40
}
···
45
* @param {number} status
46
* @param {string} message
47
*/
48
-
function error(res, status, message) {
49
res.statusCode = status;
50
res.end(message);
51
}
···
56
57
let err;
58
try {
59
-
if (req.method !== "GET") return error(res, 405, "Method not supported");
60
queueMicrotask(updateRecord);
61
62
-
let asset = record.value.assets[req.url.slice(1)];
0
0
0
63
let status = 200;
64
65
// if there's no matching file, try to treat it as a folder and use an index.html inside it
66
if (!asset) {
67
-
const path = req.url.slice(1).split("/").filter(Boolean).concat("index.html").join("/");
68
-
asset = record.value.assets[path];
69
-
}
70
-
71
-
// if there's still no matching file and a fallback is defined, try to use it
72
-
if (!asset && record.value.fallback) {
73
-
asset = record.value.assets[record.value.fallback.path];
74
-
if (record.value.fallback.status) status = record.value.fallback.status;
75
}
76
77
// if there's *still* no matching file, return a generic 404
78
-
if (!asset) return error(res, 404, "Not found");
79
80
-
// fetch the file's blob
81
-
try {
82
-
const blob = await getBlob(pds, did, asset.ref.$link);
83
84
-
if (!blob.ok) throw new Error(`Upstream error ${blob.status}: ${blob.statusText}`);
85
-
if (!blob.body) throw new Error("Blob body missing");
86
-
87
-
const contentType = blob.headers.get("content-type") || asset.file.mimeType;
88
-
res.setHeader("content-type", contentType);
89
-
res.statusCode = status;
90
-
91
-
const reader = blob.body.getReader();
92
-
while (true) {
93
-
const { done, value } = await reader.read();
94
-
if (done) break;
95
-
res.write(value);
96
-
}
97
-
98
-
res.end();
99
-
} catch (e) {
100
-
err = e;
101
-
return error(res, 502, "Bad gateway");
102
-
}
103
} finally {
104
const ms = performance.now() - start;
105
process.stdout.write(`${res.statusCode} - ${Math.round(ms)}ms\n`);
···
151
const url = new URL(`${pds}/xrpc/com.atproto.sync.getBlob`);
152
url.searchParams.set("did", did);
153
url.searchParams.set("cid", cid);
154
-
return await fetch(url);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
155
}
···
1
import { createServer } from "node:http";
2
+
import { Readable } from "node:stream";
3
import { parseArgs } from "node:util";
4
5
const { values } = parseArgs({
···
28
const doc = await resolveDid(did);
29
const pds = doc.service[0].serviceEndpoint;
30
31
+
/** @type {Error | undefined} */
32
+
let error;
33
+
34
let record = await getRecord(pds, did, collection, rkey);
35
+
let files = untar(await getBlob(pds, did, record.value.assets.ref.$link));
36
let updating = false;
37
38
async function updateRecord() {
···
40
try {
41
updating = true;
42
record = await getRecord(pds, did, collection, rkey);
43
+
files = untar(await getBlob(pds, did, record.value.assets.ref.$link));
44
+
error = undefined;
45
+
} catch (e) {
46
+
error = e;
47
} finally {
48
updating = false;
49
}
···
54
* @param {number} status
55
* @param {string} message
56
*/
57
+
function fail(res, status, message) {
58
res.statusCode = status;
59
res.end(message);
60
}
···
65
66
let err;
67
try {
68
+
if (req.method !== "GET") return fail(res, 405, "Method not supported");
69
queueMicrotask(updateRecord);
70
71
+
if (error) return fail(res, 502, "Bad gateway");
72
+
73
+
let path = req.url.slice(1);
74
+
let asset = files[req.url.slice(1)];
75
let status = 200;
76
77
// if there's no matching file, try to treat it as a folder and use an index.html inside it
78
if (!asset) {
79
+
path = req.url.slice(1).split("/").filter(Boolean).concat("index.html").join("/");
80
+
asset = files[path];
0
0
0
0
0
0
81
}
82
83
// if there's *still* no matching file, return a generic 404
84
+
if (!asset) return fail(res, 404, "Not found");
85
86
+
res.statusCode = status;
87
+
res.setHeader("content-type", getMimeType(path));
88
+
res.setHeader("content-length", asset.size);
89
90
+
const stream = Readable.fromWeb(asset.stream());
91
+
await new Promise((resolve, reject) => {
92
+
stream.pipe(res);
93
+
stream.on("error", reject);
94
+
stream.on("end", resolve);
95
+
res.on("error", reject);
96
+
});
0
0
0
0
0
0
0
0
0
0
0
0
97
} finally {
98
const ms = performance.now() - start;
99
process.stdout.write(`${res.statusCode} - ${Math.round(ms)}ms\n`);
···
145
const url = new URL(`${pds}/xrpc/com.atproto.sync.getBlob`);
146
url.searchParams.set("did", did);
147
url.searchParams.set("cid", cid);
148
+
149
+
const res = await fetch(url);
150
+
return new Uint8Array(await res.arrayBuffer());
151
+
}
152
+
153
+
/** @param {Uint8Array} data */
154
+
function untar(data) {
155
+
/** @type {Record<string, File>} */
156
+
const files = {};
157
+
let offset = 0;
158
+
159
+
while (offset < data.length) {
160
+
// check if we've hit the end (two empty 512-byte blocks)
161
+
if (data[offset] === 0) break;
162
+
163
+
// read header (512 bytes)
164
+
const header = data.slice(offset, offset + 512);
165
+
166
+
// type flag (156)
167
+
const typeflag = String.fromCharCode(header[156]);
168
+
169
+
// file size (124-135, octal string)
170
+
const sizeBytes = header.slice(124, 136);
171
+
const sizeStr = new TextDecoder().decode(sizeBytes).trim().replace(/\0/g, "");
172
+
const size = Number.parseInt(sizeStr, 8) || 0;
173
+
174
+
offset += 512;
175
+
const paddedSize = Math.ceil(size / 512) * 512;
176
+
177
+
// kkip directories and other non-file entries
178
+
if (typeflag === "5" || typeflag === "x" || typeflag === "g") {
179
+
offset += paddedSize;
180
+
continue;
181
+
}
182
+
183
+
// file name (first 100 bytes, null-terminated)
184
+
const nameBytes = header.slice(0, 100);
185
+
const nameEnd = nameBytes.indexOf(0);
186
+
const name = new TextDecoder().decode(nameBytes.slice(0, nameEnd > 0 ? nameEnd : 100));
187
+
188
+
if (!name) {
189
+
offset += paddedSize;
190
+
continue;
191
+
}
192
+
193
+
// read content
194
+
const content = data.slice(offset, offset + size);
195
+
offset += paddedSize;
196
+
197
+
files[name] = new File([content], name.split("/").pop() || name);
198
+
}
199
+
200
+
return files;
201
+
}
202
+
203
+
/** @param {string} filename */
204
+
function getMimeType(filename) {
205
+
const ext = filename.split(".").pop()?.toLowerCase();
206
+
const mimeTypes = {
207
+
txt: "text/plain",
208
+
html: "text/html",
209
+
css: "text/css",
210
+
js: "text/javascript",
211
+
json: "application/json",
212
+
png: "image/png",
213
+
jpg: "image/jpeg",
214
+
jpeg: "image/jpeg",
215
+
gif: "image/gif",
216
+
svg: "image/svg+xml",
217
+
pdf: "application/pdf",
218
+
zip: "application/zip",
219
+
xml: "application/xml",
220
+
};
221
+
return mimeTypes[ext] || "application/octet-stream";
222
}
+6
-27
src/lib/atproto.ts
···
11
description?: string;
12
13
/** A map of asset paths to blob references. */
14
-
assets: Record<string, { $type: "blob"; ref: { $link: string }; mimeType: string; size: number }>;
15
-
16
-
fallback?: {
17
-
path: string;
18
-
status?: number;
19
-
};
20
}
21
22
export default class ATProto {
···
68
return data as any as Omit<typeof data, "value"> & { value: Bundle };
69
}
70
71
-
async uploadFiles(files: File[]) {
72
-
const assets: Bundle["assets"] = {};
73
-
let throttle = Promise.resolve();
74
-
for (const file of files) {
75
-
if (!(file instanceof File)) continue;
76
-
await throttle;
77
-
78
-
const { data } = await this.#client.post("com.atproto.repo.uploadBlob", { input: file });
79
-
if (isXRPCErrorPayload(data)) throw new Error("couldn't upload file");
80
-
81
-
const filepath = file.webkitRelativePath?.replace(/^.+\//, "") ?? file.name;
82
-
assets[filepath] = {
83
-
$type: "blob",
84
-
ref: data.blob.ref,
85
-
mimeType: file.type,
86
-
size: file.size,
87
-
};
88
-
89
-
throttle = new Promise(r => setTimeout(r, 1000));
90
-
}
91
92
-
return assets;
93
}
94
95
async updateBundle(rkey: string, bundle: Bundle) {
···
101
record: { ...(bundle as any), createdAt: new Date().toISOString() },
102
},
103
});
0
104
if (isXRPCErrorPayload(data)) throw new Error("couldn't deploy");
105
}
106
···
11
description?: string;
12
13
/** A map of asset paths to blob references. */
14
+
assets: { $type: "blob"; ref: { $link: string }; mimeType: string; size: number };
0
0
0
0
0
15
}
16
17
export default class ATProto {
···
63
return data as any as Omit<typeof data, "value"> & { value: Bundle };
64
}
65
66
+
async uploadFile(file: File) {
67
+
const { data } = await this.#client.post("com.atproto.repo.uploadBlob", { input: file });
68
+
if (isXRPCErrorPayload(data)) throw new Error(`couldn't upload ${file.name}`);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
69
70
+
return data.blob;
71
}
72
73
async updateBundle(rkey: string, bundle: Bundle) {
···
79
record: { ...(bundle as any), createdAt: new Date().toISOString() },
80
},
81
});
82
+
83
if (isXRPCErrorPayload(data)) throw new Error("couldn't deploy");
84
}
85
+2
-2
src/routes/~/+layout.svelte
···
27
<header class="header">
28
<h1><a href="/~/">athost</a></h1>
29
<button class="menubutton" popovertarget="menu">
30
-
<img class="avatar" src={data.profile.avatar} alt="" width={20} />
31
-
<span>@{data.profile.displayName}</span>
32
</button>
33
<div id="menu" class="menu" popover>
34
<ul class="themes">
···
27
<header class="header">
28
<h1><a href="/~/">athost</a></h1>
29
<button class="menubutton" popovertarget="menu">
30
+
{#if data.profile.avatar}<img class="avatar" src={data.profile.avatar} alt="" width={20} />{/if}
31
+
<span>@{data.profile.handle}</span>
32
</button>
33
<div id="menu" class="menu" popover>
34
<ul class="themes">
+1
src/routes/~/+layout.ts
···
15
const atp = client(session);
16
17
const profile = await atp.getProfile(did);
0
18
19
return { session, pds: session.info.aud, did, profile };
20
} catch (e) {
···
15
const atp = client(session);
16
17
const profile = await atp.getProfile(did);
18
+
console.log(profile);
19
20
return { session, pds: session.info.aud, did, profile };
21
} catch (e) {
+3
-33
src/routes/~/sites/[name]/+page.svelte
···
33
if (typeof description !== "string" || !description) description = defaultDescription;
34
35
const files = formdata.getAll("files").filter(entry => entry instanceof File);
36
-
const assets = await atp.uploadFiles(files);
37
38
await atp.updateBundle(rkey, { ...(data.record.value as any), description, assets });
39
invalidate(`rkey:${rkey}`);
···
62
invalidate(`rkey:${rkey}`);
63
form.reset();
64
}
65
-
66
-
$inspect(data.record.value.fallback?.status);
67
</script>
68
69
<div class="detail">
···
81
{getRelativeTime(new Date(data.record.value.createdAt))}
82
</time>
83
</summary>
84
-
<ul class="deploy-files">
85
{#each Object.entries(data.record.value.assets) as [path, file]}
86
<li>
87
<span>{path}</span>
···
92
>
93
</li>
94
{/each}
95
-
</ul>
96
</details>
97
98
<section class="section">
···
118
<Icon name="toggle" />
119
<span>Settings</span>
120
</h3>
121
-
<form onsubmit={updateBundle}>
122
-
<fieldset>
123
-
<legend>Fallback</legend>
124
-
<label>
125
-
<span>path</span>
126
-
<input name="fallback_path" value={data.record.value.fallback?.path} />
127
-
</label>
128
-
<label>
129
-
<span>200</span>
130
-
<input
131
-
type="radio"
132
-
name="fallback_status"
133
-
value="200"
134
-
group={data.record.value.fallback?.status}
135
-
/>
136
-
</label>
137
-
<label>
138
-
<span>404</span>
139
-
<input
140
-
type="radio"
141
-
name="fallback_status"
142
-
value="404"
143
-
group={data.record.value.fallback?.status}
144
-
/>
145
-
</label>
146
-
</fieldset>
147
-
<button>save</button>
148
-
</form>
149
</section>
150
151
<section class="section">
···
33
if (typeof description !== "string" || !description) description = defaultDescription;
34
35
const files = formdata.getAll("files").filter(entry => entry instanceof File);
36
+
const assets = await atp.uploadFile(files[0]);
37
38
await atp.updateBundle(rkey, { ...(data.record.value as any), description, assets });
39
invalidate(`rkey:${rkey}`);
···
62
invalidate(`rkey:${rkey}`);
63
form.reset();
64
}
0
0
65
</script>
66
67
<div class="detail">
···
79
{getRelativeTime(new Date(data.record.value.createdAt))}
80
</time>
81
</summary>
82
+
<!-- <ul class="deploy-files">
83
{#each Object.entries(data.record.value.assets) as [path, file]}
84
<li>
85
<span>{path}</span>
···
90
>
91
</li>
92
{/each}
93
+
</ul> -->
94
</details>
95
96
<section class="section">
···
116
<Icon name="toggle" />
117
<span>Settings</span>
118
</h3>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
119
</section>
120
121
<section class="section">