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